Why the card gallery reads as one product rather than a mood board — surface anatomy, the two-font rhythm, earned color, and one signature element per card.
Cross-cutting laws·docs/card-design-language.md·263 lines
Kit src/app/(app)/cards/Living example · Card Gallery
Distilled from the Card Gallery (src/app/(app)/cards/). The rules (tokens, forbidden colors, standard libraries) live in AGENTS.md; this document is the compositional layer above them — why the gallery reads as one product, and how to reproduce that.
It serves two workflows:
replicate-ui skill references this as its aesthetic ground truth).01
1. Consistency is the asset. Every card — regardless of who designed the original — is translated into the same semantic tokens: one --primary, one grey ramp, one radius/elevation system. A single beautiful card is a style; fourteen cards sharing one gene pool is a product. When adapting any design, the translation into tokens is the design work, not a chore after it.
2. Density is tamed by a two-font rhythm. Big figures in the default sans; all metadata (eyebrows, table headers, units, statuses, captions) in font-mono text-xs tracking-wider. Two clearly separated reading layers let a card carry a headline + chart + table + status without turning to noise. Low-density "dribbble padding" is optimized for thumbnails, not for work — this language prefers density and pays for it with rhythm.
3. Color is earned. Every colored pixel carries information: the brand accent means "primary action / brand", green/red mean outcome, palette hues mean data categories. Nothing is colored for decoration. If a color can be removed without losing information, remove it.
4. One signature element per card. Each card keeps exactly one memorable visual — a tick meter, a waveform, a pixel mascot, a weight grid, a glowing sparkline. Everything else submits to the shared language. Identity through one element, unity through the rest.
02
<div className="w-full max-w-xl rounded-3xl border border-border bg-background">
{/* Title bar — name + primary action */}
<div className="flex items-center justify-between gap-3 px-5 py-4">…</div>
{/* Inset panel — the dense, load-bearing surface */}
<div className="mx-2 rounded-2xl border border-border/50 bg-card p-5 sm:p-6">…</div>
{/* Footer — utility icons left, secondary action right */}
<div className="flex items-center justify-between gap-3 px-4 py-3">…</div>
</div>| Surface | Token | Notes |
|---|---|---|
| Shell | bg-background + border-border, rounded-3xl | Carries title bar and footer |
| Panel | bg-card + border-border/50, rounded-2xl, mx-2 inset | Carries the data; the 8px inset is what makes the two-surface nesting legible |
| Width | max-w-xl default; max-w-2xl for table-heavy cards | Expanded states step up one size (xl → 3xl, 2xl → 4xl) |
| Separators | border-border/50..60; dashed border-t for insight/summary lines | Dashed = "derived commentary", solid = structural |
Cards follow the active theme (never force .dark); popovers use shadcn components so portals inherit tokens.
03
| Role | Classes | Example |
|---|---|---|
| Headline figure | text-4xl sm:text-5xl font-semibold tracking-tight tabular-nums | $485,970 |
| Eyebrow / section label | font-mono text-xs tracking-wider text-muted-foreground (UPPERCASE) | YOUR TOTAL NET WORTH TODAY |
| Table header / meta | same mono recipe, sentence case allowed | Name Type Share Amount |
| Delta / status caption | mono recipe + semantic color | +$4,310, 1 ERROR, 3 WARNINGS |
| Row title | text-sm font-medium (default sans) | Harborview Loft |
| Card title | text-lg font-semibold | Wealth Overview |
Numbers that change always get tabular-nums. The mono layer is where the "instrument panel" feel lives — do not promote it to sans, and do not demote headline figures to mono.
04
Priority order (from AGENTS.md, applied to cards):
--primary for the single brand accent (the original's brand color always maps here — never copied literally), muted-foreground for secondary text, border-border for lines.text-green-500 / text-red-500, soft badges as bg-green-500/10 text-green-500.orange-500, sky-500, violet-500, …) only to differentiate data categories, never as the accent. Each category binds one hue across all its appearances (tick, chip, bar segment, legend).bg-{hue}-500/15 circle + text-{hue}-500 glyph — not solid fills with white glyphs.The signature element's LIGHT — and the one effect that tells these cards from a flat block at a glance, in both themes. It is one recipe in three rungs (globals.css), picked by the SIZE of the element, never by taste:
| Rung | Utility | Recipe | Wears it |
|---|---|---|---|
| dot | glow-dot | drop-shadow(0 0 3px · 45%) | a mark ≤ 8px: a dot, a tick, the point on a line |
| line | glow-line | drop-shadow(0 0 5px · 45%) | a stroke or a fill: a sparkline, a donut arc, a meter fill, a bar cluster |
| node | glow-node | box-shadow: 0 0 0 1.5px, 0 0 22px -6px | an HTML box that is currently the point: the node the clock is on |
The light is currentColor; an element whose paint is not its text colour names its light with --glow ([--glow:var(--primary)], or a CSS module's own variable). Never write a drop-shadow(...) literal again — the recipe was re-derived fourteen times with drifting numbers (3–6px, 38–55%) before it was one utility. Three rules:
primary under an amber fill is the bug the execution-timeline card shipped. 45% is the mix that reads as light — past 50% it reads as blur.lighter compositing — which is why the knowledge-pipeline stage stays dark in both themes. Reach for additive bloom only when the content IS light and the count is in the hundreds, and then pre-render the bloom as a sprite.Cost, measured (headless Chrome, software raster, dark theme; CDP tracing of Paint + Raster + Composite): the Opt-in rate member with forty glowing ticks and arcs spends 3.0 ms/s with the glow on and 2.4 ms/s with it off — ~0.01 ms a frame. A glow at rest is a rounding error. What costs is a glow that REPAINTS: mid-walk, the Run member's active nodes were adding ~45 ms/s of raster, not because the glow moved but because the flowing edge dashes behind them shared their layer and dragged the 22px blur through every frame. Promoting the edge SVG to its own layer (will-change: transform) took the glow's on/off difference below the noise between walk windows. So the performance rule is rule one's twin: an animation never shares a layer with a glow — give the ANIMATION its own.
05
Reuse these before building anything similar — same-family views must look identical across cards:
| Primitive | File | Use |
|---|---|---|
ShareBar | @/components/ui/share-bar | Parts of a whole in one dimension — a row's share of the total (one segment + total), two sides against each other, or n segments composing 100%; xs/sm/md heights, ink (tone / primary / muted) or a data color |
Meter | @/components/feedback/meter | Any segmented tick meter (usage, score, spectrum, health) — the feedback law's status mark, fills by tone / primary / current / spectrum |
useCountUp | use-count-up.ts | Any animated figure; re-tweens from current value |
InitialsAvatar | initials-avatar.tsx | People without photos — name-hashed chart-token gradients |
| Segmented thumb | idiom | motion.span layoutId="…" + bg-card shadow-(--shadow-card) ring-1 ring-(--card-ring) sliding under font-mono pills |
| Icon button | idiom | flex size-8 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground |
| Reopen chip | idiom | Closed cards collapse to a rounded-full border bg-card font-mono pill, never disappear |
| Toasts | sonner | Every side-effect-ish action confirms via toast; bottom-center |
Icons are solar (iconify): -linear for controls, -bold for filled chips. Verify every name against @iconify-json/solar/icons.json before use.
Brand logos are a separate set: when a design legitimately needs a recognizable third-party mark (an integrations list, a channel picker, a "works with X" row — i.e. the brand is the data), use the real logo from the iconify logos set (svg-logos, @iconify-json/logos) — never hand-draw, fake, or substitute a generic glyph for it. This is the AppChip pattern in card-list/auto-templates (icon-[logos--slack-icon], …): multi-colour marks keep their palette; monochrome marks (GitHub, OpenAI, X…) resolve to currentColor via a filter so they read on either theme. Verify names against @iconify-json/logos/icons.json. Logos are never decoration — if the brand isn't the information, don't show its mark.
06
| Effect | Tool | Budget |
|---|---|---|
| Hover / selection | Tailwind transition-* | ~150ms |
| Figure changes | useCountUp | 600–800ms ease-out |
| Text/state swap | AnimatePresence mode="wait" + initial={{opacity:0,y:4}} / exit={{opacity:0,y:-4}} | 150–180ms |
| Reveal band / list add-remove | motion height: auto band, mode="popLayout" lists | 200–300ms |
| Draw-on (paths) | motion.path pathLength 0 → 1, keyed by state | 750ms — the chart law's entrance clock |
| Live simulation | one setInterval clock; all UI state derives from elapsed | 100ms tick |
Rules: ease-out by default; one moving subject at a time; every "appear / disappear / resize" goes through motion, never display toggling. Count-up tweens live in leaf components — their per-frame setState must not re-render a parent that hosts AnimatePresence exits (they freeze mid-fade otherwise). Custom keyframes go in CSS Modules, never globals.css.
07
logos set (see Shared primitives), never a fake. Invented entities never borrow a real logo; real integrations never get a fake one.Math.random or new Date() (SSR must equal client). Pseudo-randomness uses the grain recipe Math.abs(Math.sin(x * 12.9898) * 43758.5453) % 1 with explicit seeds; runtime synthesis (refresh jitter, added rows) lives in event handlers, keyed so it stays reproducible.08
To elevate an ordinary design into this language, apply in order — earlier steps buy the most:
--primary; outcome colors → green/red; category colors → one palette hue each; everything else → greys from the ramp.09
shadow-* and a visible border on the same surface (elevation is token-driven — see theming.md)logos icon exists; numbers that don't reconcile