Design System
Architecture, OKLCH design tokens, Base UI primitives, and guidelines for consuming the Celestia Design System in external projects
Design System
The Celestia Design System is a production-grade, accessible UI foundation packaged under @celestia-project/ui. It combines unstyled Base UI primitives with Tailwind CSS v4 design tokens defined in the OKLCH color space.
Architectural Principles
- Unstyled Primitives (Base UI): All interactive components (Dialog, DropdownMenu, Tabs, Tooltip, Select) inherit WAI-ARIA compliance, screen reader support, focus-trapping, and keyboard navigation directly from Base UI.
- Two Token Palettes, One Base: The app shell uses the shadcn semantic palette in OKLCH (
--background,--card,--primary, …), switchable via the.darkclass. The marketing/landing palette is forced-dark HSL (--bg,--surface,--text,--stroke). Both resolve their darkest surface to#0a0a0a, so a landing page and an app shell agree on their base. - Zero-Runtime Styling: All styles map to CSS custom properties (
var(--primary),var(--radius)). No JavaScript runtime overhead or CSS-in-JS style injection. - Decoupled & External Ready: Packaged as an ESM module with deep import paths (
@celestia-project/ui/primitive/button) and strict TypeScript types. - Accessible By Default: The global stylesheet honours
prefers-reduced-motion, setscolor-schemeper theme, and ships a single canonical focus ring that meets WCAG 2.4.11.
House Rules
- No colour literals in components. Add a token to
globals.cssinstead. The only exceptions are documentation previews that display raw values. - Never write
--x: var(--x). A self-reference is a CSS dependency cycle and silently resolves to nothing, breaking the utility that consumes it. - Elevation must increase:
--background<--card<--muted/--secondary. - Every focus ring is
focus-visible:ring-2 focus-visible:ring-ring— 2px at full strength. Diluted rings (ring-ring/30) fail WCAG 2.4.11. Enforced mechanically bypnpm audit:ui. - Pair every surface with a foreground. A surface token without a matching
-foregroundtoken is an accessibility bug waiting to happen.
Consuming in External Projects
You can install and use the design system in any Next.js, Vite, Remix, or React 19 application.
1. Installation
pnpm add @celestia-project/ui @phosphor-icons/react @base-ui/react# ornpm install @celestia-project/ui @phosphor-icons/react @base-ui/reactPeer Dependencies: Requires react@^19 and react-dom@^19.
2. Styles & Tailwind v4 Integration
Choose the integration method based on whether your application already configures Tailwind CSS v4:
Option A: Quickstart (Standalone or new projects)
In your application's root entry point (e.g. app/layout.tsx for Next.js App Router or src/main.tsx for Vite):
import "@celestia-project/ui/globals.css";This brings in Tailwind CSS v4, tw-animate-css, base styles, animations, and light/dark theme variables.
Option B: Existing Tailwind v4 Apps (@source directive)
If your app already has an existing Tailwind v4 setup and custom CSS variables, do not import @celestia-project/ui/globals.css directly to prevent style conflicts or theme variable overrides.
Instead, add the @source directive in your main CSS file (globals.css):
@import "tailwindcss";
/* Scan the UI package for utility classes */@source "../../node_modules/@celestia-project/ui";@source "../**";
@custom-variant dark (&:is(.dark *));
/* Map shadcn tokens to your CSS variables */@theme inline { --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); --color-card-foreground: var(--card-foreground); --color-popover: var(--popover); --color-popover-foreground: var(--popover-foreground); --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); --color-secondary: var(--secondary); --color-secondary-foreground: var(--secondary-foreground); --color-muted: var(--muted); --color-muted-foreground: var(--muted-foreground); --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); --color-destructive-foreground: var(--destructive-foreground); --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); --color-overlay: var(--overlay);
--radius-xs: calc(var(--radius) - 6px); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); --radius-2xl: calc(var(--radius) + 8px); --radius-3xl: calc(var(--radius) + 12px); --radius-4xl: calc(var(--radius) + 16px);
/* Fonts. `--font-sans-family` / `--font-mono-family` are the override hooks — point next/font (or any CSS) at those names, not at `--font-sans`. */ --font-sans: var(--font-sans-family, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif); --font-mono: var(--font-mono-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
/* Elevation — hard-edged "3D" lift for raised controls. */ --shadow-3d: 0 2px 0 0 var(--elevation-edge); --shadow-3d-primary: 0 2px 0 0 var(--primary); --shadow-destructive-3d: 0 2px 0 0 color-mix(in oklch, var(--destructive), black 30%);
/* Optional: Celestia landing tokens & motion */ --color-brand: hsl(var(--brand)); --color-brand-deep: hsl(var(--brand-deep)); --color-brand-foreground: hsl(var(--brand-foreground)); --color-bg: hsl(var(--bg, 0 0% 4%)); --color-surface: hsl(var(--surface, 0 0% 8%)); --color-text-primary: hsl(var(--text, 0 0% 96%)); --color-fog: hsl(var(--fog, 0 0% 53%)); --color-stroke: hsl(var(--stroke, 0 0% 15%));
--ease-out: cubic-bezier(0.23, 1, 0.32, 1); --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
/* Durations. The namespace MUST be `--transition-duration-*` and it MUST be inside `@theme` — Tailwind v4 has no `--duration-*` namespace, and a value declared in `:root` never becomes a utility. Getting either wrong produces no `duration-*` class at all, silently. */ --transition-duration-instant: 80ms; --transition-duration-fast: 150ms; --transition-duration-normal: 220ms; --transition-duration-slow: 320ms; --transition-duration-slower: 500ms;}
:root { --elevation-edge: rgb(0 0 0 / 15%);}
.dark { /* A light band, not a dark one. A darker edge cannot separate from a near-black page: the previous `rgb(0 0 0 / 50%)` rendered #050505 on a #0a0a0a page — 1.03:1, i.e. invisible. 18% white gives 1.64:1 against the page and 1.25:1 against a secondary-filled face. */ --elevation-edge: rgb(255 255 255 / 18%);}
/* Honour the user's motion preference for every token-driven animation. */@media (prefers-reduced-motion: reduce) { *, ::before, ::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; }}3. Setup Dark Mode & Theme
The design system uses standard class-based dark mode (.dark):
// Next.js App Router layout.tsxexport default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en" className="dark"> <body className="bg-background text-foreground antialiased min-h-screen"> {children} </body> </html> );}4. Component Usage
You can import components directly via the barrel export or use granular deep imports for minimal bundle size:
import { Button, Card, CardHeader, CardTitle, CardContent, Badge } from "@celestia-project/ui";import { SparkleIcon } from "@phosphor-icons/react";
export function WelcomeCard() { return ( <Card className="max-w-md"> <CardHeader> <div className="flex items-center justify-between"> <CardTitle>Celestia UI</CardTitle> <Badge variant="outline" className="gap-1 text-primary"> <SparkleIcon className="size-3" weight="fill" /> Ready </Badge> </div> </CardHeader> <CardContent className="space-y-4"> <p className="text-sm text-muted-foreground"> Zero-runtime design tokens coupled with accessible Base UI primitives. </p> <Button variant="default">Get Started</Button> </CardContent> </Card> );}Deep Imports & Tree-Shaking
| Subpath | Resolves To | Description |
|---|---|---|
@celestia-project/ui | src/index.ts | Complete barrel export of all 150+ components |
@celestia-project/ui/globals.css | src/styles/globals.css | CSS tokens & root styles |
@celestia-project/ui/primitive/* | src/components/primitive/*.tsx | Single-purpose primitive component |
@celestia-project/ui/composite/* | src/components/composite/*.tsx | Composite assembled from primitives |
@celestia-project/ui/ai | src/components/ai/index.ts | Barrel export of all AI components |
@celestia-project/ui/components/ai/* | src/components/ai/*.tsx | AI chat & agent components |
@celestia-project/ui/lib/utils | src/lib/utils.ts | Classname merging helper (cn) |
@celestia-project/ui/hooks/* | src/hooks/*.ts | Shared React hooks (e.g. useIsMobile) |
Token Reference
Semantic colours (app shell, OKLCH)
| Token Variable | Tailwind Utility | Light Value | Dark Value |
|---|---|---|---|
--background | bg-background | oklch(1 0 0) | oklch(0.145 0 0) |
--foreground | text-foreground | oklch(0.145 0 0) | oklch(0.985 0 0) |
--card / --popover | bg-card / bg-popover | oklch(1 0 0) | oklch(0.205 0 0) |
--primary | bg-primary | oklch(0.55 0.22 27) | oklch(0.68 0.22 27) |
--primary-foreground | text-primary-foreground | oklch(0.985 0 0) | oklch(0.145 0 0) |
--secondary / --muted | bg-secondary / bg-muted | oklch(0.97 0 0) | oklch(0.269 0 0) |
--muted-foreground | text-muted-foreground | oklch(0.54 0 0) | oklch(0.708 0 0) |
--destructive | bg-destructive | oklch(0.577 0.245 27.325) | oklch(0.704 0.191 22.216) |
--destructive-foreground | text-destructive-foreground | oklch(1 0 0) | oklch(0.205 0 0) |
--border | border-border | oklch(0.922 0 0) | oklch(1 0 0 / 10%) |
--input | bg-input | oklch(0.922 0 0) | oklch(1 0 0 / 15%) |
--ring | ring-ring | oklch(0.55 0.22 27) | oklch(0.68 0.22 27) |
--overlay | bg-overlay | oklch(0 0 0 / 70%) | oklch(0 0 0 / 70%) |
Elevation ordering. In dark mode the ramp must rise, and it does:
--background oklch(0.145) → --card oklch(0.205) → --muted oklch(0.269).
If you add a surface token, keep it on that ramp.
Contrast guarantees. --destructive-foreground is chosen per theme so a
filled destructive control clears WCAG AA at the 10–12px sizes the system uses:
white on #e7000b (light, 4.77:1) and oklch(0.205 0 0) on #ff6467 (dark,
6.21:1). Hover states clear it too.
Status colours
--success, --warning and --info exist so app code never reaches for raw
Tailwind palette literals. Every literal fails one theme or the other —
text-green-500 is 2.22:1 on white, text-amber-500 is 2.13:1, text-sky-500
is 2.71:1, while text-amber-900 is 2.53:1 on the dark base and
text-amber-200 is 1.24:1 on white. Since the app ships a theme switcher, a
literal is always broken for roughly half of its users.
| Token | Utility | Light | Dark |
|---|---|---|---|
--success | text-success / bg-success | oklch(0.5 0.16 150) | oklch(0.7 0.16 150) |
--success-foreground | text-success-foreground | oklch(1 0 0) | oklch(0.205 0 0) |
--warning | text-warning / bg-warning | oklch(0.5 0.17 75) | oklch(0.7 0.17 75) |
--warning-foreground | text-warning-foreground | oklch(1 0 0) | oklch(0.205 0 0) |
--info | text-info / bg-info | oklch(0.5 0.19 250) | oklch(0.7 0.19 250) |
--info-foreground | text-info-foreground | oklch(1 0 0) | oklch(0.205 0 0) |
A single lightness value cannot serve both themes: the light and dark
contrast curves cross at roughly 3.7:1 near L≈0.575, below the 4.5:1 AA floor.
That is why each hue is defined twice. The chosen values were verified at
4.98–7.89:1 against --background, --card, --muted and --secondary in
their own theme, and 5.43–7.15:1 for a -foreground on its solid fill.
Two usage shapes:
// Tinted surface — the text role keeps its AA rating on any background.<div className="rounded-sm border border-success/35 bg-success/[0.07] px-2.5 py-1.5 text-success" />
// Solid fill — pair with the `-foreground` token, never `text-white`.<div className="bg-success text-success-foreground" />Brand colours
The one chromatic family in the system. Previously hardcoded as hex literals.
| Token Variable | Tailwind Utility | Value | Role |
|---|---|---|---|
--brand | bg-brand / text-brand / border-brand | hsl(0 72% 51%) ≈ #dc2626 | Accent, links, dots, hover borders |
--brand-deep | bg-brand-deep / text-brand-deep | hsl(350 78% 40%) ≈ #b51230 | Gradient end, pressed states |
--brand-foreground | text-brand-foreground | hsl(0 0% 100%) ≈ #ffffff | Text/icons on a --brand fill |
Gradient: linear-gradient(90deg, hsl(var(--brand)) 0%, hsl(var(--brand-deep)) 100%) — defined as the .accent-gradient class in apps/web/app/landing.css (landing-scoped, not part of the package styles).
Landing palette (forced dark, HSL)
| Token Variable | Tailwind Utility | Value | Role |
|---|---|---|---|
--bg | bg-bg | hsl(0 0% 4%) ≈ #0a0a0a | Page background |
--surface | bg-surface | hsl(0 0% 8%) ≈ #141414 | Cards, raised panels |
--text | text-text-primary | hsl(0 0% 96%) ≈ #f5f5f5 | Headings, primary copy |
--fog | text-fog | hsl(0 0% 53%) ≈ #878787 | Secondary / muted copy |
--stroke | border-stroke | hsl(0 0% 15%) ≈ #262626 | Borders, dividers |
These are forced dark — literal HSL values that ignore the .dark class, so
they only belong on a page that has opted into dark explicitly.
| Page | Theme | Tokens to use |
|---|---|---|
/ (home) | Always dark, via <div className="landing dark"> | Landing palette + brand |
/feature-installer | Always dark, via <main className="dark bg-bg text-text-primary"> | Landing palette + brand |
/docs, /web-components, /components | Follows the user's theme | Semantic (bg-background, text-foreground, text-muted-foreground, border-border) |
Do not mix the two on one page. A single bg-bg bar inside an otherwise
theme-switchable page renders as a black stripe across a white page in light
mode — which is exactly what the landing nav did before it moved to
bg-background/85.
Landing surface layer
apps/web/app/landing.css carries the bespoke CSS the home page is built from.
It is deliberately plain CSS rather than utilities, for two reasons: the
compositions are long enough that a utility soup would be unreadable, and the
same class layer is shared with /feature-installer.
apps/web/
├── app/landing.css # the .landing-* class layer + layout primitives
└── components/landing/
├── content.ts # all copy, as typed data
├── icons.ts # name → Phosphor icon map
├── landing-nav.tsx # sticky nav + mobile Sheet
├── landing-hero.tsx # headline, actions, trust strip
├── command-bar.tsx # the hero's copy-to-clipboard command bar
├── landing-trace.tsx # the request-trace panel
├── landing-features.tsx # feature cards
├── landing-packages.tsx # workspace package cards
├── landing-lifecycle.tsx # feature-manager steps + ledger panel
├── landing-boundaries.tsx # boundary rules + check panel
├── landing-metrics.tsx # counted metrics strip
├── landing-cta.tsx
└── landing-footer.tsxThree conventions this file follows, all of them load-bearing:
- Colours are written as
hsl(var(--token)), notvar(--color-token). The--color-*names are Tailwind theme variables and Tailwind only emits the ones a utility references, so a plain stylesheet that depends on them breaks the day the last utility using them is deleted.--bg,--surface,--text,--fog,--strokeand--brandare always present. - Layout primitives are scoped under
.landing(.landing .container,.landing .stack-lg,.landing .grid-auto, …). Unscoped,.containerand.growwould silently override Tailwind's own utilities of the same name on every page that loads this stylesheet. - Theme-derived tokens are read with a fallback equal to their current value
—
var(--text-2xs, 11px),var(--radius-xl, 0.875rem)— so a build that drops an unreferenced theme variable degrades to the intended pixel value instead of to nothing.
The landing is composed from the same library components as the app
(Button, Badge, Separator, Progress, Kbd, Sheet, InputGroup*).
Where a component needs a size the library does not offer — the hero's
full-height command bar — the override targets the library's own
data-slot contract rather than passing a utility className:
.landing-ask [data-slot="input-group"] { height: 3.5rem; border-radius: var(--radius-xl, 0.875rem);}Radii Scale
All radii inherit from --radius (0.625rem / 10px):
| Utility | Token | Resolves to |
|---|---|---|
rounded-xs | calc(var(--radius) - 6px) | 4px |
rounded-sm | calc(var(--radius) - 4px) | 6px |
rounded-md | calc(var(--radius) - 2px) | 8px |
rounded-lg | var(--radius) | 10px |
rounded-xl | calc(var(--radius) + 4px) | 14px |
rounded-2xl | calc(var(--radius) + 8px) | 18px |
rounded-3xl | calc(var(--radius) + 12px) | 22px |
rounded-4xl | calc(var(--radius) + 16px) | 26px |
rounded-full | — | 9999px |
Elevation
Raised controls use a hard 2px edge rather than a blur, paired with
active:translate-y-[2px] active:shadow-none for the press effect.
| Utility | Token | Notes |
|---|---|---|
shadow-3d | 0 2px 0 0 var(--elevation-edge) | Edge flips per theme, and flips direction: 15% black on light, 18% white on dark. A dark band is invisible on a near-black page |
shadow-3d-primary | 0 2px 0 0 var(--primary) | Default button variant |
shadow-destructive-3d | 0 2px 0 0 color-mix(in oklch, var(--destructive), black 30%) | Destructive button variant |
Motion
| Token | Value | Use |
|---|---|---|
--ease-out | cubic-bezier(0.23, 1, 0.32, 1) | Entrances, reveals |
--ease-in-out | cubic-bezier(0.77, 0, 0.175, 1) | Two-way transitions |
--ease-drawer | cubic-bezier(0.32, 0.72, 0, 1) | Sheet / drawer / swipe |
--transition-duration-instant | 80ms | State feedback |
--transition-duration-fast | 150ms | Hover, colour changes |
--transition-duration-normal | 220ms | Default |
--transition-duration-slow | 320ms | Overlays |
--transition-duration-slower | 500ms | Large surfaces |
Durations are a closed scale — reach for a token instead of duration-347.
Because the theme block is @theme inline, these become the utilities
duration-instant … duration-slower directly:
<div className="transition-colors duration-fast ease-out" />The duration-[var(--transition-duration-fast)] form also resolves — @theme inline
still emits the variables into :root, it only inlines the value inside the utility —
but the bare utility is shorter and is what the components use.
Focus ring
One canonical treatment across the whole system:
<button className="focus-visible:ring-2 focus-visible:ring-ring" />2px at full strength. WCAG 2.4.11 requires a focus indicator of at least 3:1
against adjacent colours; --ring is picked to clear that in both themes
(4.88:1 on white, 4.18:1 on the dark base). Do not dilute it with
ring-ring/30 — that drops the indicator to 1.48:1 (light) and 1.38:1
(dark), below the floor in both themes. ring-ring/50 reaches only 1.98:1.
A focus-within ring on a wrapper (InputGroup, Combobox, Attachment) is
subject to the same rule: the trigger may differ, the dilution may not.
This is enforced, not just documented. pnpm audit:ui runs
scripts/ui-audit/focus-rings.mjs, which fails on any diluted ring in a
focus-visible / focus-within / data-[active] / data-[focused] context,
and on any width other than 2px — ring-0 is allowed, for an input that defers
to an ancestor owning the ring.
One deliberate exception: aria-invalid:ring-destructive/20 stays diluted. The
same class list sets a full-strength border-destructive, so the border carries
the invalid signal and the ring is decoration. The guard warns rather than fails.
Verification
pnpm audit:ui # all static checks, one passnode scripts/ui-audit/run.mjs --verbose| Check | Catches |
|---|---|
contrast.mjs | Token colour pairs below threshold, in both themes. Reads the values out of globals.css, so it cannot drift from them. |
token-parity.mjs | A token declared in :root but not .dark (silently inherits the light value), and a --font-* self-reference. |
compile-tokens.mjs | Custom-property cycles, and a token namespace that generates no utility (wrong namespace, or declared in :root instead of @theme). |
focus-rings.mjs | A diluted or wrong-width focus ring. |
feature-drift.mjs | A feature package writing a new file into packages/ui/ without a barrel export — dead code inside a published package. |
Each check carries a control that must produce the opposite result, so a
detector that matches too loosely cannot pass everything: compile-tokens.mjs
asserts a class that must be absent, and contrast.mjs runs a known-good pair
first and aborts if it fails.
Accessibility baseline
The global stylesheet handles these, so components do not have to:
prefers-reduced-motion: reducecollapses every animation and transition to0.01ms(notnone, soanimationend/transitionendstill fire for Base UI popups).color-schemeis set per theme, so native scrollbars and form controls match.::selectionuses--selection-bg.h1–h3gettext-wrap: balance,pgetstext-wrap: pretty.- Focus rings are visible by default; there is no
outline: nonewithout a replacement.
Interactive Portals
- Web Components: Live components showcase with search and category navigation.
- UI Showcase: Catalog of 126 components with live code demos, copyable snippets, and interactive states.