What this page is for

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.

Colour

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.

FamilyTokensFor
Basebackground, foreground, card, muted, primary, border, input, ring and their pairsEverything, and the contract shadcn components expect
Text tiersforeground, muted-foreground, subtle-foregroundCopy, then supporting copy, then indexes, labels and chrome
Ledgerledger-rule, ledger-tick, ledger-meta, ledger-meter, ledger-meter-empty, ledger-wordmark, ledger-wordmark-dimThe hairline grids and their parts
Mockmock-edge, mock-rule, mock-text, mock-nodeOnly 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.
  • The site is dark only (color-scheme: dark), so a dark: utility never applies.
  • The ledger and mock tokens are white-on-dark alphas, so a light retheme sets them too.
  • 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.

Type

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.

RoleSizeFor
text-display64/70The page title: Hero, the blog index, a post
text-statement52/62A closing statement: the CTA band, Newsletter
text-title36/44A section title, through SectionHeader
text-subtitle22/30An item inside a section: questions, cells, plans
text-lede18/28The sentence under or beside a title
text-stat60/64Figures and prices
text-eyebrow12/16Mono small caps: eyebrows, roles, periods
text-label10/14The 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.

Type scale

Geist Sans (--font-sans) · Geist Mono (--font-mono) · 10 roles

Eyebrowfont-mono text-eyebrow uppercase text-muted-foreground· SectionHeader, above a title
What you get
Displaytext-5xl lg:text-display· Hero, /blog, a post title; one per page
Your website, editable by any AI agent
Statementtext-3xl sm:text-title lg:text-statement· The CTA band and Newsletter
Compose pages from Blocks.
Titletext-title· SectionHeader, every other section
Every section opens with a statement
Subtitletext-subtitle· An item inside a section: FAQ, grid cells, plans
Content lives in git
Ledetext-lede text-muted-foreground· Under or opposite a title
One supporting sentence, never two paragraphs.
Stattext-stat font-light tabular-nums· Stats and plan prices, at font-light
2.6 hrs
Bodytext-base text-muted-foreground· Prose and cell copy
The size everything falls back to when nothing else applies.
Metadatafont-mono text-sm text-muted-foreground· Dates, categories, indexes
Product · 27 August 2026
Labelfont-mono text-label uppercase text-subtle-foreground· The footer's column headings only
Design system

Section headings

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.

Section header slots

src/components/blocks/section-header.tsx · eyebrow, meta, split

eyebrow + meta

By the numbers

The numbers behind the pitch.

Measured across 40+ client builds

One supporting sentence, never two paragraphs.

SectionHeaderSplit: lede opposite the title

Pricing

One template, three ways to license it.

Start free, upgrade the day you take on a second client.

Rhythm

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.

Shape and elevation

  • Square: cards, image frames and grid cells. Square corners on a hairline, never a rounded card.
  • Round: buttons, pills and the eyebrow tick.
  • Radius is kept for the UI drawings, images inside an article, and small marks like the quote avatar.
  • Shadows are tokens: --shadow-button, --shadow-button-primary, --shadow-prompt-card, --shadow-stage-window and --shadow-stage-float.

Shared parts

Most sections are built from the same few parts. A new Block composes them rather than redrawing them.

  • The ledger grid: a grid of cells sharing hairline rules, with a crosshair on each outer corner, and empty ruled cells closing a short last row. Used by the feature grid, team, pricing, stats and logo cloud.
  • The texture: a procedural grid of cells, animated in the hero and the closing call to action, and still everywhere else. With JavaScript off it is the same picture, just still.
  • The card frame: an image at 4:3 in a square hairline frame, shared by the blog cards, image cards and the feature split.
  • The recommended choice: a lifted panel under a pink rule, with pink checks and the primary button. Comparison and pricing use it, and pink appears once per section, for that choice.
  • Text links: a label with a chevron, for any link that is not a call to action.

Grid texture

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

Buttons

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".

Button primitive

src/components/ui/button.tsx · 6 variants × 9 sizes

variant \ sizemarketingdefaultlgsmxsiconicon-lgicon-smicon-xs
default
outline
secondary
ghost
destructive
link

What this system deliberately does not have

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.

Motion

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.

Indexable, like most pages

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.