Skip to content
AstroCraft Docs
On this theme

Typography

Finly ships one typeface, self-hosted, in one variable file. The type scale is four custom tokens plus Tailwind’s own, exposed through five shared classes. There is no typography plugin — the one place that needs prose rules gets its own ninety-line stylesheet.

The typeface

Plus Jakarta Sans, variable, weights 200–800, self-hosted through @fontsource-variable/plus-jakarta-sans. It is declared once in src/styles/fonts.css:

@font-face {
  font-family: "PlusJakartaSansVariable";
  font-style: normal;
  font-display: swap;
  font-weight: 200 800;
  src: url(@fontsource-variable/plus-jakarta-sans/files/plus-jakarta-sans-latin-wght-normal.woff2)
    format("woff2-variations");
  unicode-range: U+0000-00FF, /* … */;
}

Only the variable file ships, and only the Latin subset, so the whole family is one request. The display sizes lean on the family’s tall x-height and open apertures, which is why no second family is needed for headings.

BaseHead preloads the woff2 for faster first paint:

<link rel="preload" href={jakartaVariable} as="font" type="font/woff2" crossorigin="anonymous" />

The import gives Astro the hashed URL, so the preload and the @font-face can never point at different files.

The scale

--font-sans is wired to the family in tailwind-theme.css, with a full system fallback stack behind it, so font-sans and the html default both resolve to it.

Four sizes are added because Tailwind’s default ramp does not already cover them, each taken from the design:

--text-display: 3.25rem;   /* 52/60 hero,     -0.02em, 600 */
--text-heading: 2.625rem;  /* 42/50 section,  -0.015em, 600 */
--text-lead: 1.0625rem;    /* 17/26 lead */
--text-eyebrow: 0.6875rem; /* 11/16 eyebrow, +0.08em, 500 */

Each carries its own line height, letter spacing and weight through Tailwind v4’s --text-*--line-height convention, so text-display sets all four properties in one class.

Everything else — body copy at 14, 16 and 18 pixels, card titles, small print — is text-sm, text-base and text-lg as Tailwind ships them. Adding a fifth custom size is usually a sign that something should be reusing one of these.

The five shared classes

Defined in global.css under @layer components, because they repeat on every page:

.h1 { @apply md:text-display text-4xl font-semibold tracking-tight; }
.h2 { @apply md:text-heading text-3xl font-semibold tracking-tight; }
.h3 { @apply text-lg font-semibold; }

.description { @apply text-muted-foreground md:text-lead text-base; }

.eyebrow {
  @apply text-eyebrow text-muted-foreground flex items-center gap-2.5 uppercase;
  &::before { @apply bg-primary h-0.5 w-6 shrink-0 content-[''];  }
}

Two things to notice.

They step down on small screens. The 52-pixel and 42-pixel display sizes are desktop figures; the base classes are text-4xl and text-3xl, with the display tokens applied from md up. Write class="h1" and you get both.

.eyebrow includes its rule. The section label that opens nearly every band — “PRICING”, “HOW IT WORKS” — is drawn with a 24×2 pixel teal rule before it, and the class draws that rule as a pseudo-element. It is one class because the motif repeats in every section, and pulling it apart into a wrapper plus a span would be the same three decisions restated at a dozen call sites.

They are semantic-free: .h1 is a size, not a heading level. A card title that happens to be an <h3> in the outline can carry .h2 if that is what the design draws. Use the element for structure and the class for size.

Rendered markdown

Two pages render markdown bodies — a blog post and a customer story — and they get their typography from src/components/ui/_prose.css, a plain global stylesheet imported from the band’s frontmatter.

It has to be global rather than an Astro <style> block, and the reason is structural rather than stylistic: <Content /> renders markdown into elements that never receive the component’s scope attribute, so a scoped rule cannot reach a single one of them.

There is no @tailwindcss/typography. It is about ninety lines against a plugin whose entire value is a type scale that would then have to be overridden token by token to match this design anyway. Every value in the file was measured against the design’s own nodes: the lede at 19/32, h2 at 28/36, body at 17/29, teal 7-pixel list discs, a 3-pixel quote bar with its attribution line.

It lives in @layer components, and that is what replaces .not-prose. The typography plugin needs an opt-out class because its rules are unlayered and therefore outrank every utility. Here global.css declares the layer order explicitly:

@layer theme, base, components, utilities;

So any Tailwind utility beats any rule in the prose file, regardless of specificity. A ui/callout or ui/code-block sitting in an article body keeps its own type simply by carrying its own utilities — no marker class, and no chance of an opt-out someone forgets to add. The single exception is a <pre> whose background a highlighter sets as an inline style, which no layer can outrank; that is the one !important in the file.

Three details in it are worth copying if you write another prose surface:

  • Spacing is owner-defined. Each block states the space above it (> * + * { margin-top }), so a heading following a paragraph and a heading following a list land in the same place.
  • The opening paragraph is drawn larger via > p:first-of-type — a structural selector rather than a class, so an author never has to remember to add one.
  • Headings carry scroll-margin-top, because anchor targets sit under a fixed header and scrolling to one would otherwise put it behind the bar. The platform’s own answer, no JavaScript.

The file opens with @reference "@/styles/global.css" so @apply can reach the project’s tokens — the same line NotchedCard’s style block carries, and for the same reason.

Changing the typeface

Four steps, none of them in a component:

  1. pnpm add @fontsource-variable/<your-font> (or drop a woff2 into src/assets/).
  2. Update the @font-face block in src/styles/fonts.css — the family name, the src and the weight range.
  3. Point --font-sans at the new family name in tailwind-theme.css, keeping the system fallback stack behind it.
  4. Update the preload import in BaseHead.astro so it points at the new file.

If the new family’s metrics differ significantly, revisit the four --text-* tokens — a font with a shorter x-height usually wants a slightly larger display size for the same optical weight. Nothing else changes: every component reads font-sans or one of the five shared classes, and none of them names a typeface.

To add a second family — a mono for code, say — declare it as --font-mono in @theme (the token is already there with a system stack) and use font-mono where you want it.

NEXT STEPLayout & Spacing