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:
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'
);<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:
@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:
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.