Guides
Theming
A theme in RenDS is not a separate stylesheet you load — it's a set of CSS custom properties you set. Change three semantic tokens and your whole app retints. This guide walks through how, when, and how far.
Automatic
Dark mode, for free
RenDS uses color-scheme: light dark at the root and the CSS light-dark() function inside semantic tokens. That means:
- The page respects the user's OS preference by default. No JS, no class toggles.
- Form controls, scrollbars, and native dialogs get dark styling too.
- All semantic tokens resolve to the right value per mode automatically.
Under the hood a semantic color looks like this:
--color-text: light-dark(var(--black), var(--white));
--color-surface: light-dark(var(--white), var(--black));
--color-border: light-dark(var(--gray-200), var(--gray-900));Force a specific mode
If you need to override the OS preference (for a per-user setting, for example), set data-theme on <html>:
<html data-theme="dark"> ... </html>
<html data-theme="light"> ... </html>Toggle at runtime
function toggleTheme() {
const html = document.documentElement;
const current = html.getAttribute('data-theme') || 'light';
html.setAttribute('data-theme', current === 'light' ? 'dark' : 'light');
}Don't write separate dark-mode CSS. If you're tempted to add @media (prefers-color-scheme: dark) { ... }, you're probably hardcoding a color. Use a semantic token instead.
Attributes
Data attributes
RenDS supports three top-level data attributes on <html> that shift the whole system at once:
data-theme
Values: light, dark. Forces color mode regardless of OS preference.
data-density
Values: comfortable (default), compact, spacious. Scales spacing and touch targets up or down by adjusting --size-sm/-md/-lg/-xl and --space-unit.
<html data-density="compact"> ... </html>data-shape
Values: sharp, rounded (default), pill. Scales border-radius across all components.
<html data-shape="pill"> ... </html>These attributes can combine freely:
<html data-theme="dark" data-density="compact" data-shape="sharp">Try them live
Toggle data-density and data-shape on the whole page. The components below — and the rest of this site — update in real time. Refreshing resets to the defaults.
Card title
Watch the radius and padding change.
Customization
Three levels of override
Every theming change you'll ever make in RenDS fits one of three levels. Pick the narrowest one that does the job.
Level 1 — Override a semantic token (widest reach)
Change the meaning of accent everywhere, instantly:
:root {
--color-accent: #8B5CF6; /* violet */
--color-accent-hover: #7C3AED;
--color-accent-active: #6D28D9;
--color-accent-subtle: #EDE9FE;
}Every button, link, focus ring, selected tab, and progress bar switches together. One block of CSS.
Level 2 — Override a component token (component-wide)
Change how all buttons look without touching button CSS:
:root {
--ren-btn-radius: var(--radius-full);
--ren-btn-padding-x: var(--space-5);
--ren-btn-font-weight: var(--weight-semibold);
}Doesn't touch cards, tags, dialogs — only buttons. Use this when the design system's baseline is close but not quite right.
Level 3 — Scope component tokens to a section
Set the component token itself on a wrapping class to keep the rest of the app on defaults:
<section class="rounded-actions">
<button class="ren-btn ren-btn-primary">Rounded only</button>
</section>
<style>
.rounded-actions {
--ren-btn-radius: var(--radius-full);
}
</style>For semantic or page-wide themes, apply overrides on :root. A class-only --color-accent override does not necessarily update inherited component aliases such as --ren-btn-bg.
Why this works without !important: RenDS uses CSS Cascade Layers. Any CSS you write outside a layer automatically wins over everything inside @layer components. You never fight specificity. See Foundations · Cascade Layers for the full layer order, integration recipes for legacy CSS / other design systems, and anti-patterns.
Step by step
Build a theme from scratch
A "theme" in RenDS is typically 10–30 lines of CSS. Here's the canonical build order.
Step 1 — Pick your accent
Your brand color. This is the single biggest identity lever. Define all four accent variants so hover, active, and subtle states work:
:root {
--color-accent: #F59E0B; /* amber-500 */
--color-accent-hover: #D97706; /* amber-600 */
--color-accent-active: #B45309; /* amber-700 */
--color-accent-subtle: #FEF3C7; /* amber-100 */
--color-on-accent: #FFFFFF; /* text on solid accent */
}Step 2 — Pick density and shape
<html data-density="compact" data-shape="rounded">Step 3 — Pick typography
Change the font family tokens if the defaults (system-ui) aren't enough:
:root {
--font-sans: "Inter", system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
--font-heading: "Fraunces", Georgia, serif; /* optional */
}Step 4 — Optional: component-level tweaks
:root {
--ren-btn-font-weight: var(--weight-semibold);
--ren-card-radius: var(--radius-xl);
--ren-field-border-color: var(--color-border-strong);
}That's the whole theme
Save it as themes/my-brand.css, link it after rends/index.css, and you're done:
<link rel="stylesheet" href="rends/index.css">
<link rel="stylesheet" href="themes/my-brand.css">Example: custom brand file
/* themes/my-brand.css — create this file yourself */
:root {
--color-accent: #F59E0B;
--color-accent-hover: #D97706;
--color-accent-active: #B45309;
--color-accent-subtle: #FEF3C7;
--color-on-accent: #FFFFFF;
--font-sans: "Inter", system-ui, sans-serif;
--font-heading: "Fraunces", Georgia, serif;
--ren-card-radius: var(--radius-xl);
--ren-btn-font-weight: var(--weight-semibold);
}
:root[data-density="compact"] { /* optional density tune */ }Drop-in
Ready-made themes
All built-in presets ship together in rends/themes/appearance.css. Load that stylesheet after the foundation, then select a preset with data-theme on <html>. The CLI-generated rends/index.css already imports it; npm and manual-copy consumers opt in explicitly.
data-theme="amber-editorial"
Warm editorial palette on a Fraunces display face. Amber accent, generous card radius, comfortable density. Pairs well with long-form reading UIs, marketing pages, and newsletter dashboards.
data-theme="cyber"
High-contrast neon on near-black surfaces. Cyan/magenta accent pair, mono-first typography, sharp corners. Tuned for ops consoles, terminal-adjacent dashboards, and anything that wants to feel like a deck readout. Light and dark both ship — the light variant is a "daybreak" shift rather than a pure inversion, so contrast holds.
data-theme="minimal-mono"
A monochrome baseline: one gray ramp, one accent reserved for actions. Zero decoration. Best as a starting point for a product theme (fork and add an accent) or for screenshots and docs where color would distract.
All three themes:
- Stay AA-compliant in both light and dark modes — contrast was audited before merge.
- Set design tokens for color, type, shape, and selected component defaults without replacing component CSS.
- Can be studied in
appearance.css; copy a preset block into your own theme file when customizing it.
<html data-theme="amber-editorial">
<head>
<link rel="stylesheet" href="rends/index.css">
<!-- Omit this extra link with the CLI-generated index.css. -->
<link rel="stylesheet" href="rends/themes/appearance.css">
</head>
</html>Tool
Use the Theme Builder
If you'd rather not hand-write tokens, use Create — the built-in visual theme builder. It has pickers for base color, theme tint, chart palette, heading and body fonts, icon library, radius, density, and light/dark mode.
- Pick your combination from the sidebar.
- Preview updates live in the canvas.
- Click Templates to start from one of 8 curated starters (Minimal Zinc, Linear Indigo, Vercel Geist, Apple Blue, Editorial Rose, Forest Emerald, Playful Violet, Amber Mono).
- Click Generate to hand it a single hex and let it build an AA-safe palette around it (see next section).
- Export as CSS, JSON, or ZIP — or hit Share for a URL that re-creates your exact theme.
The Builder is especially useful when you want to explore combinations you wouldn't have thought to try. The Shuffle button rerolls every picker at once.
Tool
Generate a palette from a single hex
You have a brand color. You want a full token set around it that clears WCAG contrast requirements in both light and dark mode. That's what the Generate tab in Create does.
Hand it one hex — say #F59E0B — and it returns:
- An 11-step tonal scale (50–950) derived from your hex in OKLCH space, so lightness progresses evenly rather than fighting sRGB's gamma curve.
- Light-mode and dark-mode
--color-accent/-hover/-active/-subtle/-on-accentpicked from the scale so each pair clears contrast targets. - A contrast audit listing every pair (accent on surface, on-accent on accent, subtle-on-subtle, etc.) with the measured ratio and pass/fail.
- A ready-to-paste
[data-theme]block exporting the full token set.
Pick an output and either Apply the theme to the Builder (then refine with the other pickers) or Copy CSS and paste it into your project. No backend call; it runs entirely in the browser.
AA or AAA?
The modal has a Target toggle so you can pick which conformance level the generator should optimize for:
- AA — 4.5:1 for normal text, 3:1 for non-text UI (focus rings, icon-only buttons). The safe default for product work.
- AAA — 7:1 for text, 4.5:1 for non-text. Use when you're serving readers with low vision, older audiences, or editorial long-form content that will be read for minutes rather than seconds.
Switching the toggle re-runs the analyzer immediately. For most hues AAA just walks the accent one or two steps darker (light mode) or lighter (dark mode). For saturated yellows, oranges, and neon greens, AAA may push you all the way to step 900 on white — which reads more like a "brand-tinted text" than a brand color, and is a signal that AAA on that exact hue isn't practical without shifting the hue itself. When no step in the scale can reach the target, the generator falls back to the terminal step and flags the shortfall in the audit in amber so you see the tradeoff instead of a silent fail.
It's a tool, not a mandate. If the generator's dark-mode accent looks too muted for your brand, override it. Generated palettes are a safe baseline — you're free to deviate once you know the contrast math is under control.
Same algorithm, no UI
If you want the palette without opening Create, the generator is also a plain ES module at rends/themes/theme-generator.js:
import { generateTheme } from './rends/themes/theme-generator.js';
// AA by default.
const theme = generateTheme('#F59E0B');
console.log(theme.css); // CSS block ready to inject
console.log(theme.light); // { accent, accentHover, ..., onAccent }
console.log(theme.dark);
console.log(theme.report); // { passes: [...], warnings: [...], level }
// AAA targets 7:1 text / 4.5:1 non-text instead.
const strict = generateTheme('#F59E0B', { level: 'AAA' });
console.log(strict.level); // 'AAA'
console.log(strict.report.warnings); // shortfalls flagged per-pairUseful at build time (emit a theme file from a single config value) or in Storybook (let design pick a hex and see the palette rebuild). The level option accepts the strings 'AA' or 'AAA'; anything else normalizes to AA.
Advanced
Multi-theme apps
Use the document root for page-wide semantic themes. Direct component-token overrides can be scoped to a section, but inherited aliases are resolved where they are declared:
- Semantic aliases are declared on
:root; component defaults are declared on:rootand[data-theme]. - Changing a semantic token on an arbitrary class does not automatically recompute every inherited alias.
- Nested semantic themes and nested light/dark switches need browser verification for every affected component; they are not covered by the root-theme guarantee.
Per-section component styling
For example, scope --ren-btn-radius directly:
<style>
.promo-theme { --ren-btn-radius: var(--radius-full); }
</style>
<body>
<main>
<!-- Default theme -->
</main>
<aside class="promo-theme">
<!-- Rounded primary buttons within this aside -->
<button class="ren-btn ren-btn-primary">Upgrade</button>
</aside>
</body>Per-route (SPA)
Update data-theme on the document root when routing. Load the matching preset or custom CSS first:
document.documentElement.dataset.theme = routeConfig.theme;
// e.g. "ocean", "amber-editorial", "minimal-mono"Per-user
Save preference to localStorage on change, apply on load:
// On app init
const saved = localStorage.getItem('theme');
if (saved) document.documentElement.dataset.theme = saved;
// On toggle
function setTheme(next) {
document.documentElement.dataset.theme = next;
localStorage.setItem('theme', next);
}Share
Distribute a theme
Themes are plain CSS files, so you have several options for sharing:
As a CSS file
Simplest. Commit themes/my-brand.css to your repo, link it after rends/index.css. Done.
As JSON
The Theme Builder exports a compact JSON with just the picker choices (not the resolved CSS). This is useful when another tool will consume and reshape it:
{
"rends": "theme",
"v": 1,
"baseColor": "zinc",
"theme": "violet",
"chart": "pink",
"headingFont": "outfit",
"bodyFont": "outfit",
"iconLib": "lucide",
"radius": "full",
"density": "default",
"mode": "light"
}As a shareable URL
The Theme Builder can encode the entire state into a URL hash. Zero backend required:
https://your-site.com/create#t=eyJyZW5kcyI6InRoZW1lIiwidiI6MSwi...Opening that URL restores the exact picker state. Great for design reviews, Slack threads, or git commits ("here's the theme I'm proposing: <link>").
Hash format and versioning
The string after #t= is a base64url-encoded JSON payload:
{
"rends": "theme", // marker — decoder rejects if missing
"v": 1, // schema version — bumped when fields change
"baseColor": "Neutral",
"theme": { "name": "violet", "isBase": false },
"chart": { "name": "Lime", "isBase": true },
"font": "Inter",
"heading": "Inter",
"radius": "Rounded",
"density": "Comfortable",
"iconLib": "lucide",
"mode": "light"
}Compatibility rules:
- Newer build, older hash — works. Missing
vis treated as v1. Fields that no longer exist are ignored. - Older build, newer hash — best-effort. If
vis greater than the build knows about, the user sees a toast: "Theme was saved with a newer RenDS — some fields may not load." Pickers fall back to defaults for unknown fields. - Tampered or invalid hash — silently ignored; the builder opens with defaults.
If you build tooling on top of the hash format, pin to a known v and treat newer payloads conservatively. Version bumps are documented in CHANGELOG.md.
As a ZIP
The Builder's ZIP export bundles the resolved .css, the .json state, and a README.md with install instructions. Hand that to anyone — including non-technical teammates.
Keep going
Next
- Tokens reference — every semantic and component token in one place.
- Accessibility — don't break contrast when you theme.
- Theme Builder — the visual builder.
- Components — see every component.