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, Menu } from "@stridge/noctis";
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; }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.