@arag 0.1.0

Components

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.

Status

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.

How the component layer works

Eight conventions, established in button.css and held by every file in the directory. Five of them are visible in the markup you write.

Bare BEM class names, one file per component

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.

Variants and sizes are data-attributes

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.

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

Native state stays native

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.

Every component exposes a local custom property API

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.

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

Two rules that never bend

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.

Overriding a component

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);
}

Button

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.

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

Variants, sizes and shapes

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.

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

Groups

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

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

API

Attributes. All of them go on .button unless noted.
AttributeValuesDoes
data-variant primary, success, warn, danger, outline, quiet, link Re-points the colour locals. Omitted is the neutral surface button.
data-sizesm, lg Min block size, padding, type step and radius. Omitted is base.
data-shapepill Radius only, to --radius-full.
data-icon-onlybare Square: inline size equal to the min block size, aspect ratio 1. Requires an accessible name.
data-blockbare Fills its container. Opt-in, never the default.
data-loadingbare cursor: progress, and dims .button__label while the spinner runs.
data-attachedbare 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.
Local custom properties on .button.
PropertyDefaultControls
--button-bgvar(--color-surface)Resting fill.
--button-fgvar(--color-text)Label and icon colour.
--button-bordervar(--color-border)Resting edge.
--button-bg-hovervar(--color-surface-hover)Fill on :hover.
--button-bg-activevar(--color-surface-active)Fill on :active.
--button-border-hovervar(--color-border-strong)Edge on :hover.
--button-min-block-sizevar(--size-control)Minimum height, and the side of an icon-only button. A coarse pointer restores var(--size-touch) for every button.
--button-padding-inlinevar(--space-4)Inline padding.
--button-padding-blockvar(--space-2)Block padding.
--button-font-sizevar(--text-sm)Label type step.
--button-radiusvar(--radius-md)Corner radius.

Accessibility and decisions

Field

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.

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

Controls, groups, choices and switches

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

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

File

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.

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

Markup
<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>
Two states have to be set twice

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

User account settings
Markup
<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.

API

Attributes.
AttributeOnDoes
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.
rowstextarea.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.
Local custom properties. The --field-* set is declared on .field and inherits to every part inside it.
PropertyDefaultControls
--field-gapvar(--space-2)Space between label, control and description.
--field-label-colorvar(--color-text)Label colour.
--field-help-colorvar(--color-text-muted).field__help colour.
--field-error-colorvar(--color-danger-text).field__error colour.
--field-marker-colorvar(--color-danger)The required marker.
--field-affix-colorvar(--color-text-muted)Prefix and suffix text.
--field-affix-bordervar(--color-border-subtle)The rule between affix and control.
--field-bgvar(--color-surface)Control fill.
--field-fgvar(--color-text)Control text.
--field-bordervar(--color-border)Control edge.
--field-border-hovervar(--color-border-strong)Edge on hover.
--field-ring-colorvar(--focus-ring-color)Focus ring and the focused edge, together.
--field-radiusvar(--radius-md)Control radius.
--field-padding-inlinevar(--space-4)Control and affix inline padding.
--field-padding-blockcalc((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-sizevar(--size-control)Minimum control height. A coarse pointer restores var(--size-touch).
--field-font-sizevar(--text-base)Control and affix type step.
--field-line-heightvar(--leading-snug)Control line height. A default textarea keeps var(--leading-normal).
--choice-gapvar(--space-3)Box to label, on .choice.
--choice-label-colorvar(--color-text).choice__label colour.
--choice-control-sizevar(--space-5)Checkbox / radio side.
--choice-control-offsetvar(--space-1)Optical nudge that centres the box on the label's first line.
--switch-track-inline-sizecalc(var(--space-6) * 2)Track length.
--switch-track-block-sizevar(--space-6)Track height, and the thumb's travel basis.
--switch-thumb-insetvar(--space-1)Thumb inset from the track.
--switch-track-bgvar(--color-border)Track when off.
--switch-track-bg-checkedvar(--color-brand-solid)Track when on.
--switch-thumb-bgvar(--color-white)Thumb fill.
--fieldset-gapvar(--space-5)Space between fields in .fieldset__body.
--fieldset-bordervar(--color-border)Fieldset edge.
--fieldset-radiusvar(--radius-lg)Fieldset radius. A fieldset holds controls, so it takes the container rung.
--fieldset-paddingvar(--space-5)Fieldset padding.
--field-row-minvar(--size-column-min)The column width below which .field-row stacks.
--field-row-gapvar(--space-5).field-row gap.
--field-file-button-bgtransparent::file-selector-button fill, at rest.
--field-file-button-fgvar(--color-text)::file-selector-button text.
--field-file-button-bordervar(--color-border)::file-selector-button edge.
--field-file-button-bg-hovervar(--color-surface-hover)::file-selector-button fill on hover.
--field-file-button-bg-activevar(--color-surface-active)::file-selector-button fill on press: the same derivation .button[data-variant="outline"] uses.

Accessibility and decisions

Card

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.

Account balance

Updated 2 minutes ago

Settled balance across all connected accounts, excluding pending transfers.

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

Variants, sizes and the grid

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.

Outlined, sm

Flat: the edge alone, no shadow.

Elevated

Raised surface and shadow. Both, always.

Subtle

Sunken. For context around content, not content itself.

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

API

Attributes on .card.
AttributeValuesDoes
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-sizesm, lg Padding and gap. sm also steps the radius down to --radius-md.
data-interactivebare 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.
Local custom properties on .card, plus the two collection blocks.
PropertyDefaultControls
--card-bgvar(--color-surface)Fill. The hover and active fills are derived from it.
--card-fgvar(--color-text)Text colour.
--card-bordervar(--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-hovervar(--color-border-strong)Edge when interactive and hovered.
--card-border-widthvar(--border-width-1)Edge width, and the amount the media radius is inset by.
--card-dividervar(--color-border-subtle)Header and footer rules.
--card-radiusvar(--radius-lg)Corner radius.
--card-paddingvar(--space-6)Padding, and the bleed the parts pull against.
--card-gapvar(--space-5)Space between parts.
--card-shadowvar(--shadow-xs)Elevation.
--card-bg-hoverderived from --card-bgHover fill. Re-pointing --card-bg moves it for free.
--card-bg-activederived from --card-bgActive fill.
--card-media-aspectautoOn .card__media. Aspect ratio of the image or video inside.
--card-grid-minvar(--size-card-min)On .card-grid. Column width below which the grid drops a column.
--card-grid-gapvar(--space-6)On .card-grid.

Accessibility and decisions

Badge

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.

Neutral Brand Transfer confirmed Pending review Failed
Markup
<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>

Emphasis, sizes and counts

Neutral Brand Success Warn Danger
Markup
<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>
Small Base Large Pill 1 12 1284
Markup
<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.

API

Attributes on .badge.
AttributeValuesDoes
data-variant neutral, brand, success, warn, danger The colour family. Pairs the family's -subtle fill with its -text foreground.
data-emphasissolid Swaps to the family's -solid / -on-solid pair and drops the edge to transparent.
data-sizesm, lg Padding, type step, gap and dot size together. No block size at any step.
data-shapepill Radius only.
data-numericbare The count badge: circular at one digit, growing from there, with tabular figures.
Local custom properties on .badge.
PropertyDefaultControls
--badge-bglight-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-fgvar(--color-text-muted)Label colour.
--badge-dot-colorcurrentColor.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-bordervar(--color-border-subtle)Edge.
--badge-padding-inlinevar(--space-3)Inline padding.
--badge-padding-blockvar(--space-1)Block padding, and half the count badge's minimum width.
--badge-radiusvar(--radius-sm)Corner radius.
--badge-font-sizevar(--text-sm)Type step. The count badge's circle tracks it.
--badge-gapvar(--space-2)Dot to label.
--badge-dot-sizevar(--space-3).badge__dot diameter.

Accessibility and decisions

Alert

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.

Transfer confirmed
Payment details were updated successfully.
Markup
<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>

Variants and actions

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.

Settlement runs at 16:00 local time. Orders placed after that are queued for the next business day.
Markup
<!-- 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.

API

Attributes on .alert.
AttributeValuesDoes
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.
rolestatus, alert Markup, not styling, and urgency picks it. See below.
Local custom properties on .alert.
PropertyDefaultControls
--alert-bglight-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-fgvar(--color-text)Body text.
--alert-icon-fgcurrentColorIcon 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-bordervar(--color-border-subtle)All four edges, one even width.
--alert-padding-blockvar(--space-4)Block padding.
--alert-padding-inlinevar(--space-5)Inline padding.
--alert-radiusvar(--radius-md)Corner radius.
--alert-gap-inlinevar(--space-4)Icon and dismiss margins: the real column gaps.
--alert-gap-blockvar(--space-2)Row gap between title, body and actions.

Accessibility and decisions

Table

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.

OTC ledger: settled orders, current period
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
Markup
<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, borders and the empty row

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.

Compact and bordered
MarketLastChange
NPY / MNT3 480+1.2%
NPX / MNT912-0.4%
Comfortable, with no rows to show
MarketLastChange
No transactions in the selected period.
Markup
<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>

API

Attributes.
AttributeValuesDoes
data-density compact, comfortable On .table. Re-points the two cell padding locals. Omitted is base.
data-borderedbare On .table. Full gridline box instead of horizontal rules only.
data-stripedbare On .table. Zebra on even body rows.
data-hoverablebare On .table. Row hover fill.
data-sticky-headerbare On .table. Sticks thead th to the nearest scrolling ancestor at --z-raised.
data-numericbare 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-labelledbymarkup Required on .table-wrap. See below.
Local custom properties on .table.
PropertyDefaultControls
--table-bordervar(--color-border-subtle)Gridline colour.
--table-stripevar(--color-surface-sunken)Zebra fill.
--table-hovervar(--color-surface-hover)Row hover fill.
--table-cell-padding-blockvar(--space-3)Cell block padding. .table__empty re-points this on itself to buy its breathing room.
--table-cell-padding-inlinevar(--space-4)Cell inline padding.

Accessibility and decisions

Tabs

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.

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

The behaviour, and what happens without it

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.

Without the script: read this before you write the markup

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.

The live demos on this page obey that rule too: inspect them

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.

Variants, orientation and overflow

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.

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

Markup
<!-- 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>

API

Attributes. The four options all live on the .tabs root.
AttributeValuesDoes
data-variant enclosed, pill Omitted is the underline strip.
data-sizesm, lg Tab height, padding, type step, indicator thickness and panel padding.
data-orientationvertical Sidebar list. Moves the strip's rule and the indicator to the inline edge.
data-overflowscroll Single-row scrolling strip instead of a wrapping one.
aria-selectedtrue, 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.
hiddenon 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.
Local custom properties, all declared on .tabs.
PropertyDefaultControls
--tabs-gapvar(--space-1)Space between tabs.
--tabs-bordervar(--color-border-subtle)The strip's rule.
--tabs-border-widthvar(--border-width-1)Its thickness, and what the indicator and enclosed tabs overlap by.
--tabs-indicatorvar(--color-brand)Selected indicator colour.
--tabs-indicator-sizevar(--border-width-2)Indicator thickness.
--tabs-indicator-insetvar(--space-0)How far the indicator is inset from the tab's edges.
--tabs-list-bgtransparentStrip fill. The pill variant re-points it to a sunken track.
--tabs-list-paddingvar(--space-0)Strip padding.
--tabs-list-radiusvar(--radius-none)Strip radius.
--tabs-tab-bgtransparentResting tab fill.
--tabs-tab-bg-hovervar(--color-surface-hover)Hover fill.
--tabs-tab-bg-activevar(--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-selectedtransparentSelected fill.
--tabs-tab-fgvar(--color-text-muted)Resting label colour.
--tabs-tab-fg-selectedvar(--color-brand-text)Selected label colour.
--tabs-tab-bordertransparentTab edge. The enclosed variant re-points it when selected.
--tabs-tab-min-block-sizevar(--size-touch)Minimum tab height.
--tabs-tab-padding-inlinevar(--space-4)Tab inline padding.
--tabs-tab-padding-blockvar(--space-3)Tab block padding.
--tabs-tab-radiusvar(--radius-sm)Tab radius.
--tabs-font-sizevar(--text-base)Label type step.
--tabs-panel-padding-blockvar(--space-5)Panel block padding.
--tabs-panel-padding-inlinevar(--space-0)Panel inline padding.

Accessibility and decisions

Dialog

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.

Create a new settlement order

Create a new settlement order

Payment details were updated successfully.

Markup
<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>
About that example

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.

Sizes and placement

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.

Attributes on .dialog.
AttributeValuesDoes
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.
opennative Set by showModal(). The grid display is scoped to it: see below.
Local custom properties on .dialog. They inherit down to the parts, which is how the parts get their padding.
PropertyDefaultControls
--dialog-bgvar(--color-surface-raised)Fill.
--dialog-fgvar(--color-text)Text colour.
--dialog-bordervar(--color-border)Edge.
--dialog-radiusvar(--radius-lg)Corner radius.
--dialog-paddingvar(--space-6)Padding on the header, body and footer. The dialog box itself has none.
--dialog-max-inline-sizevar(--size-container-md)Width ceiling.
--dialog-shadowvar(--shadow-xl)Elevation.
--dialog-dividervar(--color-border-subtle)Header and footer rules.
--dialog-enter-translate0 var(--space-5)Where the entry animation travels from. The bottom sheet re-points it so it travels further.

Opening one from a link, with no script of your own

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.

Without the script

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.

Accessibility and decisions

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.

Markup
<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>
About that example

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.

Attributes and parts.
Attribute or classValuesDoes
data-placementstart, end On .menu. Which inline edge the menu aligns to against its anchor, using logical insets so it flips correctly in RTL.
data-variantdanger On .menu__item. Destructive commands, using the danger text role.
.menu__group / .menu__labelnone A labelled group of items. .menu__label is the group heading; the item's own text is .menu__label-text.
.menu__icon / .menu__shortcutnone Leading icon at --size-icon, and a trailing muted monospace hint.
.menu__separatornone 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.
Local custom properties on .menu, with resolved values.
PropertyResolves toControls
--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-spacemin(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.

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.

Without the script

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.

Toast

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.

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

Variants

Neutral

Info

Transfer confirmed

Your session expires in two minutes.

The phone number is not valid.

Attributes.
AttributeValuesDoes
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-leavingpresent or absent The offset states. The resting state is visible: see below.

Accessibility and decisions

Collapse

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.

Account balance

Cloudy today, with a light breeze.

Payment details were updated successfully.

Open and close these two: they are real <details> elements and nothing on this page is wiring them up.

Markup
<!-- 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.

Markup
<!-- 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>
About these examples

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.

bordered

Welcome back

filled

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.

Markup
<details class="collapse"> … </details>                          <!-- plain, the default -->
<details class="collapse" data-variant="bordered"> … </details>
<details class="collapse" data-variant="filled"> … </details>

API

Attributes and parts.
Attribute or classValuesDoes
data-variant bordered, filled On .collapse. Omitted is plain: transparent, no edge, no radius.
opennative, 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-expandedtrue, 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-controlsan 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__labelnone 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__innernone The animating region and its clipped, padded contents. Both are required: the padding lives on the inner so it animates away with the content.
Local custom properties, all declared on .collapse, with resolved values.
PropertyResolves toControls
--collapse-bgtransparentRoot fill.
--collapse-fgText colour.
--collapse-bordertransparentRoot edge colour.
--collapse-border-widthRoot edge thickness.
--collapse-radiusCorner radius, on the root and on the trigger.
--collapse-gapColumn gap between the label and the marker.
--collapse-padding-inlineInline padding on the trigger and the inner.
--collapse-padding-blockBlock padding on the trigger, and the block-end padding of the inner.
--collapse-font-sizeTrigger type step.
--collapse-min-block-sizeMinimum trigger height. A minimum, never a fixed size: a two-line label grows the control instead of overflowing it.
--collapse-trigger-bgtransparentResting trigger fill.
--collapse-trigger-fgTrigger 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.

Accessibility and decisions

Accordion

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.

Which file owns which class.
ClassDefined inNote
.accordionaccordion.css The group. Owns the fill, the edge, the radius and the gap.
.accordion__itemaccordion.css Goes on the same element as .collapse, never on a wrapper around it, and must be a direct child of the group.
.collapsecollapse.css The item itself.
.collapse__triggercollapse.css The <summary> or the button.
.collapse__labelcollapse.css The label inside the trigger.
.collapse__markercollapse.css The chevron. Still needs aria-hidden="true".
.collapse__contentcollapse.css The animating region.
.collapse__innercollapse.css The clipped, padded contents.
Account balance

Updated 2 minutes ago

Payment details for the selected settlement account were updated successfully.

A long label wraps to two lines and the chevron stays beside the first one.

User account settings

Cloudy today, with a light breeze.

Markup
<!-- 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>
One item open at a time

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.

Variants and sizes

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.

separated

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.

Transfer confirmed

Welcome back

flush, sm

Phone number

User account settings

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.

Create a new settlement order

bordered, lg.

Account balance

Updated 2 minutes ago

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

API

Attributes. All three live on the .accordion root.
AttributeValuesDoes
data-variant separated, flush Omitted is bordered: one boxed group, rules between rows.
data-sizesm, lg Item padding and type step. Not the trigger's minimum height, see below.
data-exclusiveboolean Documentation only, no declarations. Marks the group as single-open so a reader can see the intent in the DOM.
nameroute 1, on each <details> The native grouping that actually enforces single-open. Firefox 130 and up.
hiddenon .accordion Hides the group. Guarded explicitly, for the same layer-order reason as collapse.
Local custom properties on .accordion, with resolved values. The item rule re-points collapse's locals from these, so overriding one here reaches every item in the group.
PropertyResolves toControls
--accordion-bgGroup fill. Transparent in both separated and flush.
--accordion-borderThe group's edge, and each item's edge in separated.
--accordion-dividerThe rule between two items.
--accordion-border-widthBoth of those, and the amount the item's corner radius is inset by so its arc stays concentric with the group's.
--accordion-radiusGroup radius, inherited by the first and last item and by their triggers.
--accordion-gapSpace between items. separated opens it up.
--accordion-padding-inlineFeeds --collapse-padding-inline on every item.
--accordion-padding-blockFeeds --collapse-padding-block.
--accordion-font-sizeFeeds --collapse-font-size.

Accessibility and decisions

Progress

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.

Payment details were updated successfully.
Markup
<!-- 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>
On the div-based form, the ARIA is the component

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.

Variants, sizes, segments and indeterminate

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.

Markup
<!-- 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.

Light
Dark

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.

Transfer confirmed
Creating the settlement order
Markup
<!-- 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>

API

Attributes and parts. The data-attributes all live on the track.
Attribute or classValuesDoes
data-variant success, warn, danger Re-points the fill. Omitted is brand.
data-sizesm, lg Track thickness only. The radius stays fully rounded at every step, so it needs no size rule.
data-indeterminateboolean 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 / maxnative 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-groupnone Label above track, in a column. The label is a sibling of the track, never an overlay inside it.
.progress__labelnone A <label for> against a native <progress>, or a <span id> pointed at by aria-labelledby in the div form.
.progress__bardiv form One fill. Its width is --progress-value, set on this element.
hiddenon the track or the group Hides it. Guarded explicitly, for the same layer-order reason as collapse.
Local custom properties on .progress, with resolved values. --progress-value and --progress-fill are also read from an individual .progress__bar, which is what makes a segmented bar possible.
PropertyResolves toControls
--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-value0% 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-chunk35% Proportion of the track covered by the indeterminate chunk.

Accessibility and decisions

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.

Markup
<!-- 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.

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

Markup
<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 controls and the dots are application JavaScript

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.

Three carousel-shaped features sit above the browser floor

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.
Attributes and parts.
Attribute or classValuesDoes
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-framenone 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__controlsnone 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__nextnone 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.
Local custom properties. All are namespaced --carousel-*, because custom properties inherit and an un-namespaced local set on the scroll container would leak into every slide.
PropertyDefaultControls
--carousel-gap var(--space-4) Space between slides, and the GAP term in the formula.
--carousel-count1 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-alignstart 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.

Scrollspy

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.

Markup
<!-- 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.

Know how big the default variant's fill is

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.

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

The sticky trap

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 load
Two traps, both of which shipped as real bugs in this docs site's own scroll spy

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

API

Attributes and parts.
Attribute or classValuesDoes
data-variantfilled 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__groupnone 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.
Local custom properties, all declared on .scrollspy.
PropertyDefaultControls
--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-bgtransparent 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.

Accessibility and decisions

Money

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.

What CSS can and cannot do here

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.

The one line of JavaScript

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.

Two things measured in a real browser

1. currencyDisplay defaults to "symbol", which for MNT renders the ISO code, not the glyph. Measured on this machine:

OptionOutput
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 drift

In this browser, right now: checking…

Display

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

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

Sign

data-sign takes positive, negative or zero and colours the figure from the success, danger and muted text roles.

Colour is never the only signal

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.

Light

+₮1,250,000

(₮98,765.4)

₮0

Dark

+₮1,250,000

(₮98,765.4)

₮0

In a ledger column

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.

Transaction history
Date Transaction Amount
2026-08-24Create a new settlement order +₮1,250,000
2026-08-25Transfer confirmed (₮98,765.4)
2026-08-26Payment details were updated successfully. ₮0
2026-08-27Account balance +₮1,151,234.6

Decimal alignment

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.

The cost, stated plainly

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 amount input

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

Markup
<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>
Not 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.

API

Attributes and parts.
Attribute or classValuesDoes
data-signpositive, negative, zero Colours the figure. Must be paired with a sign character in the markup.
data-sizesm, lgType step only.
data-emphasisstrong, 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-aligndecimal Reserves minor-unit space so mixed-precision rows align on the mark.
.money__amountnone The numeral run. The one place white-space: nowrap is correct: a figure broken across lines is misread, and digits are not translated.
.money__currencynone The ₮ or ISO code. Never wrapped away from its amount.
.money__decimalsnone Optional. The minor units, so they can be de-emphasised.
.money-fieldnone Applied alongside .field on the input root.

Accessibility and decisions

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>
See it for yourself

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.

Local custom properties on .skip-link. No data attributes: there are no variants to choose between.
PropertyDefaultControls
--skip-link-bgvar(--color-surface)Fill once focused.
--skip-link-fgvar(--color-brand-text)Text colour.
--skip-link-bordervar(--color-border)Edge.
--skip-link-radiusvar(--radius-md)Corner radius.
--skip-link-padding-blockvar(--space-3)Block padding.
--skip-link-padding-inlinevar(--space-4)Inline padding.
--skip-link-inset-blockvar(--space-3)Distance from the block-start edge once visible.
--skip-link-inset-inlinevar(--space-3)Distance from the inline-start edge once visible.
--skip-link-zvar(--z-tooltip)Stacking, so it clears whatever else sits at the top of the page.

Masthead

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.

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

Sticky, inside a clipped box

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.

API

Local custom properties on .masthead. No data attributes: there is one shape and no variants.
PropertyDefaultControls
--masthead-bgvar(--color-surface)Fill.
--masthead-fgvar(--color-text)Text colour, inherited by .masthead__brand.
--masthead-bordervar(--color-border-subtle)Block-end edge.
--masthead-padding-blockvar(--space-4)Block padding.
--masthead-padding-inlinevar(--space-6)Inline padding.
--masthead-gapvar(--space-4)Space between the brand, the nav and trailing content.
--masthead-min-block-sizevar(--space-9)Floor, not a fixed height: a wrapped nav or a two-line brand grows past it.
--masthead-zvar(--z-sticky)Stacking while sticky.

Accessibility and decisions

Duo

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.

Light

Create a new project

Create a new project from a template and invite the whole settlement team

Dark

Create a new project

Create a new project from a template and invite the whole settlement team

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

API

Local custom properties on .duo. No data attributes of its own: data-theme above is the package's general subtree theming mechanism, not something this file defines.
PropertyDefaultControls
--duo-minvar(--size-card-min)Column width below which the grid drops to one column.
--duo-gapvar(--space-5)Space between panels.
Local custom properties on .duo__panel. Kept off the wrapper deliberately, so a panel carrying its own data-theme resolves its own background and text rather than inheriting the same computed value as its neighbour.
PropertyDefaultControls
--duo-paddingvar(--space-5)Panel padding.
--duo-radiusvar(--radius-lg)Corner radius.
--duo-bordervar(--color-border-subtle)Edge.
--duo-bgvar(--color-bg)Fill.
--duo-fgvar(--color-text)Text colour.

Accessibility and decisions

Icon

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

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

Why inline markup, not a file reference

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.

Sizing: pure 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.

Markup
<div style="font-size: 2rem;">
  <svg class="icon" viewBox="0 0 16 16" aria-hidden="true">…</svg>
</div>

Colour: currentColor, no exception

Every 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

Markup
<p style="color: var(--color-danger-text);">
  <svg class="icon" viewBox="0 0 16 16" aria-hidden="true">…</svg>
  Cannot be deleted
</p>

Decorative only: there is no meaningful, unlabelled mode

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.

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

The set

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.

check
close
plus
minus
chevron-left
chevron-right
info
warning
danger
external-link
search
menu
brand-facebook
brand-instagram
brand-youtube
brand-x
brand-tiktok
brand-telegram
brand-whatsapp

Why the set is closed

Adding 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).

Stat

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

Markup
<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 label that wraps

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

Markup
<div class="stat">
  <p class="stat__value">1,250,000</p>
  <p class="stat__label">Total number of orders settled this quarter</p>
</div>

Trend caption

.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

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

Sizes

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

Markup
<div class="stat" data-size="sm">…</div>
<div class="stat">…</div>
<div class="stat" data-size="lg">…</div>

Variants

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

Markup
<div class="stat" data-variant="success">
  <p class="stat__value">+4.2%</p>
  <p class="stat__label">Liquidity</p>
</div>

Alignment

.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

Markup
<div class="stat u-text-center">
  <p class="stat__value">42</p>
  <p class="stat__label">Active users</p>
</div>

What it does not do

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

Rating

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.

0 / 5

0.5 / 5

2.5 / 5

3.7 / 5 from 128 ratings

5 / 5

Markup
<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>
The stars are decoration; the number is the content

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:

  1. .rating__stars must carry aria-hidden="true". It is a picture of a number that is already on the page.
  2. The number must be present as real text, in .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.

The half step, and why it does not round

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.

3.2 / 5

3.5 / 5

3.7 / 5

3.9 / 5

Markup
<!-- 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>

Variants and sizes

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.

3.7 / 5 warn, small: 4.54:1 light, 4.71:1 dark

3.7 / 5 warn, base: the default

3.7 / 5 brand, large: 4.70:1 at worst

3.7 / 5 neutral: 15.53:1 light, 11.53:1 dark

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

Why an empty star is an outline

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.

Light

0 / 5

3.7 / 5

5 / 5

Dark

0 / 5

3.7 / 5

5 / 5

Re-point --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.

What it does not do

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.

Video

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.

Introduction to the payment settlement system
Markup
<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>

Ratio

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.

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

Fit

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.

Markup
<figure class="video" data-ratio="21/9" data-fit="contain">
  <video class="video__media" poster="poster.jpg" controls playsinline></video>
</figure>

No ratio, no source

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.

Caption

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.

Corner radius

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.

Markup
<figure class="video" data-ratio="16/9" style="--video-radius: var(--radius-lg);">
  <video class="video__media" poster="poster.jpg" controls playsinline></video>
</figure>
Local custom properties on .video.
PropertyDefaultControls
--video-ratioautoOn .video__media. Re-pointed by data-ratio.
--video-fitcoverOn .video__media. Re-pointed by data-fit="contain".
--video-bgvar(--color-surface-sunken)Letterbox fill under contain, and the placeholder band when there is no ratio and nothing loads.
--video-radiusvar(--radius-none)Corner radius on .video.

What this does not do

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.

Drawer

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.

No script, still a component

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.

Anatomy

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.

Main menu

Payment details were updated successfully.

A link inside the panel, focusable with or without script

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

The markup contract

Four things are markup, not CSS, and the component does not work without them.

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.

Placement

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.

Filters

Anchored to the inline end. Shown here in its no-script, in-flow state.

Markup
<dialog class="dialog drawer" data-placement="inline-start" open> … </dialog>
<dialog class="dialog drawer" data-placement="inline-end" open> … </dialog>

Local API

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

The behaviour

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:

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.

Motion, and reduced motion

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.

Countdown

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.

This component ships with no script by default

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

Markup
<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 target lives in one place

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.

Unit labels are your markup, not this package's strings

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.

Reaching zero

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.

Long unit labels wrap, they do not clip

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.

What it does not do

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

Code

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
}
A settlement transaction object.
Markup
<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>

No script? Then this is all you get, and it is fine

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.

The three grammars

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
Creating a settlement, and the response that follows.
Markup
<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
A terminal session: the prompt, the command and the trailing comment each colour differently.
Markup
<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>

The copy button

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 }
Markup
<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>
Local custom properties on .code.
PropertyDefaultControls
--code-copy-gapvar(--space-3)Inset of .code__copy from the figure's block-start/inline-end corner.
--code-color-accentvar(--color-brand-text)A JSON key; an HTTP method and header name. .code__key.
--code-color-stringvar(--color-success-text)A JSON/quoted string. .code__str.
--code-color-numbervar(--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-mutedvar(--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-dangervar(--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.

Escaping

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.

Composition

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.

Create a new settlement order

Updated 2 minutes ago

The same components, long labels

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.

Light

Short labels

Confirmed

Long labels

Transfer confirmed by the receiving bank
Dark

Short labels

Confirmed

Long labels

Transfer confirmed by the receiving bank

What is not built

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 →