Theming & branding cascivo
cascivo is a token-driven design system: every component reads CSS custom
properties (--cascivo-*), and a theme is just a set of those properties
scoped to a data-theme value. You match a brand by overriding tokens — never by
forking components. This page is the end-to-end recipe.
For the underlying cascade-layer rules see
CSS-LAYERS-PITFALL.md; for the full token list see
TOKENS.md. For the full list of the twelve first-party themes with their
import path ↔ data-theme value mapping, see
GETTING-STARTED.md → Theme export → data-theme value.
The three token tiers
Primitive --cascivo-blue-500: oklch(…) raw scale (rarely override)
↓
Semantic --cascivo-color-accent: var(--…-blue-500) intent — themes remap THIS tier
↓
Component --cascivo-button-primary-bg: var(--…-primary) per-component usage (brand exceptions)
Override the semantic tier for a brand. Drop to the component tier only for one-off exceptions (e.g. a pill-shaped badge in an otherwise sharp theme).
How data-theme selection works
First-party themes scope their tokens to two selectors:
@layer cascivo.theme {
[data-theme='light'],
:root:not([data-theme]) {
--cascivo-color-accent: …;
}
}
[data-theme='light']activates when you setdata-theme="light"on any element — themes are scopable to any subtree, not just:root.:root:not([data-theme])is the pre-hydration / no-attribute default so an app with nodata-themestill renders themed.
Apply a theme by setting the attribute on any container:
<main data-theme="dark">…</main>
Where does the attribute go — <html> or an element?
Both are correct, and which one applies is decided by whether you switch themes at runtime. This is the one place that answers it:
| You are… | Put data-theme on | Who sets it |
|---|---|---|
| Shipping one fixed theme | any container — <main>, #root, a <section> | you, in JSX |
Switching themes at runtime (ThemeProvider / useTheme) | <html> | ThemeProvider — do not also set it in JSX |
| Theming one subtree differently (a docs preview, an embedded widget, a dark toolbar in a light app) | that subtree's wrapper | you, in JSX |
ThemeProvider writes the attribute on document.documentElement so the whole document —
including portalled overlays that render outside your React tree — switches together. A
data-theme you also set in JSX inside a ThemeProvider app wins for its subtree and will
not follow the toggle; that mismatch is the usual cause of "the modal didn't change theme".
Scoping a subtree deliberately is still supported — just do it knowingly.
The theme export name is the data-theme value: import @cascivo/themes/<name>
and set data-theme="<name>" (e.g. @cascivo/themes/midnight ↔
data-theme="midnight"). The full import→value table for all twelve first-party
themes is in GETTING-STARTED.md.
Switching themes at runtime
Which package do I import it from?
@cascivo/core, on both install paths. The theme runtime lives there precisely so the
copy-paste path can reach it: @cascivo/core is the one cascivo package cascivo init
installs for you.
import { ThemeProvider, useTheme, setTheme, applyTheme, themePreloadScript } from '@cascivo/core'
@cascivo/react re-exports all of it under the same names, so if you are on the prebuilt
path and already import from @cascivo/react, keep doing that — it is the same code.
Until 0.17.0 these shipped only from
@cascivo/react, which is the prebuilt distribution of every component. A copy-paste adopter reading "from@cascivo/react" was being told to install 199 components to get a theme signal, so one hand-wrote the provider, the persistence, the preload script and thedata-themewiring instead.
data-theme is the what; ThemeProvider is the how — it
persists the choice, drives the attribute, and is SSR-safe, so you never hand-wire a theme
toggle (and never write a useEffect that toggles a .dark class):
import { ThemeProvider, useTheme } from '@cascivo/core'
function App() {
return (
<ThemeProvider defaultTheme="dark">
<ThemeToggle />
</ThemeProvider>
)
}
function ThemeToggle() {
const [theme, setTheme] = useTheme() // [themeName: string, setter]; calls useSignals() for you
return (
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>{theme}</button>
)
}
- Uncontrolled + persisted by default (localStorage). Pass
valueto control it from server state,storageKeyto rename the key,attributefor a non-default attribute. - Scope a subtree with
target(a ref): the provider writes the attribute to that element instead of<html>, so a panel can theme independently of the page. useTheme()/setTheme()work from any component — no React context, no prop-drilling (the active theme is a module-level signal).- Initial-theme precedence (before anything is persisted): persisted value >
defaultTheme(if you passed one) > OSprefers-color-scheme>'light'. PassdefaultThemefor a "dark by default" or custom-theme app — it wins over the visitor's OS. Omit it to follow the OS. OS preference only ever resolves to'light'/'dark', so it never clobbers a custom theme name like'midnight'. - Controlled (parent owns the state, no persistence): pass
value. Correct when server state, not the visitor, decides the theme (e.g. a per-account theme from your DB). It is SSR-safe on its own: the provider renders a tiny inline script that setsdata-themeduring HTML parsing, so the server-rendered first paint is themed with no flash and no hydration mismatch — you do not needthemePreloadScript()or a hard-coded<html>attribute for the controlled flow. (Passnonceif your CSP requires it. Atarget-scoped controlled provider can't run before its ref mounts, so it themes on the client effect instead.) - No flash on reload / SSR: inline
themePreloadScript()in your document<head>, before the app bundle, so the theme paints on the first byte. It follows the same precedence, and it setsdata-themebefore React hydrates — so addsuppressHydrationWarningto the element carrying the attribute (usually<html>), or React 19 logs a hydration mismatch:
import { themePreloadScript } from '@cascivo/core'
// server-rendered document
<html suppressHydrationWarning>
<head>
{/* defaultTheme:'dark' keeps a light-OS visitor dark; omit it to follow the OS */}
<script dangerouslySetInnerHTML={{ __html: themePreloadScript({ defaultTheme: 'dark' }) }} />
</head>
</html>
For a fixed, non-switchable theme you don't need the script at all — hard-code
data-theme="dark" on the server-rendered <html>. That never mismatches and is the right
answer for a single-theme app (see GETTING-STARTED.md).
See HEADLESS.md for the reactivity model behind this.
Using Tailwind v4? See
USING-WITH-TAILWIND.mdfor coexisting token layers, a one-attribute dark-mode bridge, and mapping cascivo semantics onto Tailwind's--color-*utilities.
The specificity footgun (read this before writing a brand theme)
:root:not([data-theme]) has specificity (0,2,0) — higher than a plain
:root (0,1,0). So this silently loses in the no-data-theme state:
/* ❌ loses to the default theme before data-theme is set */
:root {
--cascivo-color-accent: var(--brand-emerald);
}
Three ways to win, in order of preference:
0. Use @layer cascivo.override (recommended, foolproof). It is the highest
cascivo layer, so it beats tokens, components, and themes regardless of
specificity — a plain :root inside it wins with no selector-mirroring and no
data-theme required:
@layer cascivo.override {
:root {
--cascivo-color-accent: var(--brand-emerald);
}
}
1. Mirror the theme's selectors and import your brand file last. Same layer,
same selector list — source order inside @layer cascivo.theme then decides, and
last wins:
/* my-theme.css — imported AFTER the cascivo themes */
@layer cascivo.theme {
[data-theme='light'],
:root:not([data-theme]) {
--cascivo-color-accent: var(--brand-emerald);
--cascivo-color-accent-hover: var(--brand-emerald-600);
}
}
2. Override from an unlayered stylesheet or a layer ordered after
cascivo.component. Unlayered author CSS beats every cascivo layer regardless
of specificity (see CSS-LAYERS-PITFALL.md), so a plain unlayered :root { … }
also works — but only if it is genuinely unlayered everywhere it loads.
Recommended: a brand indirection variable
To override one knob at :root without fighting specificity or mirroring
selectors, point a semantic token at your own brand variable with a fallback,
then set only the brand variable:
/* once, in your brand layer */
@layer cascivo.theme {
[data-theme='light'],
:root:not([data-theme]) {
--cascivo-color-accent: var(--brand-accent, oklch(0.623 0.214 250));
}
}
/* anywhere, plain :root — no specificity fight */
:root {
--brand-accent: oklch(0.7 0.17 155); /* emerald */
}
The real override surface
A brand almost always touches more than the accent color. These are the token
groups most teams retune — set them in the same @layer cascivo.theme block.
Semantic colors (canonical names)
Override these, not the primitives. Each has -hover/-active/-subtle/
-content companions where relevant — see TOKENS.md for the
full table and which aliases are kept for back-compat.
| Role | Canonical token |
|---|---|
| Page bg | --cascivo-color-background |
| Surface | --cascivo-color-surface |
| Body text | --cascivo-color-foreground |
| Muted text | --cascivo-color-text-muted |
| Border | --cascivo-color-border |
| Accent | --cascivo-color-accent |
| On-accent | --cascivo-color-text-on-accent |
| Destructive | --cascivo-color-destructive |
| Success | --cascivo-color-success |
Some roles ship synonyms (
-foreground/-text,-content/-foreground,danger/destructive/error). The table above lists the canonical name; aliases resolve to it for back-compat. Prefer the canonical name in new code.
Radius — per role, not just one knob
--cascivo-radius-base derives a whole family by multiplier, which is convenient
but rarely matches a real brand (e.g. control=10px, card=14px, modal=20px,
badge=full). Set the per-role tokens directly when one knob isn't enough:
--cascivo-radius-control: 0.625rem; /* 10px — buttons, inputs */
--cascivo-radius-field: 0.625rem;
--cascivo-radius-surface: 0.875rem; /* 14px — cards */
--cascivo-radius-overlay: 1.25rem; /* 20px — modals, popovers */
--cascivo-radius-item: 0.25rem; /* menu/list rows */
Control heights
Defaults are 32 / 40 / 48px (sm/md/lg). Many systems run taller — override all three as first-class brand tokens:
--cascivo-control-height-sm: 2.25rem; /* 36px */
--cascivo-control-height-md: 3rem; /* 48px */
--cascivo-control-height-lg: 3.5rem; /* 56px */
Typography
Set the font stacks once (they cascade to all components and, via
@cascivo/themes/base, to plain markup):
--cascivo-font-sans: 'DM Sans', ui-sans-serif, system-ui, sans-serif;
--cascivo-font-mono: 'DM Mono', ui-monospace, monospace;
--cascivo-font-display: var(--cascivo-font-sans); /* headline/brand face */
The type scale runs --cascivo-text-xs … --cascivo-text-4xl (plus -fluid
variants). Override individual steps if your brand's scale differs.
Putting it together — a starter brand theme
/* my-theme.css — import LAST, after @cascivo/themes/light + dark */
@layer cascivo.theme {
[data-theme='light'],
:root:not([data-theme]) {
--cascivo-color-accent: oklch(0.7 0.17 155);
--cascivo-color-accent-hover: oklch(0.66 0.17 155);
--cascivo-color-text-on-accent: oklch(1 0 0);
--cascivo-radius-control: 0.625rem;
--cascivo-radius-surface: 0.875rem;
--cascivo-radius-overlay: 1.25rem;
--cascivo-control-height-md: 3rem;
--cascivo-font-sans: 'DM Sans', ui-sans-serif, system-ui, sans-serif;
}
[data-theme='dark'] {
--cascivo-color-accent: oklch(0.74 0.16 155);
}
}
Import order (see also the @cascivo/react README):
import '@cascivo/react/styles.css' // components
import '@cascivo/themes/light-dark.css' // tokens (once) + base typography + light & dark
import './my-theme.css' // brand overrides — MUST be last
Authoring a brand-new named theme
To ship a fully custom theme value (e.g. data-theme="brand"), copy the shape of
a first-party theme: scope every semantic token under [data-theme='brand']
inside @layer cascivo.theme, declare the full token set (so nothing falls
back to another theme), and import it after the tokens. Use an existing theme
file in packages/themes/src/ as the canonical checklist of tokens to define.
Deriving instead of hand-authoring
Themes derive their hover/active ladders and on-color text rather than restating
every shade: relative color syntax (oklch(from var(--base) …)),
contrast-color(), and @property-registered tokens, all behind static
fallbacks. See the Derivable theming cookbook.
Why [data-theme], not light-dark()
The CSS light-dark() function resolves a two-way light/dark pair off the
computed color-scheme. cascivo ships twelve themes
(light, dark, warm, flat, minimal, midnight, pastel, brutalist, corporate, terminal, cyberpunk, arcade) and scopes them to any container via the
[data-theme] attribute. light-dark() cannot express warm vs brutalist,
and keying on color-scheme would collapse container-scoped multi-theming to a
binary — so cascivo keeps [data-theme]. light-dark() could be used narrowly
inside the light/dark pair, but it buys little over the attribute system already
in place, so the first-party themes do not use it.
@container style() palette branching — deferred
A component's text palette can branch by the surface it sits on, via a registered
--contrast-color custom property + @container style() queries. It is powerful
micro-theming, but advanced and orthogonal to cascivo's current derive-and-
disambiguate model, and gated on broader @container style() support. It is a
future-roadmap candidate, not shipped today; the @property registrations in
@cascivo/tokens/properties.css are the substrate a future implementation would
build on.
OKLCH is the floor
Every cascivo color — primitive and semantic — is already OKLCH (see
packages/tokens/src/index.css). The common "use OKLCH so machines can derive
in-system shades" advice is therefore already satisfied; the derivation layer
(relative color, contrast-color()) is built on top of it.
As Markdown: /docs/theming.md