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:

SinceUsed for
color-mix()2023the derived focus-ring tokens
:has()2023a label’s cursor when its control is disabled
:where()2021zero-specificity rules a consumer can override with one class
scrollbar-gutter2021reserving the scrollbar’s space in ScrollArea
field-sizing2025a 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:

LadderStepsUsed by
Scale — a step on a token rampxs · sm · md · lg · xlcorn-text size, corn-stack / corn-group gap, corn-paper padding (plus none)
Control — how big a control issm · md · lgcorn-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

TokenLightDarkDescription
--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

TokenLightDarkDescription
--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

TokenLightDarkDescription
--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

TokenLightDarkDescription
--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

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

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

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

TokenNowDefaultDescription
--corn-focus-ring-width3pxThickness of every focus ring
--corn-focus-ring-colormix of --corn-primary at 15%Focus ring colour
--corn-focus-ring-error-colormix of --corn-danger at 15%Focus ring colour in an error state
--corn-focus-ring0 0 0 {width} {color}Complete focus box-shadow
--corn-focus-ring-error0 0 0 {width} {error-color}Complete error focus box-shadow
--corn-backdroprgba(0, 0, 0, 0.5)Modal and drawer backdrop
--corn-shadow-colorrgba(0, 0, 0, 0.15)Shadow tint for raised controls
--corn-chart-grid--corn-border-lightChart gridlines — recessive, hairline
--corn-chart-axis--corn-border-primaryChart axis lines
--corn-chart-surface--corn-bg-primarySurface behind a chart; the colour of the 2px gaps and rings that separate marks
--corn-chart-muted--corn-text-tertiaryDe-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.

TokenDefaultApplies at
--corn-bp-sm640pxGrid collapses to one column; container gutters narrow; horizontal nav wraps
--corn-bp-md960pxLayout side navs stop being sidebars and stack above/below the main region
--corn-bp-lg1280pxReserved; 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

TokenDefault
--corn-spacing-xs4px
--corn-spacing-sm8px
--corn-spacing-md16px
--corn-spacing-lg24px
--corn-spacing-xl32px

Typography

TokenDefaultDescription
--corn-font-familysystem-ui stackBody font
--corn-font-family-monoMonaco / Menlo stackCode font
--corn-font-size-xs11px
--corn-font-size-sm12px
--corn-font-size-base14px
--corn-font-size-lg16px
--corn-font-size-xl18px
--corn-font-weight-regular400
--corn-font-weight-medium500
--corn-font-weight-bold700
--corn-line-height1.4
--corn-control-height-smcalc(--corn-font-size-sm × line-height + 10px) ≈ 26.8pxHeight of every single-line control at sm
--corn-control-height-mdcalc(--corn-font-size-base × line-height + 14px) ≈ 33.6pxInput, Select, Combobox and Button height at md
--corn-control-height-lgcalc(--corn-font-size-lg × line-height + 22px) ≈ 44.4pxThe same at lg

Border Radius

TokenDefault
--corn-radius-sm4px
--corn-radius-md6px
--corn-radius-lg8px

Shadows

TokenDefault
--corn-shadow-sm0 1px 2px rgba(0,0,0,0.05)
--corn-shadow-md0 4px 6px rgba(0,0,0,0.1)
--corn-shadow-lg0 10px 15px rgba(0,0,0,0.1)

Transitions

TokenDefault
--corn-transition-fast100ms ease-in-out
--corn-transition-base200ms ease-in-out
--corn-transition-slow300ms ease-in-out