Every Block on this site is assembled from the same small set of decisions: eight type roles, one section rhythm, one button primitive, three families of colour tokens, and a few shared parts. This page writes them down, and renders the specimens under each rule so you can see it as well as read it.
The Block gallery shows every Block live. If you are about to design something new, the useful order is: check the gallery for a Block that already does it, then pick a type role, a token and a shared part from this page rather than choosing a size, a colour or a layout.
Every colour is a token in src/app/globals.css. Blocks reference them by
name (bg-primary, text-muted-foreground, border-ledger-rule) and never a
raw hex code.
| Family | Tokens | For |
|---|---|---|
| Base | background, foreground, card, muted, primary, border, input, ring and their pairs | Everything, and the contract shadcn components expect |
| Text tiers | foreground, muted-foreground, subtle-foreground | Copy, then supporting copy, then indexes, labels and chrome |
| Ledger | ledger-rule, ledger-tick, ledger-meta, ledger-meter, ledger-meter-empty, ledger-wordmark, ledger-wordmark-dim | The hairline grids and their parts |
| Mock | mock-edge, mock-rule, mock-text, mock-node | Only the UI drawings in FeatureRows and PreviewStage |
A few things worth knowing:
subtle-foreground measures 3.7:1 on the ground, so it is for small mono
labels and indexes, never for body copy.primary-foreground is near-black on purpose: white on the accent fails
contrast.color-scheme: dark), so a dark: utility never
applies.accent, secondary, popover and destructive exist for the shadcn
primitives under ui/. No Block uses them.Rebranding a client site means editing the values in that file, and nothing else. That promise holds only while Blocks keep referencing tokens by name. Never rename or remove a token: every Block refers to them by name, so a rename is a silent breakage rather than a build error.
Two faces, both loaded through next/font: Geist Sans for everything a
visitor reads, and Geist Mono for labels and metadata.
Eight roles carry the whole site, each a --text-* token with its line height
and tracking built in. Weight is not part of the token. Titles are set at 400,
and the stat role at 300.
| Role | Size | For |
|---|---|---|
text-display | 64/70 | The page title: Hero, the blog index, a post |
text-statement | 52/62 | A closing statement: the CTA band, Newsletter |
text-title | 36/44 | A section title, through SectionHeader |
text-subtitle | 22/30 | An item inside a section: questions, cells, plans |
text-lede | 18/28 | The sentence under or beside a title |
text-stat | 60/64 | Figures and prices |
text-eyebrow | 12/16 | Mono small caps: eyebrows, roles, periods |
text-label | 10/14 | The footer's column headings, and nothing read as prose |
Large roles step down on small screens with a Tailwind size first, for example
text-5xl lg:text-display, because 64px is most of a phone's width per word.
Geist Sans (--font-sans) · Geist Mono (--font-mono) · 10 roles
Every section title comes from SectionHeader: an optional eyebrow (a pink
tick and a mono label), the title, an optional meta note opposite it, and
an optional lede. The ledger sections set the lede opposite the title instead,
with SectionHeaderSplit.
An eyebrow has to say something the title does not. "Pricing" above "One template, three ways to license it" earns its place; "Our features" above "Our features" does not.
src/components/blocks/section-header.tsx · eyebrow, meta, split
eyebrow + meta
By the numbers
One supporting sentence, never two paragraphs.
SectionHeaderSplit: lede opposite the title
Pricing
Start free, upgrade the day you take on a second client.
Every Block is a <section> wrapping a <div class="page-inset">. The inset
centres content at a maximum of 90rem, with 1.5rem gutters that open to 3rem
from lg. It is the only horizontal measure: no Block sets its own container
width.
Sections are spaced py-16 lg:py-22 (64px, then 88px). Stats, the logo cloud,
the blog row, image cards and the preview stage sit tighter at py-16, because
the design draws them that way.
A full-bleed band (the hero's texture, the preview stage, the closing call to action) draws its background outside the inset and keeps its content inside one, so the text still lines up with the sections around it.
--shadow-button, --shadow-button-primary,
--shadow-prompt-card, --shadow-stage-window and --shadow-stage-float.Most sections are built from the same few parts. A new Block composes them rather than redrawing them.
src/components/texture/grid-field.tsx · server SVG + canvas · 2 presets
dense: animated, pointer={true}; move the cursor over it
coarse: animate={false}, ships no client JS
One primitive. A link that looks like a button is ButtonLink, and a real form
button takes buttonVariants(). Buttons are pills on a soft drop shadow, and
the primary one adds a halo in the accent. Use variant="default" for the
primary action and variant="outline" for the one beside it, at
size="marketing".
src/components/ui/button.tsx · 6 variants × 9 sizes
| variant \ size | marketing | default | lg | sm | xs | icon | icon-lg | icon-sm | icon-xs |
|---|---|---|---|---|---|---|---|---|---|
| default | |||||||||
| outline | |||||||||
| secondary | |||||||||
| ghost | |||||||||
| destructive | |||||||||
| link |
No arbitrary values. p-[13px] and text-[#4a4a4a] bypass both the
spacing rhythm and the token system, and the class you want usually exists.
Canonical Tailwind classes only.
No rounded cards and no hover zoom. Cards are square frames on a hairline, and nothing that is not a link moves when you point at it.
No client JavaScript by default. Blocks are server components. Where something is interactive, the page renders working HTML first (the FAQ is native disclosure) and a small script only improves it, so nothing breaks without one.
Animation is always gated on the visitor's setting, so anyone who has asked their system for reduced motion gets none of it. That covers the marquee in the announcement bar, the texture in the hero and closing call to action, and the FAQ opening and closing. Where a loop duplicates content to run seamlessly, the copy is hidden from assistive tech and from reduced motion.
This page carries no noindex, so it appears in the sitemap and search engines
are free to index it. That is the default for every page here.
Where you want the opposite, a page that stays published and reachable by link
but out of the sitemap, add noindex: true to its frontmatter.
privacy.mdx, terms.mdx and blocks-gallery.mdx all do.