Before adding anything to Shade, walk the decision flow and the promotion checklist. The default is keep code local first — premature design system additions lock in the wrong API.
The five layers
| Layer | Lives in | Examples |
|---|---|---|
| Token | theme-variables.css, tailwind.theme.css |
--background, --text-base, --radius-md |
| Primitive | src/components/primitives/ |
Stack, Inline, Box, Grid, Container, Text |
| Component | src/components/ui/ |
Button, Input, Dialog, Tabs, Card |
| Recipe | src/components/ui/<name>.ts (no JSX) |
inputSurface |
| Pattern | src/components/patterns/ |
PageHeader, KpiCard, Filters, GhAreaChart |
Plus one additional barrel:
page-templates/(@tryghost/shade/page-templates) — top-level page wrappers (ListPagetoday). Composes Patterns + Components + Primitives. If you're building a new shape that wraps a whole admin page, this is where it goes. Seeshade-page-templates.
Decision flow (top-to-bottom, stop at first match)
- Just a colour, size, radius, duration? → Token. Add to
theme-variables.css(semantic) ortailwind.theme.css(raw@theme). - Layout-only (spacing, alignment, structure)? → Primitive. Reuse an existing one; only add a new one if the structural shape is genuinely novel.
- Generic, accessible UI control with no Ghost-specific knowledge? → Component. Reuse first; only add if it doesn't exist and the promotion checklist below passes.
- The same chrome / focus / density rule shared across ≥ 2 components? → Recipe. A class-string function (no JSX, no React) next to the components in
src/components/ui/. - Knows about Ghost (KPIs, members, posts, newsletters, analytics)? → Pattern.
Quick gut check: generic name → Component; Ghost-shaped name → Pattern. Button is web-y; KpiCard is Ghost-y.
Allowed dependencies (one-way)
Each layer can use anything below it. The reverse is forbidden.
- A Button can't reach into a Pattern.
- A Primitive can't import a Component.
- A Recipe is pure CSS classes — no React, no JSX.
Promotion checklist — add to Shade only if ALL are true
- Reused at least twice in different surfaces. Actual second use, not "we might reuse this."
- It's generic. A
<MembersTable>that's just<Table>with three preset columns belongs in the app, not Shade. - The shape has settled. Slots and composition have been stable across both local copies for at least one iteration.
- It has a generic name.
PageHeader,KpiCard,Filters— notMembersFilterBarorPostAnalyticsHero. - The API is slots, not props. 3–6 named subcomponents (
.Title,.Actions,.Body) — not<ListPage title="..." onAdd={...} columns={...} />. - State stays with the consumer. No
useQuery, no routing, no app-context reads inside Shade.
Fail any of these? Keep it local. Build it again somewhere else first, then promote.
Common misclassifications
| Tempting | Actually | Why |
|---|---|---|
Add a variant="kpi" to Card |
New Pattern KpiCard |
Product-specific shape, generic Component shouldn't know about it |
Add <MembersFilterBar> to patterns |
Keep local in apps/admin/src/settings |
Single-surface name |
| Add a one-off class string as a recipe | Inline it in the one component | Recipes are for shared rules across ≥ 2 components |
Add a useQuery-driven <MembersList> to patterns |
Keep local — patterns are state-free | Bring-your-own state |
Wrap a <div className="flex gap-3"> as a new primitive |
Use Inline gap="md" |
Already covered |
When you've decided
- New Component or Pattern → also see the
shade-new-componentskill for the file/story/state checklist. - New Recipe → no JSX, follow the
recipeClasses+recipe()shape (seeapps/shade/src/components/ui/input-surface.ts). - New Token → add to
apps/shade/theme-variables.css(semantic + dark-mode override) orapps/shade/tailwind.theme.css(raw@theme). Never an ad-hoc CSS variable in a component file.
Source of truth
Storybook → Overview / Layers owns the layer model and promotion rules. Overview / Contributing owns the implementation requirements.