Skip to content

Design system (surfaces, borders, boxes) ​

Every colour, radius and shadow in the web app comes from a token in apps/web/src/styles/globals.css. Components reference tokens; they don't hard-code colours. That file has four theme blocks: :root (light), @media (prefers-color-scheme: dark), and an explicit [data-theme="dark"] / [data-theme="light"] pair so a chosen theme beats the OS preference. The three colour blocks restate the same values rather than sharing them, because a single combined selector can't win in both directions.

Surfaces are opaque, and borders do the separating ​

Two surfaces carry the app: the page, and the boxes on it, which is also what the chrome (AppHeader, PageBar) is drawn in, plus a tint for a box's header strip and the line that separates any of them:

RoleTokenLightDark
The page, as a tinted well--background#f3f5fc#0c0d16
Content boxes and the app's chrome (AppHeader, PageBar)--card#ffffff#161829
The strip at the top of a box--surface-muted#eff2fb#1c1f33
The line that separates any of them--border#d5daed#2c3050

All of these were translucent, over a tinted gradient, with a backdrop-blur. That is worth knowing because it explains most of the rules below:

  • --border is a real edge. It used to be white at 85% on a white card, which draws nothing. A page could hold any number of surfaces and none of them had a boundary; the only thing separating two panels was a drop shadow. This is the single change that lets a long list stay legible, and it is what the rest of the system is built on.
  • --card is flat. In dark, a translucent card took its colour from whatever happened to be behind it, so two identical panels on one page were different greys. A panel is now the same panel wherever it lands.
  • There is no --glass-blur. backdrop-filter composites a layer per overlay, and with opaque surfaces it was filtering a background nobody could see through. The sticky PageBar and LessonTabs are opaque instead of translucent-plus-blur, which is cheaper and needs no filter at all.

--shadow-panel means "this floats" ​

One shadow token, and it is not for cards. Anything in the page's own flow gets a border and nothing else. --shadow-panel belongs only to things genuinely above the page: Dialog, Popover, DropdownMenu, Select, toasts, the mobile nav sheet, the collab chat and its bubble, and the editor's FAB.

If you are adding a surface and reaching for a shadow, the question to ask is whether it floats. If it scrolls with the page, it doesn't.

Radii: one value, everywhere a border is drawn ​

--radius and --radius-panel are both 0.5rem (8px); --radius-tile is 6px and --radius-pill is a pill. There used to be an 8 to 14 to 20px scale that grew with the surface, which read as softness at button size and as an unfinished edge at panel size, and made a card nested in a panel step visibly against its parent's corner.

The bordered box with a header strip ​

The app's main structural pattern, and the shape every listing uses:

jsx
<div className="overflow-hidden rounded-panel border border-border bg-card">
  <div className="flex items-center gap-3 border-b border-border bg-surface-muted px-4 py-2.5">
    <Icon className="size-4 text-muted-foreground" />
    <h2 className="text-sm font-semibold">What's in here</h2>
    <span className="text-xs text-muted-foreground">how many</span>
  </div>
  <div className="flex flex-col divide-y divide-border">{rows}</div>
</div>

The strip is the part that earns its keep. It gives the box somewhere to say what it holds and how much, which is usually the question the reader arrives with, and which the rows can only answer by being counted. Where a count is shown next to a filtered list, count what's on screen: the hub's listing counts visibleLessons, so it stays true while a search narrows it rather than reporting a total nobody can see.

Used by: HubPage's drafts and published listings, ProfilePage's lessons, PullRequestsSection, and SectionCard, whose strip is also sticky, which carries one extra constraint (see Navigating large lessons): the card around a sticky strip must not set overflow-hidden, or it becomes the nearest scroll container and the strip stops sticking.

State pills ​

A proposal's state is a solid filled pill, not a tinted outline: down a column of rows a 10% tint doesn't read at a glance. The colours follow the convention every repository host uses:

StateTokenWhy
Open--successGreen is open, as it is everywhere else this shape appears
Merged--primaryThe brand indigo, kept for the outcome the flow aims at
Closed--secondaryGrey. Deliberately not the red a repo host would use: closing a proposal here is a routine outcome, not a failure, and nobody using this should have to know GitHub's colour vocabulary to read it as one

Fields are defined by their border, not a fill ​

Input, Textarea and SelectTrigger have no background of their own in either theme; stock shadcn gives them a dark:bg-input/30, and this app doesn't. Two reasons: light mode has no fill either, so a dark-only one makes the themes disagree about what a field is; and the app has several deliberately text-like inputs (the document title, a section's name) that pass bg-transparent and got a filled bar in dark anyway, because tailwind-merge keeps a dark:-prefixed fill and an unprefixed one side by side and the dark one then wins.

Buttons keep their dark fill. A thing you press should read as raised; a thing you type into shouldn't.

Adding to this ​

  • Style through tokens. A literal colour in a component works in one theme.
  • Don't define a colour only inside a @media or [data-theme] block; most viewers are in the un-stamped default state, and it won't apply to them.
  • New surface? Border first, and reach for a shadow only if it floats.

Copyright © 2026 Spelling Creator.