@arag 0.1.0

Components

Nineteen 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 nineteen 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 eighteen follow, so read that file before writing a twentieth.

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 nineteen 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);"><span lang="mn">Илгээх</span></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. Set the locals on a wrapper and every instance below inherits the change; set them on the element and only that one moves.

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 an ancestor to move a whole region at once. 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" lang="mn">Шинэ захиалга үүсгэх</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 or focused button so the shared border never looks broken.

Markup
<div class="button-group">
  <button class="button" type="button" data-variant="primary" lang="mn">Илгээх</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" lang="mn">Гэрэлтэй</button>
  <button class="button" type="button" aria-pressed="false" lang="mn">Харанхуй</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. Use the ARIA form when the control must stay focusable so a screen reader can reach and explain it.
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-touch)Minimum height, and the side of an icon-only button.
--button-padding-inlinevar(--space-5)Inline padding.
--button-padding-blockvar(--space-3)Block padding.
--button-font-sizevar(--text-base)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 lang="mn">Утасны дугаар</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" lang="mn">Дансны үлдэгдэл</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 — Cyrillic 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 Latin does not fit the same row once it is Mongolian, and minmax(min(track, 100%), 1fr) is what lets a column give up and take the full inline size instead of overflowing it.

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

Хэрэглэгчийн тохиргоо
Markup
<fieldset class="fieldset">
  <legend class="fieldset__legend" lang="mn">Хэрэглэгчийн тохиргоо</legend>
  <div class="fieldset__body">

    <label class="choice">
      <input class="choice__input" type="checkbox" checked>
      <span class="choice__label" lang="mn">Төлбөрийн мэдээлэл амжилттай шинэчлэгдлээ.</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" lang="mn">Гэрэлтэй</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.
rowstextarea.field__control Present: your height wins. Absent: --size-textarea.
readonly, disabled.field__control Both get a sunken fill. 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-blockvar(--space-3)Control block padding.
--field-min-block-sizevar(--size-control)Minimum control height.
--field-font-sizevar(--text-base)Control and affix type step.
--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-md)Fieldset radius.
--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.

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.

Дансны үлдэгдэл

2 минутын өмнө шинэчлэгдсэн

Settled balance across all connected accounts, excluding pending transfers.

Markup
<article class="card">
  <div class="card__header">
    <h4 class="card__title" lang="mn">Дансны үлдэгдэл</h4>
    <p class="card__subtitle" lang="mn">2 минутын өмнө шинэчлэгдсэн</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" lang="mn">Илгээх</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 only — the type inside a card is set by its parts, not by its size.

Outlined, sm

The default. A surface with a subtle edge.

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" lang="mn">Шинэ захиалга үүсгэх</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. outlined is the default, stated anyway so a card nested inside an elevated one can be pointed back at it.
data-sizesm, lg Padding and gap only.
data-interactivebare Pointer cursor, hover and active fills, and a focus ring drawn on the card via :focus-within. 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-subtle)Edge.
--card-border-hovervar(--color-border)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-none)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 Гуйвуулга баталгаажлаа 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><span lang="mn">Гуйвуулга баталгаажлаа</span>
</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-bgvar(--color-surface-sunken)Fill.
--badge-fgvar(--color-text-muted)Label colour, and the dot, which paints from currentColor.
--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.

Гуйвуулга баталгаажлаа
Төлбөрийн мэдээлэл амжилттай шинэчлэгдлээ.
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" lang="mn">Гуйвуулга баталгаажлаа</div>
  <div class="alert__body" lang="mn">Төлбөрийн мэдээлэл амжилттай шинэчлэгдлээ.</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 Cyrillic 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 sunken alert.
rolestatus, alert Markup, not styling, and urgency picks it. See below.
Local custom properties on .alert.
PropertyDefaultControls
--alert-bgvar(--color-surface-sunken)Fill.
--alert-fgvar(--color-text)Text, and the icon via currentColor.
--alert-bordervar(--color-border-subtle)The three ordinary edges.
--alert-accentvar(--color-border-strong)The leading accent edge.
--alert-accent-widthvar(--border-width-4)Thickness of that edge.
--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 Төлбөрийн мэдээлэл Status Дансны үлдэгдэл
MN-1042-7731 Bank transfer, domestic Гуйвуулга баталгаажлаа 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" lang="mn">Төлбөрийн мэдээлэл</th>
        <th scope="col">Status</th>
        <th scope="col" data-numeric lang="mn">Дансны үлдэгдэл</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" lang="mn">Гуйвуулга баталгаажлаа</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
ARDX / MNT3 480+1.2%
TAAY / 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.

Өнөөдөр үүлэрхэг, зөөлөн салхитай.

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" lang="mn">Дансны үлдэгдэл</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" lang="mn">Төлбөрийн мэдээлэл амжилттай шинэчлэгдлээ.</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" lang="mn">Хэрэглэгчийн тохиргоо</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" hidden> … </div>
  <div class="tabs__panel" role="tabpanel" id="pb3" aria-labelledby="tb3" tabindex="0" hidden> … </div>
</div>
About these examples

The tab strips on this page are wired up by the docs site's own script, not by @arag — the package ships no JavaScript. Selection, panel visibility, roving tabindex and the arrow keys are all things your application sets; the CSS only styles the result. The full contract is under Accessibility and decisions, and the docs script is a plain implementation of exactly that if you want to read one.

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.

Дахин тавтай морил

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 Mongolian rather than a stylistic alternative.

Opt-in single-row scrolling strip, for chrome with a fixed block size that a second row would break.

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 Hides it. See the note below on why this 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.

Шинэ захиалга үүсгэх

Шинэ захиалга үүсгэх

Төлбөрийн мэдээлэл амжилттай шинэчлэгдлээ.

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" lang="mn">Шинэ захиалга үүсгэх</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" lang="mn">Илгээх</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.

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" lang="mn">Дансны үлдэгдэл</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__label A labelled group of items. .menu__label is the group heading; the item's own text is .menu__label-text.
.menu__icon / .menu__shortcut Leading icon at --size-icon, and a trailing muted monospace hint.
.menu__separator 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 Mongolian 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.

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.

Гуйвуулга баталгаажлаа

Төлбөрийн мэдээлэл амжилттай шинэчлэгдлээ.

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" lang="mn">Гуйвуулга баталгаажлаа</p>
      <p class="toast__body" lang="mn">Төлбөрийн мэдээлэл амжилттай шинэчлэгдлээ.</p>
    </div>
    <button class="button toast__dismiss" data-variant="quiet" data-icon-only
            aria-label="Dismiss">…</button>
  </div>
</div>

Variants

Neutral

Info

Гуйвуулга баталгаажлаа

Өнөөдөр үүлэрхэг, зөөлөн салхитай.

Утасны дугаар

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.

Дансны үлдэгдэл

Өнөөдөр үүлэрхэг, зөөлөн салхитай.

Төлбөрийн мэдээлэл амжилттай шинэчлэгдлээ.

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" lang="mn">Дансны үлдэгдэл</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.

2 минутын өмнө шинэчлэгдсэн

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" lang="mn">Хэрэглэгчийн тохиргоо</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

@arag 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

Дахин тавтай морил

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__label Optional wrapper for the trigger's text; a bare text node becomes an anonymous grid item in the same column. Wrapping it makes the intent explicit.
.collapse__content / .collapse__inner 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 Cyrillic 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.
Дансны үлдэгдэл

2 минутын өмнө шинэчлэгдсэн

Төлбөрийн мэдээлэл амжилттай шинэчлэгдлээ.

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

Хэрэглэгчийн тохиргоо

Өнөөдөр үүлэрхэг, зөөлөн салхитай.

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" lang="mn">Дансны үлдэгдэл</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: wrap each trigger in a heading of the
     right rank for the page, so the group is navigable by heading. That is
     the single most valuable thing you can add to this component. -->
<div class="accordion">
  <div class="collapse accordion__item">
    <h3><button class="collapse__trigger" type="button"
                 id="a1-t" aria-expanded="false" aria-controls="a1"> … </button></h3>
    <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.

Гуйвуулга баталгаажлаа

Дахин тавтай морил

flush, sm

Утасны дугаар

Хэрэглэгчийн тохиргоо

Flush keeps its inline padding at zero at every size, which is the whole point of the variant: the rows line up with the prose around them.

Шинэ захиалга үүсгэх

bordered, lg.

Дансны үлдэгдэл

2 минутын өмнө шинэчлэгдсэн

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

Төлбөрийн мэдээлэл амжилттай шинэчлэгдлээ.
Markup
<!-- NATIVE — preferred. The semantics are not optional, so they
     cannot be forgotten. -->
<div class="progress-group">
  <label class="progress__label" for="up" lang="mn">Дансны үлдэгдэл</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" lang="mn">Байршуулж байна</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.64 in light mode because amber 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.

Гуйвуулга баталгаажлаа
Шинэ захиалга үүсгэх
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-group Label above track, in a column. The label is a sibling of the track, never an overlay inside it.
.progress__label 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.14 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. 4.04 / 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" lang="mn">Дансны үлдэгдэл</h4>
      <p class="card__subtitle" lang="mn">2 минутын өмнө шинэчлэгдсэн</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

This package ships none, 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-frame Optional sibling block. A single-column grid that shares row 1 between the scrollport and .carousel__controls, and drops .carousel__dots into row 2 with no explicit placement. Only needed when there are controls or dots.
.carousel__controls The overlay the two buttons are positioned inside. pointer-events: none, or it would eat every swipe, drag and wheel event aimed at the slides beneath it.
.carousel__prev / .carousel__next 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 this package ships none. 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" lang="mn">Дансны үлдэгдэл</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" lang="mn">Утасны дугаар</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.

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 Mongolian 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__group 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 Cyrillic 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 @arag ships no JavaScript. This component 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 bilingual 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('mn-MN', {
  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. mn-MN locale data may not be present, and the fallback is silent. On the browser this page was last tested in, Intl.NumberFormat.supportedLocalesOf(['mn-MN']) returned an empty array and mn-MN resolved to en-US — you would be getting English formatting while believing you had Mongolian. Check rather than assume:

const wanted = 'mn-MN';
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 ARDX 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.

Гүйлгээний түүх
Огноо Гүйлгээ Дүн
2026-08-24Шинэ захиалга үүсгэх +₮1,250,000
2026-08-25Гуйвуулга баталгаажлаа (₮98,765.4)
2026-08-26Төлбөрийн мэдээлэл амжилттай шинэчлэгдлээ. ₮0
2026-08-27Дансны үлдэгдэл +₮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.

2 минутын өмнө шинэчлэгдсэн

Markup
<div class="field money-field">
  <label class="field__label" for="amt">Дансны үлдэгдэл</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__amount The numeral run. The one place white-space: nowrap is correct — a figure broken across lines is misread, and digits are not translated.
.money__currency The ₮ or ISO code. Never wrapped away from its amount.
.money__decimals Optional. The minor units, so they can be de-emphasised.
.money-field 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 Cyrillic 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" lang="mn">Хэрэглэгчийн тохиргоо</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 one language beside another, 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

Шинэ төсөл үүсгэх

Dark

Create a new project

Шинэ төсөл үүсгэх

Markup
<div class="duo">
  <div class="duo__panel" data-theme="light">
    <span class="duo__label">Light</span>
    <p>Create a new project</p>
    <p lang="mn">Шинэ төсөл үүсгэх</p>
  </div>
  <div class="duo__panel" data-theme="dark">
    <span class="duo__label">Dark</span>
    <p>Create a new project</p>
    <p lang="mn">Шинэ төсөл үүсгэх</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 lang="mn" 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

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.

Шинэ захиалга үүсгэх

2 минутын өмнө шинэчлэгдсэн

The same components, both scripts

The whole reason the components are shaped this way. Below is one set of components with Latin labels and the same set with their Mongolian equivalents, in both themes. Nothing has a fixed width; every control grew to fit. This is the check to run against any new component.

Light / Гэрэлтэй

Latin

Confirmed

Mongolian

Гуйвуулга баталгаажлаа
Dark / Харанхуй

Latin

Confirmed

Mongolian

Гуйвуулга баталгаажлаа

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 →