<!--
  Generated from docs/ — do not edit here; run `pnpm regen`.
  Canonical: https://cascivo.com/docs/theming.md
  registry v1.6.0 · generated 2026-10-02
-->

# 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`](/docs/css-layers-pitfall.md); for the full token list see
[`TOKENS.md`](/docs/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](/docs/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:

```css
@layer cascivo.theme {
  [data-theme='light'],
  :root:not([data-theme]) {
    --cascivo-color-accent: …;
  }
}
```

- `[data-theme='light']` activates when you set `data-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 no `data-theme` still renders themed.

Apply a theme by setting the attribute on any container:

```tsx
<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](/docs/getting-started.md#theme-export--data-theme-value).

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

```ts
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 the `data-theme` wiring 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):

```tsx
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 `value` to control it from
  server state, `storageKey` to rename the key, `attribute` for 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) > OS `prefers-color-scheme` > `'light'`.** Pass `defaultTheme` for 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 sets `data-theme`
  during HTML parsing, so the server-rendered first paint is themed with no flash and no
  hydration mismatch — you do **not** need `themePreloadScript()` or a hard-coded `<html>`
  attribute for the controlled flow. (Pass `nonce` if your CSP requires it. A `target`-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
  sets `data-theme` _before_ React hydrates — so add `suppressHydrationWarning` to the element
  carrying the attribute (usually `<html>`), or React 19 logs a hydration mismatch:

```tsx
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](/docs/getting-started.md#runtime-switching--ssr-no-flash)).

See [HEADLESS.md](/docs/headless.md) for the reactivity model behind this.

---

> **Using Tailwind v4?** See [`USING-WITH-TAILWIND.md`](/docs/using-with-tailwind.md)
> for 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:

```css
/* ❌ 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:

```css
@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:

```css
/* 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:

```css
/* 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`](/docs/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:

```css
--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:

```css
--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):

```css
--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

```css
/* 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](https://github.com/cascivo/cascivo/blob/main/packages/react/README.md)):

```ts
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](https://github.com/cascivo/cascivo/blob/main/docs/cookbooks/derivable-theming.md).

---

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