A design system, in the open
Radagast.
A little forest.
A little precision.
Paper, ink, and a few carefully placed spells. The components behind this site, with two distinct themes and all the working parts left open to inspection.
npm run storybookParchment by day. Spore-light after dark.
The brief
Why a wizard who lives in the trees
Radagast the Brown kept to the forests and spoke with the beasts — of the five wizards sent to Middle-earth, he is the one who never wanted a tower. That is the character this system is named for, and it is also the whole design brief in one image: old growth and deep magic, rendered with a hard futuristic edge. Four words drove every decision from there — mystical, wizard, forest, futuristic — and two of them pull in opposite directions on purpose.
The useful reference is a naturalist’s field book: warm pages, fine dividing rules, a specimen drawn with care. Serif headings give the content room to breathe; monospaced labels keep the working parts legible. The forest comes through in the materials, not a layer of effects. Glow and motion are still in the toolkit, but quiet is the default. A button changes colour when you point at it. A card stays on the page.
A small mark, an unusual silhouette, a trace of light. Enough to suggest a story.
A field book, not a spell effect. Engraved titles, marginal notes, carefully drawn sigils.
Warm paper, forest ink, moss and amber. After dark, green-black and subdued spore-light.
Fine rules, measured spacing, clipped corners. Tools that feel precise in the hand.
The two themes
Not dark mode. A different wizard.
Radagast ships two complete, equally-designed themes. Dark mode here is not a darkened light mode — it's a different character with a different story, checked component-by-component in both.
The White Wizard
Dawn through the canopy of a sacred grove. Parchment and birch-silver surfaces, deep forest ink for text, moss green as the primary action colour and honey amber as the magic accent. Light falls from above and warms things.
The Grim Wizard
The same grove, blighted and at night. Near-black surfaces with a cold green cast, bioluminescent spore-teal as the primary and toxic blight-chartreuse as the accent, ember for heat and danger. Light comes from inside things, not the sky.
Building a second theme as a genuine design decision — rather than running the first one through a filter — is the single best idea in this system. It costs more upfront: every token needs a deliberate value in both directions, and nothing may ship checked in only one. What it buys back is a product that has two real moods instead of one mood and one compromise, and a token contract disciplined enough that adding a third theme later would mean writing values, not rewriting components.
Try it
One switch, two worlds
This toggle writes data-theme to the root <html> element through the real ThemeProvider — it isn't a page-local simulation. Everything below (and everything else on this site) re-themes instantly.
Same tokens, different world
primary and danger. The theme decides what those mean.Foundations
Every token, read live
Three layers — primitive ramps, semantic roles, Tailwind utilities — documented against the legibility floor from docs/00-OVERVIEW.md in the radagast-ui repo: body text ≥ 4.5:1, large/display text ≥ 3:1, primary body/headings ≥ 7:1. Colours are resolved to sRGB by painting them onto a canvas and reading the pixel back, so this works regardless of whether the browser reports a token in oklch(), lab(), or any other notation.
Foundations
Type
Four families loaded via next/font/google in app/layout.tsx and exposed as CSS variables — the package itself never loads a font, it only ever consumes one.
Radagast
Hero titles, page titles, the wordmark. Ceremonial, engraved. Sparingly — never below 1.5rem, never for body.
The workhorse typeface
Controls and compact UI headings. Geometric, precise, slightly odd.
Old growth and deep magic
Editorial ledes, section and folio titles, and long-form prose. The field-book voice.
--rdg-primary: oklch(0.52 0.13 152)
Code, keyboard keys, token values — everything in this page's own swatches.
Scale
Size, line-height, tracking — bundled
Each step pairs a size with a line-height and tracking. Display sizes get negative tracking; the eyebrow label gets deliberately wide positive tracking.
Signature
The rune-tracking eyebrow
A wide-letterspaced, small-caps label above a title — tracking-rune, 0.12em — is a recurring mark of the system. Every PageHeader and Section on this site uses it.
Foundations · Typography
The gallery
Every component, live
Primitives, thematic pieces, and patterns — the same three tiers as the package's src/. Nothing here is a screenshot.
primitives/core
Buttons & actions
Every variant, plus loading and disabled states.
Inline text with a TextLink in it.
primitives/core
Badges & tags
Four variants across all seven intents, plus a dismissible Tag.
primitives/core
Cards & surfaces
Four card variants, plus Surface, Avatar, AspectRatio, and Empty.
Default
Standard elevation.
A plain content plane.
Glass
Backdrop blur.
Translucent over ambient art.
Warded
Cut corner + engraved edge.
The featured treatment.
Sunken
Recessed well.
Sits below the surface.
With footer & action
Every card slot in one place.
CardContent goes here.
No spells cast yet
Empty state for lists, search results, and grimoires with nothing written in them.
primitives/core
Forms
Every control, all reachable by keyboard, all built on Base UI.
Shown on your grove profile.
primitives/display
Feedback & data
Progress, status, tabular data, and code.
Inline bg-primary reference.
| Name | Role | Status |
|---|---|---|
| Owl | Scout | active |
| Fox | Courier | resting |
| Hedgehog | Guard | idle |
info
success
warning
danger
arcane
MDX-friendly
npm install @radagast/uiprimitives/overlay
Overlays & navigation
Real triggers, real portals — Dialog, Sheet, Popover, DropdownMenu, Tooltip, Accordion, Breadcrumb, Pagination, ScrollArea.
Usage
Name a role, never a colour
This is the whole contract in one rule. It's what makes the two-theme system possible — a component that reaches past the token layer into a raw ramp cannot be re-themed.
<div className="bg-moss-600 text-white">
<p className="text-slate-200">Cast</p>
</div>Names a specific primitive ramp step and a Tailwind default grey. Locked to verdant, wrong in umbral, and invisible to the contrast floor entirely — nobody checked it.
<div className="bg-primary text-on-primary">
<p className="text-on-primary/80">Cast</p>
</div>Names roles. Resolves to moss in verdant and spore in umbral automatically, and text-on-primary is pre-checked to clear 4.5:1 against bg-primary in both.
The only files allowed to reference a primitive ramp (--rdg-moss-600) directly are semantic.css and the foundation stories that visualise the ramps — the token tables above are, quite literally, the one legitimate exception written down. Everything else, including this page, writes only the Tailwind utilities theme.css exposes: bg-surface, text-fg-muted, border-border, shadow-glow-md, tracking-rune.
Usage
Composing with next/link
The package can never import next/*, so anything that renders a link takes Base UI's render prop instead of asChild — the app supplies the routing element, the package supplies the styling.
import Link from "next/link";
import { Button } from "@radagast/ui";
<Button variant="arcane" nativeButton={false} render={<Link href="/projects/radagast" />}>
See the design system
</Button>Architecture
Built to leave — and it did
Radagast started as a folder in this repo. Every import already pointed at its future package name, so leaving required no import changes.
The app never imported from design-system/src/.... It imports from @radagast/ui — first resolved through a tsconfig.json path alias, and now a real package in its own repo. The day came, the alias was deleted, and the dependency took over the exact same specifier. No application import changes. That was the entire trick, and it is why nothing in app/ ever reached into the package by a relative or @/ path — this page included.
package.jsonname, exports, peerDeps — publish-ready, prepublishOnly guardscripts/build.mjsper-file ESM build — `"use client"` survives on exactly the files that carry itsrc/index.tsthe public barrel — the only thing consumers can importsrc/styles/primitives → semantic → theme → motion, in that ordersrc/primitives/the neutral, reusable layer — Button, Input, Dialog…src/thematic/the signature layer — RuneDivider, SporeField, Sigil…src/patterns/compositions only — Container, Section, PageHeader…dist/committed build output — what npm actually shipsRules
What extraction depended on
Enforced by review, not tooling — breaking any of these would have broken the extraction story.
react, react-dom, @base-ui/react, lucide-react. The package declares them as peers; the app provides them.
No next/link, next/image, next/navigation inside the package. Anything that links takes a render prop instead.
If a component isn't re-exported from src/index.ts, the app can't reach it — and after extraction, neither can anyone else.
Every component has a live story, and the full contract is written down in the package repo.
npm run ds:storybookRead the docsStorybook
The whole system, live
Every component in both themes, with its states, its stories, and its accessibility checks — the same reference implementation the package repo builds.