Typography
Grafio pairs two variable families: Source Serif 4 for display headings and Source Sans 3 for body and interface text. Both are self-hosted through Fontsource — not Google Fonts, not the Astro fonts API — so there is no third-party request on any page load.
The editorial voice comes largely from one decision: headings are serif and never bold. Every heading class sets font-serif font-normal. Weight is not how this design creates hierarchy; size and generous leading are.
How the fonts load
Two @font-face declarations in src/styles/fonts.css, both variable, weight axis only, latin subset only:
@font-face {
font-family: "Source Sans 3 Variable";
font-style: normal;
font-display: swap;
font-weight: 200 900;
src: url(@fontsource-variable/source-sans-3/files/source-sans-3-latin-wght-normal.woff2)
format("woff2-variations");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, /* … */;
}
Vite resolves those paths out of node_modules and hashes the files into the build. A single variable file covers the whole 200–900 weight range, so there is one request per family rather than one per weight.
Both are preloaded in BaseHead, imported as URLs so the preload points at the hashed filename:
import sansVariable from "@fontsource-variable/source-sans-3/files/source-sans-3-latin-wght-normal.woff2";
import serifVariable from "@fontsource-variable/source-serif-4/files/source-serif-4-latin-wght-normal.woff2";
---
<link rel="preload" href={sansVariable} as="font" type="font/woff2" crossorigin="anonymous" />
<link rel="preload" href={serifVariable} as="font" type="font/woff2" crossorigin="anonymous" />
The serif preload matters for Core Web Vitals: on most pages the largest contentful paint is a serif headline.
The family tokens
Three, in src/styles/tailwind-theme.css:
--font-sans:
"Source Sans 3 Variable", "Source Sans 3", ui-sans-serif, system-ui, -apple-system,
BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--font-serif:
"Source Serif 4 Variable", "Source Serif 4", ui-serif, Georgia, Cambria,
"Times New Roman", Times, serif;
--font-mono:
"SFMono-Regular", "Menlo", "Monaco", "Consolas", "Liberation Mono", "Courier New", monospace;
--font-mono is a stack only — no monospace webfont is loaded. Code in article bodies renders in whatever the reader’s system provides.
--font-sans is applied globally on html, alongside line-height: 1.6 and font-feature-settings: "liga" 1, "calt" 1. --font-serif is opted into by the heading classes.
The scale
There are no custom size steps. Grafio uses the stock Tailwind v4 scale — text-sm through text-7xl — and expresses its type in five @apply classes in src/styles/global.css:
.h1 { @apply font-serif text-4xl font-normal md:text-6xl; }
.h2 { @apply font-serif text-3xl font-normal md:text-5xl; }
.h3 { @apply font-serif text-2xl font-normal; }
.display-heading {
@apply text-base-700 dark:text-base-300 font-serif text-5xl leading-none font-normal
sm:text-6xl lg:text-7xl;
}
.description { @apply text-base-700 dark:text-base-300 md:text-lg; }
The distinction between .h1 and .display-heading is the one to internalise, because it is not obvious from the names.
.h1 / .h2 / .h3 are the reading scale — the sizes used inside articles, legal documents, cards and the 404 page. .display-heading is the page-opening scale, used by every hero <h1> and by the large section headings on the home page. They are deliberately separate: editing .h1 will not change any hero.
Two custom fluid tokens exist, both consumed only by the footer:
--text-display-link: clamp(1.5rem, 3.75vw, 3rem);
--size-footer-mark: clamp(8rem, 16.7vw, 16rem);
If you use --text-display-link yourself, note the syntax: text-(length:--text-display-link). The length: hint is load-bearing — without it tailwind-merge reads the bare token as a colour and the size never applies.
Article typography
Long-form markdown gets its own stylesheet, src/styles/article.css, imported into the components layer. One class, .article-body, styles everything by descendant selector:
.article-body {
@apply text-foreground flex flex-col gap-6 text-base leading-7;
& h2 { @apply text-foreground mt-6 scroll-mt-32 font-serif text-2xl leading-9 font-normal md:text-3xl; }
& h3 { @apply text-foreground mt-2 scroll-mt-32 font-serif text-xl leading-8 font-normal; }
& p { @apply text-base-700 dark:text-base-300; }
& a { @apply text-foreground underline decoration-current/40 decoration-1 underline-offset-4; }
& blockquote { @apply border-border text-foreground border-s-2 ps-6 font-serif text-xl leading-8; }
& code { @apply bg-muted rounded-sm px-1.5 py-0.5 text-sm; }
& pre { @apply bg-muted overflow-x-auto rounded-xl p-5 text-sm leading-6; }
}
This is deliberately not @tailwindcss/typography. That plugin installs a complete opinionated scale which would then need overriding back to serif headings and the theme’s muted pairs — a dependency bought in order to argue with it.
The scroll-mt-32 on headings is what stops a table-of-contents jump from landing a heading underneath the fixed header.
Changing the type
Four files, six edits, and the whole site follows.
1. Swap the packages.
pnpm remove @fontsource-variable/source-serif-4 @fontsource-variable/source-sans-3
pnpm add @fontsource-variable/instrument-serif @fontsource-variable/inter
2. Rewrite the two @font-face blocks in src/styles/fonts.css — the font-family name and the src: path. Keep font-weight: 200 900 (adjust if your family’s axis differs), font-display: swap, and the unicode-range.
3. Update --font-sans and --font-serif in src/styles/tailwind-theme.css so the first entry matches the new font-family name.
4. Update the two preload imports in src/layouts/BaseHead.astro to the new package paths.
Everything downstream is covered: html uses var(--font-sans), and every heading class, .display-heading, the article headings and blockquote, and the footer display link all use font-serif.
If your replacement is not variable, use the static weight files the package ships and add a @font-face per weight you actually use. Two or three is usually enough given that headings are font-normal.
Adjusting sizes
To make headings larger or smaller across the site, edit the classes rather than the components:
- Article and card headings:
.h1,.h2,.h3inglobal.css. - Page heroes and big section headings:
.display-heading. - Standfirsts and card descriptions:
.description. - Article body prose:
.article-bodyinarticle.css.
The md: and lg: steps in those classes are the responsive scale. Grafio is mobile-first throughout; add breakpoints, do not remove them.
One custom breakpoint exists beyond Tailwind’s defaults: --breakpoint-xs: 400px, for the narrowest phones.
Text balance and measure
Headlines use text-balance so they break evenly rather than leaving one orphaned word. Measures are capped explicitly — max-w-3xl on the CTA headline, max-w-[1100px] on the .site-container used by reading-heavy pages like legal documents and the 404.
If you change the type, re-check those measures rather than porting the numbers. A Figma text box measured against a fallback font under-reports what the real webfont needs; the CTA headline’s max-w-3xl was measured in a browser after the font loaded, not taken from the design file.