Account balance
Updated 2 minutes ago
Settled balance across all connected accounts, excluding pending transfers.
Twenty-six components, one file each, in src/components/. Every example on
this page is rendered by @arag itself: nothing here is a picture of a component.
Change a token and this page changes with it.
All twenty-six are written and imported by src/index.css, and so is
src/utilities.css: one <link> gets you the whole package,
not a subset.
src/components/button.css is the reference component: it states the conventions
the other twenty-five follow, so read that file before writing a twenty-seventh.
Eight conventions, established in button.css and held by every file in the
directory. Five of them are visible in the markup you write.
Blocks and elements only: .button, .button__icon,
.card__footer. There is no prefix and there are no modifier classes. A prefix
would be defending against a collision that layer order already settles: your application CSS
is unlayered, so it beats every component rule without a specificity fight. Each file wraps its
own content in @layer components, which means any single file is also correct when
linked on its own.
data-variant chooses the meaning, data-size chooses the scale, and
because an attribute holds one value per axis, two variants can never be applied at once. That
is the whole reason for the choice: class="button button--primary button--danger"
is expressible and meaningless, and data-variant is not. Boolean options are bare
attributes: data-block, data-striped,
data-interactive.
<button class="button" type="button">Default</button>
<button class="button" type="button" data-variant="primary">Primary</button>
<button class="button" type="button" data-variant="primary" data-size="sm">Small primary</button>There is not one state class anywhere in the twenty-six files. Hover is :hover, disabled
is :disabled or [aria-disabled="true"], the selected tab is
[aria-selected="true"], an open menu trigger is
[aria-expanded="true"], an invalid control is
[aria-invalid="true"], and a hidden tab panel carries hidden. The
application has to set those attributes for assistive technology anyway. Styling anything else
creates a second source of truth, and the two will drift: an .is-active that
outlives its aria-selected is a component that looks right and reads wrong.
This is the least obvious and most useful part of the design. The base rule of each component
declares a set of custom properties namespaced to that component:
--button-bg, --card-padding, --menu-item-fg, and then
reads them. Variants do nothing except re-point those locals, which is why a variant is four
declarations rather than a restated block.
The consequence for a consumer: you can retheme one instance by setting a local. No variant, no new class, no override file, nothing added to the package.
<button class="button" type="button"
style="--button-bg: var(--color-surface-sunken);
--button-border: var(--color-brand-border);
--button-padding-inline: var(--space-8);
--button-radius: var(--radius-full);">Send</button>The namespace is not decoration. Custom properties inherit, so an un-namespaced local such as
--bg set on a card would leak into every button, badge and field inside it. A
local set on a wrapper does not reach the instances below it: every component
re-declares its locals on its own root, so the inherited value is overridden there. Set the
locals on the element and only that one moves; to move a whole region, select the components
inside it, as in .checkout .button below.
No raw values: every colour, length, radius, shadow, duration and easing in the component layer comes from a token, and a literal in a component file is a bug. And no priority-raising declarations anywhere: overriding is a layer-order question, never a specificity fight.
Three ways, in order of preference. Set a local on the instance. Set a local on every instance in a region with a descendant selector, as below — not on the ancestor itself, where it is overridden by the component's own root and does nothing. Or write ordinary unlayered CSS in your application, which sits above all six of the system's layers and wins by existing. See Cascade layers for the declared order, and Theming for the colour roles the locals point at.
/* Unlayered application CSS. No specificity games, no priority raising. */
.checkout .button {
--button-radius: var(--radius-none);
}The action control. Use a real <button type="button"> for an action and a
real <a> for navigation; the class styles either, and the element is what
decides how it behaves.
<button class="button" type="button" data-variant="primary">
<svg class="button__icon" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="…"/></svg>
<span class="button__label">Create a new settlement order</span>
</button>
<button class="button" type="button">Cancel</button>Seven variants. The four solid ones read their hover and active fills from the derived state tokens, so a theme change moves them without anyone hand-picking a shade.
<button class="button" type="button">Default</button>
<button class="button" type="button" data-variant="primary">Primary</button>
<button class="button" type="button" data-variant="success">Success</button>
<button class="button" type="button" data-variant="warn">Warn</button>
<button class="button" type="button" data-variant="danger">Danger</button>
<button class="button" type="button" data-variant="outline">Outline</button>
<button class="button" type="button" data-variant="quiet">Quiet</button>
<button class="button" type="button" data-variant="link">Link</button><button class="button" type="button" data-size="sm">Small</button>
<button class="button" type="button" data-size="lg">Large</button>
<button class="button" type="button" data-shape="pill" data-variant="outline">Pill</button>
<!-- An icon-only button MUST carry an accessible name. -->
<button class="button" type="button" data-icon-only aria-label="Close">
<svg class="button__icon" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="…"/></svg>
</button>
<button class="button" type="button" data-loading>
<span class="button__spinner" aria-hidden="true"></span>
<span class="button__label">Saving</span>
</button>
<button class="button" type="button" disabled>Disabled</button>data-loading keeps the label in the flow rather than swapping it for a spinner,
so the button does not change size mid-interaction and the row of controls around it does not
jump. The spinner's animation runs on --duration-loop, which sits outside the set
that reduced motion collapses (collapsing a loop speeds it up instead of stopping it), so the
component suppresses the loop by hand under prefers-reduced-motion and leaves a
static ring.
.button-group is a row of related buttons that reads as one control. It
wraps; it does not overflow. data-attached collapses the gap and
the doubled edge between neighbours, and raises the hovered, focused or pressed button so the
shared border never looks broken. It squares only the corners that meet a neighbour; the
outer corners keep each button's own radius, so an attached row of sm buttons
has the same corner as one sm button.
<div class="button-group">
<button class="button" type="button" data-variant="primary">Send</button>
<button class="button" type="button" data-variant="quiet">Cancel</button>
</div>
<div class="button-group" data-attached>
<button class="button" type="button" aria-pressed="true">Light</button>
<button class="button" type="button" aria-pressed="false">Dark</button>
<button class="button" type="button" aria-pressed="false">System</button>
</div>| Attribute | Values | Does |
|---|---|---|
data-variant |
primary, success, warn, danger,
outline, quiet, link |
Re-points the colour locals. Omitted is the neutral surface button. |
data-size | sm, lg |
Min block size, padding, type step and radius. Omitted is base. |
data-shape | pill |
Radius only, to --radius-full. |
data-icon-only | bare | Square: inline size equal to the min block size, aspect ratio 1. Requires an accessible name. |
data-block | bare | Fills its container. Opt-in, never the default. |
data-loading | bare | cursor: progress, and dims .button__label while the
spinner runs. |
data-attached | bare | On .button-group. Collapses the gap and the doubled edges. |
disabled / aria-disabled="true" | native | Both are styled, in ink rather than opacity: the sunken fill,
--color-text-subtle and --color-border-subtle, the same on
every variant that has a fill or an edge. outline keeps its transparent
fill and loses its strong edge. quiet and link have neither,
so they keep their own label colour and dim with
--state-disabled-opacity. Use the ARIA form when the control must stay focusable so a
screen reader can reach and explain it. |
aria-pressed="true" | native | A toggle's on state: the selected tint, a --color-brand edge and
--color-text label. For the default, outline or
quiet button; on a solid variant it replaces the fill. |
| Property | Default | Controls |
|---|---|---|
--button-bg | var(--color-surface) | Resting fill. |
--button-fg | var(--color-text) | Label and icon colour. |
--button-border | var(--color-border) | Resting edge. |
--button-bg-hover | var(--color-surface-hover) | Fill on :hover. |
--button-bg-active | var(--color-surface-active) | Fill on :active. |
--button-border-hover | var(--color-border-strong) | Edge on :hover. |
--button-min-block-size | var(--size-control) | Minimum height, and the side of an icon-only button. A coarse pointer restores var(--size-touch) for every button. |
--button-padding-inline | var(--space-4) | Inline padding. |
--button-padding-block | var(--space-2) | Block padding. |
--button-font-size | var(--text-sm) | Label type step. |
--button-radius | var(--radius-md) | Corner radius. |
aria-label, translated with the rest of the UI. The glyph is not a
name..button__icon aria-hidden="true". It is decorative, and an
unhidden icon leaks whatever it exposes into the button's accessible name.aria-disabled="true" over disabled where the user needs to
be told why the action is unavailable; a disabled control is not
focusable and cannot be reached to be explained.min-block-size plus padding.
data-icon-only is the one exception, and it is allowed precisely because its
content is a glyph rather than a translated string..button__label carries no nowrap and no truncation. A clipped
translated label is a bug, not a layout strategy.data-block is opt-in on purpose: a full-width button in a wide layout is a
very large target for a very small action.A label, a control, and its help or error text, plus the pieces a form-heavy product needs
around it: affix groups, checkboxes and radios, switches, fieldsets, and a row of fields that
collapses to a stack on its own. The parts read the block's locals by inheritance, so a
.field__* part requires a .field ancestor.
Eight digits, no country code.
Enter an amount greater than zero.
<div class="field">
<label class="field__label" for="f-phone" data-required>Phone number</label>
<input class="field__control" id="f-phone" type="tel" required aria-describedby="f-phone-help">
<p class="field__help" id="f-phone-help">Eight digits, no country code.</p>
</div>
<div class="field">
<label class="field__label" for="f-amount">Account balance</label>
<input class="field__control" id="f-amount" type="text" inputmode="numeric"
aria-invalid="true" aria-describedby="f-amount-err">
<p class="field__error" id="f-amount-err" role="alert">Enter an amount greater than zero.</p>
</div>The invalid state is driven by aria-invalid="true" and by nothing else. That
attribute is what a screen reader announces, so making it the styling hook as well means the
two can never drift: there is no .is-invalid to add and then forget to remove.
Border and focus ring read the same local, so an invalid control's edge and its ring can never
disagree.
.field__control is one class for input, select and
textarea. A textarea with no rows gets
--size-textarea rather than the single-line control height: translated text runs
longer and needs somewhere to put it.
<div class="field-row">
<div class="field">
<label class="field__label" for="f-select">Order type</label>
<select class="field__control" id="f-select">
<option>Market</option>
<option>Limit</option>
</select>
</div>
<div class="field">
<label class="field__label" for="f-readonly">Reference</label>
<input class="field__control" id="f-readonly" type="text" value="MN-1042-7731" readonly>
</div>
</div>
<div class="field">
<label class="field__label" for="f-note">Note</label>
<textarea class="field__control" id="f-note"></textarea>
</div>.field-row collapses to a stack with no media query. That is deliberate rather
than a shortcut: the breakpoint belongs to the content, not to the viewport. A pair of labels
that fits a desktop row in English does not fit the same row once it is translated, and
minmax(min(track, 100%), 1fr) is what lets a column give up and take
the full inline size instead of overflowing it.
input[type="file"] carries .field__control like every other control,
so its box (background, border, radius, padding, hover / active / focus-visible / disabled)
is already styled with no code added for it. The one part that rule could never reach is
::file-selector-button, the native button rendered inside the control, which is
styled here on the same tokens as .button[data-variant="outline"], now the neutral
secondary: transparent fill, --color-text, --color-border and
--color-surface-hover on hover. Both pairs are already gated in
scripts/contrast.mjs, so this control adds no new colour pair for the gate to
learn.
PDF or image, up to 10MB.
<div class="field">
<label class="field__label" for="f-file" data-required>Attach a document</label>
<input class="field__control" id="f-file" type="file" required
accept=".pdf,.jpg,.jpeg,.png" aria-describedby="f-file-help">
<p class="field__help" id="f-file-help">PDF or image, up to 10MB.</p>
</div>
<div class="field">
<label class="field__label" for="f-file-multi">Supporting documents (disabled)</label>
<input class="field__control" id="f-file-multi" type="file" multiple disabled>
</div>The filename the browser prints after the button is UA-rendered text with no selector that
reaches it, so white-space: normal and overflow-wrap: anywhere are
set on the control itself in case the engine inherits them: a long filename wraps
inside the control in Chromium rather than being clipped or forcing the control wider.
.field__group attaches a prefix and a suffix to a control. The wrapper carries
the border, the radius and the fill while the input inside it goes transparent, so the three
boxes render as one continuous control, and focus is handled on the wrapper with
:focus-within so the ring surrounds the affixes too.
The balance is not sufficient.
<div class="field__group">
<span class="field__prefix" aria-hidden="true">₮</span>
<input class="field__control" id="f-sum" type="text" inputmode="numeric" value="1 250 000">
<span class="field__suffix" aria-hidden="true">MNT</span>
</div>
<!-- Invalid: BOTH hooks, from the same event. -->
<div class="field__group" data-invalid>
<span class="field__prefix" aria-hidden="true">₮</span>
<input class="field__control" id="f-sum-bad" type="text" value="0"
aria-invalid="true" aria-describedby="f-sum-bad-err">
</div>
<p class="field__error" id="f-sum-bad-err" role="alert">The balance is not sufficient.</p>.field__group[data-invalid] and .choice[data-disabled] duplicate a
state that really lives on the descendant input. CSS cannot reach an ancestor without
:has(), and :has() would raise this package's Firefox floor from
120 to 121, so the wrapper carries its own attribute and the markup must set
both, from the same event. aria-invalid on the input stays the
authoritative signal; data-invalid is presentation only. Likewise
data-disabled is presentation only: the input still needs the real
disabled attribute, which is what removes it from the tab order and from form
submission.
<fieldset class="fieldset">
<legend class="fieldset__legend">User account settings</legend>
<div class="fieldset__body">
<label class="choice">
<input class="choice__input" type="checkbox" checked>
<span class="choice__label">Payment details were updated successfully.</span>
</label>
<!-- data-disabled on the wrapper AND disabled on the input. -->
<label class="choice" data-disabled>
<input class="choice__input" type="radio" name="notify" disabled>
<span class="choice__label">Notify me by post (unavailable)</span>
</label>
<label class="choice">
<span class="switch">
<input class="switch__input" type="checkbox" role="switch" checked>
<span class="switch__thumb" aria-hidden="true"></span>
</span>
<span class="choice__label">Light mode</span>
</label>
</div>
</fieldset>The switch is a real checkbox with appearance: none: the input is the
track, and the thumb is a sibling <span> rather than a pseudo-element,
because Firefox does not generate pseudo-element boxes on <input> and this
package's Firefox floor is 120. Because the control stays a checkbox, keyboard operation comes
free: it is in the tab order, Space toggles it, it submits with the form, and the focus ring
from base.css lands on it. No tabindex, no key handler, no
JavaScript. role="switch" is required: it is what makes assistive technology
announce on/off rather than checked/unchecked.
| Attribute | On | Does |
|---|---|---|
data-required | .field__label |
Draws the marker. Decorative only: the control still needs required or
aria-required="true", which is what is announced and what the form
validates against. |
aria-invalid="true" | .field__control |
The invalid state. Border, hover border and focus ring all move to the danger family. |
data-invalid | .field__group |
The duplicate hook the affix wrapper needs. Set it together with
aria-invalid on the input inside. |
data-disabled | .choice |
Dims the wrapper and its label. Set it together with disabled on the
input inside. |
role="switch" | .switch__input |
Required. Changes the announcement from checked/unchecked to on/off. |
type="file" | .field__control |
Styles ::file-selector-button on the outline button's tokens. The
control's own box needs no attribute: it is already .field__control. |
rows | textarea.field__control |
Present: your height wins. Absent: --size-textarea. |
readonly, disabled | .field__control |
Both get a sunken fill, and they are told apart in ink: readonly keeps
the full text colour and the --color-border edge, because it is still a
control; disabled drops to --color-text-subtle and
--color-border-subtle, with no opacity. readonly is
qualified to input and textarea, because every
<select> matches :read-only. |
| Property | Default | Controls |
|---|---|---|
--field-gap | var(--space-2) | Space between label, control and description. |
--field-label-color | var(--color-text) | Label colour. |
--field-help-color | var(--color-text-muted) | .field__help colour. |
--field-error-color | var(--color-danger-text) | .field__error colour. |
--field-marker-color | var(--color-danger) | The required marker. |
--field-affix-color | var(--color-text-muted) | Prefix and suffix text. |
--field-affix-border | var(--color-border-subtle) | The rule between affix and control. |
--field-bg | var(--color-surface) | Control fill. |
--field-fg | var(--color-text) | Control text. |
--field-border | var(--color-border) | Control edge. |
--field-border-hover | var(--color-border-strong) | Edge on hover. |
--field-ring-color | var(--focus-ring-color) | Focus ring and the focused edge, together. |
--field-radius | var(--radius-md) | Control radius. |
--field-padding-inline | var(--space-4) | Control and affix inline padding. |
--field-padding-block | calc((var(--field-min-block-size) - var(--field-font-size) * var(--field-line-height) - var(--border-width-1) * 2) / 2) | Control block padding: half of whatever room is left in --field-min-block-size once one line box and two borders are subtracted, so the control's own height still governs. textarea keeps a plain var(--space-2); a native select ignores line-height, so this resets to var(--space-0) there and the UA centres its one line inside --field-min-block-size instead. |
--field-min-block-size | var(--size-control) | Minimum control height. A coarse pointer restores var(--size-touch). |
--field-font-size | var(--text-base) | Control and affix type step. |
--field-line-height | var(--leading-snug) | Control line height. A default textarea keeps var(--leading-normal). |
--choice-gap | var(--space-3) | Box to label, on .choice. |
--choice-label-color | var(--color-text) | .choice__label colour. |
--choice-control-size | var(--space-5) | Checkbox / radio side. |
--choice-control-offset | var(--space-1) | Optical nudge that centres the box on the label's first line. |
--switch-track-inline-size | calc(var(--space-6) * 2) | Track length. |
--switch-track-block-size | var(--space-6) | Track height, and the thumb's travel basis. |
--switch-thumb-inset | var(--space-1) | Thumb inset from the track. |
--switch-track-bg | var(--color-border) | Track when off. |
--switch-track-bg-checked | var(--color-brand-solid) | Track when on. |
--switch-thumb-bg | var(--color-white) | Thumb fill. |
--fieldset-gap | var(--space-5) | Space between fields in .fieldset__body. |
--fieldset-border | var(--color-border) | Fieldset edge. |
--fieldset-radius | var(--radius-lg) | Fieldset radius. A fieldset holds controls, so it takes the container rung. |
--fieldset-padding | var(--space-5) | Fieldset padding. |
--field-row-min | var(--size-column-min) | The column width below which .field-row stacks. |
--field-row-gap | var(--space-5) | .field-row gap. |
--field-file-button-bg | transparent | ::file-selector-button fill, at rest. |
--field-file-button-fg | var(--color-text) | ::file-selector-button text. |
--field-file-button-border | var(--color-border) | ::file-selector-button edge. |
--field-file-button-bg-hover | var(--color-surface-hover) | ::file-selector-button fill on hover. |
--field-file-button-bg-active | var(--color-surface-active) | ::file-selector-button fill on press: the same derivation .button[data-variant="outline"] uses. |
.field__label must be a real <label>, carrying
for or wrapping the control. A styled <span> gives the
control no accessible name and no second click target, and neither a placeholder nor a
nearby heading is a substitute..field__help and .field__error must both be referenced from the
control with aria-describedby, or they exist for sighted users only.role="alert", or aria-live="polite" on a container already in the
DOM before the message arrives. Moving focus is not enough to announce text that appeared
while focus was somewhere else.aria-describedby. Mark the affix aria-hidden="true" where it merely
repeats the label..fieldset__legend must be a real <legend> and the first
child of the <fieldset>. That pair is the accessible grouping, and it is
the only correct way to label a set of radios: a heading above the box is not one..choice, or point a
<label for> at the input..choice aligns to start, never center. A two-line
label with a vertically centred box reads as broken alignment, and two-line labels
are the common case here rather than the exception.A surface that groups related content: header, media, body, footer. Market rows, account summaries, kiosk tiles. Header, footer and media bleed to the card's inner edge with a negative inline margin and put the padding back on themselves, so a divider or an image spans the full width instead of floating inside the padding.
Updated 2 minutes ago
Settled balance across all connected accounts, excluding pending transfers.
<article class="card">
<div class="card__header">
<h4 class="card__title">Account balance</h4>
<p class="card__subtitle">Updated 2 minutes ago</p>
</div>
<div class="card__body">
<p>Settled balance across all connected accounts, excluding pending transfers.</p>
</div>
<div class="card__footer">
<div class="button-group">
<button class="button" type="button" data-size="sm" data-variant="primary">Send</button>
<button class="button" type="button" data-size="sm" data-variant="quiet">History</button>
</div>
</div>
</article>Three variants and two size steps. Size changes padding and gap, and sm steps
the radius down one rung with them: the type inside a card is set by its parts, not by its
size.
Flat: the edge alone, no shadow.
Raised surface and shadow. Both, always.
Sunken. For context around content, not content itself.
One real link, stretched over the whole card.
Hover and focus both land on the card.
<div class="card-grid">
<article class="card" data-variant="outlined" data-size="sm"> … </article>
<article class="card" data-variant="elevated"> … </article>
<article class="card" data-variant="subtle"> … </article>
<!-- The card is NOT a link. It CONTAINS one. -->
<article class="card" data-interactive>
<div class="card__header">
<h4 class="card__title"><a class="card__link" href="#card">Create a new settlement order</a></h4>
<p class="card__subtitle">One real link, stretched over the whole card.</p>
</div>
<div class="card__body"><p>Hover and focus both land on the card.</p></div>
</article>
</div>.card-grid answers to the content's own minimum rather than to a viewport width
someone guessed, so there is no media query. It stretches cards to the tallest in the row:
stretch only ever grows the shorter card, never clips it, which is also what gives
.card__footer's automatic margin something to push against, so footers line up
across a row of unequal cards.
.card__media is the one part with no live example on this page, because the docs
site ships no images. The markup is:
<article class="card">
<div class="card__media" style="--card-media-aspect: 16 / 9;">
<img src="market.avif" alt="" width="960" height="540">
</div>
<div class="card__body"> … </div>
</article>The card deliberately does not clip its own overflow: that would clip the focus ring
:focus-within draws on it, and an outline that is half there is worse than none,
so .card__media does its own clipping instead, on a box with no ring to lose. Its
radius is the card's radius minus the card's border width, so the clip follows the inside of
the border rather than sitting a hair proud of it. As :first-child or
:last-child it goes edge to edge and squares the corners that now meet the
content.
| Attribute | Values | Does |
|---|---|---|
data-variant |
outlined, elevated, subtle |
Fill, edge and shadow. Omitted, the card carries a hairline --shadow-xs at
rest. outlined drops that shadow entirely, for a card nested inside another
card or in a dense grid where even a hairline repeated many times reads as noise. |
data-size | sm, lg |
Padding and gap. sm also steps the radius down to
--radius-md. |
data-interactive | bare | Pointer cursor, hover and active fills, and the one focus ring, drawn on the card via
:focus-within; the stretched link draws none of its own. Establishes the
containing block for
.card__link. |
| Property | Default | Controls |
|---|---|---|
--card-bg | var(--color-surface) | Fill. The hover and active fills are derived from it. |
--card-fg | var(--color-text) | Text colour. |
--card-border | var(--color-border) | Edge. The same edge a button wears; elevated drops it to --color-border-subtle because its shadow already draws the boundary. |
--card-border-hover | var(--color-border-strong) | Edge when interactive and hovered. |
--card-border-width | var(--border-width-1) | Edge width, and the amount the media radius is inset by. |
--card-divider | var(--color-border-subtle) | Header and footer rules. |
--card-radius | var(--radius-lg) | Corner radius. |
--card-padding | var(--space-6) | Padding, and the bleed the parts pull against. |
--card-gap | var(--space-5) | Space between parts. |
--card-shadow | var(--shadow-xs) | Elevation. |
--card-bg-hover | derived from --card-bg | Hover fill. Re-pointing --card-bg moves it for free. |
--card-bg-active | derived from --card-bg | Active fill. |
--card-media-aspect | auto | On .card__media. Aspect ratio of the image or video inside. |
--card-grid-min | var(--size-card-min) | On .card-grid. Column width below which the grid drops a column. |
--card-grid-gap | var(--space-6) | On .card-grid. |
href, no
role="link", no tabindex, no click handler duplicating one. The
correct pattern is that the card contains one real <a>, normally
wrapping the title, carrying .card__link; that link is the only thing in the
tab order and the card shows the focus for it. Accessible name, context menu, middle-click
and "open in new tab" all keep working, and none of them survive a click handler on a div.position: relative to
come back out above the overlay..card__title a real heading. A card in a grid is something a
screen-reader user navigates by heading, and a <div> that merely looks like
a title is invisible to that.data-variant="elevated" sets both the raised surface and the shadow,
and that is not belt-and-braces. In light mode --color-surface and
--color-surface-raised are the same colour and the shadow carries the whole
effect; in dark mode the shadow is nearly invisible and the two surfaces differ by a full
ramp step. Colour alone is a no-op in light, shadow alone is a no-op in dark, and either on
its own ships a card that is flat in exactly one theme.Small status and count labels: order status, market state, account flags. Two independent
axes: data-variant chooses the colour family (what the badge means) and
data-emphasis chooses how loud it is.
<span class="badge">Neutral</span>
<span class="badge" data-variant="brand">Brand</span>
<span class="badge" data-variant="success">
<span class="badge__dot"></span>Transfer confirmed
</span>
<span class="badge" data-variant="warn">Pending review</span>
<span class="badge" data-variant="danger">Failed</span><span class="badge" data-emphasis="solid">Neutral</span>
<span class="badge" data-emphasis="solid" data-variant="brand">Brand</span>
<span class="badge" data-emphasis="solid" data-variant="success">Success</span>
<span class="badge" data-emphasis="solid" data-variant="warn">Warn</span>
<span class="badge" data-emphasis="solid" data-variant="danger">Danger</span><span class="badge" data-size="sm">Small</span>
<span class="badge" data-size="lg">Large</span>
<span class="badge" data-shape="pill" data-variant="brand">Pill</span>
<!-- Digits, not a translated string — so settled geometry is correct here. -->
<span class="badge" data-numeric>1</span>
<span class="badge" data-numeric>12</span>
<span class="badge" data-numeric data-variant="danger" data-emphasis="solid">1284</span>Two axes rather than one is a deliberate call. Folding them together would mean ten values:
brand, brand-solid, success,
success-solid, and so on, and there would then be no way to say "same meaning,
more emphasis" without knowing both halves of the name. With two axes a consumer flips emphasis
across a whole list without touching any of their meanings, and a new family costs one rule per
axis.
| Attribute | Values | Does |
|---|---|---|
data-variant |
neutral, brand, success, warn,
danger |
The colour family. Pairs the family's -subtle fill with its
-text foreground. |
data-emphasis | solid |
Swaps to the family's -solid / -on-solid pair and drops the
edge to transparent. |
data-size | sm, lg |
Padding, type step, gap and dot size together. No block size at any step. |
data-shape | pill |
Radius only. |
data-numeric | bare | The count badge: circular at one digit, growing from there, with tabular figures. |
| Property | Default | Controls |
|---|---|---|
--badge-bg | light-dark(var(--color-surface-sunken), var(--color-surface-raised)) | Fill. The neutral chip is sunken in light and raised in dark: a chip sits on a ground, and dark --color-surface-sunken is darker than the page, which reads as a hole rather than a chip. |
--badge-fg | var(--color-text-muted) | Label colour. |
--badge-dot-color | currentColor | .badge__dot fill, split out from --badge-fg so a variant can send the dot somewhere the label does not follow. warn does that: the label stays the neutral -text colour and only the dot carries the bright --color-warn. brand does too, with --color-brand, so under a preset whose text is ink (orange, mono) the dot is what stays in colour. data-emphasis="solid" resets it back to currentColor (later in source order than the variant rules), so a written .badge__dot inside a solid badge matches its own label instead of painting orange on orange. A plain warn badge draws this dot without any markup: see the decisions below. |
--badge-border | var(--color-border-subtle) | Edge. |
--badge-padding-inline | var(--space-3) | Inline padding. |
--badge-padding-block | var(--space-1) | Block padding, and half the count badge's minimum width. |
--badge-radius | var(--radius-sm) | Corner radius. |
--badge-font-size | var(--text-sm) | Type step. The count badge's circle tracks it. |
--badge-gap | var(--space-2) | Dot to label. |
--badge-dot-size | var(--space-3) | .badge__dot diameter. |
.badge__dot is decorative: it repeats meaning the label already carries and
the variant colour already signals. Keep it out of the accessible name: an empty
<span> with no text content, or aria-hidden="true" if it ever
carries any. Colour and the dot are never the only channel; the label is always present.<span class="badge" data-variant="warn">Pending review</span> with no
.badge__dot: a generated ::before paints it in
--badge-dot-color (the bright --color-warn). Warn is the one variant
that cannot do without it. Its label is the neutral text colour in both themes, and in dark its
fill is the plain surface, so without the dot a warn badge would be a neutral chip with an
orange edge. If the markup does carry a .badge__dot inside a warn badge it is
hidden, so there is never a second dot. Solid warn (data-emphasis="solid") and
the count badge (data-numeric) are excluded: the orange fill already says warn,
and a dot would break the count's circle. Every other variant takes its dot only when the
markup writes one.<span>.-text on -solid, say) is not
safe, which is why every variant re-points both locals together and never one of them.white-space: nowrap,
no inline size, no maximum inline size and no text-overflow in the base rule. A
translated status string is often far longer than its English source, and a clipped status is
a status the user cannot read. data-numeric is the one exception, because its content is
digits rather than a translated string.An inline message: form errors, system notices, settlement warnings. Not a toast: an alert sits in the flow, next to the thing it is about, and stays until the condition clears. Three grid columns: icon, content, dismiss.
<div class="alert" data-variant="success" role="status">
<svg class="alert__icon" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="…"/></svg>
<div class="alert__title">Transfer confirmed</div>
<div class="alert__body">Payment details were updated successfully.</div>
<button class="button alert__dismiss" type="button"
data-variant="quiet" data-icon-only aria-label="Close">
<svg class="button__icon" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="…"/></svg>
</button>
</div>Four families. info is the brand family: an informational notice is the product
speaking in its own voice, so it does not need a fifth hue. An alert is perfectly legal with a
body and no title.
<!-- info and success are polite: role="status". -->
<div class="alert" data-variant="info" role="status">
<svg class="alert__icon" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="…"/></svg>
<div class="alert__body">Settlement runs at 16:00 local time.</div>
</div>
<!-- warn and danger interrupt: role="alert". Actions compose with .button-group. -->
<div class="alert" data-variant="warn" role="alert">
<svg class="alert__icon" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="…"/></svg>
<div class="alert__title">Your session expires in two minutes</div>
<div class="alert__body">Anything you have not submitted will be lost.</div>
<div class="alert__actions button-group">
<button class="button" type="button" data-size="sm" data-variant="outline">Stay signed in</button>
<button class="button" type="button" data-size="sm" data-variant="quiet">Sign out</button>
</div>
</div>The icon is placed in row 1 only, so it pins to the first line of the message and stays there while the body wraps to as many lines as the string needs. The column gap is deliberately zero and the inline gaps are carried by margins on the icon and the dismiss button instead: an alert with no icon would otherwise still pay for that empty track's gap and sit visibly indented from its neighbours.
| Attribute | Values | Does |
|---|---|---|
data-variant |
info, success, warn, danger |
Fill to the family's -subtle, text to its -text, both edges
to its -border. Omitted is the neutral alert: sunken in light, raised in dark. |
role | status, alert |
Markup, not styling, and urgency picks it. See below. |
| Property | Default | Controls |
|---|---|---|
--alert-bg | light-dark(var(--color-surface-sunken), var(--color-surface-raised)) | Fill. The neutral alert is sunken in light and raised in dark: an alert sits on a ground, and dark --color-surface-sunken is darker than the page, which reads as a hole rather than a message. On a raised ground the fill merges and the 1px border carries the edge. |
--alert-fg | var(--color-text) | Body text. |
--alert-icon-fg | currentColor | Icon colour, split out from --alert-fg so a variant can point the icon somewhere the text does not follow. warn does: text stays the normal reading colour, and only the icon (plus the border and fill) carries the orange. info does too, with --color-brand, so under orange or mono the icon still marks the alert in colour. |
--alert-border | var(--color-border-subtle) | All four edges, one even width. |
--alert-padding-block | var(--space-4) | Block padding. |
--alert-padding-inline | var(--space-5) | Inline padding. |
--alert-radius | var(--radius-md) | Corner radius. |
--alert-gap-inline | var(--space-4) | Icon and dismiss margins: the real column gaps. |
--alert-gap-block | var(--space-2) | Row gap between title, body and actions. |
danger and warn want
role="alert", which is assertive and interrupts: right for a failed transfer or
a settlement about to lapse. info and success want
role="status", which waits for a pause. Never put role="alert" on a
success message: assertive interruption for good news trains people to ignore the assertive
channel..alert and then fill it; injecting the whole element announces nothing in
several screen readers.aria-describedby, so it is reachable from the control..alert__icon is decorative: the variant colour and the message both already
carry the meaning. Mark it aria-hidden="true"..alert__dismiss must be a real <button type="button"> with
an accessible name, translated with the rest of the UI. Never a bare
<div> with a click handler, never an <a href="#">, and
never a bare × character as the name. It is a grid item in column 3 rather than an absolutely
positioned box, so it reserves its own space and can never sit on top of a title that grew to
two lines in translation..alert__dismiss never takes data-size="sm": it is the primary
way out of the alert, and the compact 32px size is too dense for it. A coarse pointer restores
every button to the 44px touch floor regardless of size; the difference is the more generous
36px default that stays on a precise pointer..alert__actions.<strong> inside an alert.
<strong> inherits --alert-fg, which measures 6.02:1 at worst
across all four variants in both themes, and it is bold: weight is the whole treatment.
Reaching for a solid role here would make it worse, not better: those are fill roles chosen
to carry -on-solid text, and on a subtle background they land well under
AA.Data tables: the OTC ledger, market lists, distribution reports. The file is thin on purpose.
reset.css already collapses borders and base.css already gives every
table its width, its cell padding, its gridline colour, its sunken header fill, its caption
styling and its right-aligned [data-numeric] cells. What the component layer adds
is what a data table needs and a prose table does not: a scroll container, density,
zebra, hover, a sticky header and an empty state.
| Reference | Payment details | Status | Account balance |
|---|---|---|---|
| MN-1042-7731 | Bank transfer, domestic | Transfer confirmed | 1 250 000 |
| MN-1042-7732 | Card settlement | Pending review | -84 500 |
| MN-1042-7733 | Bank transfer, cross-border | Failed | 0 |
<div class="table-wrap" tabindex="0" role="region" aria-labelledby="ledger-caption">
<table class="table" data-striped data-hoverable data-sticky-header>
<caption id="ledger-caption">OTC ledger — settled orders, current period</caption>
<thead>
<tr>
<th scope="col">Reference</th>
<th scope="col">Payment details</th>
<th scope="col">Status</th>
<th scope="col" data-numeric>Account balance</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">MN-1042-7731</th>
<td>Bank transfer, domestic</td>
<td><span class="badge" data-variant="success" data-size="sm">Transfer confirmed</span></td>
<td data-numeric>1 250 000</td>
</tr>
</tbody>
</table>
</div>Density moves cell padding and nothing else: not the type scale, not the borders, not the
touch targets of controls inside cells. Compact is for a trader watching a ledger on a large
screen; comfortable is for the kiosk, where the row is also a tap target.
data-bordered opts back into the full gridline box for the cases that need it, a
comparison matrix or a report read column-wise.
| Market | Last | Change |
|---|---|---|
| NPY / MNT | 3 480 | +1.2% |
| NPX / MNT | 912 | -0.4% |
| Market | Last | Change |
|---|---|---|
| No transactions in the selected period. | ||
<table class="table" data-density="compact" data-bordered> … </table>
<!-- The empty state is one row inside tbody. colspan must match the real
column count: it cannot be computed without :has(), and a mismatched
span leaves a ragged edge. -->
<table class="table" data-density="comfortable">
<tbody>
<tr><td class="table__empty" colspan="3">No transactions in the selected period.</td></tr>
</tbody>
</table>| Attribute | Values | Does |
|---|---|---|
data-density |
compact, comfortable |
On .table. Re-points the two cell padding locals. Omitted is base. |
data-bordered | bare | On .table. Full gridline box instead of horizontal rules only. |
data-striped | bare | On .table. Zebra on even body rows. |
data-hoverable | bare | On .table. Row hover fill. |
data-sticky-header | bare | On .table. Sticks thead th to the nearest scrolling
ancestor at --z-raised. |
data-numeric | bare | On any th or td. End alignment and tabular figures come
from base.css; the component adds the wrapping ban. |
tabindex="0", role="region",
aria-labelledby | markup | Required on .table-wrap. See below. |
| Property | Default | Controls |
|---|---|---|
--table-border | var(--color-border-subtle) | Gridline colour. |
--table-stripe | var(--color-surface-sunken) | Zebra fill. |
--table-hover | var(--color-surface-hover) | Row hover fill. |
--table-cell-padding-block | var(--space-3) | Cell block padding. .table__empty re-points this on itself to buy its breathing room. |
--table-cell-padding-inline | var(--space-4) | Cell inline padding. |
overflow-x on a
<table> does nothing useful, so the table is wrapped in
.table-wrap. A scroll region that cannot be focused is a keyboard trap in
reverse, so the wrapper needs tabindex="0" to be arrow-scrollable,
role="region" so its name is announced, and
aria-labelledby pointing at the table's <caption>.<caption>, not a <div> above the table.
The native element is programmatically the table's accessible name, and it is what
aria-labelledby should point at. A div above the table is invisible to that, and
moving it inside the scroll container would make it scroll away from the table it names.[data-numeric] is the one place white-space: nowrap is
correct. A figure broken across two lines is not slow to read, it is misread: 1 250
000 wrapped after the second group reads as two numbers, and a minus sign left alone on line
one is a reconciliation error waiting to happen. Digits are also not translated, so the
long-text rule that governs every other cell does not apply. Every other cell wraps, and when
the table as a whole no longer fits, .table-wrap scrolls it. Wrap first, scroll
second, truncate never: shrinking the type, hiding columns or truncating cells each hide a
number somebody is reconciling against a bank statement.<tr>. A row background
is painted beneath its cells, and base.css gives every <th>
an opaque sunken fill, so a <th scope="row"> would punch an unstriped,
unhovered hole through the middle of the row. Hover is declared after zebra on purpose:
identical specificity means source order decides, and hover must beat the stripe. Do not
reorder them.<th> has an opaque fill from
base.css. Make it transparent anywhere and the header text lands on top of
moving figures. It sits at --z-raised, not --z-sticky: it only has
to sit above the rows in its own table, and --z-sticky would also put it above
any menu a cell opens..table__cell--numeric class. "This cell holds a
figure" is a property of the data, not of this component, so a bare table in a rendered
report gets the same treatment and a server template can emit the attribute from the column
type without knowing which class the page uses.A tab strip and its panels. Variant, size, orientation and overflow are all data-attributes on
the .tabs root, so one attribute restyles the whole widget. The examples below are
static markup: arrow-key roving and panel switching are application JavaScript, and this page
ships none.
Cloudy today, with a light breeze.
Second panel.
Third panel.
<div class="tabs">
<div class="tabs__list" role="tablist">
<button class="tabs__tab" type="button" role="tab"
id="tb1" aria-selected="true" aria-controls="pb1" tabindex="0">
<svg class="tabs__icon" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="…"/></svg>
<span class="tabs__label">Account balance</span>
<span class="tabs__badge">3</span>
</button>
<button class="tabs__tab" type="button" role="tab"
id="tb2" aria-selected="false" aria-controls="pb2" tabindex="-1">
<span class="tabs__label">Payment details were updated successfully.</span>
</button>
<!-- aria-disabled, never the disabled attribute: the tab must stay
focusable so arrow-key roving still reaches it. -->
<button class="tabs__tab" type="button" role="tab"
id="tb3" aria-selected="false" aria-controls="pb3" tabindex="-1" aria-disabled="true">
<span class="tabs__label">User account settings</span>
</button>
</div>
<div class="tabs__panel" role="tabpanel" id="pb1" aria-labelledby="tb1" tabindex="0"> … </div>
<div class="tabs__panel" role="tabpanel" id="pb2" aria-labelledby="tb2" tabindex="0"> … </div>
<div class="tabs__panel" role="tabpanel" id="pb3" aria-labelledby="tb3" tabindex="0"> … </div>
</div>Tabs is the one component in this package whose markup cannot work on its own: an ARIA
tablist is aria-selected, roving tabindex, arrow keys and one
visible panel, none of which CSS can maintain. The opt-in sidecar is a second tag:
<link rel="stylesheet" href="node_modules/@arag/css/src/index.css">
<script type="module" src="node_modules/@arag/css/src/behaviors/tabs.js"></script>It finds every .tabs on the page, selects the tab you marked
aria-selected="true" (or the first enabled one if you marked none), hides the
other panels, and from then on owns three attributes: aria-selected and
tabindex on the tabs, hidden on the panels. Everything else in the
markup contract is yours and is required: the roles, the id on each tab, the
aria-controls pointing at its panel and the aria-labelledby pointing
back. It never invents an id: a tab whose aria-controls names
nothing still roves and still selects, it simply has no panel to show.
Arrow keys move along the strip and selection follows focus, no second
keypress to activate, because the panels are already in the document and revealing one costs
an attribute write. Left/Right horizontally,
Up/Down when the root is data-orientation="vertical",
Home and End to the ends, wrapping at both. A tab marked
aria-disabled="true" is stepped over and cannot be selected.
Every panel is visible, stacked in source order. Nothing is hidden and nothing is
unreachable. That is the promise, and it only holds if you write no
hidden attribute on any panel: the script adds them on load. Ship
panels pre-hidden and a reader whose script failed sees panel one and can never reach the
rest, which is content that exists and cannot be read.
For the same reason, prefer <a class="tabs__tab" href="#its-panel-id">
over a <button> wherever the no-script case matters: with no script the
strip is a working table of contents into the panels below it, and with the script the jump
is suppressed and it is a tablist. Leave aria-selected off the markup too
unless you need a specific tab open first, so that no tab paints as selected while every
panel is showing.
None of the tab demos above author hidden on a panel. They used to, on every panel
but the first, which made this page contradict the note it had just written: a reader who copied
what the page did rather than what it said shipped pre-hidden panels. What changed is not the
markup contract but this site's own docs/assets/docs.js, which now collapses each
demo group once at load the way tabs.js does for a real install, instead of only
setting hidden in response to a click or an arrow key. View source on a demo above
and you are reading the same markup the snippet tells you to write.
Turn scripting off and this page behaves exactly as the note above promises an install will: every panel of every demo renders open and stacked in source order, all of it readable, none of it unreachable. That is the fallback working, not the page breaking.
The script is 2031 B against the 2 KB per-behaviour budget, imports nothing, and adds no
attribute or class of its own. Animation, lazy panel loading and syncing the selection to the
URL are deliberately not in it: tabs.css styles none of them, and each is a
decision only your product can make. The examples on this page are wired by the docs site's
own script, which is a plain implementation of the same contract.
Underline is the default and has no rule of its own: the base locals are it.
enclosed joins the selected tab to the panel; pill is a segmented
control on a sunken track, which is what the kiosk wants.
The selected tab shares its fill with the panel and drops the edge between them.
Second panel.
Welcome back
Second panel.
Third panel.
<div class="tabs" data-variant="enclosed"> … </div>
<div class="tabs" data-variant="pill" data-size="lg"> … </div>A vertical list gives every label the full inline size of the sidebar and lets it wrap at no cost. That is very often the right answer for long translated labels rather than a stylistic alternative.
Second panel.
Third panel.
Opt-in single-row scrolling strip, for chrome with a fixed block size that a second row would break.
Second panel.
Third panel.
Fourth panel.
<!-- The list must also carry aria-orientation, so arrow-key handling
matches what the user sees. That attribute is markup, not CSS. -->
<div class="tabs" data-orientation="vertical" data-size="sm">
<div class="tabs__list" role="tablist" aria-orientation="vertical"> … </div>
…
</div>
<div class="tabs" data-overflow="scroll"> … </div>| Attribute | Values | Does |
|---|---|---|
data-variant |
enclosed, pill |
Omitted is the underline strip. |
data-size | sm, lg |
Tab height, padding, type step, indicator thickness and panel padding. |
data-orientation | vertical |
Sidebar list. Moves the strip's rule and the indicator to the inline edge. |
data-overflow | scroll |
Single-row scrolling strip instead of a wrapping one. |
aria-selected | true, false |
On .tabs__tab. The selected colour and the indicator. |
aria-disabled="true" | on the tab | Dims it and suppresses both the hover and the press, while leaving it focusable. |
hidden | on the panel, set by tabs.js |
Hides it. Do not author it: the script sets it on load, and a page whose script never arrives must show every panel. See the note below on why the hide works. |
| Property | Default | Controls |
|---|---|---|
--tabs-gap | var(--space-1) | Space between tabs. |
--tabs-border | var(--color-border-subtle) | The strip's rule. |
--tabs-border-width | var(--border-width-1) | Its thickness, and what the indicator and enclosed tabs overlap by. |
--tabs-indicator | var(--color-brand) | Selected indicator colour. |
--tabs-indicator-size | var(--border-width-2) | Indicator thickness. |
--tabs-indicator-inset | var(--space-0) | How far the indicator is inset from the tab's edges. |
--tabs-list-bg | transparent | Strip fill. The pill variant re-points it to a sunken track. |
--tabs-list-padding | var(--space-0) | Strip padding. |
--tabs-list-radius | var(--radius-none) | Strip radius. |
--tabs-tab-bg | transparent | Resting tab fill. |
--tabs-tab-bg-hover | var(--color-surface-hover) | Hover fill. |
--tabs-tab-bg-active | var(--color-surface-active) |
Press fill: the next step along the same axis as the hover, so rest → hover → press
moves in one direction. On a selected tab it is re-pointed to that tab's own fill taken
by --state-active-amount, so a press deepens the selected fill instead of
replacing it with a neutral. |
--tabs-tab-bg-selected | transparent | Selected fill. |
--tabs-tab-fg | var(--color-text-muted) | Resting label colour. |
--tabs-tab-fg-selected | var(--color-brand-text) | Selected label colour. |
--tabs-tab-border | transparent | Tab edge. The enclosed variant re-points it when selected. |
--tabs-tab-min-block-size | var(--size-touch) | Minimum tab height. |
--tabs-tab-padding-inline | var(--space-4) | Tab inline padding. |
--tabs-tab-padding-block | var(--space-3) | Tab block padding. |
--tabs-tab-radius | var(--radius-sm) | Tab radius. |
--tabs-font-size | var(--text-base) | Label type step. |
--tabs-panel-padding-block | var(--space-5) | Panel block padding. |
--tabs-panel-padding-inline | var(--space-0) | Panel inline padding. |
.tabs__list takes role="tablist" (plus
aria-orientation="vertical" when the root is vertical); each
.tabs__tab takes role="tab", an id,
aria-selected and aria-controls pointing at its panel; each
.tabs__panel takes role="tabpanel", tabindex="0" and
aria-labelledby pointing back at its tab.tabindex="0" on it,
tabindex="-1" on the rest), and arrow keys plus Home/End move between them. That
is application JavaScript. The CSS must not fight it, which is why nothing in the file sets
pointer-events, visibility, or order..tabs__icon is decorative and needs aria-hidden="true".
.tabs__badge must not be the only carrier of its meaning: put the number into
the tab's accessible name too, or a screen reader user never hears it.:hover
in the package sits inside @media (hover: hover), which never matches a touch
screen; :active never does, because it is the feedback a touch user gets
instead of hover, and reset.css suppresses the OS tap highlight. Without
it a tapped tab would acknowledge nothing at all. The selected tab presses too, so re-tapping
the tab you are already on gets an answer rather than a dead strip, and only its fill moves,
so the indicator on ::after still carries "selected" as a shape while the press
colour is on the tab. The enclosed variant keeps its border and dropped edge and the pill
variant keeps its --shadow-xs for the same reason.data-overflow="scroll" exists for
chrome with a fixed height that a second row would break.[hidden] guard. The panel rule is written
.tabs__panel:not([hidden]), and the :not() is load-bearing.
reset.css sets [hidden] { display: none }, but
layer order is compared before specificity and components sits
above reset, so a bare .tabs__panel { display: block } would beat
[hidden] outright and every panel in the widget would render at once. Any future
display declaration in the file needs the same guard.data-overflow="scroll" is deliberately unstyled; this package
leaves scrollbars to the application.Built on the native <dialog> element, opened with
showModal(). Not a div. The native element gives focus trapping, an inert
background, Escape to close, and top-layer rendering above every stacking context on the page,
four hard problems, all solved by the platform. Because it renders in the top layer,
--z-modal is never needed here.
<dialog class="dialog" id="new-order" aria-labelledby="dlg-title">
<form method="dialog">
<div class="dialog__header">
<h2 class="dialog__title" id="dlg-title">Create a new settlement order</h2>
<button class="button dialog__dismiss" type="submit" formmethod="dialog"
data-variant="quiet" data-icon-only aria-label="Close">
<svg class="button__icon" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="…"/></svg>
</button>
</div>
<div class="dialog__body"> … </div>
<div class="dialog__footer">
<button class="button" type="submit" data-variant="quiet">Cancel</button>
<button class="button" type="submit" data-variant="primary">Send</button>
</div>
</form>
</dialog>The dialog above is a real <dialog> carrying the open
attribute, and a docs-only rule takes it out of the UA's absolute positioning so it can sit
in the flow of this page at all. In an application it is opened with
showModal() and renders in the top layer, over an inert background:
document.getElementById('new-order').showModal();Its buttons are type="button" here and do nothing on purpose: this copy is the
anatomy specimen, and a dismiss that worked would delete the example off the page until you
reloaded. The markup block below is the real thing: type="submit" inside a
<form method="dialog">, which closes the dialog with no JavaScript at all.
Open it for real under the size table opens a working copy.
Three size steps plus full for the kiosk, and two placements. The bottom sheet is
the right pattern on the phone target: it rises into the bottom third of the screen where the
thumb already is, rather than parking its actions at the top of a large display where they
cannot be reached one-handed.
| Attribute | Values | Does |
|---|---|---|
data-size |
sm, lg, full |
Maximum inline size, and padding at sm. full pins both
block sizes to the viewport for a fixed terminal. Omitted is medium. |
data-placement |
center, bottom |
center is the default, stated explicitly so an instance can be switched
back. bottom is the full-bleed sheet, rounded only on its leading block
edge. |
open | native | Set by showModal(). The grid display is scoped to it: see
below. |
| Property | Default | Controls |
|---|---|---|
--dialog-bg | var(--color-surface-raised) | Fill. |
--dialog-fg | var(--color-text) | Text colour. |
--dialog-border | var(--color-border) | Edge. |
--dialog-radius | var(--radius-lg) | Corner radius. |
--dialog-padding | var(--space-6) | Padding on the header, body and footer. The dialog box itself has none. |
--dialog-max-inline-size | var(--size-container-md) | Width ceiling. |
--dialog-shadow | var(--shadow-xl) | Elevation. |
--dialog-divider | var(--color-border-subtle) | Header and footer rules. |
--dialog-enter-translate | 0 var(--space-5) | Where the entry animation travels from. The bottom sheet re-points it so it travels further. |
A dialog can take the drawer's contract unchanged. Ship it with the
open attribute and an id, and point a link at it:
<a class="button" href="#terms" data-drawer aria-haspopup="dialog">Terms</a>
<dialog class="dialog" id="terms" open aria-labelledby="terms-title">
<form method="dialog">
<div class="dialog__header">
<h2 class="dialog__title" id="terms-title">Terms</h2>
<button class="button dialog__dismiss" type="submit" data-variant="quiet">Close</button>
</div>
<div class="dialog__body">…</div>
</form>
</dialog>src/behaviors/drawer.js closes that dialog on load, because a trigger names it,
and opens it with showModal() when the link is used; focus returns to the link
when it closes. It is the same module and the same two steps as for a drawer, so there is no
dialog.js.
The dialog is an open panel in the document flow: .dialog[open]:not(:modal)
is position: static, so it pushes the content after it down and two of them
never land on one spot. The link scrolls to it and its close button dismisses it, from the
platform. Put it after the main content, as you would a drawer. What you lose: it does not
overlay the page, and once dismissed it cannot be reopened without a reload.
showModal() moves focus into the dialog on open. Do not fight it with a
JavaScript focus() call.autofocus on the primary action is usually right, so Enter confirms.
Never autofocus a destructive action: a delete button sitting
under a finger already resting on Enter is a data-loss bug, not a shortcut. Focus the dismiss
or the safe action instead.aria-labelledby pointing at the
.dialog__title id, or aria-label where there is no visible
title.<form method="dialog"> and the platform closes the
dialog and reports the pressed button for free. Prefer a
<button formmethod="dialog"> for the dismiss control..dialog__dismiss is icon-only and must carry an accessible name..dialog__dismiss never takes data-size="sm": on a bottom-sheet
dialog it is the primary exit, and the compact 32px size is too dense for it. A coarse pointer
restores every button to the 44px touch floor regardless of size; the difference is the more
generous 36px default that stays on a precise pointer.display: grid is scoped to .dialog[open]. The
rule that hides a closed dialog is the UA's
dialog:not([open]) { display: none }, and any author declaration outranks a UA
one, so a bare .dialog { display: grid } renders every closed dialog on the
page. This is the easiest way to break a native dialog and it fails silently in review..dialog__body carries min-block-size: 0 because a grid
item's automatic minimum size is its content size, and without zeroing it the dialog would
grow past its maximum instead of the body scrolling.dvb, not vb or svb. On
mobile the URL bar shows and hides as the user scrolls; vb would tuck the footer
under the bar while it is showing, and svb would leave a permanent dead strip
once it hides.--duration-normal collapses to an instant under
prefers-reduced-motion. There is no exit animation: animating a dialog
out needs transition-behavior: allow-discrete or
@starting-style, both of which land in Firefox 129, above this package's floor.
The dialog closes instantly, which is correct and never reads as broken.A dropdown of commands: account menus, row actions, market filters. Positioned with ordinary
absolute positioning against a .menu-anchor wrapper, deliberately not with the
popover attribute or CSS anchor positioning: both sit above the browser floor, and
an unpositioned anchored menu has no graceful fallback: it lands in document flow.
<div class="menu-anchor">
<button class="button" type="button"
aria-haspopup="menu" aria-expanded="false" aria-controls="acct">…</button>
<div class="menu" id="acct" role="menu" hidden>
<div class="menu__group" role="group" aria-labelledby="acct-grp">
<div class="menu__label" id="acct-grp">Account</div>
<button class="menu__item" role="menuitem" type="button">
<span class="menu__label-text">Account balance</span>
<span class="menu__shortcut" aria-hidden="true">Ctrl B</span>
</button>
</div>
<hr class="menu__separator" role="separator">
<button class="menu__item" role="menuitem" data-variant="danger">…</button>
</div>
</div>The menu above is shown open and un-positioned so it can sit in the flow of this page; a
real one is absolutely positioned below its anchor and toggled with the
hidden attribute. The trigger picks up its pressed fill automatically from
[aria-expanded="true"]: menu.css re-points
button.css's own --button-bg local rather than hard-coding a
colour, so an open trigger holds whatever pressed fill its own variant declared.
| Attribute or class | Values | Does |
|---|---|---|
data-placement | start, end |
On .menu. Which inline edge the menu aligns to against its anchor, using
logical insets so it flips correctly in RTL. |
data-variant | danger |
On .menu__item. Destructive commands, using the danger text role. |
.menu__group / .menu__label | none | A labelled group of items. .menu__label is the group heading; the item's
own text is .menu__label-text. |
.menu__icon / .menu__shortcut | none | Leading icon at --size-icon, and a trailing muted monospace hint. |
.menu__separator | none | Inset by the menu's own padding rather than bled to the edge: the menu is a scroll container, and an overhang would earn it a horizontal scrollbar. |
| Property | Resolves to | Controls |
|---|---|---|
--menu-min-inline-size | |
Floor, so a two-item menu does not collapse to the width of its shortest label. Capped
against --menu-inline-space, or an 18rem floor would be wider than a 320px
phone. |
--menu-inline-space | min(100%, calc(100vi - var(--space-7))) |
How much inline room the menu has, and the cap on both the floor and the ceiling. In
normal flow that is the containing block, so a menu inside a padded panel stops at the
panel's edge instead of hanging out of it. A menu inside a .menu-anchor is
out of flow and re-points this to the viewport term alone, because there
100% is only as wide as the trigger. Re-point it yourself if you position a
menu some other way. |
--menu-max-inline-size | |
Ceiling, which forces a long command label to wrap rather than run to the edge of the screen. |
--menu-max-block-size | |
Beyond this the menu scrolls inside itself instead of past the bottom of the viewport. |
--menu-item-min-block-size | |
Tap target on the kiosk and phone. |
--menu-shadow | |
Elevation above the page. |
aria-haspopup and aria-expanded; the menu needs
role="menu" and its items role="menuitem". Arrow-key navigation,
Home and End, Escape to close, and returning focus to the trigger are all application
JavaScript. The CSS is written not to fight any of it.role="menu" is the wrong
role. A menu is a set of commands. Navigation should be a plain <nav>
and a list: this is a common and genuinely harmful mistake, because the menu role changes how
a screen reader announces and navigates the whole thing.currentColor, not a glyph, so nothing leaks into the accessible name and there
is nothing for a translator to find..menu-anchor an anchor-name and replace four inset
declarations; nothing else changes.When the items are links rather than commands, build the menu on the native disclosure. The
anchor is a <details>, the trigger is its <summary>, and
the surface is a <nav> with no menu role anywhere:
<details class="menu-anchor">
<summary class="button">On this page</summary>
<nav class="menu" aria-label="On this page">
<a class="menu__item" href="#fees">Fees and settlement times</a>
<a class="menu__item" href="#help">Get help</a>
</nav>
</details>That markup works as written: menu.css takes the bordered, padded box
base.css gives a bare <details> off the anchor, hides the
summary's disclosure triangle, and holds the trigger's pressed fill while the element is
open. No aria-expanded: a <summary> already
reports its state. Tab moves between the links, as it does between any links;
arrow-key roving belongs to role="menu", which this does not take.
src/behaviors/menu.js upgrades exactly this markup, and nothing with
role="menu". It adds the three things a disclosure has no notion of:
Escape closes the menu and returns focus to the trigger, a click or a focus move
outside closes it, and picking a link closes it.
The menu opens and closes from its trigger, by pointer and by Enter or Space, and every link in it is reachable and works: that is the platform, not the script. What you lose: it stays open until the trigger is used again.
Transient notifications. A fixed-position region holds a column of them at
--z-toast. The region does not intercept pointer events; each toast does: the
classic implementation bug is a full-screen invisible region swallowing every click on the
page.
Transfer confirmed
Payment details were updated successfully.
<div class="toast-region" role="status" aria-live="polite" aria-atomic="true">
<div class="toast" data-variant="success">
<svg class="toast__icon" viewBox="0 0 16 16" aria-hidden="true">…</svg>
<div>
<p class="toast__title">Transfer confirmed</p>
<p class="toast__body">Payment details were updated successfully.</p>
</div>
<button class="button toast__dismiss" data-variant="quiet" data-icon-only
aria-label="Dismiss">…</button>
</div>
</div>Neutral
Info
Transfer confirmed
Your session expires in two minutes.
The phone number is not valid.
| Attribute | Values | Does |
|---|---|---|
data-variant |
neutral, info, success, warn, danger |
On .toast. Re-points background, text and the accent edge to that
family's -subtle / -text / -border roles.
info borrows the brand family: there is deliberately no info ramp. |
data-placement |
top-center, bottom-end, bottom-center |
On .toast-region. Omitted is top-end. On the phone target, bottom
placement collides with the home indicator and sits under the thumb, so top-end is the
safer default and bottom-center is the deliberate choice when the toast is the primary
feedback for a thumb-driven action. |
data-entering / data-leaving | present or absent | The offset states. The resting state is visible: see below. |
aria-live and aria-atomic. Use
polite for ordinary toasts and assertive only for errors:
assertive interrupts whatever the screen reader is currently saying, which is rude for a
success message and correct for a failed payment.opacity: 0 at rest, a missed attribute toggle
ships an invisible notification, an unacceptable silent failure on a path that
tells someone their money moved.--duration-normal, so tokens.css collapses it automatically..toast__dismiss never takes data-size="sm": on a bottom toast
it is the primary way to dismiss by hand, and the compact 32px size is too dense for it. A
coarse pointer restores every button to the 44px touch floor regardless of size; the
difference is the more generous 36px default that stays on a precise pointer.The disclosure primitive: one region that shows and hides, plus the trigger that toggles it.
Two markup routes are supported and both land on the same classes: a native
<details>/<summary> pair, and a button plus a region driven
by aria-expanded. Route 1 is the recommended one: keyboard support, open state and
the accessible name all come from the platform, and it works with no JavaScript at all.
accordion composes a group of these; it does not restate them.
Cloudy today, with a light breeze.
Open and close these two: they are real <details> elements and
nothing on this page is wiring them up.
<!-- ROUTE 1 — native details/summary. Recommended. -->
<details class="collapse" open>
<summary class="collapse__trigger">
<span class="collapse__label">Account balance</span>
<!-- Decoration. The state is already announced from [open]. -->
<span class="collapse__marker" aria-hidden="true"></span>
</summary>
<div class="collapse__content">
<div class="collapse__inner"> … </div>
</div>
</details>Route 2 is for the cases where the application already owns the state (an exclusive group, a
URL-driven panel, a controlled component). The trigger carries aria-expanded and
aria-controls, and .collapse__content must be its
immediate next sibling: the open state is selected with the +
combinator, because :has() is above this package's Firefox floor and there is no
parent selector below it.
Updated 2 minutes ago
Collapsed by aria-expanded="false" on the trigger above.
Still focusable, so a screen reader can reach it and hear why.
<!-- ROUTE 2 — button + region, when the application owns the state. -->
<div class="collapse" data-variant="bordered">
<button class="collapse__trigger" type="button"
id="tx-history-t" aria-expanded="false" aria-controls="tx-history">
<span class="collapse__label">User account settings</span>
<span class="collapse__marker" aria-hidden="true"></span>
</button>
<!-- IMMEDIATE next sibling of the trigger. role/aria-labelledby are worth
adding once the region is long or there are several on a page. -->
<div class="collapse__content" id="tx-history"
role="region" aria-labelledby="tx-history-t">
<div class="collapse__inner"> … </div>
</div>
</div>
<!-- aria-disabled, never the disabled attribute: the trigger must stay
focusable so assistive tech can reach it and explain the state. -->
<button class="collapse__trigger" type="button"
aria-expanded="false" aria-controls="q4" aria-disabled="true"> … </button>Collapse ships no JavaScript. The <details> examples above and
every accordion further down are real and fully working: open and close
them; that is the platform, not this page. The route 2 examples are
static markup and inert: the docs site's own script wires up tabs, menus,
dialogs and toasts, but it deliberately does not wire up a collapse, because route 2 exists
precisely for applications that already have their own state to toggle. Toggling one is two
lines: flip aria-expanded on the trigger and let the CSS follow.
Three variants, all of them nothing but re-pointed locals. plain is the default
and has no rule of its own: no fill, no edge, no radius, for a disclosure sitting in running
prose or inside a card that already draws the box.
Welcome back
The trigger's resting fill is a real colour here, so the hover and active mixes at the top of the file resolve to the sunken surface's own hover and active steps, in both themes, with nothing added.
<details class="collapse"> … </details> <!-- plain, the default -->
<details class="collapse" data-variant="bordered"> … </details>
<details class="collapse" data-variant="filled"> … </details>| Attribute or class | Values | Does |
|---|---|---|
data-variant |
bordered, filled |
On .collapse. Omitted is plain: transparent, no edge, no
radius. |
open | native, route 1 | On the <details>. Rotates the marker and drops the trigger's end
corners. Set by the user agent; never mirror it into a class. |
aria-expanded | true, false |
On .collapse__trigger, route 2. Required. This file
styles the attribute and nothing else: there is no .is-open class
anywhere, because a second source of truth will drift away from the one assistive tech
reads. |
aria-controls | an id |
On the trigger, route 2. Points at the region. |
aria-disabled="true" | on the trigger | Dims it and suppresses the hover fill, while leaving it focusable. |
aria-hidden="true" | on the marker | Required on .collapse__marker. It is decoration; the
state it depicts is already announced. |
hidden |
on the trigger, content or marker | Hides it outright. Guarded explicitly in the file: see the note under Accessibility and decisions. |
.collapse__label | none | Optional wrapper for the trigger's text; a bare text node becomes an anonymous grid item in the same column. Wrapping it makes the intent explicit. |
.collapse__content / .collapse__inner | none | The animating region and its clipped, padded contents. Both are required: the padding lives on the inner so it animates away with the content. |
| Property | Resolves to | Controls |
|---|---|---|
--collapse-bg | transparent | Root fill. |
--collapse-fg | | Text colour. |
--collapse-border | transparent | Root edge colour. |
--collapse-border-width | | Root edge thickness. |
--collapse-radius | | Corner radius, on the root and on the trigger. |
--collapse-gap | | Column gap between the label and the marker. |
--collapse-padding-inline | | Inline padding on the trigger and the inner. |
--collapse-padding-block | | Block padding on the trigger, and the block-end padding of the inner. |
--collapse-font-size | | Trigger type step. |
--collapse-min-block-size | | Minimum trigger height. A minimum, never a fixed size: a two-line label grows the control instead of overflowing it. |
--collapse-trigger-bg | transparent | Resting trigger fill. |
--collapse-trigger-fg | | Trigger text colour. |
--collapse-trigger-bg-hover |
--collapse-trigger-bg mixed with --state-shade at |
Derived, not pointed at a surface role. A collapse does not know what it is sitting on, and in dark mode the raised surface is one ramp step lighter than the plain one, so a fixed hover token would go the wrong way on some grounds. Re-point the resting fill and both state fills follow. |
--collapse-trigger-bg-active |
the same mix at |
Pressed fill. |
--collapse-marker-color | |
Chevron colour. Muted text, not a border role: see below. |
--collapse-marker-size | |
Chevron box inline size. Its block size is this times the trigger's line height, so the box is exactly one line tall. |
<details> element is already exposed
as a disclosure button with its state, its accessible name and its keyboard handling
supplied by the engine, and it works before the JavaScript bundle lands or if it never lands
at all. Reach for route 2 only when something else genuinely owns the open state.auto block size is not
animatable, and all three ways round that were weighed against the package floor of
Chrome/Edge 123, Firefox 120, Safari 17.5. interpolate-size: allow-keywords is
Chrome 129 and ships in neither Firefox nor Safari, rejected.
transition-behavior: allow-discrete is the near miss: below the floor in Chrome
and Safari, but Firefox 129, nine versions short, rejected. What is used instead is
grid-template-rows: 0fr → 1fr on a one-row grid whose item clips its own
overflow, animatable since Chrome 107, Firefox 66 and Safari 16.<details> has its content hidden by the user
agent with display: none on an internal slot, and no author declaration can
reach inside that. ::details-content fixes exactly this and landed in Chrome
131, above the floor. So a native <details> opens
instantly and only its chevron rotates; route 2 is the one whose region slides. If
a smooth open matters more than free keyboard handling and no-JS behaviour, that is the
trade you are making, and it is why the chevron rotation is shared by both routes rather
than bundled into the animation.0fr track is applied only where the markup positively proves the region is
closed. Written the other way round, any region that lost its selector (a wrapper slipped
between trigger and region, a missing aria-expanded) would silently render at
zero height: invisible content, no error, nothing in the DOM to explain it. The worst
failure this way round is a section that does not animate.0fr clips, it does not remove. A region collapsed by
aria-expanded="false" is still in the accessibility tree and any control inside
it is still in the tab order. If the region holds focusable content, set hidden
on it as well once it is closed, which is why the file restates every
display selector with [hidden] attached. Layer order is compared
before specificity, so a plain .collapse__content { display: grid }
in the components layer beats reset.css's
[hidden] { display: none } outright and every collapsed region in route 2 would
stay on screen. Add a row to that guard whenever you add a display.align-items: center floats the marker in the middle of a two-line header; flex
with flex-start fixes that and breaks the one-line case, pinning everything to
the top whenever the touch minimum is taller than the content. Grid does both at once,
because align-content works on a grid and does not work on a single-line flex
container: align-content: center centres the row track in the free space the
touch minimum creates, and align-items: start puts each item at the top of that
track.currentColor. No
glyph, no icon font, no arrow in a content property: an arrow in a content
property is a string, and strings get sent to translators, machine-translated, or dropped by
a font missing that codepoint. Borders cannot be any of those things.--color-text-muted measures 7.98:1 light and 8.16:1 dark.
--color-border-subtle is documented as decorative and does not clear the bar;
the -border roles generally sit around 2.2:1 and are never right for a control
boundary or a state signal.transition-behavior, which is rejected above. Anything that must escape the
region's box (a menu, a popover) has to be portalled out of it. The inner's block-start
padding buys back what the clip would otherwise take from a focus ring on a control sitting
at the very edge of the region.:has() anywhere in this package. It would shorten several rules here, but
it would raise the documented Firefox floor from 120 to 121; sibling combinators and the
[open] attribute do the same work at the floor, at the cost of writing the
open-state rules twice, once per route.A group of collapses that reads as one control. This component owns exactly two classes,
.accordion and .accordion__item, and nothing else: the item itself,
the trigger, the label, the marker, the region and the inner all come from
collapse.css and are not redefined here. All the accordion does to them is
re-point collapse's own locals and add the corners that make the stack read as one block. Both
markup routes work inside it, and <details> is still the recommended
one: the accordion below is real, with no JavaScript behind it.
| Class | Defined in | Note |
|---|---|---|
.accordion | accordion.css |
The group. Owns the fill, the edge, the radius and the gap. |
.accordion__item | accordion.css |
Goes on the same element as .collapse, never on a
wrapper around it, and must be a direct child of the group. |
.collapse | collapse.css |
The item itself. |
.collapse__trigger | collapse.css |
The <summary> or the button. |
.collapse__label | collapse.css |
The label inside the trigger. |
.collapse__marker | collapse.css |
The chevron. Still needs aria-hidden="true". |
.collapse__content | collapse.css |
The animating region. |
.collapse__inner | collapse.css |
The clipped, padded contents. |
Updated 2 minutes ago
A long label wraps to two lines and the chevron stays beside the first one.
Cloudy today, with a light breeze.
<!-- data-exclusive documents the intent; the native `name` does the work. -->
<div class="accordion" data-exclusive>
<!-- BOTH classes, on the SAME element. -->
<details class="collapse accordion__item" name="faq" open>
<summary class="collapse__trigger">
<span class="collapse__label">Account balance</span>
<span class="collapse__marker" aria-hidden="true"></span>
</summary>
<div class="collapse__content">
<div class="collapse__inner"> … </div>
</div>
</details>
<details class="collapse accordion__item" name="faq"> … </details>
<details class="collapse accordion__item" name="faq"> … </details>
</div>
<!-- Route 2 inside an accordion: the trigger and the region are direct
siblings. Do not wrap the trigger in a heading — see below. -->
<div class="accordion">
<div class="collapse accordion__item">
<button class="collapse__trigger" type="button"
id="a1-t" aria-expanded="false" aria-controls="a1"> … </button>
<div class="collapse__content" id="a1"><div class="collapse__inner"> … </div></div>
</div>
</div>data-exclusive carries no declarations. It changes behaviour,
not appearance, and a rule that existed only to be found by grep would be worse
than saying so here. It is a documented markup hook: it puts the attribute that describes
the widget on the widget, where a reviewer can see the intent in the DOM.
The behaviour comes from the native name attribute: give every
<details> in the group the same name and the engine closes the others.
Support at this package's floor is three of four: Chrome 120, Edge 120 and Safari 17.2 are
all under it, but Firefox shipped it in 130, ten major versions above the
120 floor. The failure is benign, which is why this is still the recommended route: Firefox
below 130 ignores an unknown attribute, so the group keeps working and simply allows more
than one item open. Nothing breaks, nothing is hidden, nothing shifts. If single-open is a
hard requirement rather than a preference, do not rely on name: in route 2 it
is application JavaScript setting aria-expanded="false" on the others. There is
no CSS-only exclusive accordion at this floor, and the radio-and-label trick sometimes
offered gives the group the wrong role and the wrong keyboard model.
bordered is the default and needs no rule: one boxed group with rules between
the rows. separated makes every item its own card with air between them;
flush keeps the rules and drops the outer box, for an accordion already sitting
inside a card or a dialog where a second edge would just be noise. Sizes step padding and the
type step only.
Each item takes the border, the radius and the fill; the group's own box goes
away. It re-points --collapse-radius rather than restating corner
rules, so collapse's own "an open trigger drops its end corners" rule keeps
working here with nothing added.
Welcome back
Phone number
Flush keeps its inline padding at zero at every size, which is the whole point of the variant: the rows line up with the prose around them.
bordered, lg.
Updated 2 minutes ago
<div class="accordion"> … </div> <!-- bordered, the default -->
<div class="accordion" data-variant="separated"> … </div>
<div class="accordion" data-variant="flush" data-size="sm"> … </div>
<div class="accordion" data-size="lg"> … </div>| Attribute | Values | Does |
|---|---|---|
data-variant |
separated, flush |
Omitted is bordered: one boxed group, rules between rows. |
data-size | sm, lg |
Item padding and type step. Not the trigger's minimum height, see below. |
data-exclusive | boolean | Documentation only, no declarations. Marks the group as single-open so a reader can see the intent in the DOM. |
name | route 1, on each <details> |
The native grouping that actually enforces single-open. Firefox 130 and up. |
hidden | on .accordion |
Hides the group. Guarded explicitly, for the same layer-order reason as collapse. |
| Property | Resolves to | Controls |
|---|---|---|
--accordion-bg | | Group fill. Transparent in both separated and flush. |
--accordion-border | | The group's edge, and each item's edge in separated. |
--accordion-divider | | The rule between two items. |
--accordion-border-width | | Both of those, and the amount the item's corner radius is inset by so its arc stays concentric with the group's. |
--accordion-radius | | Group radius, inherited by the first and last item and by their triggers. |
--accordion-gap | | Space between items. separated opens it up. |
--accordion-padding-inline | | Feeds --collapse-padding-inline on every item. |
--accordion-padding-block | | Feeds --collapse-padding-block. |
--accordion-font-size | | Feeds --collapse-font-size. |
.collapse__content must be direct siblings: collapse's closed state is
.collapse__trigger[aria-expanded="false"] + .collapse__content, and the
accordion's corners are .accordion__item > .collapse__trigger. Inside an
<h3> neither matches, so the region never collapses and the first and
last rows lose their corners. Reading the state through the heading would need
:has(), which is Firefox 121, one above this package's floor. If the page needs
heading navigation anyway, the application must toggle hidden on the region
itself, the first and last triggers keep square corners, and the heading's own margin and
type face land on the trigger and need resetting. Prefer route 1: a
<summary> is already exposed as a disclosure button.aria-expanded and aria-controls in
route 2, and .collapse__marker still carries aria-hidden="true".
Nothing about being inside a group changes collapse's contract.plain variant. The
group owns the fill, the edge and the radius; a data-variant on the item would
draw a second box inside the first.--collapse-min-block-size is deliberately not stepped down at
sm. A disclosure header is a tap target on the kiosk and on the phone
at every size. The trigger's grid centres its row track inside whatever free space the touch
minimum creates, so keeping it costs nothing but a slightly airier small accordion..accordion__item and .collapse are single-class selectors, so a
bare .accordion__item rule would tie on specificity and lose on source order:
accordion.css sorts before collapse.css in the import list, and
every item would keep its standalone padding and radius. The combinator settles it, and it
also stops a nested accordion from inheriting its parent's item styling.filled if an instance opts
into it), so without that the hover fill would paint square into the group's rounded corner
on the first and last rows. The last trigger keeps its end corners only while its region is
shut; once open, a rounded trigger edge in the middle of the stack reads as a seam.--color-border-subtle, and that is correct
here and only here: it separates two rows of the same control, it is decoration
rather than a control boundary, and it is not the only signal for any state, so SC 1.4.11's
3:1 does not apply to it. The marker, which is a state signal, is held to that bar
in collapse.css.border-top-left-radius would not.A determinate or indeterminate progress indicator: uploads, batch settlement runs, multi-step
forms. The preferred markup is the native <progress> element, which carries
the value, the maximum and the progressbar role for free and needs no ARIA at all.
A div-based form exists for the two things <progress> cannot do: a
segmented bar, and a track that must contain real markup. Variant, size and the indeterminate
state are data-attributes on the track; the value never is. Value updates are application
JavaScript, so every bar on this page is static.
<!-- NATIVE — preferred. The semantics are not optional, so they
cannot be forgotten. -->
<div class="progress-group">
<label class="progress__label" for="up">Account balance</label>
<progress class="progress" id="up" value="62" max="100"></progress>
</div>
<!-- DIV-BASED — for a segmented bar, or a track that must hold markup.
--progress-value goes on the BAR, not on the wrapper. -->
<div class="progress-group">
<span class="progress__label" id="up-l">Uploading</span>
<div class="progress" role="progressbar" aria-labelledby="up-l"
aria-valuenow="62" aria-valuemin="0" aria-valuemax="100">
<span class="progress__bar" style="--progress-value: 62%"></span>
</div>
</div>The fill is a background, and a background is invisible to assistive
technology. role="progressbar", aria-valuenow,
aria-valuemin and aria-valuemax are not decoration on top of a
working control: without them a screen reader announces an empty group and the value is
simply lost. The wrapper is identified by the role rather than by a data-attribute for the
same reason: the application has to set it anyway, so keying the styling off anything else
would create a second source of truth that will drift.
Indeterminate is the one case that differs: drop aria-valuenow
entirely and keep the minimum and maximum. An absent
aria-valuenow is what signals "indeterminate"; setting it to zero announces
"0 percent", which is a different and wrong claim.
Four fills and three thicknesses. Every variant points at --color-family
rather than the neighbouring -solid or -border roles, both of which
fail against this track: --color-brand-border measures 1.91 light and 2.41 dark,
and --color-warn-solid measures 2.73 in light mode because orange is light at every
usable step.
<!-- A bare progress with no .progress__label still needs an accessible
name, so these carry aria-label. In an application, prefer the group. -->
<progress class="progress" value="45" max="100" aria-label="…"></progress>
<progress class="progress" data-variant="success" value="100" max="100" aria-label="…"></progress>
<progress class="progress" data-variant="warn" value="80" max="100" aria-label="…"></progress>
<progress class="progress" data-variant="danger" data-size="lg" value="18" max="100" aria-label="…"></progress>
<progress class="progress" data-size="sm" value="62" max="100" aria-label="…"></progress>The same four fills against the same track, in both themes. The separation is lightness-led, not hue-led, which is the point of the choice: in OKLCH the track sits at L 93.6% in light and L 10.8% in dark, while every fill sits between L 42% and L 66%, 30 to 50 lightness points apart in either theme. Someone who cannot resolve hue still sees exactly where the fill ends.
A segmented bar is the first of the two reasons to reach for the div-based form: several
.progress__bar children share the row, each with its own
--progress-value and, where it helps, its own --progress-fill. The
wrapper still carries one set of ARIA describing the total.
<!-- SEGMENTED. One role, one set of aria-value* for the total. -->
<div class="progress" data-size="lg" role="progressbar" aria-labelledby="s-l"
aria-valuenow="85" aria-valuemin="0" aria-valuemax="100">
<span class="progress__bar" style="--progress-value: 55%"></span>
<span class="progress__bar" style="--progress-value: 20%; --progress-fill: var(--color-success)"></span>
<span class="progress__bar" style="--progress-value: 10%; --progress-fill: var(--color-warn)"></span>
</div>
<!-- INDETERMINATE, div form: NO .progress__bar child, and
aria-valuenow is dropped entirely rather than set to zero. -->
<div class="progress" data-indeterminate role="progressbar" aria-labelledby="i-l"
aria-valuemin="0" aria-valuemax="100"></div>
<!-- INDETERMINATE, native: no value attribute. -->
<progress class="progress" data-indeterminate data-size="lg"></progress>| Attribute or class | Values | Does |
|---|---|---|
data-variant |
success, warn, danger |
Re-points the fill. Omitted is brand. |
data-size | sm, lg |
Track thickness only. The radius stays fully rounded at every step, so it needs no size rule. |
data-indeterminate | boolean | Sweeping chunk painted by the track's own background. The div form must carry no
.progress__bar child while indeterminate, and must drop
aria-valuenow. |
value / max | native | On <progress>. Omit value for the native
indeterminate state. |
role="progressbar" | div form | Required. It is also the styling hook: the flex row that lays out the bars is scoped to it. |
aria-valuenow / -valuemin / -valuemax |
div form | Required, except aria-valuenow while indeterminate.
See the note above. |
.progress-group | none | Label above track, in a column. The label is a sibling of the track, never an overlay inside it. |
.progress__label | none | A <label for> against a native <progress>, or a
<span id> pointed at by aria-labelledby in the div
form. |
.progress__bar | div form | One fill. Its width is --progress-value, set on this element. |
hidden | on the track or the group | Hides it. Guarded explicitly, for the same layer-order reason as collapse. |
| Property | Resolves to | Controls |
|---|---|---|
--progress-track | |
Track fill. It measures 1.08 light and 1.09 dark against the page, so it cannot delimit itself, hence the real border below. |
--progress-track-border | |
The track's boundary. 3.55 / 4.39 against the page background and 4.28 / 3.63
against a surface, so SC 1.4.11 is satisfied on whichever it lands on.
--color-border-subtle would not do. |
--progress-fill | |
The fill, in both the native and the div form. 5.02 light / 6.57 dark against the track. |
--progress-thickness | |
Track block size. The one place in this system where a fixed cross-axis size is right: the track holds no text, so it never has to grow to fit a longer translation. |
--progress-radius | |
Track radius. The track clips its own overflow, so neither form restates it. |
--progress-value | 0% |
Set per instance by the application, on the track or on a single bar:
style="--progress-value: 62%". A custom property rather than a
data-attribute because it is a continuous quantity, and an attribute selector cannot
express one. |
--progress-chunk | 35% |
Proportion of the track covered by the indeterminate chunk. |
<progress> carries the role
and the value without being asked, which is exactly why it is the better default: its
semantics cannot be forgotten. Reach for the div form only for a segmented bar or a track
that must contain markup.role="progressbar" with aria-valuenow,
aria-valuemin and aria-valuemax are not optional extras. Without
them there is nothing to announce.aria-valuenow entirely and keeps the
minimum and maximum. Zero is a claim about progress; absence is the claim that progress is
unknown.::-webkit-progress-value, ::-moz-progress-bar { … } is dropped in its entirety
by both engines (Gecko does not know the -webkit- pseudo, Blink does not know
the -moz- one), and the fill silently vanishes everywhere, leaving an empty
track that still reports 62 percent. There is no :is() escape either:
:is() is forgiving about its arguments, but pseudo-elements are not
valid inside it at all.base.css sets accent-color on progress, which
governs the UA-drawn bar and is deliberately not restated here. It stops applying the moment
appearance: none lands, which is precisely why the fill has to be re-declared
through those pseudo-elements rather than inherited. In Blink,
::-webkit-progress-bar is set transparent so the element's own box keeps
painting the track, the border, the radius and the indeterminate sweep, one place instead
of two that drift apart, and it makes Blink match what Gecko already does.aria-live="polite" on the
label, never on the track. A live region on the track re-fires on every
value change and floods the user with percentages.prefers-reduced-motion: reduce the sweep stops and leaves a
static centred marker, not a blank bar. --duration-loop is
deliberately outside the set that tokens.css collapses, because collapsing a
loop does not stop it: a sweep re-timed to a single millisecond is a strobing bar, which is
materially worse than the animation the user asked to be rid of. The loop is suppressed here
instead and the chunk is pinned to the centre of the track. It cannot be misread as a
determinate value, because a determinate fill always starts flush against the inline-start
edge and this one touches neither edge; the state itself is carried by the label text and by
the absent aria-valuenow, which is what assistive tech reads either way.infinite loop would teleport the chunk from the far edge back to the near one
once per cycle. The chunk is also symmetric about its centre, so it reads identically in a
right-to-left document, which matters because CSS gradients still have no logical direction
keyword and to right is the only thing available.inline-size: 100%. Every colour, length, radius, duration and easing curve
is a token.A scroll-snap container, and nothing else. The core of this component works with zero
JavaScript: swipe, trackpad, shift-wheel and keyboard scrolling are the browser's own
scrolling, and scroll-snap-type: inline mandatory is what makes each slide come to
rest in the right place. Where snap is unsupported it degrades to a plain scrolling row that
still shows every slide. Drag the row below; nothing on this page is wired up to it.
Slides are direct children of .carousel. There is no
.carousel__track, and its absence is a decision rather than an omission: see
Accessibility and decisions.
<!-- role, aria-roledescription and an accessible name are required: a
carousel with no name is announced as an unlabelled group. tabindex="0"
is required too — see the keyboard bullet under Accessibility. -->
<div class="carousel" data-slides="3"
role="group" aria-roledescription="carousel"
aria-label="Account summaries" tabindex="0">
<!-- Slides are DIRECT CHILDREN. There is no track element. The name
carries the position, because "slide" alone tells the user nothing. -->
<div class="card carousel__slide"
role="group" aria-roledescription="slide" aria-label="1 / 7">
<div class="card__header">
<h4 class="card__title">Account balance</h4>
<p class="card__subtitle">Updated 2 minutes ago</p>
</div>
<div class="card__body"><p>Settled balance across all connected accounts.</p></div>
</div>
<div class="card carousel__slide"
role="group" aria-roledescription="slide" aria-label="2 / 7"> … </div>
…
</div>The slide size is one formula, set once on the root and inherited by every slide:
slide = min(100%, max(SLIDE-MIN, (100% - (N - 1) × GAP) / N))N slides across the scrollport leave N − 1 gutters between them, so the gutters come out of the 100% before the division, not after. Skip that and three slides plus two gaps are wider than the container: the third hangs over the edge and never comes to rest flush at its snap point. Two slides subtract one gap, three subtract two, one subtracts none.
There is no media query anywhere in the file, and that is the better answer rather
than merely the shorter one. The percentages resolve against the
scroll container's own content box, so a three-up carousel dropped into a narrow
sidebar on a wide desktop reflows correctly: a viewport media query gets that case exactly
backwards. The max() puts a floor under the share at
--size-column-min (), so once the
computed share drops below a readable column the slide stops shrinking and the carousel quietly
becomes a peek-and-scroll row instead of a squashed grid. The outer min() is the
other end of the same clamp: in a container narrower than the floor itself the slide gives up
the floor and takes the full inline size rather than overflowing.
peek leaves the following slide partly visible: the honest way to say "there is
more here", visible without hover and surviving a touch kiosk that has no cursor. Its
arithmetic is the base formula with one more subtrahend,
(100% − (N − 1) × GAP − PEEK) / N. full gives one slide the whole
scrollport, and zeroes the gap, the inline padding and the radius with it.
data-slides="auto" sizes each slide to its own content, and composes with
peek by ignoring it: content-sized slides already leave the next one showing.
<div class="carousel" data-variant="peek" data-slides="2" …> … </div>
<div class="carousel" data-slides="auto" …> … </div>
<div class="carousel" data-variant="full" …> … </div>.carousel-frame is a separate block, the same relationship
.button-group has to .button, not a required wrapper. It exists for
exactly one reason: the prev/next controls have to be positioned against the scrollport, and
an absolutely positioned child of a scroll container scrolls away with the
content. Use .carousel on its own when there are no controls and no
dots.
<div class="carousel-frame">
<div class="carousel" data-variant="full"
role="group" aria-roledescription="carousel"
aria-label="Full-width gallery" tabindex="0"> … </div>
<!-- A sibling of the scrollport, not a child of it: an absolutely
positioned child would scroll away with the slides. The overlay is
pointer-events: none; the two buttons opt back in. -->
<div class="carousel__controls">
<button class="button carousel__prev" type="button"
data-icon-only data-shape="pill" aria-label="Previous slide">
<svg class="button__icon" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="…"/></svg>
</button>
<button class="button carousel__next" type="button"
data-icon-only data-shape="pill" aria-label="Next slide"> … </button>
</div>
<!-- Real buttons if they are clickable. A span with a click handler is not
focusable, not operable from the keyboard and announces nothing. -->
<div class="carousel__dots">
<button class="carousel__dot" type="button" aria-label="Slide 1 of 4" aria-current="true"></button>
<button class="carousel__dot" type="button" aria-label="Slide 2 of 4"></button>
…
</div>
</div>The carousel ships none of it today, and CSS cannot scroll anything. It is a handful of lines:
el.scrollBy({ left: -el.clientWidth }) and
el.scrollBy({ left: el.clientWidth }) for the two buttons, or better,
slide.scrollIntoView({ inline: "start", block: "nearest" }) on the neighbouring
slide so the destination is always a real snap point. left and
clientWidth are physical, so read the sign from the computed direction rather
than hard-coding it if an RTL locale is ever shipped. For the dots, an
IntersectionObserver over the slides moves aria-current="true"
along: it stays right whether the user scrolled by swipe, wheel, keyboard or button, which a
click handler on the dots alone does not, and it is the same observer the app already wants
for lazy-loading slide media.
Nothing in the file hides a control at either end of the scroll range, and that is
deliberate. A button that vanishes on the last slide moves the layout out from under
the finger already reaching for it, which is strictly worse than a button that is present and
no-ops. If it must be marked unavailable, give it aria-disabled="true", which
.button already styles and which keeps it focusable so a screen reader can still
explain it.
The floor this package promises is Chrome/Edge 123, Firefox 120, Safari 17.5. These are not used, and a reader will ask why:
scroll-snap-stop: always: the property that stops one fast flick from
skipping three slides, and the natural companion to data-variant="full".
Firefox only shipped it in 139. Using it would move the documented floor for a behaviour a
large share of users would not get, and the failure is silent: it is a no-op, so nobody
notices until a report says "it skips slides". An application that knows its own
floor is higher can opt in from its own unlayered sheet in one line, which already
wins over this file: .carousel__slide { scroll-snap-stop: always; }::scroll-marker, ::scroll-marker-group and
::scroll-button(): these would be the true zero-JavaScript dots and arrows,
with no application code at all. Chromium-only and far above the floor, and there is
nothing to fall back to, so the dots would simply not exist in two of three engines.animation-timeline: scroll() / view(): would let a dot or a
progress rail track scroll position with no IntersectionObserver. Not in
Safari at the floor, and it degrades to a static element with no indication at all.| Attribute or class | Values | Does |
|---|---|---|
data-slides |
1, 2, 3, auto |
On .carousel. Sets --carousel-count, which is the N in the
sizing formula. auto drops the formula and sizes each slide to its own
content, the honest value for mixed-width media. |
data-variant |
peek, full |
On .carousel. Omitted is a plain N-up row. peek subtracts a
gutter so the next slide stays partly visible; full gives one slide the
whole scrollport. |
tabindex="0" | on .carousel |
Required, unless every slide contains a focusable element of its own. See the first bullet under Accessibility. |
aria-current="true" | on .carousel__dot |
The current dot. Signalled twice: brand fill and a widened dot, so it still reads in greyscale. Exactly one dot at a time; remove the attribute from the rest. |
aria-disabled="true" | on a control | Styled by .button, not by this file. Keeps the control focusable, which
is the point. |
.carousel-frame | none | Optional sibling block. A single-column grid that shares row 1 between the scrollport
and .carousel__controls, and drops .carousel__dots into row 2
with no explicit placement. Only needed when there are controls or dots. |
.carousel__controls | none | The overlay the two buttons are positioned inside. pointer-events: none,
or it would eat every swipe, drag and wheel event aimed at the slides beneath it. |
.carousel__prev / .carousel__next | none | Compose with .button: class="button carousel__prev",
normally with data-icon-only and data-shape="pill". These
rules only place the button and lift it off the content behind it. |
hidden |
on the frame, root, a slide or the dots | Hides it. Each of those selectors is restated with [hidden] attached, for
the reason recorded in the Tabs section. |
| Property | Default | Controls |
|---|---|---|
--carousel-gap |
var(--space-4) |
Space between slides, and the GAP term in the formula. |
--carousel-count | 1 |
The N. data-slides sets it; set it directly for an N the attribute does
not cover. |
--carousel-slide-min |
var(--size-column-min) |
The floor under a slide's share, the same floor the grid utilities use. Below it the carousel becomes a peek-and-scroll row rather than a squashed grid. |
--carousel-slide-size |
min(100%, max(SLIDE-MIN, (100% − (N − 1) × GAP) / N)) |
The whole formula, set once on the root and read by every slide's
flex-basis. |
--carousel-peek |
var(--space-8) |
Only on [data-variant="peek"]. The gutter that keeps the next slide
partly visible; it is subtracted inside the formula, which is restated in that rule
rather than folded into the base one. |
--carousel-snap-align | start |
Where a slide comes to rest against the scrollport. |
--carousel-padding-inline |
var(--space-4) |
Inline padding on the scrollport. full zeroes it. |
--carousel-padding-block |
var(--space-1) |
Block-axis room so a slide's shadow or focus ring is not clipped by the scrollport. There is no block size anywhere in the file: the carousel is exactly as tall as its tallest slide, in either language. |
--carousel-scroll-padding |
var(--carousel-padding-inline) |
scroll-padding-inline, so the snapped slide aligns to the padded edge
rather than the border edge. A separate local only so a consumer can decouple the
two. |
--carousel-radius |
var(--radius-lg) |
Slide radius. full re-points it to --radius-none. |
--carousel-frame-gap |
var(--space-4) |
On .carousel-frame. The space between the scrollport and the dot
row. |
--carousel-control-inset |
var(--space-3) |
On .carousel__controls. How far the two buttons sit in from the
scrollport's inline edges. |
--carousel-dot-size |
var(--space-3) |
The visible dot. One of the two fixed dimensions in the file, allowed because a dot is a glyph rather than a translated string. |
--carousel-dot-hit |
var(--space-4) |
Transparent hit area around the dot, clipped off the fill with
background-clip: content-box, so the target clears WCAG 2.2 SC 2.5.8
without putting a blob that size on screen. |
--carousel-dot-color |
var(--color-border-strong) |
Resting dot. Not a decorative -border role: the fill is the
control's entire visible boundary, so SC 1.4.11 applies and a role measuring around
2.2:1 would not clear 3:1. |
--carousel-dot-color-hover |
var(--color-text-muted) |
Hover fill. |
--carousel-dot-color-current |
var(--color-brand) |
Current fill, paired with the widened dot. |
tabindex="0" on .carousel, or
guarantee that every slide contains a focusable element of its own. With neither, a
keyboard-only user cannot scroll the carousel at all: the arrow keys have nothing to
act on and everything past the first screenful is simply unreachable. Chromium began focusing
keyboard-scrollable containers automatically only after the version this package
floors at, and the other engines do not do it, so it cannot be relied on. This is not a
nicety; it is the difference between usable and not..carousel takes role="group",
aria-roledescription="carousel" and a name via aria-label or
aria-labelledby: a carousel with no name is announced as an unlabelled group
and the user has no idea what it holds. Each .carousel__slide takes
role="group", aria-roledescription="slide" and a name that
carries position: aria-label="2 / 5", or
aria-labelledby pointing at a heading inside the slide..carousel__track. The
other common shape is a flex track nested inside a separate scrollport, and it buys nothing
here: snap works on any descendant of the scrollport and gap works on the
scroll container itself. It also costs something real: the track would be the flex container,
so scroll-padding on the scrollport and padding on the track become
two values a maintainer has to keep in sync by hand, and the first person to change one and
not the other gets a snap point that lands half a gutter off.<button>s. A
<span> with a click handler is not focusable, not operable from the
keyboard, not reachable by a switch or a screen reader, and announces nothing, and on the
kiosk it is the one thing a user is most likely to poke at. If they are purely an indicator
rather than a control, use a real list and mark the current item.inline mandatory, not proximity: a slide left
arbitrarily half-shown, with no peek gutter to explain it, reads as a rendering fault rather
than as content continuing. overscroll-behavior-inline: contain is what stops a
flick past the last slide from scrolling the page behind the carousel or firing the browser's
back gesture on a touch device.scroll-behavior: smooth is gated behind
prefers-reduced-motion: no-preference directly rather than through a duration
token, because a duration token is not what it is. A carousel that glides is precisely what
motion sensitivity is about: large, fast, off-axis movement across most of the viewport,
started by something other than the user's own finger. Under reduce the scroll
still happens, it just happens instantly. There is no autoplay in the file and there
cannot be one without JavaScript: an application that adds one must suppress it
under reduce itself, because a loop cannot be fixed by shortening it.data-variant="peek", which signals the same thing with layout instead of with
chrome.Scrollspy is a behaviour, not a component. The scroll position and the "which section am I in" calculation are entirely application JavaScript, and no @arag component ships that today. What this file provides is the nav that a scrollspy drives: a list of links to in-page sections, with a style for the one link the application has marked current. Nothing here observes, measures or highlights anything by itself.
The active state is [aria-current="true"] on the link, never a class. The
application has to set that attribute for assistive technology anyway, so styling anything else
would be a second source of truth that will drift out of sync with the first.
<!-- The nav needs an accessible name of its own, or a page with two navs
gives the user two indistinguishable "navigation" landmarks. -->
<nav class="scrollspy" aria-labelledby="toc-h">
<h2 id="toc-h" class="u-sr-only">On this page</h2>
<!-- role="list" is REQUIRED, not optional: this file sets list-style: none,
and some screen readers stop announcing list semantics the moment a list
loses its markers. -->
<ul class="scrollspy__list" role="list">
<li><a class="scrollspy__link" href="#pay">Account balance</a></li>
<li>
<a class="scrollspy__link" href="#pay-card" aria-current="true">…</a>
<!-- One level of nesting, deliberately. A nav that needs three is a
table of contents for a document that needs splitting. -->
<ul class="scrollspy__group" role="list">
<li><a class="scrollspy__link" href="#pay-bank">Phone number</a></li>
</ul>
</li>
<!-- aria-disabled, never a removed href: it must stay focusable so a
screen reader can reach it and say why it is unavailable. -->
<li><a class="scrollspy__link" href="#prefs" aria-disabled="true">…</a></li>
</ul>
</nav>The active marker is a fill, a weight change and a colour change. There is
deliberately no accent rule on the inline-start edge: it repeated a fourth time what
--scrollspy-link-bg-current, --weight-semibold and
--color-brand-text already say, and the docs sidebar's own current state dropped
the same rule for the same reason. The weight change cannot reflow the column, because the link
is a full-width block.
The fill used to be transparent, on the premise that colour and weight were
already two signals. Measured, that premise was false twice over: a transparent current fill
against a transparent non-current fill is 1.00:1 in both themes: not a weak
signal, no signal, and --color-brand-text sits on the same rung of the
lightness ladder as --color-text-muted (--l-700 light,
--l-300 dark): current measures 8.05 light / 8.17 dark against
--color-bg and non-current measures 8.00 / 8.14, so the two differ in chroma
alone and are all but identical in greyscale. --weight-semibold was carrying the
whole state by itself.
--scrollspy-link-bg-current is now --color-brand-subtle, which is
what the sidebar of this very page uses for its own current page link. Against the
non-current sibling's ground that measures 1.26:1 on --color-bg in light
and 1.21:1 in dark, 1.34:1 on --color-surface in light: small, but a
real luminance step where there was none, so it survives greyscale where the hue difference
does not. --color-brand-text on that fill is 6.37:1 light / 6.77:1 dark, so the
label stays comfortable.
Two things are worth knowing about its size. 1.2–1.3:1 is well under the 3:1 that SC 1.4.11
asks of a non-text signal carrying a state on its own: this fill does not carry it
on its own, the weight does, and the fill widens the margin. And on
--color-surface in dark mode it measures 1.00:1:
--color-brand-subtle is --color-brand-900 and
--color-surface is a neutral on the same rung, so on a raised ground in dark the
fill differs in hue alone. A nav inside a dialog or a card should re-point
--scrollspy-link-bg-current the way it already re-points
--scrollspy-link-bg-hover, or take data-variant="filled". Never put
the edge rule back.
data-variant="filled" gives the state an active block, and is the supported way to
buy a signal that clears 3:1 on its own: for a nav beside busy content, for a kiosk screen read
from a metre away, or wherever colour vision cannot be assumed. The fill is
--color-brand-solid and not --color-brand-subtle: the block is a
structural signal, so SC 1.4.11 applies and it has to clear 3:1. It does, at 5.72 light / 6.03
dark against --color-bg, while --color-brand-subtle measures
1.26 / 1.21, which would be colour-alone dressed up as a shape if it were carrying the state
alone. In the default variant it is not; the weight is. That is the whole distinction between
the two variants.
<nav class="scrollspy" data-variant="filled" aria-labelledby="toc-h"> … </nav>The nested group indents with a rule, not with whitespace. Indentation only
reads as hierarchy while every item is one line, because the eye is comparing the start
positions of a clean column of first lines. The moment one title wraps (and a translated title
wraps in a nav column that comfortably held its English source on a single line), the second
line begins at the indent as well, and a wrapped parent and an unwrapped child become the same
shape. A vertical rule survives that: it runs the whole block size of the group regardless of
how the lines fall, so the nesting stays legible at two, three or five lines per item. That
spine is --color-border-subtle rather than --color-border because it
is genuinely decorative: the items are already grouped by the DOM and by the indent, and it
signals no state at all, so the 3:1 bar that applies to the filled variant's block does not
apply to it.
position: sticky sticks to the nearest ancestor scroll container,
not to the page. Put this nav inside any ancestor whose overflow is anything
other than visible (a layout wrapper carrying overflow-x: hidden to
tame some unrelated element is the usual culprit, and it is invisible when you read the
markup), and the nav sticks to that box instead. That box is not scrolling, so the
nav simply never sticks, and there is nothing the file can add to defend itself. The fix is
always to remove the ancestor's overflow. The two examples above demonstrate this by
accident: .example on this page carries overflow: hidden, so those
navs are sticky within their own example box and not within the page.
A second, quieter version: sticky also does nothing when the nav's own parent is no taller than the nav, because the parent's padding box is the entire sticky range. In a two-column grid the column track usually is taller; in a flex row where the nav is stretched to the row's height, it is exactly the same height and nothing moves.
The nav takes its own overflow-y because a long table of contents is taller than
the viewport, and overscroll-behavior-y: contain so that reaching its end does
not hand the remaining gesture back to the page. Its scrollbar is left unstyled, like every
other scrollbar in this package.
The JavaScript contract, precisely enough to implement from. None of it is in the CSS:
const nav = document.querySelector('.scrollspy');
const items = [...nav.querySelectorAll('.scrollspy__link')]
.map(link => ({ link, head: document.getElementById(link.hash.slice(1)) }))
.filter(item => item.head); // collect once, in document order
let pending = null;
function update() {
pending = null;
// A threshold line a fixed distance below the top of the viewport: the
// sticky page header's block-size, plus a little.
const line = header.offsetHeight + 24;
// The LAST heading whose top is above the line.
let current = items[0]; // clamp 1: nothing above it yet
for (const item of items) {
if (item.head.getBoundingClientRect().top <= line) current = item;
}
// clamp 2: at the bottom of the document the last section wins outright, or
// a short final section can never become current however far you scroll.
if (scrollY + innerHeight >= document.body.scrollHeight - 2) {
current = items[items.length - 1];
}
for (const item of items) {
if (item === current) item.link.setAttribute('aria-current', 'true');
else item.link.removeAttribute('aria-current'); // REMOVE, not "false"
}
// The nav scrolls itself, so the current link can sit off its own edge.
current.link.scrollIntoView({ block: 'nearest' });
}
// A TIMER, never requestAnimationFrame. See the warning below.
function onScroll() {
if (pending === null) pending = setTimeout(update, 100);
}
addEventListener('scroll', onScroll, { passive: true });
addEventListener('resize', onScroll, { passive: true });
addEventListener('visibilitychange', update); // one unconditional update
update(); // and one on loadAn IntersectionObserver on the headings is the usual wrong answer, and
it is unreliable. A heading is a few dozen pixels tall, so at most scroll positions
no heading intersects the viewport at all: the observer does not fire, and the nav
freezes on whatever was current when the last heading left the screen. When two headings
do intersect, the observer reports them in document order rather than by position,
which marked the wrong one. Observing whole sections instead has the mirror-image failure:
with long sections several intersect at once and the callback has to re-rank them anyway.
Reading getBoundingClientRect().top against a threshold line is one cheap pass
per event, needs no observer, and answers the question actually being asked (which heading
did I last scroll past) at every scroll position, including the ones where nothing
intersects.
requestAnimationFrame does not fire in a tab the compositor is not
painting: a background tab, a minimised or occluded window, some embedded webviews.
A scroll handler throttled through rAF therefore parks with a pending frame that never
arrives, and comes back stale, or never comes back at all if the pending flag is only cleared
inside the callback. Throttle with a timer instead: a trailing setTimeout of
about a tenth of a second is the same throttle in practice and keeps working regardless, and
run one unconditional update on visibilitychange.
| Attribute or class | Values | Does |
|---|---|---|
data-variant | filled |
On .scrollspy. Omitted is the default, which needs no rule of its own:
the base locals are it. filled re-points three locals and rounds all four
corners of the link. |
aria-current="true" | on .scrollspy__link |
The active link, and the only state this file styles. Exactly one at a time; remove
the attribute from the others rather than setting "false": "false"
is valid and means "not current", so it is not wrong, but the selector does not match it
and a nav full of dead attributes is noise. |
aria-disabled="true" | on the link | A section that is not currently reachable. Dims it and suppresses both the hover and
the press, while leaving it focusable: never remove the href. |
role="list" |
on .scrollspy__list and .scrollspy__group |
Required, not optional. This file sets list-style: none,
and some screen readers stop announcing list semantics the moment a list loses its
markers. |
.scrollspy__group | none | One level of nesting, indented by a rule plus padding. One level only, deliberately. |
hidden |
on the nav, the list, a group or a link | Hides it. Each selector is restated with [hidden] attached, for the reason
recorded in the Tabs section. |
| Property | Default | Controls |
|---|---|---|
--scrollspy-inset |
var(--space-6) |
inset-block-start: how far below the top of the scrollport the nav
sticks. |
--scrollspy-gutter |
var(--space-8) |
Subtracted from 100dvb for the nav's own max block size, so it never runs
edge to edge. |
--scrollspy-spine |
var(--color-border-subtle) |
The nesting rule on .scrollspy__group. Decorative, so the 3:1 bar does
not apply. |
--scrollspy-spine-width |
var(--border-width-1) |
Its thickness. |
--scrollspy-link-fg |
var(--color-text-muted) |
Resting label colour. |
--scrollspy-link-fg-hover |
var(--color-text) |
Hover label colour. |
--scrollspy-link-fg-current |
var(--color-brand-text) |
Active label colour. filled flips it to
--color-brand-on-solid. |
--scrollspy-link-bg | transparent |
Resting fill. |
--scrollspy-link-bg-hover |
var(--color-surface-hover) |
Hover fill. Derived from --color-surface, which is what a sidebar nav
sits on. Inside a dialog or a card, re-point this to
--color-surface-raised-hover: in dark mode the raised surface is a full
step lighter, so a hover derived from the wrong ground moves darker when it
should move lighter. |
--scrollspy-link-bg-active |
var(--color-surface-active) |
Press fill: the next step along the same axis as the hover, so rest → hover → press
moves in one direction. On a current link it is re-pointed to that link's own fill
taken by --state-hover-amount, so a press deepens the current fill instead
of replacing it with a neutral. |
--scrollspy-link-bg-current |
var(--color-brand-subtle) |
Active fill. 1.26:1 light / 1.21:1 dark against --color-bg, a real
luminance step, but not a 3:1 one, and 1.00:1 against --color-surface in
dark. See the warning above. filled re-points it to
--color-brand-solid. |
--scrollspy-link-min-block-size |
var(--size-touch) |
A minimum, never a block size: this is tapped on the phone, and a wrapped two-line title has to grow the row rather than spill out of it. |
--scrollspy-link-padding-block |
var(--space-2) |
Link block padding. |
--scrollspy-link-padding-inline |
var(--space-4) |
Link inline padding, and also the group's indent on both sides of its spine. |
--scrollspy-link-radius |
var(--radius-sm) |
Link radius, inline-end corners only, an asymmetry left over from the accent rule
that used to sit on the inline-start edge and not yet re-decided.
filled raises it to --radius-md and rounds all four. |
--scrollspy-font-size |
var(--text-sm) |
Label type step. |
<nav> needs an accessible name of its own, via
aria-labelledby pointing at a visually hidden heading or via
aria-label. A page with two unnamed navs gives the user two indistinguishable
"navigation" landmarks.role="list" on both the list and any nested group is required, not decoration.
reset.css only strips markers where role="list" has already made the
semantics explicit, and this file makes the same bargain: some screen readers stop announcing
list semantics the moment a list loses its markers.aria-current="true" on exactly one link and remove the attribute
from the rest. Do not set aria-current="false": it is a valid value meaning "not
current", so it is not wrong, but the selector does not match it and a nav full of dead
attributes is noise.--scrollspy-link-bg-current is
--color-brand-subtle, which measures 1.26:1 light / 1.21:1 dark against the
non-current sibling's ground, real, but under 3:1, and 1.00:1 on --color-surface
in dark. See the warning above for the full numbers. Where the current section has to be
findable at a glance, or where the block itself must clear 3:1, re-point that local or use
data-variant="filled".:hover
in the package sits inside @media (hover: hover), which never matches a touch
screen; :active never does, because it is the feedback a touch user gets
instead of hover, and reset.css suppresses the OS tap highlight. Without
it a tapped link would acknowledge nothing at all. The current link presses too: it is the
one a reader is most likely to tap, and only its fill moves, so
--weight-semibold still carries "current" in greyscale while the press colour is
on it.link.scrollIntoView({ block: "nearest" }). The nav scrolls itself, so
the current item can otherwise sit off its edge with nothing to show the reader where they
are.text-wrap: pretty and
overflow-wrap: break-word, with no nowrap, no ellipsis and no line
clamp. That wrapping is also what forces the nesting treatment to be a rule rather than an
indent.base.css underlines every <a>; the link removes it, because
inside a nav list the underline turns the column into a block of rules and buries the active
link in them.outline-offset so it is not
clipped by the nav's own overflow-y: auto. The carousel's slides and dots do the
same thing against the scrollport for the same reason.Figures that have to be read as money: a ledger column, an account balance, an order total,
an amount input. Everything on this page below is formatted live in your browser
by Intl.NumberFormat: the numbers are real output, not strings typed into the
markup, and they will change if you open this page under a different locale.
CSS cannot insert a thousands separator and cannot round a decimal. Those are string operations, and this component ships no JavaScript. It styles the result: tabular figures that line up in a column, the sign treatment, the currency symbol, and the shape of the input. The digits come from one line of application code.
That split is not a limitation being apologised for: it is the correct boundary. The
grouping character and the decimal mark are locale-dependent, and this is a
localised product. A design system that hardcoded , and . would be
shipping a bug. Intl gets it right per locale for free.
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'MNT',
currencyDisplay: 'narrowSymbol',
minimumFractionDigits: 0,
maximumFractionDigits: 2
}).format(1250000.5)minimumFractionDigits: 0 with maximumFractionDigits: 2 is exactly
"two decimals only when they are needed": a whole number prints clean, a half prints one place,
a third rounds to two.
1. currencyDisplay defaults to "symbol", which for MNT
renders the ISO code, not the glyph. Measured on this machine:
| Option | Output |
|---|---|
currencyDisplay: 'symbol' (default) | MNT 1,250,000.5 |
currencyDisplay: 'code' | MNT 1,250,000.5 |
currencyDisplay: 'narrowSymbol' | ₮1,250,000.5 ← the one you want |
currencySign: 'accounting' | (MNT 98,765.4): parentheses for negatives |
2. Locale data may not be present for the locale you ask for, and the fallback is
silent. When a browser build lacks a locale,
Intl.NumberFormat.supportedLocalesOf([wanted]) returns an empty array and
the formatter resolves to its default instead: you would be getting another locale's
formatting while believing you had the one you asked for. Check rather than assume:
const wanted = 'en-US';
const got = Intl.NumberFormat.supportedLocalesOf([wanted]);
const locale = got.length ? got[0] : 'en-US'; // decide, do not driftIn this browser, right now: checking…
The root is a native <data> element. Its value attribute holds
the machine-readable number and the text node holds the formatted string, so the raw figure
survives copy-paste, scraping and testing, and the two can never drift because one is derived
from the other at render time.
1250000
1250000.5
1250000.456
0
-98765.4
<data class="money" value="1250000.5">₮1,250,000.5</data>
<!-- the text node is whatever Intl produced; value stays the raw number -->
<data class="money" data-sign="negative" value="-98765.4">(₮98,765.4)</data>data-sign takes positive, negative or zero
and colours the figure from the success, danger and muted text roles.
A negative figure distinguished only by being red is invisible to the most common
form of colour blindness, and invisible in a printed report. The markup must carry the
sign itself: a minus, or accounting parentheses. data-sign reinforces that; it
does not replace it. currencySign: 'accounting' produces the parentheses for you.
+₮1,250,000
(₮98,765.4)
₮0
+₮1,250,000
(₮98,765.4)
₮0
A table cell marked data-numeric already right-aligns and switches on tabular
figures: that lives in base.css, so a plain server-rendered table gets it without
knowing this component exists. .money composes on top for the sign and the
currency part.
| Date | Transaction | Amount |
|---|---|---|
| 2026-08-24 | Create a new settlement order | +₮1,250,000 |
| 2026-08-25 | Transfer confirmed | (₮98,765.4) |
| 2026-08-26 | Payment details were updated successfully. | ₮0 |
| 2026-08-27 | Account balance | +₮1,151,234.6 |
Tabular figures align on the right edge, which is the same as aligning on the decimal mark
only while every row has the same number of decimal places. "Two decimals only when needed"
guarantees they do not. data-align="decimal" reserves the minor-unit space so the
marks line up anyway.
Inside a decimal-aligned column the application must emit a
.money__decimals element on every figure (empty when there are no
minor units), because CSS cannot synthesise an element that is not there. The alternative
was to pad every row to two decimals, which was rejected: the tögrög has no circulating
subunit, so .00 on every ledger row implies a precision the settlement does not
have.
The input root carries both classes, class="field money-field": it composes
.field rather than rebuilding the label, affix group, focus ring and error
wiring, all of which are the parts most likely to drift on the accessibility details.
Updated 2 minutes ago
<div class="field money-field">
<label class="field__label" for="amt">Account balance</label>
<div class="field__group">
<span class="field__prefix" aria-hidden="true">₮</span>
<input class="field__control" id="amt" type="text"
inputmode="decimal" autocomplete="off">
</div>
</div>type="number"
It looks right and is wrong for money. It renders spinners nobody wants on a currency
amount, its parsing is locale-hostile, it silently rejects a pasted value that
contains grouping separators (which is exactly what a user copies out of a spreadsheet),
and valueAsNumber hands back a float. type="text" with
inputmode="decimal" gives the numeric keypad on the phone without any of that.
The ₮ lives in a .field__prefix, outside the editable value, so it can never be
selected, deleted or submitted.
| Attribute or class | Values | Does |
|---|---|---|
data-sign | positive, negative, zero |
Colours the figure. Must be paired with a sign character in the markup. |
data-size | sm, lg | Type step only. |
data-emphasis | strong, quiet |
A total row, or a secondary figure. Declared before data-sign on purpose so
sign wins the colour: sign is information, quiet is decoration. Do not reorder. |
data-align | decimal |
Reserves minor-unit space so mixed-precision rows align on the mark. |
.money__amount | none | The numeral run. The one place white-space: nowrap is correct: a figure
broken across lines is misread, and digits are not translated. |
.money__currency | none | The ₮ or ISO code. Never wrapped away from its amount. |
.money__decimals | none | Optional. The minor units, so they can be de-emphasised. |
.money-field | none | Applied alongside .field on the input root. |
data-sign is reinforcement.<data value> keeps the machine-readable figure beside the human one. A
screen reader announces the text; anything parsing the page gets the number.aria-hidden and lives in the prefix, so it
is not read as part of the value. The field's own label says what the amount is for.blur, keep the raw value in a data- attribute or a hidden input,
and re-parse from that rather than from the displayed string.font-variant-numeric feature, not a font swap: the
face keeps its own shapes and only the advance widths are equalised, so the column
aligns without the figures changing character.A single, well-known accessibility pattern: an off-screen link that becomes visible on
keyboard focus and jumps past repeated navigation straight to the page's main content. No
parts: a single-purpose control, like .badge or .progress at
their simplest.
There is no live preview here. At rest the link sits transform: translateY(-200%)
off-screen, so a box that rendered it inline would show nothing: an empty box is not a
demonstration of anything, and forcing it permanently visible would misrepresent a component
whose entire job is to stay out of the way until it is needed. The markup, the same way
.card__media above gets a bare code sample instead of a live one:
<a class="skip-link" href="#main">Skip to content</a>You already have the means to. This very page ships one: it is the first focusable
element in <body>, immediately before the masthead. Press
Tab once from the top of this page (or Shift+Tab back to
it from anywhere else), and it drops into view reading "Skip to content". Activate it and
focus moves to <main id="main">, past the entire sidebar.
Must be the first focusable element in <body>, and its target id must
exist on the landmark it skips to, typically <main id="main">. The link
text is a short phrase sized to its own content, with no fixed width anywhere in the rule, so
a longer translation only makes the pill wider; there is nothing here for it to
clip against.
| Property | Default | Controls |
|---|---|---|
--skip-link-bg | var(--color-surface) | Fill once focused. |
--skip-link-fg | var(--color-brand-text) | Text colour. |
--skip-link-border | var(--color-border) | Edge. |
--skip-link-radius | var(--radius-md) | Corner radius. |
--skip-link-padding-block | var(--space-3) | Block padding. |
--skip-link-padding-inline | var(--space-4) | Inline padding. |
--skip-link-inset-block | var(--space-3) | Distance from the block-start edge once visible. |
--skip-link-inset-inline | var(--space-3) | Distance from the inline-start edge once visible. |
--skip-link-z | var(--z-tooltip) | Stacking, so it clears whatever else sits at the top of the page. |
:hover and deliberately no :active. This is
a keyboard-only surface: it sits off-screen until it receives focus, so no pointer or touch
user ever reaches it at rest to tap or press it in the first place: there is nothing here
that is ever "pressed". That matters more than it used to, now that
src/reset.css sets -webkit-tap-highlight-color: transparent
globally on html: any pressable surface that skips :active gets
no touch feedback at all. Here that omission is correct, not an oversight.:focus-visible is unconditional, not gated behind
@media (hover: hover) the way every :hover rule in this package
is. The guard exists to stop a :hover rule from latching onto a touch tap;
this file has no :hover rule for it to latch onto, so the guard has nothing to
protect against.transform: translateY(-200%), not a positional inset and not
visibility or display. The link keeps its box, so it stays in
focus order and reachable by assistive technology at every moment, only its paint
position moves on focus.The page header: a brand mark, a slot for whatever navigation or trailing actions the page
needs, and a spacer that pushes trailing content to the inline end. The one primitive every
top-level page in this package wants: the docs site you are reading and
examples/winsome.html both use it as-is.
<header class="masthead">
<a class="masthead__brand" href="#top">Brand</a>
<!-- Page-local nav classes and gap — .masthead does not style the links. -->
<nav class="u-flex u-gap-5" aria-label="Sections">
<a href="#balance">Balance</a>
<a href="#settings">User account settings</a>
</nav>
<div class="masthead__spacer"></div>
<button class="button" type="button" data-size="sm" data-variant="outline">Sign out</button>
</header>.masthead supplies the row, the brand slot and the spacer only: it does not
style the nav's own links, so colour, hover state and gap stay the consumer's page-local CSS.
The u-gap-5 above stands in for that: both current consumers give their own nav
gap: var(--space-5), which keeps adjacent link taps separated by real space
rather than by inflating the links themselves. The 44px touch floor governs
controls; WCAG 2.2 SC 2.5.8 exempts a
target sized by the text of a sentence or a nav list, and an inline nav link is exactly
that.
position: sticky sticks to the nearest ancestor scroll container, not to the
page: see the sticky trap under Scrollspy for the full mechanism.
.example on this page carries overflow: hidden, so the masthead
above is sticky within its own example box and never within the page, by the same accident
as the two scrollspy demos. On the real page you are reading right now the masthead
is the one that is actually stuck: scroll, and it stays put, because nothing
between it and the document clips or scrolls independently.
| Property | Default | Controls |
|---|---|---|
--masthead-bg | var(--color-surface) | Fill. |
--masthead-fg | var(--color-text) | Text colour, inherited by .masthead__brand. |
--masthead-border | var(--color-border-subtle) | Block-end edge. |
--masthead-padding-block | var(--space-4) | Block padding. |
--masthead-padding-inline | var(--space-6) | Inline padding. |
--masthead-gap | var(--space-4) | Space between the brand, the nav and trailing content. |
--masthead-min-block-size | var(--space-9) | Floor, not a fixed height: a wrapped nav or a two-line brand grows past it. |
--masthead-z | var(--z-sticky) | Stacking while sticky. |
gap that adjacent taps stay separated by
space instead.min-block-size, never a fixed block-size: a two-line
brand or an overflowing nav grows the header rather than being cut off. Measured against a
masthead carrying long nav labels, the nav block grows from about 27px for one
row to 106px wrapped, with zero overflow at a 320px viewport.position from unlayered application CSS,
which already beats every layer in this package..masthead does not style the nav's own links or a theme toggle placed in the
trailing slot: colour, hover state and gap for whatever sits in the row past the spacer
stay page-local CSS. It supplies only the row, the brand slot and the spacer.A responsive grid of self-contained comparison panels, most often short labels beside long ones, or one theme beside another. Each panel grows to its own content; nothing here assumes both sides are the same length.
Create a new project
Create a new project from a template and invite the whole settlement team
Create a new project
Create a new project from a template and invite the whole settlement team
<div class="duo">
<div class="duo__panel" data-theme="light">
<span class="duo__label">Light</span>
<p>Create a new project</p>
<p>Create a new project from a template and invite the whole settlement team</p>
</div>
<div class="duo__panel" data-theme="dark">
<span class="duo__label">Dark</span>
<p>Create a new project</p>
<p>Create a new project from a template and invite the whole settlement team</p>
</div>
</div>Every panel needs the .duo__panel class. Unlike the docs-only
version this replaced, .duo does not style a bare child with a
.duo > * selector: the per-panel padding, border, background and colour live
on the named part, matching every other multi-part component in this package
(.card__header, .collapse__trigger). A child with no class gets
none of that, which is the single easiest way to get this component visually wrong. Not
limited to theme pairs either: a panel can carry a lang attribute alone for a pure
translation comparison, or neither attribute for a plain side-by-side content grid.
| Property | Default | Controls |
|---|---|---|
--duo-min | var(--size-card-min) | Column width below which the grid drops to one column. |
--duo-gap | var(--space-5) | Space between panels. |
| Property | Default | Controls |
|---|---|---|
--duo-padding | var(--space-5) | Panel padding. |
--duo-radius | var(--radius-lg) | Corner radius. |
--duo-border | var(--color-border-subtle) | Edge. |
--duo-bg | var(--color-bg) | Fill. |
--duo-fg | var(--color-text) | Text colour. |
.duo__panel class is not optional. A bare child of
.duo gets no padding, border or background from this component: see the
markup contract above. This is the one thing every consumer of the old docs-only
.duo had to be updated for when it moved into the package.data-theme on a panel is not decoration: it invokes the package's real
subtree theming, so a panel marked data-theme="dark" genuinely resolves dark
tokens for its own background and text regardless of the page's own theme. It is not a
colour swatch standing in for the real thing..duo__label is a plain <span>, not a heading: it names
the panel visually (Light, Dark, a language name) but carries no landmark or heading
semantics of its own. Where the panels need to be findable in a screen reader's heading
list, wrap each panel's content in its own heading rather than relying on the label.A small inline glyph that sits beside text and matches it. icon.css does not draw
anything: it sizes and colours markup pasted from src/icons/, a curated, closed
set of nineteen: twelve UI glyphs drawn for this system and seven third-party brand marks.
There is no free-text SVG field anywhere in this system: a CMS allow-lists an
icon by name from this set, which is the whole reason icon.css exists as
its own component rather than a paste-it-yourself convention: a free-text SVG field is the
raw-HTML hole this schema exists to avoid, by another door.
Saved successfully
<p>
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">
<path d="…" fill="none" stroke="currentColor" stroke-width="1.6"
stroke-linecap="square" stroke-linejoin="miter"/>
</svg>
Saved successfully
</p>This package has no build step, so a sprite sheet or a referenced .svg file were
both on the table and both rejected. An <img src="icon.svg"> cannot inherit
currentColor: it paints whatever colour the file itself says, which is wrong the
moment the surrounding text changes colour, theme or data-brand preset.
<use href="file.svg#id"> to an external file recovers colour inheritance in
most current browsers but carries real same-origin, CSP and back-forward-cache caveats this
system will not ask a kiosk or a mobile app to live with. Inline <svg> markup
has neither problem: it is part of the document, so currentColor resolves the same
way any other inherited property does. The accepted cost is duplication: the same markup lives
once in src/icons/ and again wherever a page or the CMS places it, which is exactly
why the set stays small rather than forty: fewer names is a smaller version of the same
problem, not a different one.
em, no size variant--icon-size defaults to var(--size-icon), the same 1em
token .button__icon, .alert__icon and .toast__icon already
read. An icon has no compound of its own the way a button's padding and font-size are a compound
(it is one square that should equal the type step of whatever it sits beside), which
1em already gives it by inheriting font-size. There is no
data-size attribute here for that reason: a fourth sizing mechanism would only
compete with the three that already work this way. Standing alone and larger (an icon-box
preset, a hero glyph) is a font-size change on the ancestor, not a new attribute.
<div style="font-size: 2rem;">
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">…</svg>
</div>currentColor, no exceptionEvery file in src/icons/ paints with stroke="currentColor" (a filled
dot, and every brand mark, uses fill="currentColor") and nothing else. That is the only way one file per
icon can be correct in both themes and under every colour preset:
a hard-coded fill would need a copy per colour, the duplication this component's delivery
mechanism already accepts once and will not accept twice. --icon-color is available
to retint an instance without touching the surrounding text colour; the icon below sits inside
danger-coloured text and needs no override at all, because it already inherits.
Cannot be deleted
<p style="color: var(--color-danger-text);">
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">…</svg>
Cannot be deleted
</p>Every icon in this set ships aria-hidden="true" already, inside the file. There is
no path here for an icon to carry its own accessible name, because the only place to put one (a
<title> inside the SVG) is a single string, and this system has no localised
string type for it. Baking one language into a <title> is the exact defect
the long-text and localisation rule exists to prevent everywhere else in this package, only smaller. So the icon
is always paired with something that carries the meaning: visible text beside it, as in every
example above, or, for an icon-only control, an aria-label on the control itself,
the same pattern Button's data-icon-only
already documents.
<button class="button" type="button" data-variant="quiet" data-icon-only
aria-label="Close">
<svg class="button__icon" viewBox="0 0 16 16" aria-hidden="true">…</svg>
</button>Twelve UI glyphs, each earning its place from a real need already in this package rather than
"a complete set usually has one of these": see icon.css's own header for the full
argument, including what was rejected and why (a disclosure chevron for collapse/accordion, a
video play glyph). Then seven brand- marks for social links, which are
sourced, not drawn: path data from Simple Icons 16.32.0 (CC0 1.0), kept
verbatim and only fitted to the 16-grid. They are filled rather than stroked because the marks
are, and they remain their owners' trademarks: src/icons/README.md has the
licence, the trademark note, and why there is no LinkedIn mark. Like every icon here they are
monochrome currentColor; brand colours are not shipped.
checkcloseplusminuschevron-leftchevron-rightinfowarningdangerexternal-linksearchmenubrand-facebookbrand-instagrambrand-youtubebrand-xbrand-tiktokbrand-telegrambrand-whatsappAdding an icon is not a drop-in. A UI glyph costs a new file drawn to the
src/icons/README.md contract (16 16 viewBox, live area 3
to 13, 1.6 stroke weight, currentColor only,
aria-hidden already set), a line added to the set above, and, once the CMS side
lands, a new entry in its own name-keyed allow-list. That friction is deliberate: it is the same
cost as adding a token or a component, not the cost of pasting a <path> into a
text field. What this component does not do: it is not a substitute for a label
(an icon beside no text and no aria-label'd control is not a supported
composition, see above), it does not draw disclosure markers for
Collapse or Accordion (both draw their own from currentColor borders specifically so
a translator never meets an arrow baked into a string), and it does not redraw brand marks in
its own house style (a trademarked mark is not a geometric glyph: the brand-
files are the owners' shapes, copied, not reinterpreted).
A figure with a label: a KPI, a dashboard counter, a portfolio summary
tile. Reach for .stat when the number is the content and a
short phrase says what it counts, not .money, which adds a
currency mark, a sign colour and ledger decimal alignment a plain counter
does not need. The figure reuses money's numeral treatment: tabular,
lining digits that stay on the same baseline as a money figure sitting
next to one.
1,250,000
Total orders
<div class="stat">
<p class="stat__value">1,250,000</p>
<p class="stat__label">Total orders</p>
</div>
Value first, label second: the eye lands on the figure and the label
answers the question it just raised. The element on
.stat__value is a free choice: use <data
value="1250000"> when the raw number is copied or scraped
downstream, the same reason .money uses it, or a plain
<p> when it is not. .stat__label wraps on
ordinary rules: it is never truncated and never gets
white-space: nowrap. That exemption exists in exactly one
place, on .stat__value, because a figure broken across two
lines is misread and digits are not translated; a label is translated,
so it wraps like any other sentence in this package.
A translated string routinely runs 15–25% longer than its English source, and a stat label is exactly the kind of short phrase that grows into two lines at the same width an English label fits on one. Nothing here fights that: the value stays on its own line and the label reflows underneath it.
1,250,000
Total number of orders settled this quarter
<div class="stat">
<p class="stat__value">1,250,000</p>
<p class="stat__label">Total number of orders settled this quarter</p>
</div>
.stat__delta is an optional third part for a short trend
caption, "+4.2% from last month". It is text, not an icon slot: an arrow
glyph alone would make colour the only signal for direction, which is the
same problem .money's sign colour avoids by requiring the
sign in the text as well. Say the direction in words or a
+/- the reader does not have to infer from hue.
+4.2%
Liquidity
Up from last month
<div class="stat" data-variant="success">
<p class="stat__value">+4.2%</p>
<p class="stat__label">Liquidity</p>
<p class="stat__delta">Up from last month</p>
</div>
data-size="sm" and data-size="lg" move the type
step only, not the weight, not the gap between value and label. The
label always takes the step below the value, at every size, so the
relationship between the two never drifts. This is the same rule
.money applies to its amount and currency mark.
1,250,000
Total orders
1,250,000
Total orders
1,250,000
Total orders
<div class="stat" data-size="sm">…</div>
<div class="stat">…</div>
<div class="stat" data-size="lg">…</div>
data-variant="success", "warn" and
"danger" colour the value and the delta, never the label.
The label names the metric, and its name does not become true-er or
false-er depending on the reading, so it stays
--color-text-muted regardless of variant. Only the value and
the delta are re-pointed, because those are the parts that are actually a
reading of the metric's current state.
+4.2%
Liquidity
78%
Disk usage
-12.5%
Error rate
<div class="stat" data-variant="success">
<p class="stat__value">+4.2%</p>
<p class="stat__label">Liquidity</p>
</div>
.stat is start-aligned by default, which is correct inside a
start-aligned card or a table cell, the common case. Centring a tile in
a dashboard grid is a layout decision about where the stat sits among its
siblings, not a fact about the stat itself, so it is not a variant here.
Add .u-text-center to the instance instead; it already wins
over this layer with no specificity trick required.
42
Active users
<div class="stat u-text-center">
<p class="stat__value">42</p>
<p class="stat__label">Active users</p>
</div>
.stat does not format the number: no thousand separator, no
rounding, no decimal trimming. That is the application's job, the same
split .money documents: Intl.NumberFormat
produces the string, this component only sets its typography. It carries
no background, border or padding of its own: it is not a card, and
composes inside one rather than imitating it. It has no icon or arrow
slot for its optional delta; direction belongs in the text, not in a
glyph colour alone. And it defines no third size or fourth variant beyond
sm/lg and success/warn/
danger, a plain .stat with no
data-variant is a complete, correct instance, not a
component waiting for one.
A star rating, 0–5, rendered from a number: product reviews, testimonials, a merchant's
score in a listing. Display only: there is no .rating a
user can click, tab into or submit. The value is a unitless custom property on the root,
--rating-value, in stars rather than in percent; each star works out its own
share of it from its position, so the application does no arithmetic and there is no second
place for the number to be wrong. Values outside 0–5 clamp themselves: -1
renders empty, 9 renders full.
The root is inline-level, the same choice money makes for
a figure: a rating drops into a running line without an override, and a rating that owns its
own row is that same element inside whatever block the layout already has, which is why
each example below sits inside a <p>. Block-level cannot be undone the other
way round without a consumer rule.
<span class="rating" style="--rating-value: 3.7">
<span class="rating__stars" aria-hidden="true">
<span class="rating__star"></span>
<span class="rating__star"></span>
<span class="rating__star"></span>
<span class="rating__star"></span>
<span class="rating__star"></span>
</span>
<span class="rating__value">3.7 / 5</span>
<span class="rating__count">from 128 ratings</span>
</span>A row of clipped boxes is not information. The stars carry no text, no content
and no image: to a screen reader they are five empty spans. So the contract is two
lines long, and both are required:
.rating__stars must carry
aria-hidden="true". It is a picture of a number that is already on the
page..rating__value.Do both and the rating announces as "3.7 / 5" with no ARIA beyond that one attribute. Skip the second and the rating is announced as nothing at all: not "unlabelled", not "image": the only node with any content is hidden, so a screen reader passes over the whole component in silence and the reader never learns the product was rated. That is worse than a wrong label, because nobody filing the bug can see it.
Where the visible design has no room for the digits, keep the text and hide it visually
instead of deleting it: <span class="rating__value u-sr-only">3.7 /
5</span>. u-sr-only is the
package's one visually-hidden helper; do not add a second.
role="img" with an aria-label on .rating__stars also
works and is not the recommendation. The string then lives in an attribute: it is
invisible to translation pipelines that walk text nodes, it cannot be selected or copied,
and when it goes stale nothing on screen disagrees with it. Reach for it only where a text
node is genuinely impossible.
Each star is an empty box clipped to a pentagram with clip-path and painted with
a two-stop gradient that switches from the fill colour to the ground at
--rating-star-fill. That percentage is worked out per star from the single number
on the root: :nth-child gives star n an index, the star computes
value − n + 1, and clamp() pins the result into 0…1
before it is multiplied by 100%. At 3.7, star 3 computes 1.7 and clamps to full,
star 4 computes 0.7, star 5 computes −0.3 and clamps to empty. The clamp is what makes
an out-of-range value safe rather than a special case.
It does not round. 3.7 renders as seven tenths of the fourth star, not as a
half and not as a whole. Half steps are what most rating data quantises to, but quantising is
the application's arithmetic: a component that silently snapped 3.7 to 3.5 would report a
number nobody supplied, and the .rating__value text beside it would then disagree
with the picture. Round before you set --rating-value if your product wants
halves.
<!-- Four tenths apart. Only the inline custom property changes. -->
<span class="rating" style="--rating-value: 3.2">…</span>
<span class="rating" style="--rating-value: 3.5">…</span>
<span class="rating" style="--rating-value: 3.7">…</span>
<span class="rating" style="--rating-value: 3.9">…</span>Three fills and three type steps. The stars are --size-icon (1em), so they follow
the font size the root sets and data-size needs no declaration of its own. The
figures are the worst case across all nine main-colour presets, fill against
--rating-track.
<span class="rating" data-size="sm" style="--rating-value: 3.7">…</span>
<span class="rating" style="--rating-value: 3.7">…</span>
<span class="rating" data-variant="brand" data-size="lg" style="--rating-value: 3.7">…</span>
<span class="rating" data-variant="neutral" style="--rating-value: 3.7">…</span>The obvious design, a gold star drawn straight on top of an opaque grey one, fails in this
system's dark theme. --color-warn against
--color-border measures 1.06:1 light and 1.30:1 dark: a dark page
forces the empty star light, because anything dark enough to separate from gold disappears into
the background, and gold is light too. The boundary inside a partly filled star becomes a pure
hue edge, invisible in greyscale, and invisible to a large minority of readers. No other
neutral role rescues it: --color-border-strong and --color-text-subtle
land at 1.04–1.45, and --color-border-subtle separates from the fill only by
disappearing against the page itself.
So the star is built the way progress builds its track. The star
element is painted --color-border and clipped to the pentagram: that
hairline is the outline, and it is what makes an empty star visible at all, at 3.55:1
light and 4.39:1 dark against the page. Its ::before is inset by one border width,
clipped to the same shape, and carries the gradient over --rating-track
(--color-surface, within 1.21:1 of the page). The boundary that carries the
value is therefore fill against track, 4.54:1 light and 4.71:1 dark,
and an unfilled star reads as a hole with an outline, which is what an empty star has
always looked like.
--rating-track off a standard ground
--rating-track is the one value in the component that has to know what it was
put on top of. On the page or on a card it is right as shipped, within 1.21:1 in
both themes either way. On a tinted panel, a solid brand block or an image, set it to
match. An outlined star with the wrong ground inside it is the single easiest way to get this
component visibly wrong, and CSS cannot detect it.
It is not an input. An input needs a <fieldset> of radios,
a real focus ring, arrow-key handling, a name and a required story: none of
which a display rating has, and all of which would have to be retrofitted badly if this file
pretended to cover both. The markup deliberately leaves room for one: the five stars are
separate elements in a row, so a later rating field can put a
<label class="rating__star"> on each and reuse the shape, the colour and the
fill mechanism unchanged.
It is not a percentage bar: progress owns "how
far along is this", and this owns "how good was it". It does not count: the
scale is five stars because there are five elements, and a sixth would need a sixth
:nth-child rule, because nothing in CSS reads a sibling's position at this browser
floor. It does not format: "3.7" is a string the application produced, and the
decimal mark belongs to the reader's locale, so use Intl.NumberFormat for it
exactly as money does. And it does not draw a star from a
character: ★ takes its metrics from whichever font actually
serves it, which on a kiosk or an Android phone is routinely a fallback the design never saw,
and a string handed to a translator eventually comes back translated.
One naming rule holds the long-text line: .rating__value is
digits only, and it is the one element in the component that may not wrap:
a rating split after the solidus reads as two numbers, and a numeral is not translated,
so the ban on nowrap does not reach it. Any translated string, such as
"from 128 ratings" or "3.7 out of 5", belongs in
.rating__count or in a u-sr-only sibling, never in there.
nowrap on a translated phrase is a horizontal scrollbar on a phone, and keeping the
digits and the words apart is the whole reason there are two elements.
Styles a native <video>. It is not a player and will never become one:
the transport bar is whatever the browser draws for the controls attribute, and
its shadow DOM is not addressable from CSS at any floor. There is no custom control bar here
and no third-party embed: self-hosted src only, the same rule image.css follows for src.
<figure class="video" data-ratio="16/9">
<video class="video__media" poster="poster.jpg" controls playsinline>
<source src="clip.mp4" type="video/mp4">
Your browser cannot play this recording.
</video>
<figcaption>Introduction to the payment settlement system</figcaption>
</figure>Six values, matching data-ratio on the image block exactly: auto
(default), 1/1, 4/3, 3/2, 16/9,
21/9. The ratio lives on the <video> element itself, not the
<figure>, the same place card.css and the image block already
put it, so this is not a new pattern to learn.
<figure class="video" data-ratio="1/1">
<video class="video__media" poster="poster.jpg" controls playsinline></video>
</figure>
<figure class="video" data-ratio="4/3">…</figure>
<figure class="video" data-ratio="21/9">…</figure>data-fit="cover" (default) crops to fill the ratio box; data-fit="contain"
letterboxes instead, and --video-bg is what paints the bars: it defaults to
--color-surface-sunken so an unset video reads as a placeholder rather than a
transparent gap.
<figure class="video" data-ratio="21/9" data-fit="contain">
<video class="video__media" poster="poster.jpg" controls playsinline></video>
</figure>With no data-ratio and nothing playable (no working <source>,
no poster), the element does not collapse to a sliver and does not overflow its
column. With no intrinsic ratio to preserve, CSS's replaced-element sizing falls back to a
flat 150px tall box at the container's full width (verified at 375px, 900px and
1200px, the height stays 150px, it does not scale with width), and --video-bg
fills it so the placeholder reads as intentional. The instant a poster or real metadata loads,
the box takes on that content's own ratio instead.
No .video__caption class: a bare <figcaption> already gets
its margin, size and muted colour from base.css, identically to the image block's
own caption. No truncation and no line-clamp: a caption is translated prose, and a translated
string routinely runs 15–25% longer than its English source, so it wraps rather than clips.
Unset by default. --video-radius is a plain local, not a data-*
variant: there is one shape axis here, not several, so a consumer retheming one instance sets
the custom property directly rather than reaching for a modifier that does not exist.
<figure class="video" data-ratio="16/9" style="--video-radius: var(--radius-lg);">
<video class="video__media" poster="poster.jpg" controls playsinline></video>
</figure>| Property | Default | Controls |
|---|---|---|
--video-ratio | auto | On .video__media. Re-pointed by data-ratio. |
--video-fit | cover | On .video__media. Re-pointed by data-fit="contain". |
--video-bg | var(--color-surface-sunken) | Letterbox fill under contain, and the placeholder band when there is no ratio and nothing loads. |
--video-radius | var(--radius-none) | Corner radius on .video. |
This component will never grow a custom transport bar, never wrap a YouTube or Vimeo iframe,
and never ship autoplay logic. Controls are the browser's; a poster and its first decoded
frame share one object-fit because the platform defines it that way, not because
this file does anything extra. If a consumer sets the native autoplay attribute
directly on <video>, that is an HTML attribute this stylesheet has no power
to gate (CSS cannot read or suppress a boolean attribute), so respecting a viewer's reduced-
motion preference for an autoplaying clip is the consumer's responsibility, not this file's.
An off-canvas panel that slides in from an inline edge: site navigation on a phone, a
filter panel beside a table, a details pane beside a list. It is not a new overlay: it is
a dialog with a different geometry, so .dialog and
.drawer go on the same <dialog> element and
.drawer only re-points dialog's locals. The same composition
.accordion__item uses on .collapse.
The drawer is the first component in this package with an optional behaviour, and it
obeys the rule that makes that acceptable: with
src/behaviors/drawer.js absent or failed, the drawer is an open
<dialog> sitting in the document flow: a bordered, shadowed
panel whose title, body, links and buttons are all present, readable and operable. The
trigger link scrolls to it. The close button dismisses it, from the platform, with no
script at all. Nothing is hidden and nothing is a dead control, so there is no
<noscript> markup path.
That is not a fallback someone remembered to write. Every declaration that pins the
panel to a screen edge is scoped to :modal, which cannot match until
something has called showModal(), so the in-flow panel is the state the CSS
starts in, and the script is what leaves it. What you lose without the script: the panel
does not overlay the page, and once dismissed it cannot be reopened without a reload.
The drawer owns exactly one class. Every part below comes from
dialog and is not redefined: .dialog__header,
.dialog__title, .dialog__dismiss, .dialog__body,
.dialog__footer. The preview is the no-script state: an open, non-modal
dialog, which is why it sits in the page rather than over it.
<a class="button" href="#site-nav" data-drawer aria-haspopup="dialog">Menu</a>
… the page …
<dialog class="dialog drawer" id="site-nav" data-placement="inline-start"
open aria-labelledby="site-nav-title">
<form method="dialog">
<div class="dialog__header">
<h2 class="dialog__title" id="site-nav-title">Main menu</h2>
<button class="button dialog__dismiss" type="submit"
data-variant="quiet" data-icon-only aria-label="Close">
<svg class="button__icon" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="…"/></svg>
</button>
</div>
<div class="dialog__body"> … </div>
</form>
</dialog>
<script type="module" src="node_modules/@arag/css/src/behaviors/drawer.js"></script>Four things are markup, not CSS, and the component does not work without them.
<a href="#site-nav" data-drawer>, where the href is the
drawer's own id. A <button> with no script is a control that does
nothing; a link scrolls to the panel, which is a real thing to do. data-drawer
takes no value: the href already names the target. Add
aria-haspopup="dialog".open. That attribute is the
fallback, not an oversight. The script closes it during its own evaluation, before first
paint in practice, but only if a trigger on the page names it, so a drawer without one
stays the in-flow panel.<form method="dialog"> submit
button. The platform closes a dialog on that submission whether it is modal or
not, which is exactly why the close control still works with no script. Do not wire close
in JavaScript.The drawer also needs an accessible name, the same way a dialog does:
aria-labelledby pointing at the .dialog__title id, or
aria-label where there is no visible title.
Two values, both logical, and they mirror: under dir="rtl" an
inline-start drawer is anchored to the right of the screen and slides in from
the right. inline-start is the default and needs no attribute.
<dialog class="dialog drawer" data-placement="inline-start" open> … </dialog>
<dialog class="dialog drawer" data-placement="inline-end" open> … </dialog>| Property | Default | What it does |
|---|---|---|
--drawer-inline-size |
var(--size-container-sm) |
The panel's width ceiling. The panel is always 100% of the viewport below that, so a phone gets a full-bleed panel and a desktop gets a fixed one. |
--dialog-radius |
var(--radius-none) |
Dialog's own local, re-pointed. Set it back to a radius for a rounded inner edge: a full-height drawer's other two corners are past the viewport edge, so rounding all four rounds only the two that are visible. |
--dialog-max-inline-size |
var(--drawer-inline-size) |
Dialog's width ceiling, pointed at the drawer's. Re-point
--drawer-inline-size, not this. |
src/behaviors/drawer.js is a plain ES module the consumer adds with a second
tag, the same opt-in shape fonts.css uses. It is 1010 B against the 2 KB
per-behaviour budget, has no imports and no dependencies, and does four things:
<dialog> whose id an
a[data-drawer] trigger on the page names in its href, and no
other. That is a drawer or a plain .dialog alike: the class is not asked
for, the trigger is. A closed <dialog> is display: none,
so closing one that nothing can reopen would not hide a panel, it would delete it. An
open dialog no trigger names is left exactly as the markup shipped it: open, in flow,
readable;a[data-drawer] trigger's href,
with showModal();close(), because all three fire the same event);showModal(). Closing is the platform's, from
<form method="dialog">. Loading the module twice is a no-op: the guard
is a data-arag-drawer attribute on <html>.The trigger gets no aria-expanded, deliberately: a modal dialog makes the
whole background inert, so while the drawer is open the trigger is not perceivable and has
no state worth announcing.
The panel slides in over --duration-normal. There is no exit animation:
that needs transition-behavior: allow-discrete or
@starting-style, both Firefox 129 and above this package's Firefox 120 floor,
the same call dialog made. The drawer closes instantly, which
never reads as broken.
Under prefers-reduced-motion: reduce the translation is zeroed outright, on
top of the duration collapse every token-driven animation already gets. A drawer travels
its own full width, which is an order of magnitude more motion than a dialog's rise, so it
gets the belt as well as the braces.
Four .stats in a row, ticking toward a target instant: a
sale ending, a launch, a registration deadline. The unit is not a
lookalike box built to resemble a stat; it is one, composed the
same way .accordion composes .collapse: this
component supplies the row and the dividers between units, and
.stat supplies everything inside
one, unchanged.
A countdown with no script is the date, written out as text, not a
row of zeroes, not four empty boxes. The four .countdown__unit
elements carry hidden IN THE MARKUP, by hand, from the
start, so a reader with scripting off (or a failed script load) sees
only the plain sentence in .countdown__fallback, with a
real <time datetime> inside it. Adding
src/behaviors/countdown.js as a second, optional
<script type="module"> tag REPLACES that sentence
with the live, ticking grid: it never shows both at once, and the
sentence is never a placeholder waiting on script; it is a complete,
correct answer on its own. See
behaviors/README.md for the
house rule this follows.
Promotion ends:
14
days
07
hours
51
minutes
31
seconds
<div class="countdown">
<p class="countdown__fallback">
Promotion ends:
<time datetime="2026-12-31T00:00:00+08:00">31 December 2026</time>
</p>
<div class="stat countdown__unit u-text-center" hidden>
<p class="stat__value" data-countdown-unit="days" data-countdown-pad="1">0</p>
<p class="stat__label">days</p>
</div>
<div class="stat countdown__unit u-text-center" hidden>
<p class="stat__value" data-countdown-unit="hours">00</p>
<p class="stat__label">hours</p>
</div>
<div class="stat countdown__unit u-text-center" hidden>
<p class="stat__value" data-countdown-unit="minutes">00</p>
<p class="stat__label">minutes</p>
</div>
<div class="stat countdown__unit u-text-center" hidden>
<p class="stat__value" data-countdown-unit="seconds">00</p>
<p class="stat__label">seconds</p>
</div>
</div>
<script type="module" src="node_modules/@arag/css/src/behaviors/countdown.js"></script>
The example above ships hidden removed and real digits filled
in purely so this page reads correctly for a visitor with scripting on:
the actual shipped markup keeps every unit hidden, as the
code sample below the preview shows.
The <time datetime> inside .countdown__fallback
is the ONLY place the target instant is written. There is no second,
script-only copy of it to keep in sync: the behaviour reads the same
attribute the sentence already shows, so the fallback and the live grid
can never drift apart the way two copies of one fact eventually do.
data-countdown-unit="days|hours|minutes|seconds" on a
.stat__value tells the behaviour which field to write a
number into: a protocol key, the same status "success" has
on data-variant="success" elsewhere in this package, never
display text. The .stat__label next to it ("days", "Tage",
whatever the page is in) is ordinary consumer markup the script never
reads and never writes. data-countdown-pad is an optional
minimum digit count for that field (default 2, pass 1 for
an unpadded day count, as the example above does).
Both attributes fail narrowly, by design: a mistake in one costs that
one field and nothing else on the page. A data-countdown-pad
outside 1–21 (the range Intl.NumberFormat accepts) is
clamped into it rather than thrown on, so pad="99" renders
21 digits, which is visibly wrong and points straight at the typo. A
data-countdown-unit naming anything but those four keys is
skipped and the element is left exactly as authored; it is not written
to, and it does not stop the other fields, or the other countdowns on
the page, from ticking. One shared timer drives every
.countdown, which is why this matters more than it looks:
before it was fixed, either mistake took every countdown down with it.
A target already in the past when the page loads never reveals the grid at all: the static fallback sentence already reads correctly as history and is left exactly as authored. A live grid that reaches zero mid-session freezes there, every field at its zero-padded zero, and stops ticking. Neither path can show a negative number.
Translated unit labels can run a full third longer than an English source, so
.countdown wraps rather than forcing four units onto one
line no matter how narrow the viewport gets. A unit's divider is a plain
1px border-inline-end between two regions: see
countdown.css's own header for the one accepted limitation this creates
on a wrapped row.
countdown.css does not restate anything .stat
already provides: the tabular, lining numerals that keep the digits
from jittering sideways once a second come from .stat__value
unchanged. It carries no background, border or radius of its own, the
same as .stat; wrap one in a .card for the
tile look. And countdown.js formats every digit through
Intl.NumberFormat but never composes a sentence of its own
(the one place a hand-written sentence was needed, the fallback, is
exactly the part this package asks the consumer to write).
base.css already gives <pre><code> a mono face, a sunken
background, radius, horizontal scroll and correct spacing (src/base.css:291-327).
code.css adds exactly three things on top of that: a caption, a copy affordance,
and syntax-highlight colours for the three grammars the opt-in highlight.js behaviour
knows. It is a <figure>, the same <figure> +
<pre><code> (+ <figcaption>) shape the CMS's own
code block already emits.
This is not a general-purpose syntax highlighter, and it was never meant to be one.
highlight.js is a small, hand-written tokenizer for exactly three grammars:
JSON, HTTP and shell, chosen because they are what a settlement API
doc actually needs. Nothing else is recognised; an unmatched data-lang value, or
none at all, renders as plain, correct, unhighlighted monospace text. There is no dependency
here and never will be one: see this repo's own "no framework, no bundler, no dependencies"
rule for behaviours, which this file follows to the letter.
{
"id": "txn_8f21",
"amount": 125000.5,
"currency": "MNT",
"settled": true
}
<figure class="code">
<pre><code data-lang="json">{ "id": "txn_8f21", "amount": 125000.5 }</code></pre>
<figcaption>A settlement transaction object.</figcaption>
</figure>
<script type="module" src="node_modules/@arag/css/src/behaviors/highlight.js"></script>Rule 7 forbids a control that does nothing without script, so code.css's markup
never contains a copy button. The button you see on the examples on this page is not evidence of
that contract, though: this docs site never loads highlight.js at all (the
<script> tag in the copyable markup below is never executed here); the button
comes from this site's own initCopy() in docs/assets/docs.js, a plain
reimplementation of the same idea for demo purposes. In a real install, highlight.js
creates the button and appends it to .code the moment it runs, and only for a figure
that carries data-copy-label: see the copy button, below.
With the script absent, failed, or the attribute missing, there is no button anywhere in the DOM,
not a hidden or disabled one. Highlighting works the same way: data-lang is inert
without the script, read by no CSS rule anywhere.
What is left without the script is a complete, working component: plain monospace text, exactly
as base.css alone renders it, and <pre>'s content is ordinary
selectable text: a user copies it by selecting and pressing their platform's copy shortcut,
which is a complete, working way to get the code out. Test this literally: load the page with
scripting disabled and use the block by hand.
JSON: strings, numbers, booleans/null and punctuation, and a quoted string
immediately followed by : is read as a key rather than a value, so
{"ok": true} colours the key and the literal differently. Scientific notation
(1e10) is not matched: a settlement figure is a decimal, not an exponent, and an
unmatched exponent still renders correctly as plain text.
HTTP: a request line (method, path, version), a status line (version, code,
reason) and header lines (name vs value), found anywhere in the block rather than parsed line by
line. A status code is coloured as a plain number; it is not tinted by its own 2xx/3xx/4xx
severity: see highlight.js's own header for why that severity colouring was the
first thing cut once three grammars, a copy button and a real escaping guarantee did not fit the
2 KB budget together. Header names accept [\w-]+ rather than the full RFC 7230
token set, a deliberate, documented simplification, the same shape as the exponent cut above.
POST /v1/settlements HTTP/1.1
Host: api.northpay.example
Authorization: Bearer abc123
HTTP/1.1 201 Created
Content-Type: application/json
<figure class="code">
<pre><code data-lang="http">POST /v1/settlements HTTP/1.1
Host: api.northpay.example
Authorization: Bearer abc123
HTTP/1.1 201 Created
Content-Type: application/json</code></pre>
<figcaption>Creating a settlement, and the response that follows.</figcaption>
</figure>Shell: a $ prompt recognised only at the very start of a line, the
command name directly after it, any -x / --long flag, and a single- or
double-quoted string, plus a trailing # comment on any line, which reuses
.code__com, the same class a consumer can already hand-mark-up themselves, so
nothing they wrote stops working now that this grammar also emits it. Deliberately thin: no
pipes, no subshells, no variable expansion, no heredocs: a shell block that uses none of the
four still renders correctly, just uncoloured beyond what it contains. ROADMAP named all three
grammars from the start; shell was cut for budget in an earlier pass and restored once six
hardcoded copy-button strings came out to make room: see highlight.js's own header
for that trade.
$ npm run check
Everything is fine. # ran clean
<figure class="code">
<pre><code data-lang="shell">$ npm run check
Everything is fine. # ran clean</code></pre>
<figcaption>A terminal session — the prompt, the command and the trailing comment each colour
differently.</figcaption>
</figure>Appended by highlight.js, once, to every .code figure
that asks for one, a .button (rule "minimum diff, reuse first": no new
control was invented) positioned over the block-start corner by
.button.code__copy. There is no built-in label in any language.
Every word the button can show comes off the figure's own data-copy-* attributes,
verbatim, in whatever language the page is already in: see below. A click that
navigator.clipboard cannot fulfil (the API is missing, the page is not a secure
context, or the browser rejects the write) swaps in
data-copy-fail-label if the figure supplies one, and otherwise leaves the text
unchanged rather than inventing a word; the button carries aria-live="polite" so
a change that does happen is announced, not just painted.
The selector is .button.code__copy, not .code__copy, and the second
class is load-bearing rather than decorative. Every declaration in that rule re-points a
custom property .button[data-variant="quiet"] also sets, so a single-class
selector loses the specificity comparison to it and the button renders with a transparent
background over the code it overlays. If you are restyling the copy button in your own CSS,
qualify your selector the same way: your stylesheet is unlayered and beats all six layers,
but only once it outranks quiet.
The button only exists on a figure that supplies data-copy-label: the exact text
the button shows at rest, in whatever language the page is already in. There is no built-in
default, on purpose: an English "Copy" button on a French page is the same defect as a button
that silently does nothing, so a consumer who never writes the attribute gets no button at all
rather than one worded in the wrong language. Two more attributes are optional:
data-copy-done-label swaps the text in after a successful clipboard write, and
data-copy-fail-label after a failed one: leave either off and the button's text
simply does not change for that outcome. The copy (or the failure) still happened; there is just
no consumer-supplied word to show for it.
{ "ok": true }
<figure class="code" data-copy-label="Copy"
data-copy-done-label="Copied" data-copy-fail-label="Copy failed">
<pre><code data-lang="json">{ "ok": true }</code></pre>
</figure>
<script type="module" src="node_modules/@arag/css/src/behaviors/highlight.js"></script>| Property | Default | Controls |
|---|---|---|
--code-copy-gap | var(--space-3) | Inset of .code__copy from the figure's block-start/inline-end corner. |
--code-color-accent | var(--color-brand-text) | A JSON key; an HTTP method and header name. .code__key. |
--code-color-string | var(--color-success-text) | A JSON/quoted string. .code__str. |
--code-color-number | var(--color-warn-text) | A JSON number/boolean/null; an HTTP status code. .code__num. --color-warn-text is the normal reading colour in both themes, so a number reads as ordinary text and never as orange. |
--code-color-muted | var(--color-text-muted) | Punctuation; an HTTP version string; a shell comment: the shipped shell grammar emits this for a trailing # comment, and a consumer's own hand-marked-up comment span gets the identical colour. .code__punct / .code__com. |
--code-color-danger | var(--color-danger-text) | .code__err. Not emitted by any of the three shipped grammars today: reserved for a consumer's own hand-marked-up error token. |
No new tokens exist for this component: every one of the five locals above re-points an
existing semantic colour role from tokens.css, so retheming brand or switching
[data-brand] carries the highlighted block along with it, with nothing new for
npm run check's custom-property count to track.
highlight.js reads a block's existing text with .textContent and
rebuilds it with document.createTextNode and span.textContent only:
never innerHTML, never string concatenation into markup. A block whose source text
is literally </code></pre><img src=x onerror=alert(1)> renders as
visible text; no <img> is created and no script runs. See
highlight.js's own header for the full guarantee and how it was verified.
The components compose rather than growing special cases. The dialog example above is already
four of them: a dialog containing a field with a currency prefix, and a footer of buttons. The
alert below composes the same button group, and nothing in alert.css knows what a
button is.
Updated 2 minutes ago
The whole reason the components are shaped this way. Below is one set of components with short labels and the same set with deliberately long ones, in both themes. Nothing has a fixed width; every control grew to fit. This is the check to run against any new component.
Utilities. A single static, hand-written file of roughly 120–150 classes covering flex and grid, gap, padding and margin on the space scale, the type scale, text and background colour, and display. No generator, no config file, no scanner, no arbitrary-value syntax. Because utilities sit above components in the layer order, a utility beats a component without any specificity trick, and unlayered application CSS still beats both. Utilities →