Theme System
Themes are set via data-st-theme on any ancestor element (usually <html>). Without it, prefers-color-scheme is respected automatically. Try the theme switcher in the header — it drives this exact attribute.
<html data-st-theme="dark"> <!-- dark theme -->
<html data-st-theme="dim"> <!-- dim theme -->
<html data-st-theme="light"> <!-- explicit light -->npx strata-css init locks you to light theme — on purpose, but easy to miss
data-st-theme="light" to your <html> tag if the attribute isn't already present. That silently overrides the prefers-color-scheme auto-detection described above — dark-mode users won't get a dark theme until you either remove that attribute or wire up switching yourself, below. init has no concept of a theme toggle; it only sets a static starting value.Switching themes in JS
document.documentElement.setAttribute('data-st-theme', 'dark')Preventing flash on load
Read the stored preference before hydration so the theme attribute is set before first paint:
<script>
const t = localStorage.getItem('theme')
if (t) document.documentElement.setAttribute('data-st-theme', t)
</script>This site's implementation
app/layout.tsx — an inline script runs in <head> before the body renders, and components/ThemeToggle.tsx writes to localStorage and the attribute on click.Tokens
Every value a theme could plausibly change lives in one place: a set of CSS custom properties (--st-*) declared under @layer st-base. Components never hardcode a color or a shadow — they read one of these variables instead. That's the entire trick behind theming: change what --st-primary points to, and every button, link and focus ring using it updates at once, with zero component-level code.
Not every token changes between themes, though — some are genuinely theme-aware (a different value per light/dark/dim), and some are just shared constants that happen to live in the same block. Both kinds are listed below, grouped by what they're for, with which is which called out per group — plus the actual default value each one ships with, so you know what you're changing before you change it.
Brand colors — theme-aware
The core palette. Each has a plain and a -hover variant, and every one of them takes a different value in dark and dim mode — that's most of what "dark mode" actually is, under the hood.
| Token | Light (default) | Dark | Dim | Used for |
|---|---|---|---|---|
--st-primary | #0d6efd | #6ea8fe | #90cdf4 | Your brand color — buttons, links, focus rings, active states. |
--st-primary-hover | #0b5ed7 | #8bb9fe | #a8d8f6 | |
--st-secondary | #6c757d | #a0a7ae | #9ca3af | The muted counterpart — outline buttons, less prominent actions. |
--st-secondary-hover | #5c636a | #b4bac0 | #b0b7be | |
--st-success | #198754 | #75b798 | #86efac | Positive states — success alerts, valid form fields. |
--st-success-hover | #157347 | #8ecbaf | #9af2bc | |
--st-danger | #dc3545 | #ea868f | #fca5a5 | Errors and destructive actions — delete buttons, invalid fields. |
--st-danger-hover | #bb2d3b | #f1a1a8 | #fdb7b7 | |
--st-warning | #ffc107 | #ffda6a | #fcd34d | Caution states — warning alerts and badges. |
--st-warning-hover | #ffca2c | #ffe083 | #fdda65 | |
--st-info | #0dcaf0 | #6edff6 | #67e8f9 | Neutral informational states — info alerts and badges. |
--st-info-hover | #31d2f2 | #88e5f7 | #80ecfa | |
--st-light | #f8f9fa | #f8f9fa | #374151 | A light neutral — text/icons that need to stay light even in dark mode (e.g. on a colored badge). |
--st-dark | #212529 | #adb5bd | #d1d5db | The dark counterpart to --st-light. |
Surfaces & text — theme-aware
The page's actual background and text colors — these are what make dark mode look dark, independent of the brand colors above.
| Token | Light (default) | Dark | Dim | Used for |
|---|---|---|---|---|
--st-bg | #ffffff | #212529 | #2d3748 | The page background — <body> reads this directly. |
--st-bg-secondary | #f8f9fa | #2b3035 | #374151 | A slightly offset surface — cards, code blocks, anything above the page background. |
--st-text | #212529 | #dee2e6 | #e2e8f0 | The default body text color. |
--st-text-muted | #6c757d | #8c959e | #9ca3af | Dimmer text — captions, helper text, anything secondary. |
--st-border | #dee2e6 | #495057 | #4a5568 | The default border color used across components. |
Two more live in this same group but don't change with theme — --st-border-radius (default 0.375rem) and --st-border-width (default 1px) control the shape of borders, not their color, so light/dark/dim all share the same value.
Typography — constant across all themes
| Token | Default | Used for |
|---|---|---|
--st-font-family | -apple-system, …, sans-serif | The default font stack for the whole page — the system font, not a webfont. |
--st-font-size-base | 1rem | The base body text size everything else scales from. |
--st-line-height-base | 1.5 | The default line height for body text. |
--st-font-weight-base | 400 | The default weight for body text. |
--st-heading-weight | 600 | The weight used for h1–h6. |
Shadows — theme-aware
Same three shadow sizes in every theme, but dark and dim use a heavier, more opaque shadow — a shadow tuned for a white background barely reads against a dark one.
| Token | Light (default) | Dark | Dim | Used for |
|---|---|---|---|---|
--st-shadow-sm | 0 0.125rem 0.25rem rgba(0,0,0,.075) | rgba(0,0,0,.3) | rgba(0,0,0,.25) | A subtle shadow — small elements, inputs. |
--st-shadow | 0 0.5rem 1rem rgba(0,0,0,.15) | rgba(0,0,0,.5) | rgba(0,0,0,.4) | The default shadow — cards, dropdowns. |
--st-shadow-lg | 0 1rem 3rem rgba(0,0,0,.175) | rgba(0,0,0,.6) | rgba(0,0,0,.5) | A pronounced shadow — modals, popovers, anything that should feel like it's floating. |
The dark/dim columns above show only the opacity, not the full offset/blur — those stay identical to the light value in every theme; only how dark and opaque the shadow itself is changes.
Transitions — constant across all themes
Speeds and easing curves for animation. -theme variants exist specifically for the color/background swap when the theme itself changes, kept separate so you can tune "how fast does the whole page fade to dark" independently of every other transition on the site.
| Token | Default | Used for |
|---|---|---|
--st-duration | 200ms | The default transition duration. |
--st-duration-fast | 100ms | Quicker alternative. |
--st-duration-slow | 400ms | Slower alternative. |
--st-duration-theme | 150ms | How long a theme switch itself takes to fade in. |
--st-easing | cubic-bezier(.4,0,.2,1) | The default easing curve. |
--st-easing-in | cubic-bezier(.4,0,1,1) | Ease-in variant. |
--st-easing-out | cubic-bezier(0,0,.2,1) | Ease-out variant. |
--st-easing-theme | cubic-bezier(.22,1,.36,1) | The easing curve used specifically for theme-switch transitions. |
--st-transition | all var(--st-duration) var(--st-easing) | Ready-made shorthand built from the tokens above. |
--st-transition-fast | all var(--st-duration-fast) var(--st-easing) | The quicker version of the same shorthand. |
Z-index scale — constant across all themes
A shared stacking order so a dropdown, a sticky header and a modal never fight for the same layer by accident.
| Token | Default |
|---|---|
--st-z-dropdown | 1000 |
--st-z-sticky | 1020 |
--st-z-fixed | 1030 |
--st-z-modal-backdrop | 1040 |
--st-z-modal | 1050 |
--st-z-tooltip | 1060 |
--st-z-toast | 1070 |
Focus ring — theme-aware
| Token | Light (default) | Dark | Dim |
|---|---|---|---|
--st-focus-ring | 0 0 0 .25rem rgba(13,110,253,.25) | rgba(110,168,254,.25) | rgba(144,205,244,.25) |
The box-shadow every interactive element gets on keyboard focus (:focus-visible). It's tinted to match --st-primary in each theme, so it stays visible without ever clashing.
Skeleton loader
Used by the shimmer loading placeholder (data-st-skeleton). The colors are theme-aware, timing and shape aren't:
--st-skeleton-base | theme-aware | #e2e8f0 light · #2d3748 dark · #374151 dim | The shimmer's resting background color. |
--st-skeleton-shine | theme-aware | #f8fafc light · #4a5568 dark · #4b5563 dim | The lighter color the shine sweep animates through. |
--st-skeleton-duration | constant | 1.5s | How long one shimmer sweep takes. |
--st-skeleton-radius | constant | 4px | Corner rounding for skeleton bars — separate from --st-border-radius so you can tune it independently. |
Spacing — constant across all themes
| Token | Default | Used for |
|---|---|---|
--st-gutter-x | 1.5rem | The default column gutter width for the grid system. |
--st-gutter-y | 0 | The default column gutter height (row gap) for the grid system. |
See the spacing utilities for the rest of the spacing scale, which is applied directly as utility values rather than through tokens.
Not covered here: per-component override tokens
@layer st-base and is what actually changes when the theme switches. A separate set — --st-btn-color, --st-badge-color, --st-table-dark-bg, --st-tooltip-bg, --st-nav-pills-active-color, and about a dozen more — lives inside individual component rules instead. Those don't vary by theme; they're customization hooks for one button, one card, one instance (.btn-primary { --st-btn-bg: gold; }), not part of the theme system itself.