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-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 to a section (opt-in)
Scoping an override to a class keeps the rest of the app on defaults:
<section class="brand-rose">
<button class="ren-btn ren-btn-primary">Rose only</button>
</section>
<style>
.brand-rose {
--color-accent: #F43F5E;
--color-accent-hover: #E11D48;
--color-accent-subtle: #FFE4E6;
}
</style>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-weight: var(--weight-semibold);
--ren-card-radius: var(--radius-xl);
--ren-field-border: 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="rends/components/index.css">
<link rel="stylesheet" href="themes/my-brand.css">Example: "amber-editorial"
/* themes/amber-editorial.css */
: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-weight: var(--weight-semibold);
}
:root[data-density="compact"] { /* optional density tune */ }Drop-in
Ready-made themes
RenDS ships a small set of reference themes under rends/themes/. Each is a plain CSS file — link it after rends/index.css and you're done. They're also meant to be read: every one is short enough to understand end-to-end, and copying the structure is the fastest way to learn the token vocabulary.
themes/amber-editorial.css
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.
themes/cyber.css
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.
themes/minimal-mono.css
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.
- Override only semantic tokens. No component CSS is touched, so upgrades stay painless.
- Are safe to use as references — copy, rename, and edit rather than importing and re-overriding.
<link rel="stylesheet" href="rends/index.css">
<link rel="stylesheet" href="rends/components/index.css">
<link rel="stylesheet" href="rends/themes/amber-editorial.css">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
Because themes are just CSS custom properties, nothing stops you from running multiple themes on one page. The rules:
- Tokens cascade. A child element inherits from its ancestor unless overridden.
- Override at any ancestor. The scope is "from here down".
Per-section
<body>
<main>
<!-- Default theme -->
</main>
<aside class="promo-theme">
<!-- Rose accent within this aside -->
<button class="ren-btn ren-btn-primary">Upgrade</button>
</aside>
</body>Per-route (SPA)
Set data-theme or a class on the route container when routing:
document.getElementById('app').dataset.theme = routeConfig.theme;
// e.g. "dark", "light", "brand-b2b"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.