Noctis

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

  1. Theme seed — retheme everything from one input.
  2. Generation-time override — replace one engine primitive before derivation; dependents re-derive.
  3. Cascade override — win one emitted engine variable by cascade; nothing re-derives.
  4. Role — retheme one intent everywhere it appears.
  5. Component token — retune one component's published seam.
  6. 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.

RungReachRe-derives?Where you set it
Theme seedThe whole token setYes — the engine re-solves everythingThemeProvider initialInput, or the System Controls
Generation-time overrideOne engine primitive + its dependentsYes — dependents re-solve against itThemeProvider overrides, or generateTheme(seed, { overrides })
Cascade overrideOne emitted engine variableNo — nothing re-derivesAn unlayered CSS rule on --noctis-engine-*
RoleOne intent, everywhere it appearsNo — consumers just inherit the new valueA --noctis-color-* role variable at any scope
Component tokenOne component's anatomy seamNo — the published var is read directlyA --noctis-{component}-* token on any ancestor
Slot CSSOne part of one componentNo — plain CSS on the rendered elementA [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.

The override ladderEach rung down narrows the blast radius — width here is reach. Start at the top; descend only when you need to, and stop at the highest rung that does the job.
EXTERNAL
Theme seedthe whole token set
PLATFORM
Generation overrideone primitive + its dependents re-derive
PLATFORM
Cascade overrideone emitted variable, nothing re-derives
ON-CHAIN
Roleone intent, everywhere it appears
ON-CHAIN
Component tokenone component's anatomy seam
NEUTRAL
Slot CSSone part of one component
Each rung down narrows the blast radius — width here is reach. Start at the top; descend only when you need to, and stop at the highest rung that does the job.

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:

TSX
// 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:

Seed accent
ActionAction
Override
ActionAction

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:

CSS
/* 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.

CSS
/* 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:

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

Default
Wider popup

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:

CSS
[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 through calc(<base> * var(--noctis-seed-font-scale)), so one value resizes the entire type scale.
  • Radius--noctis-seed-radius. The xsxl box steps each derive from it through a min() cap, so surfaces re-round but stay bounded; --noctis-radius-control follows the knob uncapped (true pills at the 9999px default, square at seed 0), while --noctis-radius-full is the one constant — a fixed 9999px for genuine circles, not seed-derived.
  • Density--noctis-seed-density (default 1). Every spacing step resolves through calc(<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.

CSS
/* A denser data region — tighter spacing, shorter controls — without touching colour. */
.dashboard { --noctis-seed-density: 0.85; }

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.