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

  1. 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.
  2. Two Token Palettes, One Base: The app shell uses the shadcn semantic palette in OKLCH (--background, --card, --primary, …), switchable via the .dark class. 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.
  3. Zero-Runtime Styling: All styles map to CSS custom properties (var(--primary), var(--radius)). No JavaScript runtime overhead or CSS-in-JS style injection.
  4. Decoupled & External Ready: Packaged as an ESM module with deep import paths (@celestia-project/ui/primitive/button) and strict TypeScript types.
  5. Accessible By Default: The global stylesheet honours prefers-reduced-motion, sets color-scheme per 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.css instead. 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 by pnpm audit:ui.
  • Pair every surface with a foreground. A surface token without a matching -foreground token 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

bash
pnpm add @celestia-project/ui @phosphor-icons/react @base-ui/react# ornpm install @celestia-project/ui @phosphor-icons/react @base-ui/react

Peer 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):

tsx
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):

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):

tsx
// 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:

tsx
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

SubpathResolves ToDescription
@celestia-project/uisrc/index.tsComplete barrel export of all 150+ components
@celestia-project/ui/globals.csssrc/styles/globals.cssCSS tokens & root styles
@celestia-project/ui/primitive/*src/components/primitive/*.tsxSingle-purpose primitive component
@celestia-project/ui/composite/*src/components/composite/*.tsxComposite assembled from primitives
@celestia-project/ui/aisrc/components/ai/index.tsBarrel export of all AI components
@celestia-project/ui/components/ai/*src/components/ai/*.tsxAI chat & agent components
@celestia-project/ui/lib/utilssrc/lib/utils.tsClassname merging helper (cn)
@celestia-project/ui/hooks/*src/hooks/*.tsShared React hooks (e.g. useIsMobile)

Token Reference

Semantic colours (app shell, OKLCH)

Token VariableTailwind UtilityLight ValueDark Value
--backgroundbg-backgroundoklch(1 0 0)oklch(0.145 0 0)
--foregroundtext-foregroundoklch(0.145 0 0)oklch(0.985 0 0)
--card / --popoverbg-card / bg-popoveroklch(1 0 0)oklch(0.205 0 0)
--primarybg-primaryoklch(0.55 0.22 27)oklch(0.68 0.22 27)
--primary-foregroundtext-primary-foregroundoklch(0.985 0 0)oklch(0.145 0 0)
--secondary / --mutedbg-secondary / bg-mutedoklch(0.97 0 0)oklch(0.269 0 0)
--muted-foregroundtext-muted-foregroundoklch(0.54 0 0)oklch(0.708 0 0)
--destructivebg-destructiveoklch(0.577 0.245 27.325)oklch(0.704 0.191 22.216)
--destructive-foregroundtext-destructive-foregroundoklch(1 0 0)oklch(0.205 0 0)
--borderborder-borderoklch(0.922 0 0)oklch(1 0 0 / 10%)
--inputbg-inputoklch(0.922 0 0)oklch(1 0 0 / 15%)
--ringring-ringoklch(0.55 0.22 27)oklch(0.68 0.22 27)
--overlaybg-overlayoklch(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.

TokenUtilityLightDark
--successtext-success / bg-successoklch(0.5 0.16 150)oklch(0.7 0.16 150)
--success-foregroundtext-success-foregroundoklch(1 0 0)oklch(0.205 0 0)
--warningtext-warning / bg-warningoklch(0.5 0.17 75)oklch(0.7 0.17 75)
--warning-foregroundtext-warning-foregroundoklch(1 0 0)oklch(0.205 0 0)
--infotext-info / bg-infooklch(0.5 0.19 250)oklch(0.7 0.19 250)
--info-foregroundtext-info-foregroundoklch(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:

tsx
// 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 VariableTailwind UtilityValueRole
--brandbg-brand / text-brand / border-brandhsl(0 72% 51%) ≈ #dc2626Accent, links, dots, hover borders
--brand-deepbg-brand-deep / text-brand-deephsl(350 78% 40%) ≈ #b51230Gradient end, pressed states
--brand-foregroundtext-brand-foregroundhsl(0 0% 100%) ≈ #ffffffText/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 VariableTailwind UtilityValueRole
--bgbg-bghsl(0 0% 4%) ≈ #0a0a0aPage background
--surfacebg-surfacehsl(0 0% 8%) ≈ #141414Cards, raised panels
--texttext-text-primaryhsl(0 0% 96%) ≈ #f5f5f5Headings, primary copy
--fogtext-foghsl(0 0% 53%) ≈ #878787Secondary / muted copy
--strokeborder-strokehsl(0 0% 15%) ≈ #262626Borders, 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.

PageThemeTokens to use
/ (home)Always dark, via <div className="landing dark">Landing palette + brand
/feature-installerAlways dark, via <main className="dark bg-bg text-text-primary">Landing palette + brand
/docs, /web-components, /componentsFollows the user's themeSemantic (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/Directory Structure
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.tsx

Three conventions this file follows, all of them load-bearing:

  1. Colours are written as hsl(var(--token)), not var(--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, --stroke and --brand are always present.
  2. Layout primitives are scoped under .landing (.landing .container, .landing .stack-lg, .landing .grid-auto, …). Unscoped, .container and .grow would silently override Tailwind's own utilities of the same name on every page that loads this stylesheet.
  3. 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:

css
.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):

UtilityTokenResolves to
rounded-xscalc(var(--radius) - 6px)4px
rounded-smcalc(var(--radius) - 4px)6px
rounded-mdcalc(var(--radius) - 2px)8px
rounded-lgvar(--radius)10px
rounded-xlcalc(var(--radius) + 4px)14px
rounded-2xlcalc(var(--radius) + 8px)18px
rounded-3xlcalc(var(--radius) + 12px)22px
rounded-4xlcalc(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.

UtilityTokenNotes
shadow-3d0 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-primary0 2px 0 0 var(--primary)Default button variant
shadow-destructive-3d0 2px 0 0 color-mix(in oklch, var(--destructive), black 30%)Destructive button variant

Motion

TokenValueUse
--ease-outcubic-bezier(0.23, 1, 0.32, 1)Entrances, reveals
--ease-in-outcubic-bezier(0.77, 0, 0.175, 1)Two-way transitions
--ease-drawercubic-bezier(0.32, 0.72, 0, 1)Sheet / drawer / swipe
--transition-duration-instant80msState feedback
--transition-duration-fast150msHover, colour changes
--transition-duration-normal220msDefault
--transition-duration-slow320msOverlays
--transition-duration-slower500msLarge 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:

tsx
<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:

tsx
<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

bash
pnpm audit:ui                 # all static checks, one passnode scripts/ui-audit/run.mjs --verbose
CheckCatches
contrast.mjsToken colour pairs below threshold, in both themes. Reads the values out of globals.css, so it cannot drift from them.
token-parity.mjsA token declared in :root but not .dark (silently inherits the light value), and a --font-* self-reference.
compile-tokens.mjsCustom-property cycles, and a token namespace that generates no utility (wrong namespace, or declared in :root instead of @theme).
focus-rings.mjsA diluted or wrong-width focus ring.
feature-drift.mjsA 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: reduce collapses every animation and transition to 0.01ms (not none, so animationend / transitionend still fire for Base UI popups).
  • color-scheme is set per theme, so native scrollbars and form controls match.
  • ::selection uses --selection-bg.
  • h1–h3 get text-wrap: balance, p gets text-wrap: pretty.
  • Focus rings are visible by default; there is no outline: none without 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.