jwh
Radagast / Interface field notesDesign & code

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.

Explore the componentsLocal reference npm run storybook
Fig. 01Cross section
Old growth, new tools.R / 01

Parchment 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.

Mystical

A small mark, an unusual silhouette, a trace of light. Enough to suggest a story.

Wizard

A field book, not a spell effect. Engraved titles, marginal notes, carefully drawn sigils.

Forest

Warm paper, forest ink, moss and amber. After dark, green-black and subdued spore-light.

Futuristic

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.

Verdant · light
verdant · light

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.

cleanbenevolentceremonial
Vitality
Umbral · dark
umbral · dark

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.

coldwatchfulcorrupted
Vitality

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.

Theme controlVerdant ⇄ Umbral
primaryaccent
Mana
Corruption

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.

Verdant · light
--rdg-canvas
--rdg-canvas-subtle
--rdg-surface
--rdg-surface-hover
--rdg-surface-raised
--rdg-surface-overlay
Umbral · dark
--rdg-canvas
--rdg-canvas-subtle
--rdg-surface
--rdg-surface-hover
--rdg-surface-raised
--rdg-surface-overlay

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.

Cinzel--rdg-font-display

Radagast

Hero titles, page titles, the wordmark. Ceremonial, engraved. Sparingly — never below 1.5rem, never for body.

Space Grotesk--rdg-font-sans

The workhorse typeface

Controls and compact UI headings. Geometric, precise, slightly odd.

Newsreader--rdg-font-serif

Old growth and deep magic

Editorial ledes, section and folio titles, and long-form prose. The field-book voice.

JetBrains Mono--rdg-font-mono

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

xsRadagast reads at any size
smRadagast reads at any size
baseRadagast reads at any size
lgRadagast reads at any size
xlRadagast reads at any size
2xlRadagast reads at any size
3xlRadagast reads at any size
4xlRadagast reads at any size
5xlRadagast reads at any size
6xlRadagast reads at any size

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.

solidneutralprimaryaccentarcanedangerwarningsuccess
subtleneutralprimaryaccentarcanedangerwarningsuccess
outlineneutralprimaryaccentarcanedangerwarningsuccess
runeneutralprimaryaccentarcanedangerwarningsuccess
dismissibleremovable

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.

new

CardContent goes here.

RBAvatar
Surface
16:9

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.

60

primitives/display

Feedback & data

Progress, status, tabular data, and code.

x
x
K

Inline bg-primary reference.

Three familiars, live.
NameRoleStatus
OwlScoutactive
FoxCourierresting
HedgehogGuardidle
terminal
npm install @radagast/ui

primitives/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.

avoid
bad.tsx
<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.

correct
good.tsx
<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.

app/example/page.tsx
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 guard
scripts/build.mjsper-file ESM build — `"use client"` survives on exactly the files that carry it
src/index.tsthe public barrel — the only thing consumers can import
src/styles/primitives → semantic → theme → motion, in that order
src/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 ships

Rules

What extraction depended on

Enforced by review, not tooling — breaking any of these would have broken the extraction story.

Peer dependencies only

react, react-dom, @base-ui/react, lucide-react. The package declares them as peers; the app provides them.

No Next.js imports

No next/link, next/image, next/navigation inside the package. Anything that links takes a render prop instead.

Everything public is barrelled

If a component isn't re-exported from src/index.ts, the app can't reach it — and after extraction, neither can anyone else.

Go deeper

Every component has a live story, and the full contract is written down in the package repo.

npm run ds:storybookRead the docs

Storybook

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.

The Storybook is served from its own deployment — it needs a domain root of its own, so it is framed rather than hosted under a subpath.Open in a new tab