Design System

Typography

How fonts are loaded and applied across every app.

packages/design-system/lib/fonts.ts exports a single className string that every app puts on its <html> element:

packages/design-system/lib/fonts.ts
import { cn } from '@kreogen/design-system/lib/utils';
import { GeistMono } from 'geist/font/mono';
import { GeistSans } from 'geist/font/sans';

export const fonts = cn(
  GeistSans.variable,
  GeistMono.variable,
  'touch-manipulation font-sans antialiased'
);
apps/app/app/layout.tsx
<html lang="en" className={fonts} suppressHydrationWarning>

Fonts come from the geist npm package, not from next/font/google. The files are installed as a dependency and served from your own origin, so there is no request to Google's CDN on first paint, nothing to configure in a Content Security Policy, and no third-party availability in the critical path.

How it reaches Tailwind

GeistSans.variable and GeistMono.variable are classNames that define --font-geist-sans and --font-geist-mono. globals.css maps those onto Tailwind's font tokens:

packages/design-system/styles/globals.css
@theme inline {
  --font-sans: var(--font-geist-sans);
  --font-mono: var(--font-geist-mono);
}

Which is what makes font-sans and font-mono resolve, and why fonts carries font-sans itself — the variables are declared by the class, so the element that declares them is the natural place to apply the default.

antialiased and touch-manipulation ride along: the first because these are variable fonts rendered light-on-dark as often as the reverse, the second because it removes the 300ms tap delay on touch devices.

Changing the font

Install it, then swap the imports. A variable font from fontsource works the same way, as does a local file through next/font/local:

packages/design-system/lib/fonts.ts
import localFont from 'next/font/local';

const sans = localFont({
  src: './fonts/YourFont.woff2',
  variable: '--font-geist-sans',
  display: 'swap',
});

export const fonts = cn(sans.variable, 'touch-manipulation font-sans antialiased');

Keep the CSS variable names, or update the @theme inline mapping to match. Change one without the other and font-sans falls back to the system stack — which looks close enough to be missed in review and wrong on every screen.

next/font requires the loader to be called at module scope with literal arguments. Building the config object dynamically fails at build time with a message about the call site rather than about the value.

Adding a third family

fonts is a plain string, so a heading or display face is one more variable:

export const fonts = cn(
  GeistSans.variable,
  GeistMono.variable,
  display.variable,
  'touch-manipulation font-sans antialiased'
);

Then add --font-display: var(--font-display) inside @theme inline to get a font-display utility. Every family costs a font file on first paint, so add them because a design calls for them, not in case.

Prose

@tailwindcss/typography is loaded as a plugin, and its --tw-prose-* variables are pointed at the design system's own tokens at the bottom of globals.css. So prose blocks — long-form marketing copy, CMS content, legal pages — follow the theme and dark mode rather than carrying their own greys.