@iyulab/components uses CSS custom properties for theming. Components cannot render correctly
without them — every border, background, and color in the shadow styles resolves through
var(--u-…), and an undefined custom property makes the whole declaration invalid. CSS emits no
error when this happens: controls simply lose their borders and backgrounds, silently.
So the first question for any app is how the tokens get into the document. There are two ways, and you need exactly one of them.
import '@iyulab/components/styles/tokens.css'; // light + dark in one fileDark mode activates when the document has theme="dark"; tokens.css scopes it as
:root[theme="dark"], so both sheets coexist safely.
Use this when you are not calling Theme.init() — static pages, SSR, or any screen that
renders outside an app shell.
| Your screen | Import | Why |
|---|---|---|
| Follows the user's theme preference | styles/tokens.css |
Both sheets; theme="dark" switches them |
| Fixed light design — the layout hardcodes light panels (a login card on a dark photo, a print view, an embedded widget on a known background) | styles/light.css only |
|
| Fixed dark design | styles/dark.css only |
⚠ A fixed-light screen must not import
tokens.css. If the user's OS is dark, the dark sheet wins and you get dark input fields on a white card — the layout was never going to follow the theme, but the tokens will. Ship only the sheet your design actually commits to.
Theme.init() injects the same sheets and adds theme switching, persistence, and system-theme
detection. See Initialization.
⚠
Theme.init()is not just a theme-switching utility — it is the style bootstrap. If you use@iyulab/modern-app, its shell callsTheme.init()for you during boot. That means screens rendered outside the shell — login, onboarding, error pages, embedded widgets — do not get tokens from it. Use Option A there, or callTheme.init()yourself.
In development builds, components log a one-time console warning when no token sheet is found.
import { Theme } from '@iyulab/components';
await Theme.init({
default: 'system', // 'light' | 'dark' | 'system'
useBuiltIn: true, // inject light.css / dark.css (default: true)
store: { // persist to localStorage (optional)
type: 'localStorage',
key: 'theme'
}
});useBuiltIn: false is used when you provide your own CSS variable definitions.
All tokens follow --u-{color}-{shade}:
| Color | Variable prefix |
|---|---|
| Neutral (grays) | --u-neutral- |
| Blue | --u-blue- |
| Green | --u-green- |
| Yellow | --u-yellow- |
| Red | --u-red- |
| Orange | --u-orange- |
| Teal | --u-teal- |
| Cyan | --u-cyan- |
| Purple | --u-purple- |
| Pink | --u-pink- |
Shade scale: 0, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000
Example:
--u-blue-500: #2196F3; /* primary blue */
--u-neutral-100: #F5F5F5; /* light background */Theme.init()reads the stored preference (ifstoreis configured) or usesoptions.default.- For
'system', aprefers-color-schememedia query listener is set up. - The matching stylesheet (
light.cssordark.css) is injected intodocument.headas a<style>tag (oradoptedStyleSheets) — in front of the document's other styles, because these are the defaults layer (see Custom Themes). - Switching via
Theme.set('dark')replaces the injected sheet.
Components use the tokens internally, so all components automatically respond to theme changes.
Between the raw palette and the components sits a role layer. Components never reference
--u-blue-600 for anything that carries meaning; they reference a role. That is what makes a
one-line brand override reach every control instead of half of them.
/* my-theme.css */
:root {
--u-primary-color: #6200EA; /* brand accent — the usual one-liner */
--u-primary-color-weak: #7C3AED; /* optional: tune the other steps */
--u-primary-color-strong: #4C1D95;
}| Role | Meaning | Default hue |
|---|---|---|
primary |
brand, emphasis, focus, links, checked states | blue |
info |
informational status | blue |
success |
success, completion | green |
warning |
caution | yellow |
danger |
error, risk, validation failure | red |
Each role has five steps on a single intensity axis:
--u-{role}-color-weakest /* faint graphics — progress-bar buffers */
--u-{role}-color-weaker /* borders */
--u-{role}-color-weak /* graphics on the page background — fills, focus rings */
--u-{role}-color /* solid surfaces — carries --u-{role}-txt-color on top */
--u-{role}-color-strong /* text and icons on the page background */
The axis is intensity, but the two darkest steps carry a contrast guarantee and are therefore bound to a usage:
| Step | Guarantee (WCAG 2.2 — SC 1.4.3 text · SC 1.4.11 non-text) |
|---|---|
--u-{role}-color |
--u-{role}-txt-color on it ≥ 4.5 |
--u-{role}-color-strong |
≥ 4.5 against --u-bg-color |
--u-{role}-bg-color |
body text ≥ 4.5 · -strong icon on it ≥ 3.0 |
Those two cannot be one step, because in dark the requirements point in opposite directions:
the page background is #121212 and the on-color is #FFFFFF, so a shade readable on the page
cannot carry white text and vice versa. In light both requirements collapse into one condition
(contrast(shade, #FFF) ≥ 4.5), which is why the two steps sit closer together there.
A consequence worth knowing: the palette shade behind a step differs per family and per theme.
--u-primary-color is blue-700 in light and blue-600 in dark; --u-success-color is
green-800. Material-style ramps are ordered by hue, not luminance, so equal shade numbers do not
mean equal strength. The values are enforced by tests/build/token-contrast.test.ts — if you
remap a step, that test tells you whether the result is still readable.
primary and info share a default hue on purpose: they are different roles. Rebranding
changes primary and leaves informational blue where it is.
Overriding a role reaches everything with that semantic, including the semantic tokens layered on top of it:
--u-primary-color → --u-input-border-color-focus (border — non-text, 3.0)
--u-primary-color-strong → --u-txt-color-hover / -active (text — 4.5)
--u-icon-color-hover / -active
--u-link-txt-color
--u-danger-color → --u-input-border-color-invalid
Note which side of the split each one lands on: text routes through -strong, borders through
-color. Before 1.16.0 the text tokens read --u-primary-color, which is a surface shade —
in dark that put 3.07:1 text on the page background.
Rather than listing components here (a list drifts — it was wrong before), the rule is enforced
in tests/build/role-token-layer.test.ts: no rule outside a [color=…] selector may reference
a palette primitive directly.
u-tag, u-badge, u-button, u-checkbox and u-spinner take a color attribute
(color="purple"). Those are decorative choices with no role meaning, so they read the
palette directly and are deliberately immune to role overrides. <u-tag color="green"> stays
green after you rebrand.
u-badge[color="blue"] was the one exception until 1.16.0 — it read --u-primary-color, so a
rebrand moved the blue badge and left the other eight where they were. It now reads the palette
like its siblings.
The same color attribute also accepts the five role values. They are the opposite of the
decorative axis: they say what the thing means, they follow a rebrand, and they inherit the
contrast contract.
<u-button color="danger">Delete</u-button> <!-- means "destructive" -->
<u-button color="red">Delete</u-button> <!-- means "red", stays red after rebrand -->| Axis | Values | Follows rebrand | Contrast |
|---|---|---|---|
| Role | primary info success warning danger |
yes | guaranteed by the contract tests |
| Decorative | blue green red orange teal cyan purple pink (yellow where applicable) |
no — deliberately immune | you pick the hue, you own the pairing |
If your brand is red, color="red" makes brand and danger the same name. color="danger"
is how you say the second one.
A role value brings its foreground with it. That is the point of the axis, not a detail —
the surface and the text on it arrive as a pair, so warning renders dark text on yellow rather
than the white text every decorative value uses. The same applies where a mark sits on the page
background instead of on a filled surface (variant="link", u-checkbox[variant="outline"],
u-spinner): those read the -strong step, because a surface step used as text on the page
background measures 3.07 in dark and fails AA.
⚠ Role values are additive — every decorative value renders exactly as before.
Adding primary exposed an existing asymmetry rather than creating one. color="neutral" is
the default on every component that has the attribute, but it resolves two different ways:
| Component | color="neutral" resolves to |
So color="primary" is… |
|---|---|---|
u-button · u-tag · u-spinner |
the brand hook (--u-primary-color) |
the same colour, said explicitly |
u-badge · u-checkbox |
a grey (--u-neutral-800 / --u-neutral-600) |
a genuinely different colour |
Prefer color="primary" when you mean "the brand colour" — it says so, and it reads the same
on all five components. neutral is kept as-is because changing either group would move
already-published renders; unifying it is a visual change, not a naming one.
u-spinner has a second wrinkle: it draws on the page background, so color="primary" reads the
-strong step while the default still reads the surface step. Both clear the 3.0 non-text
threshold (dark: 3.07 vs 5.17), so the default is not a defect — but the explicit value is the
safer one in dark.
Backgrounds are not one axis. Three families exist because they answer different questions:
--u-bg-color[-hover|-active|-disabled] interaction state of a surface
--u-bg-color-raised chrome adjacent to the page — toolbars, table
headers, footers, pagination
--u-panel-bg-color a container floating above the page — cards,
dialogs, drawers, menus
--u-{role}-bg-color a status surface — alert backgrounds, selected rows
The last three look similar in light and diverge in dark, which is where mixing them shows:
--u-panel-bg-coloris white in light — a floating panel is lifted by its shadow, not by a tint. In dark it lightens, because shadows do not read there.--u-bg-color-raisedis tinted in both — chrome has no shadow, so it needs a tint even in light (neutral-50light /neutral-300dark).--u-{role}-bg-coloris a pale status tint that keeps body text at 4.5 and its own-strongicon at 3.0.--u-warning-bg-colorsits one palette step differently from the other four; yellow's tint strength is asymmetric between themes and matching the number would have made the light surface nearly invisible.
Do not express elevation with --u-bg-color-hover — it is an interaction state, and a raised
surface that is also hoverable would have nothing left to say.
Role tokens are palette aliases, not computed values — components may use color-mix() locally,
but the sheet does not. If you brand with a single color and want the other steps derived:
:root {
--u-primary-color: #6200EA;
--u-primary-color-weak: color-mix(in srgb, var(--u-primary-color) 80%, white);
--u-primary-color-weakest: color-mix(in srgb, var(--u-primary-color) 15%, white);
--u-primary-color-strong: color-mix(in srgb, var(--u-primary-color) 80%, black);
}Mechanical mixing loses the hand-tuned lightness curve of the built-in palette, which is why the defaults are aliases. For a brand color it is usually the right trade.
⚠Derived steps do not inherit the contrast guarantee. The defaults were picked by measurement
(see the table above); color-mix() has no idea what --u-{role}-txt-color is or what the page
background is. If you derive, check the two that carry guarantees:
contrast(--u-{role}-color, --u-{role}-txt-color) ≥ 4.5
contrast(--u-{role}-color-strong, --u-bg-color) ≥ 4.5
The same applies when you override --u-primary-color outright — the built-in default is AA
against white, your brand color may not be. --u-{role}-txt-color exists so you can adjust the
foreground rather than being stuck with white (--u-warning-txt-color ships dark for exactly this
reason: no yellow shade carries white text at 4.5).
--u-chart-color-1 … --u-chart-color-8 are the colours for chart series (categories: one
colour per product, per region). Use them in order — series 1 gets slot 1 — and never cycle: a
ninth series is folded into "Other" or split into small multiples, not given a generated colour.
A series keeps its colour when a filter hides others.
The eight were chosen as a set and checked together: similar lightness, enough colour to not read as gray, neighbours distinguishable under red–green colour-vision deficiency and at full colour vision, and — in the dark sheet — at least 3:1 against the background. Slot 1 is the same blue as the default primary, so a single-series chart looks like the product.
- They do not follow
--u-primary-color. If only slot 1 moved with a re-brand, it would no longer be checked against the other seven. To re-brand charts, redefine all eight together. - No slot is a status colour. Success, warning and danger mean something; a series does not.
- Slot 6 (yellow) is below 3:1 on a white background — a chart that uses six or more series needs a legend or direct labels, which a multi-series chart should have anyway.
- The dark values are not the dark decorative ramps: those are muted for badges and tags and read as gray in a chart, so the dark sheet sets its own eight, in the same hues and order.
You can override any token — palette primitives included:
:root {
--u-neutral-50: #1A1A2E; /* dark surface */
}Load your sheet however you like: a plain static import is enough. The built-in sheets are
inserted ahead of the document's other styles, so anything you load wins at equal specificity
no matter when it arrives. You do not need to sequence your override after Theme.init().
⚠ Before 1.44.0 you did, and it failed quietly if you didn't. The built-in sheets were appended to the end of
<head>, while a static import is placed by the bundler while the document parses — so the defaults landed last and won, and an override sheet did nothing at all. There was no error and no warning, and if your sheet happened to agree with the defaults on some tokens it read as partially applied rather than as ignored. If you are on an older version, either upgrade or keep loading your sheet afterawait Theme.init(...).
⚠ Specificity still decides. These are all :root rules, so a :where(:root) wrapper — or
anything else that drops specificity to 0 — loses to the built-in sheet regardless of order.
In dark mode the same holds for the scale tokens — radius, spacing, the type scale, motion
and fonts. dark.css declares those at the same specificity as :root, so one :root override
applies in both modes. Colors are different on purpose: the dark palette and the colors derived
from it are scoped to :root[theme="dark"] and win over a plain :root rule, so a light-tuned
color override does not leak into dark. To override a color in dark mode too, declare it there:
:root { --u-primary-color: #0B5FFF; }
:root[theme="dark"] { --u-primary-color: #5B9BFF; }⚠ Before 1.44.1 the scale tokens were scoped like the colors, so a
:rootoverride of radius or the type scale — including@iyulab/enterprise's preset — silently lost in dark mode, andprefers-reduced-motionwas ignored there because the durations were redeclared at the higher specificity.
Or override per-component via CSS custom properties:
u-button {
--btn-radius: 999px; /* pill buttons everywhere */
}Two generated references, both checked against their source by tests:
- design-tokens.md — every global token (role, semantic, palette), generated
from
light.css. - css-custom-properties.md — every per-component hook, generated from
each component's
@csspropJSDoc.
To fully manage your own design system:
await Theme.init({ useBuiltIn: false });Then provide all --u-* tokens yourself. Components will still read them from the document.
--u-font-base ships a system-UI stack. On the platforms most consumers target it already
resolves to a font that covers the script the OS is configured for — -apple-system and
BlinkMacSystemFont map to the platform UI font, which on a Japanese macOS is Hiragino Sans
and on a Korean Windows is Malgun Gothic. For most apps nothing needs to change.
Where it goes wrong is the mixed case: a browser whose UI language differs from the
content's script. Then the stack falls through to Helvetica/Arial, neither of which has
CJK coverage, and the browser substitutes per-glyph — often a different face than the rest
of the paragraph, with a different vertical rhythm. The symptom is subtle: text is readable
but the line looks uneven, and numerals or punctuation sit at a different weight.
Insert the script's face before the generic families and keep the rest of the stack intact — you are extending the fallback chain, not replacing it:
:root:lang(ja) {
--u-font-base: -apple-system, BlinkMacSystemFont, 'Hiragino Sans', 'Yu Gothic',
'Noto Sans JP', 'Segoe UI', sans-serif;
}
:root:lang(ko) {
--u-font-base: -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', 'Malgun Gothic',
'Noto Sans KR', 'Segoe UI', sans-serif;
}
:root:lang(zh-CN) {
--u-font-base: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei',
'Noto Sans SC', 'Segoe UI', sans-serif;
}
:root:lang(zh-TW) {
--u-font-base: -apple-system, BlinkMacSystemFont, 'PingFang TC', 'Microsoft JhengHei',
'Noto Sans TC', 'Segoe UI', sans-serif;
}
:root:lang(th) {
--u-font-base: -apple-system, BlinkMacSystemFont, 'Thonburi', 'Leelawadee UI',
'Noto Sans Thai', 'Segoe UI', sans-serif;
}
:root:lang(ar) {
--u-font-base: -apple-system, BlinkMacSystemFont, 'Geeza Pro', 'Segoe UI',
'Noto Sans Arabic', sans-serif;
}This requires <html lang="ja"> to be set. If your app switches language at runtime, set
lang on the same element you set theme on — both are document-level state.
Note the ordering rule. -apple-system/BlinkMacSystemFont stay first: when the OS
already matches the content language they resolve correctly and give you the platform's
own metrics. The named faces are the fallback for the mismatch case, and Noto Sans <script>
is last among them because it is the one a consumer might have installed but the OS would
not pick on its own.
--u-font-mono has no CJK coverage by design — the fixed-width faces in it are Latin-only.
Where code blocks contain CJK comments, the browser substitutes a proportional face for those
runs and the column alignment breaks. If that matters, append a CJK monospace explicitly:
:root:lang(ja) {
--u-font-mono: ui-monospace, 'Cascadia Code', Menlo, 'BIZ UDGothic', 'MS Gothic', monospace;
}⚠These tokens have no literal fallback. Unlike colours, font stacks are not wired into
each use site — the literals are long enough that baking them everywhere costs more than it
returns, and a missing font stack degrades to the browser default without breaking the layout
(a missing colour does break it). So a consumer who does not load the token sheet gets the
browser default font, not the stack above. Load the sheet or set font-family yourself.
⚠**--u-font-display/-modern/-rounded name webfonts** (Inter, Nunito, Quicksand)
that this package does not ship. They fall through to the system stack unless you load the
font yourself. They are opt-in accents, not defaults — --u-font-base never depends on a
webfont.
Alongside the font stacks, the sheet defines seven semantic steps — display, title,
subtitle, body, label, caption, overline — each with four properties
(-size, -weight, -leading, -tracking). Rebranding typography means overriding those
tokens, not restyling every screen:
:root {
--u-text-title-size: 22px;
--u-text-title-weight: 800;
}Use the steps from markup with u-text
rather than referencing the tokens in your own CSS:
<u-text level="1" variant="display">Document title</u-text>
<u-text variant="subtitle" tone="weak">One-line description</u-text>
<u-text variant="caption" tone="weak">Helper text</u-text>The visual step (variant) and the document level (level) are independent, so a
second-level heading can be the largest thing on the page without the outline lying about it.
⚠Referencing the tokens directly is right in one case — when you are authoring a component
with its own shadow CSS. Then write them with a fallback, e.g.
font-size: var(--u-text-title-size, 20px). If page markup is reaching for these tokens, that
place wants u-text instead.
⚠A step is four properties, not five. overline is not upper-cased for you: transforming
text changes what the author wrote and does nothing for CJK, which would make the same step
look different depending on the language. Apply text-transform at the site that wants it.
--u-density is the base font size of form controls, not a scale factor. It defaults to 14px
and is read by u-button (at its default size), u-button-group, and u-form — and because
u-form sets it as its own font-size, every control inside the form inherits it. Set it on an
ancestor to make a screen denser or roomier:
.app-shell { --u-density: 15px; } /* roomier */⚠ Values below
14pxare not supported. Control paddings and icon hit areas are sized inem, so they shrink with this token: at13pxten targets in this package fall under the 24×24 CSS px required by WCAG 2.2 SC 2.5.8 (checkbox, select trigger, the select's search field, rating stars, …), and at12pxtwelve do. The 24px guarantee this package makes holds at14pxand above. If you need a denser grid, scale the data surface instead —@iyulab/data-componentshas its own--dc-font-sizefor table text, which leaves the controls alone.
Tokens cover color and typography globally. For per-component presentation that is an application design decision rather than a library default, style the exposed CSS parts directly.
u-input::part(input) { font-size: 1.125rem; }
u-input::part(container) { border-radius: 0.5rem; }Each component's parts are listed in its @csspart JSDoc.
Components do not set text-align — they inherit the browser default. Alignment is a design decision, so apply it in your app:
/* Right-align numeric inputs */
u-input[type="number"]::part(input) {
text-align: right;
font-variant-numeric: tabular-nums; /* fixed-width digits */
}Attribute selectors like [type="number"] only work on properties the component reflects back to the host element. u-input reflects type, variant, and clearable, so the selector above matches whether you set it as an HTML attribute or as a JS/React property. For non-reflected properties, select by a class you control instead:
u-input.amount::part(input) { text-align: right; }font-variant-numeric: tabular-nums is what makes right alignment actually useful — it locks digit width so place values line up. Without it, proportional digits leave the columns ragged.
Why isn't right alignment the default for numeric inputs? Right alignment pays off when values are stacked vertically and place values are compared down a column — which is why
@iyulab/flex-tableright-aligns its number columns and cell editors. A standalone form field has no column to align against, and forcing it would silently shift existing layouts and push the value away from a currency symbol placed in theprefixslot. Opt in where the comparison context actually exists.
u-input does not format values (thousands separators, currency, locale decimals). Its value is the raw string the control holds, so it stays a faithful form primitive. Format for display in your app layer, or use a grid component such as flex-table when you need formatted, column-aligned numbers.
type="number" ships a click stepper (min/max/step aware — see the u-input reference), but the size of that step is a field-meaning decision the library cannot make: a quantity field wants step="1", a KRW amount usually wants step="1000", a two-decimal currency wants step="0.01". Set step per field; there is no built-in "currency" input type.