Customization
Customizing Noctis is a ladder, not a free-for-all. Each rung reaches further than the last and costs one line of CSS — start at the top and only descend when you need to. Reach for the highest rung that does the job.
The ladder
- Theme seed — retheme everything from one input.
- Generation-time override — replace one engine primitive before derivation; dependents re-derive.
- Cascade override — win one emitted engine variable by cascade; nothing re-derives.
- Role — retheme one intent everywhere it appears.
- Component token — retune one component's published seam.
- Slot CSS — the escape hatch when no token is minted.
Every var name below is generated from the token graph — none is invented, and each line works against the system as it ships.
| Rung | Reach | Re-derives? | Where you set it |
|---|---|---|---|
| Theme seed | The whole token set | Yes — the engine re-solves everything | ThemeProvider initialInput, or the System Controls |
| Generation-time override | One engine primitive + its dependents | Yes — dependents re-solve against it | ThemeProvider overrides, or generateTheme(seed, { overrides }) |
| Cascade override | One emitted engine variable | No — nothing re-derives | An unlayered CSS rule on --noctis-engine-* |
| Role | One intent, everywhere it appears | No — consumers just inherit the new value | A --noctis-color-* role variable at any scope |
| Component token | One component's anatomy seam | No — the published var is read directly | A --noctis-{component}-* token on any ancestor |
| Slot CSS | One part of one component | No — plain CSS on the rendered element | A [data-slot="noctis-…"] rule |
Only the top two re-derive: a generation override moves the cause, so dependents follow. Everything below moves an effect — one value, no re-solve.
1. Theme seed
The widest reach: change the engine's input — background, accent, or contrast — and the OKLCH engine re-derives the entire token set. Every surface, text tier, border, control, and status colour moves together, with no rebuild. This is what the System Controls drive at runtime, and what ThemeProvider accepts as its initialInput seed at the root.
Reach for it when you want a different look — a lighter canvas, a different brand hue — across the whole product. See the Theme engine for the three inputs and how they resolve.
2. Generation-time override
Half a rung below the seed sits the engine's own override seam — a way to re-point a single --noctis-engine-* primitive without re-seeding the whole theme. It comes in two flavours with very different reach; this is the wider one.
A generation-time override replaces an engine primitive before derivation, so everything that depends on it re-derives. Pass it to generateTheme(seed, { overrides }), or — the usual way — to ThemeProvider's overrides prop at the root. The override key is the engine primitive id (the --noctis-engine- prefix stripped: "accent", "bg-1", "border-default"), and the value is any CSS colour:
// Re-point the accent primitive; every dependent re-derives against it.
<ThemeProvider overrides={{ accent: "oklch(0.72 0.19 35)" }}>
<App />
</ThemeProvider>Because the override feeds derivation, the dependents follow: the accent hover and active states re-nudge off the new hue, the focus ring and selection mixes re-solve, and on-accent text re-contrasts. Override the canvas (bg-1) instead and the text tiers re-solve their APCA contrast against it, the elevation scopes shift off it, and the shadow composites embed it. The surprise to plan for: polarity follows the resolved canvas, not a mode flag — push bg-1 across the light/dark boundary without setting mode and the whole UI flips text and border polarity to suit the new canvas, because auto-mode reads brightness off the resolved bg-1.
The demo regenerates the theme twice — once on the seed, once with { overrides: { accent: … } } — and reads the re-derived accent out of each into its own panel. The seed accent sits on the left; the overridden one, with its accent family re-derived off the new hue, on the right:
"use client";
import { ENGINE_VAR_PREFIX, generateTheme } from "@stridge/noctis-theme-engine";
import { useTheme } from "@stridge/noctis-theme-engine/react";
import { useTranslations } from "next-intl";
import type { ReactNode } from "react";
/**
* The generation-time-override rung: an entry in the `overrides` map replaces an engine primitive
* *before* derivation, so every dependent re-derives against it. Here the override re-points the `accent`
* primitive; the whole accent family — base and on-accent text — re-solves off the new hue, exactly as
* `ThemeProvider overrides={{ accent: … }}` would at the root.
*
* `generateTheme` returns the engine primitives as a value map. Rather than scope a live theme to the
* panel (the `--noctis-color-*` roles components read are static `:root` aliases, so a subtree can't be
* re-themed by writing engine vars alone), the panel reads the re-derived accent straight from the map —
* via the public `ENGINE_VAR_PREFIX`, never a raw `--noctis-*` literal — and paints its swatch / chip /
* link inline, so the seed accent (left) and the override (right) sit side by side.
*/
const OVERRIDE_ACCENT = "oklch(0.72 0.19 35)";
/** One panel: regenerate the theme — optionally with an `accent` override — and read the re-derived accent. */
function Panel({ override, label, action }: { override?: string; label: string; action: string }) {
const { input } = useTheme();
const map = generateTheme(input, override ? { overrides: { accent: override } } : undefined);
const accent = map[`${ENGINE_VAR_PREFIX}accent`];
const accentForeground = map[`${ENGINE_VAR_PREFIX}accent-fg`];
return (
<div className="flex flex-col gap-4 rounded-xl border border-border bg-background p-5">
<div className="flex items-center justify-between gap-3">
<span className="truncate text-mini font-medium text-muted">{label}</span>
<span
aria-hidden
className="size-5 shrink-0 rounded-full border border-border"
style={{ backgroundColor: accent }}
/>
</div>
{/* The accent fill + its on-accent text, both re-derived from this panel's override. */}
<span
className="rounded-control px-3 py-2 text-center text-small font-medium"
style={{ backgroundColor: accent, color: accentForeground }}
>
{action}
</span>
<span className="text-small font-medium" style={{ color: accent }}>
{action}
</span>
</div>
);
}
export default function GenerationOverride(): ReactNode {
const t = useTranslations("customization.generationOverride");
return (
<div className="grid w-full max-w-md grid-cols-1 gap-4 sm:grid-cols-2">
<Panel label={t("seed")} action={t("action")} />
<Panel override={OVERRIDE_ACCENT} label={t("override")} action={t("action")} />
</div>
);
}
Reach for a generation-time override when one engine input is wrong for your product but the derivation around it should still hold — when dependents should follow.
3. Cascade override
The plainer flavour — and the symmetry with rung 2 is the thing to hold onto. Both target the same variable (--noctis-engine-accent in these examples); they differ only in timing. Generation override lands before derivation, so dependents follow; cascade override lands after, on the emitted variable, so nothing else budges — generation override moves the cause, cascade override moves one effect. The engine emits every primitive inside @layer noctis.engine, so any unlayered CSS rule on a --noctis-engine-* variable wins by cascade — unlayered rules beat layered ones regardless of source order:
/* Wins by cascade — but changes this one variable only. */
:root { --noctis-engine-accent: oklch(0.72 0.19 35); }The caveat is the whole difference: a cascade override changes that one emitted variable and nothing re-derives. The accent hover/active states, the focus ring, on-accent text, and the selection mixes were all computed at generation time off the old accent — they keep their old values, so the accent set falls out of step with the variable you just moved. Use cascade CSS for a genuine one-off nudge to a single primitive; reach back up to the generation-time override (rung 2) the moment dependents should follow.
4. Role
One step in: override a semantic role to retune one intent wherever it appears. Setting the public role variable at any scope inherits down into every component that consumes it.
/* Re-hue the accent for one branded region — every focus ring, link, and checked control follows. */
.brand { --noctis-color-accent: oklch(0.7 0.18 145); }Set it on :root and the change is global; scope it to a region and it inherits only there. Reach for a role override when an intent — the accent, a border, a status colour — should read differently in part of the product, not just one component.
5. Component token
The headline rung — the published per-component seam. Each component mints a curated set of public --noctis-{component}-… tokens for the anatomy-level knobs a consumer would plausibly retune. Set one on any ancestor and every instance of that component in the region picks it up — elevation scopes and all — while one consumer line still beats every built-in variant.
The contract's canonical illustration is a button radius:
.marketing { --noctis-button-border-radius: 9999px; }A portaled overlay is the one wrinkle: a menu, popover, or tooltip mounts its popup on the <body>, so it never inherits a custom property from an in-tree ancestor. Its published token has to ride somewhere the popup actually sees — :root to retune every instance, or the popup part itself (Menu.Content) to retune one.
The demo below carries that case: the wider menu sets --noctis-menu-content-min-width on its own Menu.Content, widening the popup in one line; the default menu beside it is untouched.
"use client";
import { Button } from "@stridge/noctis/button";
import { Menu } from "@stridge/noctis/menu";
import { ChevronDown } from "lucide-react";
import type { CSSProperties } from "react";
/** One menu, rendered twice; the `wide` copy retunes its own published min-width token. */
function DemoMenu({ label, wide = false }: { label: string; wide?: boolean }) {
return (
<Menu.Root>
<Menu.Trigger
render={
<Button variant="secondary" endIcon={ChevronDown}>
{label}
</Button>
}
/>
{/* The popup portals to <body>, so the token rides on the popup part itself — not an in-tree
ancestor, whose custom properties the portaled popup never inherits. */}
<Menu.Content style={wide ? ({ "--noctis-menu-content-min-width": "18rem" } as CSSProperties) : undefined}>
<Menu.Item>Rename</Menu.Item>
<Menu.Item>Duplicate</Menu.Item>
<Menu.Item>Move to…</Menu.Item>
</Menu.Content>
</Menu.Root>
);
}
/**
* The component-token rung: one public token, retuned in one line. The token is set through a typed
* `style` object — the lint-clean way TSX sets a custom property. A menu popup portals out of the tree,
* so the token can't ride on an in-tree wrapper (the portaled popup wouldn't inherit it); a real
* consumer sets it on `:root` to widen every menu, or on this `Menu.Content` to widen just one. The
* wider menu here takes the override; the default beside it does not.
*/
export default function ComponentTokenOverride() {
return (
<div className="flex flex-wrap items-start gap-8">
<div className="flex flex-col gap-2">
<span className="text-mini text-subtle">Default</span>
<DemoMenu label="Project" />
</div>
<div className="flex flex-col gap-2">
<span className="text-mini text-subtle">Wider popup</span>
<DemoMenu label="Project" wide />
</div>
</div>
);
}
The full set of tokens each component mints is on its page's Design tokens table and in the token reference.
6. Slot CSS
The escape hatch. The seam is curated — roughly 6–15 tokens per component for the anatomy-level decisions, not the full property × state matrix, not layout glue, and not one-off optical nudges. When the knob you want isn't minted, target the part's data-slot directly — and every slot value carries the noctis- namespace, so the selector is noctis-{component}-{part}, never the bare part name:
[data-slot="noctis-menu-item"] { font-variant-numeric: tabular-nums; }Every rendered part carries a data-slot; the vocabulary for each component is generated into packages/noctis/SLOTS.md. Reach for slot CSS last — if you find yourself overriding the same knob across components, it likely wants to be minted as a token instead.
Density
Density is one of the three runtime seeds — the public knobs the foundation scales derive from, alongside Text Size and Radius. Each is a single value the System Controls drive, and each re-shapes the whole UI live without re-seeding the colour engine.
- Text Size —
--noctis-seed-font-scale. Every--noctis-text-*size resolves throughcalc(<base> * var(--noctis-seed-font-scale)), so one value resizes the entire type scale. - Radius —
--noctis-seed-radius. Thexs–xlbox steps each derive from it through amin()cap, so surfaces re-round but stay bounded;--noctis-radius-controlfollows the knob uncapped (true pills at the9999pxdefault, square at seed0), while--noctis-radius-fullis the one constant — a fixed9999pxfor genuine circles, not seed-derived. - Density —
--noctis-seed-density(default1). Every spacing step resolves throughcalc(<base> * <step> * var(--noctis-seed-density)), so one value re-spaces the spacing scale, control heights, and region padding together.
These are public variables: set one on :root to move it globally, or scope it to a region to re-shape only that subtree.
/* A denser data region — tighter spacing, shorter controls — without touching colour. */
.dashboard { --noctis-seed-density: 0.85; }Scoping a subtree with ThemeScope
ThemeScope is the ergonomic primitive for that subtree scope. Set any combination of radius, density, and fontScale — each a named preset, a raw value, or "reset" — and every primitive inside re-tunes through the cascade, with no per-component prop. It defaults to a display: contents wrapper so it adds no layout box (pass as/className to put the scope on a real element). The seed rides the CSS cascade for in-tree descendants and a portal-safe React context for floating overlays declared inside it (a Dialog, Popover, Select, Menu, tooltip…), so they re-tune too even though they mount on <body> — and it stays usable in Server Components, since the wrapper element is still server-rendered and only the context provider it nests is a client boundary. Raw component-token overrides ride its style prop down the cascade to in-tree descendants.
import { ThemeScope } from "@stridge/noctis";
<ThemeScope radius="sharp" density="compact">
<Dashboard /> {/* square + dense — the colour engine is untouched */}
</ThemeScope>Scopes nest, and the innermost wins; values are absolute — re-derived from the seed, never compounding. reset snaps a knob back to the app root — whatever NoctisProvider or :root established — ignoring every enclosing scope, so a corner of a heavily-scoped region can opt back into the global default without hard-coding it. The per-prop "reset" value resets one axis; the boolean reset resets every unset seed (a per-prop value still overrides its own axis).
"use client";
import { ThemeScope } from "@stridge/noctis";
import { Button } from "@stridge/noctis/button";
import { Input } from "@stridge/noctis/input";
import type { ReactNode } from "react";
/** A dashed-outline region so each scope boundary reads at a glance. */
function Region({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex flex-col gap-3 rounded-md border border-dashed border-border p-3">
<span className="text-mini text-subtle">{label}</span>
{children}
</div>
);
}
/**
* Seed scopes nest and reset. The outer `ThemeScope` squares and tightens its whole subtree; a nested
* scope re-rounds just its inner controls (density still inherited); a deeper `radius="reset"` snaps
* radius back to the app default — past both enclosing scopes — while keeping the inherited density.
* Pure CSS, so the same nesting works in Server Components.
*/
export default function ThemeScopeNesting() {
return (
<ThemeScope radius="sharp" density="compact">
<Region label="radius=sharp · density=compact">
<div className="flex flex-wrap items-center gap-3">
<Button variant="primary">Sharp + dense</Button>
<Input.Root className="max-w-xs">
<Input.Control aria-label="Sharp field" placeholder="Sharp field" />
</Input.Root>
</div>
<ThemeScope radius="rounded">
<Region label="radius=rounded (density inherited)">
<Button variant="secondary">Rounded, still dense</Button>
</Region>
</ThemeScope>
<ThemeScope radius="reset">
<Region label={'radius="reset" → app default'}>
<Button variant="secondary">Back to app radius</Button>
</Region>
</ThemeScope>
</Region>
</ThemeScope>
);
}
reset governs the seeds only. Reset a raw component token the native CSS way — style={{ "--noctis-button-border-radius": "initial" }} makes the variable fall back to its default.
Scoping a single component
When only one control should differ, skip the wrapper: every component that renders a styled box takes the same radius, density, and fontScale knobs as props — the per-instance sugar over ThemeScope.
<Button radius="pill">Get started</Button>
<Input.Root radius="rounded" density="compact" />
<Select.Trigger radius="rounded" />The component becomes its own scope: a set prop stamps the matching data-*-scope attribute and --noctis-seed-* knob on the component's rendered root, so it — and its subtree — re-tune through the very same cascade. The values are identical to ThemeScope's (a named preset, a raw value, or "reset"), and an inner ThemeScope or a closer prop still wins. Overlays carry the knobs on their visible parts — the floating popup or content panel, and field-like triggers and inputs; the prop sits on the part itself, so it scopes that part directly. A portaled popup also inherits a surrounding ThemeScope now, so the per-part prop is only needed to override the inherited scope, not to reach the panel in the first place. The shared SeedScopeProps type is exported for typing your own wrappers.
Reach for a seed when the shape of the UI should change — its size, its corners, its breathing room — independent of its colour. The colour engine is untouched; only the derived scales re-resolve.
Scoping colour with ColorScope
To re-theme the colour of a subtree — a differently-branded section, a light island in a dark app — reach for ColorScope, the colour counterpart to ThemeScope. Colour can't ride a pure-CSS calc() like the radius/density/type-scale seeds: it's solved by the OKLCH engine (APCA-legible text, derived ramps, elevation scopes). So ColorScope runs the engine for its seed and emits the resulting --noctis-engine-* set for itself; the token layer's static [data-noctis-theme-scope] block re-points every --noctis-color-* role against it, so every primitive inside re-skins with no per-component prop — exactly like the elevation scopes.
import { ColorScope } from "@stridge/noctis";
<ColorScope background="oklch(0.21 0.04 264)" accent="oklch(0.7 0.17 160)">
<Dashboard /> {/* a fully re-themed island — surfaces, text, borders all re-solved */}
</ColorScope>
<ColorScope accent="#10b981">
<SuccessPanel /> {/* same background, a green accent — the rest is inherited */}
</ColorScope>Give it a full seed (background + accent + contrast) or only the axes you want to change — an unset axis inherits the enclosing scope, else the app's NoctisProvider theme, else the default. Pass preset="Light" to base it on a named preset, or overrides for per-primitive engine overrides that participate in derivation. Scopes nest and the innermost wins.
The rule is keyed on a hash of the resolved seed, not a per-instance id, and written into a shared, refcounted stylesheet in <head>: one rule per distinct live seed, so a page of many scopes over a few seeds keeps one rule per seed, not per instance (deduplicated), and a rule is dropped the moment its last scope unmounts or re-seeds. That keeps live theming bounded — dragging a theme control re-seeds every scope each frame, and each frame's old seed is freed rather than piling up. On the server's first paint the rule renders inline, then hands off to the sheet after hydration — so there's no flash. Floating overlays work: a Popover, Select, Dialog, menu, or tooltip declared inside re-skins too, even though it portals to <body> — the scope key rides React context to the popup, which adopts it so the scope's rule reaches it through the portal. Under a strict CSP, pass nonce for the style.
Surfaces, text, borders, and the accent all re-solve for this seed.
Surfaces, text, borders, and the accent all re-solve for this seed.
"use client";
import { ColorScope, Surface } from "@stridge/noctis";
import { Button } from "@stridge/noctis/button";
import { Popover } from "@stridge/noctis/popover";
/** One re-themed island: a small panel of real components, all re-skinned by the surrounding scope. */
function Panel({ label }: { label: string }) {
return (
<Surface elevation="elevated" bordered className="flex w-60 flex-col gap-3 rounded-lg p-4">
<span className="text-small font-medium text-foreground">{label}</span>
<p className="text-mini text-muted">Surfaces, text, borders, and the accent all re-solve for this seed.</p>
<div className="flex gap-2">
<Button variant="primary" size="sm">
Save
</Button>
<Popover.Root>
<Popover.Trigger
render={
<Button variant="secondary" size="sm">
Open
</Button>
}
/>
<Popover.Popup>
<Popover.Title>Floating, re-themed</Popover.Title>
<Popover.Description>The popup portals out, yet inherits this colour scope.</Popover.Description>
</Popover.Popup>
</Popover.Root>
</div>
</Surface>
);
}
/**
* Two `ColorScope` islands on one page: a full seed re-themes everything (canvas, surfaces, text,
* accent), while an accent-only scope keeps the surrounding background and re-tints just the accent.
* Open either "Open" button to see the floating popover inherit its scope through the portal.
*/
export default function ColorScopeDemo() {
return (
<div className="flex flex-wrap gap-4">
<ColorScope background="oklch(0.21 0.04 264)" accent="oklch(0.7 0.17 160)">
<Panel label="Full seed" />
</ColorScope>
<ColorScope accent="oklch(0.62 0.22 25)">
<Panel label="Accent only" />
</ColorScope>
</div>
);
}
Hoist the default, wrap only the exceptions
A page of alternating white and black sections looks like it wants one ColorScope per section — and it can have them. Scopes deduplicate by seed, so 20 wrappers over 2 colours share 2 rules, not 20, and those rules are freed as sections unmount. You never have to ration scopes to keep the <head> lean.
What's left to trim is the wrappers themselves. Hoist one colour as the page default and wrap only the other. Make the whole page white — a single top-level ColorScope, or the app-wide NoctisProvider theme seed — so the white sections inherit it with no wrapper of their own, and give a ColorScope only to the black sections.
// One default colour (or set it on NoctisProvider theme); white sections inherit, black sections opt in.
<ColorScope background="oklch(0.99 0 0)" accent="oklch(0.62 0.19 275)">
<Hero /> {/* white — inherits, no wrapper */}
<ColorScope background="oklch(0.16 0 0)">
<Features /> {/* black — the only wrapped sections */}
</ColorScope>
<Testimonials /> {/* white — inherits */}
<ColorScope background="oklch(0.16 0 0)">
<Pricing /> {/* black */}
</ColorScope>
</ColorScope>That drops the wrapper count from 20 to 10, and the tree now reads as its own intent: white by default, black here and here. The emitted CSS is the same 2 rules either way. Pick whichever colour dominates as the default and scope the minority; if the split is even, hoist either one.
Canvas, text, border, and the accent all re-solve for this section.
no wrapperCanvas, text, border, and the accent all re-solve for this section.
<ColorScope>Canvas, text, border, and the accent all re-solve for this section.
no wrapperCanvas, text, border, and the accent all re-solve for this section.
<ColorScope>Canvas, text, border, and the accent all re-solve for this section.
no wrapper"use client";
import { ColorScope } from "@stridge/noctis";
import { Button } from "@stridge/noctis/button";
/** One page section. Paints the *canvas* role (`bg-background`), so it shows whatever scope encloses it. */
function Section({ title, wrapper }: { title: string; wrapper: "inherited" | "scoped" }) {
return (
<div className="flex items-center justify-between gap-4 border border-border bg-background p-5">
<div className="flex flex-col gap-1">
<span className="text-small font-medium text-foreground">{title}</span>
<p className="text-mini text-muted">Canvas, text, border, and the accent all re-solve for this section.</p>
</div>
<div className="flex items-center gap-3">
<code className="text-mini text-muted">{wrapper === "inherited" ? "no wrapper" : "<ColorScope>"}</code>
<Button variant="primary" size="sm">
Action
</Button>
</div>
</div>
);
}
// One accent, reused by every scope — so its re-solve per canvas is visible.
const ACCENT = "oklch(0.62 0.19 275)";
const WHITE = "oklch(0.99 0 0)";
const BLACK = "oklch(0.16 0 0)";
/**
* The hoisted alternating pattern. A homepage of alternating white/black sections looks like it needs
* 20 `ColorScope`s, and it can have them: scopes deduplicate by seed, so 20 wrappers over 2 colours
* share 2 rules, not 20.
*
* What hoisting trims is the wrappers. Make one colour the default with a single top-level `ColorScope`
* (here, white), so the white sections inherit it with no wrapper of their own, and give a `ColorScope`
* only to the black sections. That halves the wrapper count while the emitted CSS stays the same 2 rules.
*/
export default function ColorScopeAlternatingDemo() {
return (
// The hoist: one white scope becomes the page default. Every unwrapped section inherits it.
<ColorScope background={WHITE} accent={ACCENT} className="flex flex-col gap-3 rounded-lg">
<Section title="Hero — white" wrapper="inherited" />
<ColorScope background={BLACK} accent={ACCENT}>
<Section title="Features — black" wrapper="scoped" />
</ColorScope>
<Section title="Testimonials — white" wrapper="inherited" />
<ColorScope background={BLACK} accent={ACCENT}>
<Section title="Pricing — black" wrapper="scoped" />
</ColorScope>
<Section title="FAQ — white" wrapper="inherited" />
</ColorScope>
);
}