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:
pnpm add @fontsource-variable/<your-font>(or drop a woff2 intosrc/assets/).- Update the
@font-faceblock insrc/styles/fonts.css— the family name, thesrcand the weight range. - Point
--font-sansat the new family name intailwind-theme.css, keeping the system fallback stack behind it. - Update the preload import in
BaseHead.astroso 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.