Color
Overview
Monogem ships four selectable themes — Onyx, Sapphire, Ruby, Emerald — built on one shared color system. Pure-gray neutrals, surfaces, borders, and status colors are identical across every theme; a theme changes only which color carries brand emphasis. Onyx is the neutral, monochrome theme (its emphasis color is near-black, not a hue); Sapphire, Ruby, and Emerald swap that same emphasis role for blue, red/magenta, and green. See Theming.
Separately from theme, every surface stays on a red-reserved-for-danger rule: --destructive
is the only semantic that means "dangerous action," and a bounded feedback family (blue /
green / amber, alongside red) exists solely so status messaging can distinguish info, success,
warning, and error. It stays confined to status/feedback surfaces and never becomes theme
identity — see Feedback.
Color has two layers (same tiering as Scale):
- Primitives — raw color ramps (
--neutral-*,--red-*,--blue-*,--green-*,--amber-*), plus the three theme ramps. The "paint pots." Never used directly. - Semantic tokens — intent names (
--primary,--background,--info-subtle…), defined for light and dark and, for the brand-emphasis tokens, per theme. Use only these.
Principles
- Semantic over literal. Components reference
--primary, never--neutral-900. - Foreground pairing. Every surface token ships with a matching text token proven to be
readable on it (
--card+--card-foreground). - Accessible by default. Every text-on-surface pair meets WCAG 2.1 AA (≥ 4.5:1 for
normal text) — measured, not assumed. Several tokens were darkened to pass:
muted-foreground(→ neutral-600),destructive(→ red-600), and the green/amber ends of the feedback ramps (their-700/-950stops sit lower than blue’s and red’s at the same step, because equal HSL lightness is not equal luminance). - Red means danger, only.
--destructiveis still the only semantic that carries a “dangerous action” meaning. The feedback family’s red (--destructive-subtle*) is the error member of a status set — same hue, a different, narrower job (a message, not an action). - Feedback colour is bounded. Blue/green/amber exist only for status intent on feedback surfaces. They never become decoration, theming, or a general palette.
- Theme by re-pointing. Light and dark share the same names; only what they point to changes. The Onyx/Sapphire/Ruby/Emerald brand themes use the identical move on a second axis — see Theming.
Tokens — Primitives (the paint pots)
Neutrals — pure gray (hue 0, saturation 0%). Lightness % shown.
| Shade | L% | Typical role |
|---|---|---|
--neutral-50 | 98 | Page background (light) |
--neutral-100 | 96 | Muted / secondary surfaces |
--neutral-200 | 90 | Borders, dividers |
--neutral-300 | 83 | Focus ring (dark) |
--neutral-400 | 64 | Muted text (dark) |
--neutral-500 | 45 | — |
--neutral-600 | 32 | Muted text (light) |
--neutral-700 | 25 | — |
--neutral-800 | 15 | Borders / surfaces (dark) |
--neutral-900 | 9 | Onyx — primary (light) |
--neutral-950 | 4 | Text (light) / background (dark) |
How the ramp is scaled: Only lightness changes (pure gray). The steps are deliberately uneven — tight at the light and dark ends, wider in the middle — because that's where UI needs the most shades, and it matches how we perceive lightness. Values mirror Tailwind's neutral scale (tuned by eye, not a formula). Onyx (
900, 9%) is near-black, not pure black — softer and more premium. HSL isn't perfectly perceptually even; that's a tradeoff we took for Figma compatibility.
Red — destructive and the error member of the feedback family (--red-50 … --red-950).
--red-500 is the base danger tone; --red-900 is the deeper dark-mode variant for the solid
--destructive; --red-50 / --red-700 / --red-300 back the soft error Alert.
Feedback ramps — --blue-* (info), --green-* (success), --amber-* (warning), 11 stops
each. One shared shape: stops 50–400 reuse --red-*’s exact lightness
cadence (97 / 94 / 89 / 82 / 71), so the tinted end of all four feedback ramps moves in
lockstep and pastes into Figma as “one ramp at four hues.” Stops 500–950 are pulled darker for
green and amber than for blue at the same step — a green or amber foreground has to sit
visibly darker to clear AA on a light tint (equal HSL lightness ≠ equal luminance). Saturation
is tuned per hue for a calm, non-neon read. Components reference only ~4 stops per ramp; the full
11 ship anyway, exactly as --red-* does.
Theme ramps — --sapphire-*, --ruby-*, --emerald-*, 11 stops each, same shape as
above. Consumed only through [data-theme] in tokens/semantic.css — see
Theming. Deliberately separate primitives from
--blue-* / --red-* / --green-*, even though Sapphire/Ruby/Emerald sit near those hues: a
theme's brand identity never repurposes a status color, and vice versa.
Tokens — Semantic (what components use)
Each token points at a primitive. Surfaces are paired with their foreground.
| Token | Light → | Dark → | Use for |
|---|---|---|---|
--background / --foreground | 50 / 950 | 950 / 50 | Page base + body text |
--card / --card-foreground | 50 / 950 | 950 / 50 | Cards, raised panels |
--popover / --popover-foreground | 50 / 950 | 950 / 50 | Menus, tooltips |
--primary / --primary-foreground | 900 / 50 | 50 / 900 | Main buttons & actions |
--secondary / --secondary-foreground | 100 / 900 | 800 / 50 | Secondary buttons |
--muted / --muted-foreground | 100 / 600 | 800 / 400 | Subtle backgrounds, hint text |
--accent / --accent-foreground | 100 / 900 | 800 / 50 | Hover highlights |
--destructive / --destructive-foreground | red-600 / 50 | red-900 / 50 | Delete, dangerous actions |
--border | 200 | 800 | Lines, dividers |
--input | 200 | 800 | Form field borders |
--ring | 900 | 300 | Focus outline |
(Numbers = neutral shade the token points to.)
Feedback — the four-intent family
The status-messaging colour set. Each intent ships a “subtle” trio — a tinted surface, a
foreground that reads on it (title + icon), and a quiet edge. destructive gets the trio too,
alongside its existing solid --destructive / --destructive-foreground pair.
| Token | Light → | Dark → | Use for |
|---|---|---|---|
--info-subtle / --info-subtle-foreground / --info-subtle-border | blue 50 / 700 / 300 | blue 950 / 300 / 800 | Info Alert surface / title + icon / edge |
--success-subtle / --success-subtle-foreground / --success-subtle-border | green 50 / 700 / 300 | green 950 / 300 / 800 | Success Alert surface / title + icon / edge |
--warning-subtle / --warning-subtle-foreground / --warning-subtle-border | amber 50 / 700 / 300 | amber 950 / 300 / 800 | Warning Alert surface / title + icon / edge |
--destructive-subtle / --destructive-subtle-foreground / --destructive-subtle-border | red 50 / 700 / 300 | red 950 / 300 / 800 | Error Alert surface / title + icon / edge |
(Numbers = the feedback-ramp stop the token points to.)
- Body text on a subtle surface stays
--foreground(≥ 15:1 either mode) — only the title takes--{intent}-subtle-foreground. A coloured multi-line paragraph reads poorly. -subtle-foregroundis-700(light) /-300(dark) for every intent — measured 5.8–7.1:1 light, 8.7–11.1:1 dark on the matching-subtlesurface. Warning’s is a dark amber, not bright yellow; yellow can’t clear 4.5:1 on a light tint.-subtle-border(~1.4–2.4:1 against the page) is a boundary cue with the same low-contrast character as--borderitself (≈ 1.2:1), not held to a text ratio.- No solid
--info/--success/--warning. Nothing needs a saturated feedback fill yet. When something does (a solid banner), that adds a--{intent}/--{intent}-foregroundpair — deferred, not designed around. - These are system tokens: Toast, form-field error surfaces, and future status messaging
reuse them. No component defines its own
alert-*/toast-*feedback colour.
Sidebar composition
A dedicated token family so a sidebar surface reads as visually distinct from the main content — same neutrals, new names, no new primitives.
| Token | Light → | Dark → | Use for |
|---|---|---|---|
--sidebar / --sidebar-foreground | 100 / 900 | 900 / 50 | Sidebar surface + its text |
--sidebar-primary / --sidebar-primary-foreground | 900 / 50 | 50 / 900 | Active / selected nav item |
--sidebar-accent / --sidebar-accent-foreground | 200 / 900 | 800 / 50 | Hover on a nav item |
--sidebar-border | 200 | 800 | Sidebar edge, dividers |
--sidebar-ring | 900 | 300 | Focus outline inside the sidebar |
No --sidebar-input — a search field inside a sidebar uses --input directly, which already
resolves to the same value as --sidebar-border in both modes.
Theming — Onyx / Sapphire / Ruby / Emerald
Monogem ships four themes: Onyx (the monochrome identity), Sapphire (blue), Ruby (red/magenta), and Emerald (green). A theme is a second, independent axis from light/dark mode — a Sapphire page can be light or dark, exactly like Onyx can.
Selection mechanism. data-theme="onyx" | "sapphire" | "ruby" | "emerald" on <html>,
alongside — never combined with — the .dark class that selects dark mode. Apply the attribute
before first paint (for example from a small inline script in the document head) so the page
doesn't flash the default theme, and persist the visitor's choice however your app stores
preferences.
Token scope is deliberately narrow. A theme re-points only --primary /
--primary-foreground, --ring, and the --sidebar-primary* / --sidebar-ring trio that
already mirrors --primary. Every other semantic token — surfaces, borders, --muted,
--destructive, the info/success/warning "subtle" family — is untouched by [data-theme] and
stays exactly as :root / .dark define it in every theme. Same component contracts, same
status meanings, regardless of theme: an Alert's four intents render identically in all four
themes, and Ruby's primary and --destructive stay visually distinct — magenta-crimson vs.
orange-red — even though both are "warm".
| Theme | Light --primary | Dark --primary | Source hue |
|---|---|---|---|
| Onyx | neutral-900 | neutral-50 | — (monochrome) |
| Sapphire | sapphire-700 | sapphire-500 | 216° (#0F52BA) |
| Ruby | ruby-700 | ruby-500 | 337° (#E0115F) |
| Emerald | emerald-700 | emerald-500 | 140° (#50C878) |
Each brand theme picks one ramp stop pair: -700 in light (paired with white --primary-foreground) and -500 in dark (paired with near-black --primary-foreground, light enough to
pop on a near-black surface). Both stops are chosen, not just the nearest to the source hue, so a
solid --primary swatch clears WCAG AA 4.5:1 in every case — Sapphire 6.16:1 / 5.69:1,
Ruby 5.66:1 / 5.49:1, Emerald 6.42:1 / 6.68:1 (light / dark). --ring reuses --primary's value,
same pairing :root already uses for Onyx.
Border width
Not a token — a recipe against the existing scale. No new value is needed.
| Building this | Use | px |
|---|---|---|
| Default border — cards, inputs, sidebar, dividers | --scale-px | 1 |
| Emphasis border — selected state, drag target, thick focus outline | --scale-0-5 | 2 |
Focus outline. The --scale-0-5 (2px) emphasis border is the focus ring for every
interactive control. It is drawn in --ring on the browser's :focus-visible heuristic, traces
the control's own corner radius, and stays --ring even when the control is invalid — only the
control's border may turn --destructive, never the ring. Two geometries ship, both intentional:
flush, the ring sitting directly against the control's outer edge (form fields, Tabs,
Accordion), and offset, a 2px gap between edge and ring (Button,
Card). Offset is a stylistic choice for standalone action surfaces; it
does not change discoverability, which comes from the 2px width and --ring sitting at the far
end of the neutral ramp from --background (contrast clears the WCAG 1.4.11 3:1 minimum
in both themes — see Input).
Disabled dimming
A disabled control is shown at reduced opacity: opacity-50 (0.5) across every Monogem
primitive, applied uniformly to the whole control — and, for a form field, to the whole field
unit (label, control, and any helper or error text together, so relative contrast between the
parts stays intact). The 0.5 factor isn't a token; a project re-theming the system can tune it,
but it has to stay dim enough to read as inactive without the label, value, or message becoming
unrecognizable.
Opacity is never the only signal. It pairs with the native disabled attribute, which by itself
removes the control from the tab order and blocks interaction. A non-native control that can only
carry aria-disabled="true" gets neither for free — the implementation must still set
tabindex="-1" and suppress the control's own activation (Card does
exactly this for whole-card navigation). WCAG's contrast minimums (1.4.3, 1.4.11) exempt disabled
controls, so the dimmed result doesn't have to clear AA — but "exempt from the formula" isn't
"illegible." See Input for the form-field detail.
Rationale
Why onyx for primary (not a color)? A near-black primary reads as confident and premium, and
keeps the system calm — one color family instead of a rainbow. It's also maximally flexible: a
future project makes the system its own by re-pointing --primary to a brand hue in one line.
That swap-ability is the payoff of semantic tokens — the four built-in themes are that exact move,
made selectable.
Why only --primary / --ring / --sidebar-primary* change per theme? Those are the tokens
that carry brand emphasis — the "this is the one thing to notice" color. Surfaces, borders, and
status colors carry structure and meaning instead, so a theme must not change component contracts
or repurpose a status color to look distinct. Narrow scope also keeps a theme small — one override
block, not a parallel copy of every semantic token.
Why -700 (light) / -500 (dark), not the source hue's own stop? The three source colors
(Sapphire #0F52BA, Ruby #E0115F, Emerald #50C878) are identity references, not final UI
values — none of their raw lightness values clear 4.5:1 white-text contrast at a useful "solid
button" saturation. It's the same "darken/lighten to pass" move applied to muted-foreground,
destructive, and the feedback ramps: pick the ramp stop nearest the source hue that actually
clears AA, not the literal source value.
Why the primary flip in dark mode? On a near-black background, a near-black button would
vanish. Flipping --primary to near-white keeps it the most prominent thing on the screen —
"primary" is about emphasis, and emphasis is relative to the background.
Why red for destructive, and nothing else? Red is the near-universal signal for stop / danger. If red only ever means "this is irreversible," users learn to trust it instantly. Let red creep into non-dangerous UI and that trust erodes. (The feedback family’s error member reuses the same red ramp for a narrower job — a message, not an action — so the association stays coherent.)
Why a feedback colour family in a monochrome system? Status messaging is the one place where colour does real semantic work that shape and text can’t fully replace at a glance: info, success, warning, and error need to be distinguishable, and four greys aren’t. It’s fenced in: subtle surfaces only, no solid fills, no decorative use, reusable by every status component so none invents its own. The restraint isn’t “no colour ever,” it’s “colour only where it earns its keep.”
Why subtle-only, and no solid feedback fill? The soft treatment (tinted surface + coloured
title + icon) covers every case in the current component set. A solid feedback banner is a
heavier pattern with its own contrast pairing; adding --{intent} / --{intent}-foreground
before a component needs them would be guessing. --destructive keeps its solid pair only
because Button/Badge already rely on it.
Why do green and amber go darker than blue at the same stop? HSL lightness isn’t perceptual
luminance — amber at 45% L is far brighter than blue at 45% L. Holding the whole ramp to one
lightness curve would leave green and amber foregrounds failing AA on their own tint. Pulling
their 500–950 stops down is the same “darken to pass” call already made for muted-foreground
and destructive.
Why pure gray neutrals? No tint means nothing fights a future brand color, and grays are the hardest thing to make look wrong. It's the safest, most reusable base — exactly right for a foundation meant to be adapted.
Why foreground pairing? Guessing "what text color goes on this surface?" is how contrast bugs happen. Shipping every surface with a guaranteed-readable partner removes the guess.
Why does Sidebar get its own tokens instead of reusing --card or --secondary? Because a
sidebar is a persistent, structural surface — not an occasional card — it earns a dedicated name
even though (for now) it points at the same neutrals as --secondary. That's what lets a sidebar
diverge later (a brand tint, a darker panel) without a component ever needing to change which
token it reads.
Why is --sidebar-accent not just --accent? --accent and --sidebar now sit on the same
primitive, so a hover state built from --accent would be invisible against the sidebar's own
background. --sidebar-accent steps one further out instead — reusing --border/--input's
value, still no new primitive.
Why no border-width tokens? --scale-px already exists and is already commented "hairline
borders only" — the exact same restraint call as Spacing: the right number of new tokens is zero
when the scale already has the value.
Do / Don’t
Do
- Use semantic tokens only (
hsl(var(--primary))), never raw ramps. - Always pair a surface with its foreground (
--card+--card-foreground). - Reserve
--destructivefor genuinely dangerous, hard-to-undo actions. - Use the
--{intent}-subtle*trio for status/feedback surfaces (info / success / warning / error); keep body copy on--foregroundand let the title carry the colour. - Use
--sidebar-*tokens for any sidebar surface, never repoint--card/--secondaryfor it. - Reach for
--scale-px/--scale-0-5for border width — never a raw1px/2px. - Add a new theme (if one is ever needed) as one more
[data-theme="…"]block re-pointing the same narrow token set — not a parallel semantic-token file.
Don’t
- Reference
--neutral-*/--red-*/--blue-*/--green-*/--amber-*/--sapphire-*/--ruby-*/--emerald-*directly in a component. - Use red for anything but danger (no red "sale" badges here).
- Use the feedback family for decoration, charts, or theming — it’s for status intent only.
- Define a component-scoped feedback token (
--alert-*,--toast-*) — reuse the system trio. - Invent a one-off color — if a need is real, add it as a semantic token with a rationale.
- Invent a
--sidebar-input— reuse--input, it already covers it. - Repurpose a status color (
--destructive, the feedback family) to make a theme "look more distinct" — a theme changes brand emphasis only, never status meaning. - Add a per-component theme override (
if (theme === "ruby") …) — every visual change belongs intokens/semantic.css's[data-theme]blocks, never in component code.