Colour
Neba does not accept arbitrary colour values. color is one of six semantic families, not #4072cd, and a family is a set of tokens defined in styles.css.
<Button color="danger">Delete</Button>Only five values per family are hand-picked; everything else is color-mix()ed off those five. Those five are also the only thing you have to touch to re-skin a family.
The palette
The values below are read back from what the browser actually computed. Hit the theme toggle and the table switches to the dark values.
How a family is built
A family is a set of --neba-{family}-{role} tokens.
The five that are hand-picked
--neba-{color}-solid /* the fill's base colour */
--neba-{color}-solid-hover /* −4.5 lightness */
--neba-{color}-solid-active /* −12 lightness */
--neba-{color}-on-solid /* text on the fill */
--neba-{color}-accent /* readable on a surface (for text/outline variants) */solid and accent are not two shades of one idea. solid is a background to put text on; accent is text to put on a background. That is why accent is the lighter and more saturated of the two: it is the one that has to hold contrast against a white page.
on-tint is the third of that pair, derived rather than picked. panel and soft are both washes of accent, so an accent label on one of them is a colour on a pale copy of itself — the ink and the bed move together and the gap cannot be opened by changing either. Pulling the ink 28% toward --neba-fg breaks the coupling, and it flips with the theme on its own.
The rest, derived
--neba-{color}-fill /* solid at --neba-fill-alpha (88%) */
--neba-{color}-fill-hover
--neba-{color}-fill-active
--neba-{color}-panel /* accent 8% + --neba-glass-bg — an outline control's surface */
--neba-{color}-panel-hover /* accent 16% */
--neba-{color}-panel-press /* accent 24% */
--neba-{color}-soft /* accent 10% — the text variant's hover wash */
--neba-{color}-soft-hover /* accent 17% */
--neba-{color}-soft-press /* accent 25% */
--neba-{color}-on-tint /* accent 72% + --neba-fg — text on panel or soft */
--neba-{color}-line /* accent 22% — the hairline */
--neba-{color}-line-hover /* accent 40% */
--neba-{color}-ring /* accent 55% — the focus ring */The derived block is repeated per theme root. A custom property resolves its
var()s on the element that declares it, so derived tokens declared only on:rootwould freeze to their light values inside a.darksubtree. Design language has the full reasoning.
Container surfaces are never dyed
Only controls let a family flood their surface. Box, Card and TextField read a neutral, undyed set of three steps rather than the family's own --neba-{color}-panel.
--neba-panel /* white 66% (dark 5%) */
--neba-panel-hover /* white 82% (dark 7%) */
--neba-panel-press /* white 92% (dark 9%) */What a container holds is other people's content, and it arrives with its own colours: body text, links, buttons, fields. Tinting the sheet underneath puts every one of them on a background they were not chosen against. So the family stops at the hairline, the focus ring and the caret, and the sheet stays white. A Button is the opposite case (its surface is the thing being coloured), so it keeps the family fill.
It is also why the three steps rise in opacity rather than lightness: an engaged surface holds more light instead of turning grey.
One consequence. On a
solidBox or Card, which has no border,colorhas nothing left to reach and makes no visible difference. On a container,coloris effectively the prop that picks the edge.
Contrast
Every colour is defined in oklch(), because its lightness axis matches perception: which is what lets all six families be pinned to the same number.
- Text on a fill holds 4.5:1. All three steps (
solid,hover,active) checked against the 88%-opaque fill over a white page. accentclears 5:1 on white, which is the surface it is for. On a bed made of its own colour the ink ison-tint, which clears 4.5:1 on every step of both thepaneland thesoftladder, in both themes.- Chroma sits near 90% of the sRGB gamut ceiling. Well under it and the colour reads grey at the very same brightness; over it and the browser clips.
- In dark mode ink is measured on a raised sheet, not the bare one. The panel ladder is opacity, so it costs nothing on a white page and a great deal on a near-black one. Design language has the reasoning.
Lightness is not as free as it looks. With a white on-solid and a fill at 88%, holding 4.5:1 pins the fill to the high 40s / low 50s. A brighter fill means a darker ink, and warning is the one family that does exactly that.
Changing a colour
Re-declare the five and the whole family follows. To give the dark theme its own values, declare the same five on .dark.
:root {
--neba-primary-solid: oklch(50% 0.24 300);
--neba-primary-solid-hover: oklch(45.5% 0.232 300);
--neba-primary-solid-active: oklch(38% 0.204 300);
--neba-primary-on-solid: oklch(99% 0.004 300);
--neba-primary-accent: oklch(54% 0.26 300);
}Neba's dark theme follows prefers-color-scheme and can be forced either way with .dark / [data-theme='dark'|'light'].
To scope an override, make the element a theme root
For a different family colour in one part of the app rather than all of it, the wrapper has to be a theme root: it needs .dark, .light or [data-theme='…'] on it.
<!-- Does nothing: the buttons keep their old colour -->
<div style="--neba-primary-solid: oklch(50% 0.24 300)">…</div>
<!-- Works -->
<div data-theme="light" style="--neba-primary-solid: oklch(50% 0.24 300)">…</div>The theme roots are where the derived block is declared, and a custom property resolves its var()s on the element that declares it. Put the five on a plain <div> and those five values are inherited, but --neba-primary-fill is still the one :root computed from the old solid. Making the element a theme root is what recomputes the derived block there.
The two panels below are exactly the same markup. Only the right one is wrapped that way.
Do not override a derived token. Overriding something like
--neba-primary-paneldirectly changes that one token and leaves the other twelve on the original family colour. Always edit the five base values instead.
Adding a family
Adding one to the library is two edits: the NebaColor union in src/types.ts and five lines in styles.css per theme. The derived block computes the rest.
From the consuming side, NebaColor is a closed union, so a new name cannot be passed in. If you need another family, please open an issue.
The tokens that are not families
The neutral tokens live in the same file.
--neba-surface /* the page background */
--neba-fg /* body text */
--neba-muted-fg /* secondary text */
--neba-border /* a neutral rule */
--neba-disabled-bg /* inactive controls — the family is dropped, not faded */
--neba-disabled-fg
--neba-disabled-border
--neba-glass-bg /* the undyed acrylic a family panel is mixed into */Why a disabled control drops its family rather than fading it is in Design language: a faded accent still says "this is the primary action", only blurrier.