Theming
Corn UI is styled entirely with CSS custom properties. Override any token to match your brand.
import { ... } from '@corndev/ui' Setup
Import the default theme once at the top of your app — this loads every design token as a CSS custom property on :root.
import '@corndev/ui/theme';
Or from CSS:
@import '@corndev/ui/theme';
Re-theming in one step
Override the brand tokens and every component follows. This is the whole mechanism — there is no theme object, no provider, and no build step.
@import '@corndev/ui/theme';
:root {
--corn-primary: #0ea5e9;
--corn-primary-hover: #38bdf8;
--corn-primary-active: #0284c7;
}
You never need !important or selector overrides: component CSS reads tokens and never hardcodes a colour, so your declaration on :root wins on its own.
A :root override sets the light theme. Dark mode keeps its own values for any token it restates — see Dark mode for how to re-theme that side.
Derived tokens follow automatically
Focus rings, overlays, and thumb shadows are composed from the brand tokens using color-mix(), so they re-tint when you change --corn-primary or --corn-danger. You do not restate them:
/* This alone re-tints every focus ring in the library. */
:root { --corn-primary: #16a34a; }
Override a derived token directly only when you want it to stop tracking its source:
:root {
/* Thinner ring, still your brand hue */
--corn-focus-ring-width: 2px;
/* Opt out of the hue entirely */
--corn-focus-ring: 0 0 0 2px rgba(0, 0, 0, 0.4);
}
Scoped themes
Tokens are ordinary custom properties, so they cascade. Set them on any element to theme part of a page:
.checkout-panel {
--corn-primary: #16a34a;
--corn-radius-md: 2px;
}
Dark mode
Set data-theme="dark" on the document root. The bundled dark theme restates the colour tokens; the derived tokens re-compose from them, so focus rings adapt with no extra work.
document.documentElement.setAttribute('data-theme', 'dark');
Re-theming dark mode
The dark theme ships on :root[data-theme='dark'], which deliberately outranks a plain :root. That is what lets you re-theme the library without dark mode quietly inheriting your light values — but it also means a :root block cannot change a dark colour. Match the same selector:
@import '@corndev/ui/theme';
/* Light */
:root { --corn-primary: #0ea5e9; }
/* Dark — needs the matching selector, not a bare [data-theme='dark'] */
:root[data-theme='dark'] { --corn-primary: #38bdf8; }
A bare [data-theme='dark'] { … } scores lower and will not take effect. This applies to every token the dark theme restates; tokens it leaves alone are derived and follow your :root override on both sides.
Following the OS preference
prefers-color-scheme is not wired up for you — the attribute is the only switch. To track the OS setting, either flip the attribute in JS, or declare your own dark palette in a media query:
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
--corn-bg-primary: #0f172a;
--corn-text-primary: #ffffff;
/* …the rest of your dark palette */
}
}
Note that this second form does not pick up the bundled dark theme — it only applies the tokens you list. Flipping the attribute is the simpler route:
const mq = matchMedia('(prefers-color-scheme: dark)');
const sync = () => document.documentElement.setAttribute('data-theme', mq.matches ? 'dark' : 'light');
sync();
mq.addEventListener('change', sync);
A light/dark toggle
Corn UI ships no toggle component: the part that matters runs before the page paints, which a component cannot do. A toggle is two small pieces, and this site uses exactly these.
1. In <head>, before any stylesheet paints. Without it the page renders
light and then flips, which is visible on every load of a dark page:
<script>
(() => {
try {
const saved = localStorage.getItem('theme');
const theme = saved ?? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
} catch {
// A private window or blocked site data makes localStorage throw.
document.documentElement.setAttribute('data-theme', 'light');
}
})();
</script>
2. The button. It names the state it switches to, and saves the choice where it can:
<button id="theme-toggle" class="corn-button" data-variant="button" data-level="neutral" data-emphasis="quiet" type="button"></button>
<script>
const button = document.getElementById('theme-toggle');
const paint = theme => {
const next = theme === 'dark' ? 'light' : 'dark';
button.textContent = `${next[0].toUpperCase()}${next.slice(1)} mode`;
button.setAttribute('aria-label', `Switch to ${next} mode`);
};
paint(document.documentElement.getAttribute('data-theme') ?? 'light');
button.addEventListener('click', () => {
const next = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
paint(next);
try { localStorage.setItem('theme', next); } catch { /* not persisted */ }
});
</script>
Copy the first piece even if you write the button differently. It is the one that is easy to leave out, and nothing reports it missing until someone loads a dark page on a slow connection.
Browser support
The baseline is modern evergreen: the current and previous versions of Chrome, Edge, Firefox and Safari. Decided deliberately rather than by accident, because the alternative is paying for it in every stylesheet — the question had been open long enough that components were being written around selectors nobody had ruled in or out.
What that admits, and where it is used:
| Since | Used for | |
|---|---|---|
color-mix() | 2023 | the derived focus-ring tokens |
:has() | 2023 | a label’s cursor when its control is disabled |
:where() | 2021 | zero-specificity rules a consumer can override with one class |
scrollbar-gutter | 2021 | reserving the scrollbar’s space in ScrollArea |
field-sizing | 2025 | a textarea that grows with its content |
field-sizing is the one that is genuinely new, so anything relying on it must
degrade to something usable rather than something broken.
On an older browser the derived tokens fall back to the default purple and red focus rings — still visible, but no longer tracking your brand colour — and everything else themes normally.
Two size ladders, and which is which
data-size means one of two things depending on what it is on, and the two
ladders are deliberately different lengths:
| Ladder | Steps | Used by |
|---|---|---|
| Scale — a step on a token ramp | xs · sm · md · lg · xl | corn-text size, corn-stack / corn-group gap, corn-paper padding (plus none) |
| Control — how big a control is | sm · md · lg | corn-input, corn-select, corn-combobox, corn-button, corn-badge |
A scale mirrors the token ramp behind it, so it has a step for every token —
corn-text reaches all five font sizes, and md names the 14px base
explicitly rather than leaving it as the value you get by writing nothing.
A control ladder is three steps because a control is sized for a hit area, not
for a typographic ramp: sm, the default md, and lg. Adding xs there
would mean a control below the 24×24px minimum target size WCAG 2.2 asks for,
which is a bug rather than an option.
At each step every single-line control is the same height, from
--corn-control-height-sm, -md and -lg: an input, a select, a combobox, a
text button and an icon button line up in any row. The tokens are an input’s
own geometry by default, so they follow the type scale. Set them to make every
control taller or denser at once.
A value outside its ladder does nothing and says nothing — CSS cannot error,
so data-size="xs" on a control matches no rule and gives no signal. It renders
at the default size, which is the md step: every control repeats md’s
geometry in its base class precisely so that an unrecognised value, or no
data-size at all, lands somewhere sensible rather than on the browser’s own
defaults — where <input class="corn-input"> with no size would measure
23.6px, under the 24px target this page cites above.
scripts/size-fallback.test.js fails the build if a base rule and its md
rule ever disagree.
The published types name the accepted values for each component, and
scripts/option-values.test.js fails the build if a documented value ever loses
the rule behind it.
Design tokens
Every token below is overridable.
Colors — Brand
| Token | Light | Dark | Description |
|---|---|---|---|
--corn-primary | #7c3aed | #a78bfa | Primary brand color |
--corn-primary-hover | #6d28d9 | #c4b5fd | Primary hover state |
--corn-primary-active | #5b21b6 | #9b74f7 | Primary active/pressed state |
--corn-primary-light | #ede9fe | #2e1065 | Primary background tint — emphasis="soft" |
--corn-secondary | #4f46e5 | #818cf8 | Secondary brand color |
--corn-secondary-hover | #4338ca | #a5b4fc | Secondary hover state |
--corn-secondary-active | #3730a3 | #7c7ff5 | Secondary active state |
--corn-secondary-light | #e0e7ff | #1e1b4b | Secondary background tint — emphasis="soft" |
Colors — Status
| Token | Light | Dark | Description |
|---|---|---|---|
--corn-success | #047857 | #34d399 | Success actions and indicators |
--corn-success-light | #d1fae5 | #064e3b | Success background tint |
--corn-warning | #b45309 | #fbbf24 | Warning actions and indicators |
--corn-warning-light | #fef3c7 | #78350f | Warning background tint |
--corn-danger | #b91c1c | #f87171 | Destructive actions and errors |
--corn-danger-light | #fee2e2 | #450a0a | Danger background tint |
--corn-info | #1d4ed8 | #60a5fa | Informational indicators |
--corn-info-light | #dbeafe | #0c2340 | Info background tint |
Colors — Text
| Token | Light | Dark | Description |
|---|---|---|---|
--corn-text-primary | #1a1a1a | #ffffff | Main body text |
--corn-text-secondary | #4d4d4d | #e0e0e0 | Secondary / muted text |
--corn-text-tertiary | #6b6b6b | #b0b0b0 | Placeholder, caption and helper text |
--corn-text-inverse | #ffffff | #1a1a1a | Text on dark backgrounds |
Colors — Backgrounds
| Token | Light | Dark | Description |
|---|---|---|---|
--corn-bg-primary | #ffffff | #0f172a | Page / card background |
--corn-bg-secondary | #f9f9f9 | #1a2332 | Subtle section background |
--corn-bg-tertiary | #f0f0f0 | #2a3342 | Input and code backgrounds |
Colors — Borders
| Token | Light | Dark | Description |
|---|---|---|---|
--corn-border-light | #e5e5e5 | #3a4554 | Subtle dividers |
--corn-border-primary | #d0d0d0 | #4a5568 | Default borders |
--corn-layout-divider | --corn-border-primary | --corn-border-primary | Lines between Layout's regions when divided is set — tunable apart from control borders |
--corn-border-secondary | #a0a0a0 | #7a8a9a | Emphasized borders |
Colors — Chart / Data Viz
Series colours for charts. Unlike every other colour here these are not derived from --corn-primary — they are independently validated steps, and composing them from the brand would break their colour-vision and contrast guarantees. scripts/chart-palette.test.js re-runs those checks against the shipped values.
Assign slots in fixed order and never cycle them. A ninth series folds into “Other”, becomes small multiples, or picks up a second encoding channel.
| Token | Light | Dark | Description |
|---|---|---|---|
--corn-chart-1 | #4a3aa7 | #9085e9 | Series slot 1 — violet |
--corn-chart-2 | #eb6834 | #d95926 | Series slot 2 — orange |
--corn-chart-3 | #1baf7a | #199e70 | Series slot 3 — aqua |
--corn-chart-4 | #eda100 | #c98500 | Series slot 4 — yellow |
--corn-chart-5 | #e87ba4 | #d55181 | Series slot 5 — magenta |
--corn-chart-6 | #008300 | #008300 | Series slot 6 — green |
--corn-chart-7 | #2a78d6 | #3987e5 | Series slot 7 — blue |
--corn-chart-8 | #e34948 | #e66767 | Series slot 8 — red |
All eight are validated for adjacent pairs — stacked segments, grouped bars, lines. Only slots 1–3 clear the stricter all-pairs gates that scatter, bubble and choropleth need, where every series can end up beside every other. Past three in those forms, fold to “Other” or facet into small multiples.
On the light surface --corn-chart-3, --corn-chart-4 and --corn-chart-5 sit below 3:1. That is permitted, but it obliges relief: give those series visible direct labels or a table view. Every dark slot clears 3:1.
Status colours are reserved for state and must never be reused as a series colour — --corn-danger on a series tells the reader that series is failing.
Sequential ramp
One hue, low to high, for magnitude — heatmaps, choropleths, intensity. seq-1 always sits nearest the surface, so the ramp reads the same direction in both themes instead of inverting when the user flips the toggle.
| Token | Light | Dark | Description |
|---|---|---|---|
--corn-chart-seq-1 | #b8a5fc | #7c3aed | Lowest magnitude |
--corn-chart-seq-2 | #9575f8 | #9575f8 | |
--corn-chart-seq-3 | #7c3aed | #b8a5fc | |
--corn-chart-seq-4 | #5b21b6 | #ddd6fe | Highest magnitude |
There is no diverging ramp yet; it lands with the first component that needs one.
Semantic / derived
Composed from the tokens above. Override the source colour and these follow; override these to break the link. These swatches are live: they show the theme you are viewing, so flip the toggle to see the other.
| Token | Now | Default | Description |
|---|---|---|---|
--corn-focus-ring-width | 3px | Thickness of every focus ring | |
--corn-focus-ring-color | mix of --corn-primary at 15% | Focus ring colour | |
--corn-focus-ring-error-color | mix of --corn-danger at 15% | Focus ring colour in an error state | |
--corn-focus-ring | 0 0 0 {width} {color} | Complete focus box-shadow | |
--corn-focus-ring-error | 0 0 0 {width} {error-color} | Complete error focus box-shadow | |
--corn-backdrop | rgba(0, 0, 0, 0.5) | Modal and drawer backdrop | |
--corn-shadow-color | rgba(0, 0, 0, 0.15) | Shadow tint for raised controls | |
--corn-chart-grid | --corn-border-light | Chart gridlines — recessive, hairline | |
--corn-chart-axis | --corn-border-primary | Chart axis lines | |
--corn-chart-surface | --corn-bg-primary | Surface behind a chart; the colour of the 2px gaps and rings that separate marks | |
--corn-chart-muted | --corn-text-tertiary | De-emphasised series, e.g. sparkline history behind the current period |
Breakpoints
Reference values for the responsive rules built into the components. CSS cannot read a custom property inside a media query condition, so changing these tokens will not move the breakpoints — the component stylesheets carry the same numbers as literals. They are published so your own layout code can line up with the library’s, and so the values are stated somewhere rather than implied.
| Token | Default | Applies at |
|---|---|---|
--corn-bp-sm | 640px | Grid collapses to one column; container gutters narrow; horizontal nav wraps |
--corn-bp-md | 960px | Layout side navs stop being sidebars and stack above/below the main region |
--corn-bp-lg | 1280px | Reserved; nothing keys off it yet |
To move a breakpoint, override the rule rather than the token:
@media (max-width: 800px) {
.corn-layout-body { flex-direction: column; }
}
Spacing
| Token | Default |
|---|---|
--corn-spacing-xs | 4px |
--corn-spacing-sm | 8px |
--corn-spacing-md | 16px |
--corn-spacing-lg | 24px |
--corn-spacing-xl | 32px |
Typography
| Token | Default | Description |
|---|---|---|
--corn-font-family | system-ui stack | Body font |
--corn-font-family-mono | Monaco / Menlo stack | Code font |
--corn-font-size-xs | 11px | |
--corn-font-size-sm | 12px | |
--corn-font-size-base | 14px | |
--corn-font-size-lg | 16px | |
--corn-font-size-xl | 18px | |
--corn-font-weight-regular | 400 | |
--corn-font-weight-medium | 500 | |
--corn-font-weight-bold | 700 | |
--corn-line-height | 1.4 | |
--corn-control-height-sm | calc(--corn-font-size-sm × line-height + 10px) ≈ 26.8px | Height of every single-line control at sm |
--corn-control-height-md | calc(--corn-font-size-base × line-height + 14px) ≈ 33.6px | Input, Select, Combobox and Button height at md |
--corn-control-height-lg | calc(--corn-font-size-lg × line-height + 22px) ≈ 44.4px | The same at lg |
Border Radius
| Token | Default |
|---|---|
--corn-radius-sm | 4px |
--corn-radius-md | 6px |
--corn-radius-lg | 8px |
Shadows
| Token | Default |
|---|---|
--corn-shadow-sm | 0 1px 2px rgba(0,0,0,0.05) |
--corn-shadow-md | 0 4px 6px rgba(0,0,0,0.1) |
--corn-shadow-lg | 0 10px 15px rgba(0,0,0,0.1) |
Transitions
| Token | Default |
|---|---|
--corn-transition-fast | 100ms ease-in-out |
--corn-transition-base | 200ms ease-in-out |
--corn-transition-slow | 300ms ease-in-out |