Дансны үлдэгдэл
2 минутын өмнө шинэчлэгдсэн
Settled balance across all connected accounts, excluding pending transfers.
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.
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.
Eight conventions, established in button.css and held by every file in the
directory. Five of them are visible in the markup you write.
Blocks and elements only: .button, .button__icon,
.card__footer. There is no prefix and there are no modifier classes. A prefix
would be defending against a collision that layer order already settles — your application CSS
is unlayered, so it beats every component rule without a specificity fight. Each file wraps its
own content in @layer components, which means any single file is also correct when
linked on its own.
data-variant chooses the meaning, data-size chooses the scale, and
because an attribute holds one value per axis, two variants can never be applied at once. That
is the whole reason for the choice: class="button button--primary button--danger"
is expressible and meaningless, and data-variant is not. Boolean options are bare
attributes — data-block, data-striped,
data-interactive.
<button class="button" type="button">Default</button>
<button class="button" type="button" data-variant="primary">Primary</button>
<button class="button" type="button" data-variant="primary" data-size="sm">Small primary</button>There is not one state class anywhere in the 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.
This is the least obvious and most useful part of the design. The base rule of each component
declares a set of custom properties namespaced to that component —
--button-bg, --card-padding, --menu-item-fg — and then
reads them. Variants do nothing except re-point those locals, which is why a variant is four
declarations rather than a restated block.
The consequence for a consumer: you can retheme one instance by setting a local. No variant, no new class, no override file, nothing added to the package.
<button class="button" type="button"
style="--button-bg: var(--color-surface-sunken);
--button-border: var(--color-brand-border);
--button-padding-inline: var(--space-8);
--button-radius: var(--radius-full);"><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.
No raw values — every colour, length, radius, shadow, duration and easing in the component layer comes from a token, and a literal in a component file is a bug. And no priority-raising declarations anywhere: overriding is a layer-order question, never a specificity fight.
Three ways, in order of preference. Set a local on the instance. Set a local on 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);
}The action control. Use a real <button type="button"> for an action and a
real <a> for navigation; the class styles either, and the element is what
decides how it behaves.
<button class="button" type="button" data-variant="primary">
<svg class="button__icon" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="…"/></svg>
<span class="button__label" lang="mn">Шинэ захиалга үүсгэх</span>
</button>
<button class="button" type="button">Cancel</button>Seven variants. The four solid ones read their hover and active fills from the derived state tokens, so a theme change moves them without anyone hand-picking a shade.
<button class="button" type="button">Default</button>
<button class="button" type="button" data-variant="primary">Primary</button>
<button class="button" type="button" data-variant="success">Success</button>
<button class="button" type="button" data-variant="warn">Warn</button>
<button class="button" type="button" data-variant="danger">Danger</button>
<button class="button" type="button" data-variant="outline">Outline</button>
<button class="button" type="button" data-variant="quiet">Quiet</button>
<button class="button" type="button" data-variant="link">Link</button><button class="button" type="button" data-size="sm">Small</button>
<button class="button" type="button" data-size="lg">Large</button>
<button class="button" type="button" data-shape="pill" data-variant="outline">Pill</button>
<!-- An icon-only button MUST carry an accessible name. -->
<button class="button" type="button" data-icon-only aria-label="Close">
<svg class="button__icon" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="…"/></svg>
</button>
<button class="button" type="button" data-loading>
<span class="button__spinner" aria-hidden="true"></span>
<span class="button__label">Saving</span>
</button>
<button class="button" type="button" disabled>Disabled</button>data-loading keeps the label in the flow rather than swapping it for a spinner,
so the button does not change size mid-interaction and the row of controls around it does not
jump. The spinner's animation runs on --duration-loop, which sits outside the set
that reduced motion collapses — collapsing a loop speeds it up instead of stopping it — so the
component suppresses the loop by hand under prefers-reduced-motion and leaves a
static ring.
.button-group is a row of related buttons that reads as one control. It
wraps; it does not overflow. data-attached collapses the gap and
the doubled edge between neighbours, and raises the hovered or focused button so the shared
border never looks broken.
<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>| Attribute | Values | Does |
|---|---|---|
data-variant |
primary, success, warn, danger,
outline, quiet, link |
Re-points the colour locals. Omitted is the neutral surface button. |
data-size | sm, lg |
Min block size, padding, type step and radius. Omitted is base. |
data-shape | pill |
Radius only, to --radius-full. |
data-icon-only | bare | Square: inline size equal to the min block size, aspect ratio 1. Requires an accessible name. |
data-block | bare | Fills its container. Opt-in, never the default. |
data-loading | bare | cursor: progress, and dims .button__label while the
spinner runs. |
data-attached | bare | On .button-group. Collapses the gap and the doubled edges. |
disabled / aria-disabled="true" | native | Both are styled. Use the ARIA form when the control must stay focusable so a screen reader can reach and explain it. |
| Property | Default | Controls |
|---|---|---|
--button-bg | var(--color-surface) | Resting fill. |
--button-fg | var(--color-text) | Label and icon colour. |
--button-border | var(--color-border) | Resting edge. |
--button-bg-hover | var(--color-surface-hover) | Fill on :hover. |
--button-bg-active | var(--color-surface-active) | Fill on :active. |
--button-border-hover | var(--color-border-strong) | Edge on :hover. |
--button-min-block-size | var(--size-touch) | Minimum height, and the side of an icon-only button. |
--button-padding-inline | var(--space-5) | Inline padding. |
--button-padding-block | var(--space-3) | Block padding. |
--button-font-size | var(--text-base) | Label type step. |
--button-radius | var(--radius-md) | Corner radius. |
aria-label, translated with the rest of the UI. The glyph is not a
name..button__icon aria-hidden="true". It is decorative, and an
unhidden icon leaks whatever it exposes into the button's accessible name.aria-disabled="true" over disabled where the user needs to
be told why the action is unavailable; a disabled control is not
focusable and cannot be reached to be explained.min-block-size plus padding.
data-icon-only is the one exception, and it is allowed precisely because its
content is a glyph rather than a translated string..button__label carries no nowrap and no truncation. A clipped
Mongolian label is a bug, not a layout strategy.data-block is opt-in on purpose: a full-width button in a wide layout is a
very large target for a very small action.A label, a control, and its help or error text — plus the pieces a form-heavy product needs
around it: affix groups, checkboxes and radios, switches, fieldsets, and a row of fields that
collapses to a stack on its own. The parts read the block's locals by inheritance, so a
.field__* part requires a .field ancestor.
Eight digits, no country code.
Enter an amount greater than zero.
<div class="field">
<label class="field__label" for="f-phone" data-required 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.
.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.
<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.
The balance is not sufficient.
<div class="field__group">
<span class="field__prefix" aria-hidden="true">₮</span>
<input class="field__control" id="f-sum" type="text" inputmode="numeric" value="1 250 000">
<span class="field__suffix" aria-hidden="true">MNT</span>
</div>
<!-- Invalid: BOTH hooks, from the same event. -->
<div class="field__group" data-invalid>
<span class="field__prefix" aria-hidden="true">₮</span>
<input class="field__control" id="f-sum-bad" type="text" value="0"
aria-invalid="true" aria-describedby="f-sum-bad-err">
</div>
<p class="field__error" id="f-sum-bad-err" role="alert">The balance is not sufficient.</p>.field__group[data-invalid] and .choice[data-disabled] duplicate a
state that really lives on the descendant input. CSS cannot reach an ancestor without
:has(), and :has() would raise this package's Firefox floor from
120 to 121 — so the wrapper carries its own attribute and the markup must set
both, from the same event. aria-invalid on the input stays the
authoritative signal; data-invalid is presentation only. Likewise
data-disabled is presentation only: the input still needs the real
disabled attribute, which is what removes it from the tab order and from form
submission.
<fieldset class="fieldset">
<legend class="fieldset__legend" 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.
| Attribute | On | Does |
|---|---|---|
data-required | .field__label |
Draws the marker. Decorative only — the control still needs required or
aria-required="true", which is what is announced and what the form
validates against. |
aria-invalid="true" | .field__control |
The invalid state. Border, hover border and focus ring all move to the danger family. |
data-invalid | .field__group |
The duplicate hook the affix wrapper needs. Set it together with
aria-invalid on the input inside. |
data-disabled | .choice |
Dims the wrapper and its label. Set it together with disabled on the
input inside. |
role="switch" | .switch__input |
Required. Changes the announcement from checked/unchecked to on/off. |
rows | textarea.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. |
| Property | Default | Controls |
|---|---|---|
--field-gap | var(--space-2) | Space between label, control and description. |
--field-label-color | var(--color-text) | Label colour. |
--field-help-color | var(--color-text-muted) | .field__help colour. |
--field-error-color | var(--color-danger-text) | .field__error colour. |
--field-marker-color | var(--color-danger) | The required marker. |
--field-affix-color | var(--color-text-muted) | Prefix and suffix text. |
--field-affix-border | var(--color-border-subtle) | The rule between affix and control. |
--field-bg | var(--color-surface) | Control fill. |
--field-fg | var(--color-text) | Control text. |
--field-border | var(--color-border) | Control edge. |
--field-border-hover | var(--color-border-strong) | Edge on hover. |
--field-ring-color | var(--focus-ring-color) | Focus ring and the focused edge, together. |
--field-radius | var(--radius-md) | Control radius. |
--field-padding-inline | var(--space-4) | Control and affix inline padding. |
--field-padding-block | var(--space-3) | Control block padding. |
--field-min-block-size | var(--size-control) | Minimum control height. |
--field-font-size | var(--text-base) | Control and affix type step. |
--choice-gap | var(--space-3) | Box to label, on .choice. |
--choice-label-color | var(--color-text) | .choice__label colour. |
--choice-control-size | var(--space-5) | Checkbox / radio side. |
--choice-control-offset | var(--space-1) | Optical nudge that centres the box on the label's first line. |
--switch-track-inline-size | calc(var(--space-6) * 2) | Track length. |
--switch-track-block-size | var(--space-6) | Track height, and the thumb's travel basis. |
--switch-thumb-inset | var(--space-1) | Thumb inset from the track. |
--switch-track-bg | var(--color-border) | Track when off. |
--switch-track-bg-checked | var(--color-brand-solid) | Track when on. |
--switch-thumb-bg | var(--color-white) | Thumb fill. |
--fieldset-gap | var(--space-5) | Space between fields in .fieldset__body. |
--fieldset-border | var(--color-border) | Fieldset edge. |
--fieldset-radius | var(--radius-md) | Fieldset radius. |
--fieldset-padding | var(--space-5) | Fieldset padding. |
--field-row-min | var(--size-column-min) | The column width below which .field-row stacks. |
--field-row-gap | var(--space-5) | .field-row gap. |
.field__label must be a real <label>, carrying
for or wrapping the control. A styled <span> gives the
control no accessible name and no second click target, and neither a placeholder nor a
nearby heading is a substitute..field__help and .field__error must both be referenced from the
control with aria-describedby, or they exist for sighted users only.role="alert", or aria-live="polite" on a container already in the
DOM before the message arrives. Moving focus is not enough to announce text that appeared
while focus was somewhere else.aria-describedby. Mark the affix aria-hidden="true" where it merely
repeats the label..fieldset__legend must be a real <legend> and the first
child of the <fieldset>. That pair is the accessible grouping, and it is
the only correct way to label a set of radios — a heading above the box is not one..choice, or point a
<label for> at the input..choice aligns to start, never center. A two-line
Cyrillic label with a vertically centred box reads as broken alignment, and two-line labels
are the common case here rather than the exception.A surface that groups related content: header, media, body, footer. Market rows, account summaries, kiosk tiles. Header, footer and media bleed to the card's inner edge with a negative inline margin and put the padding back on themselves, so a divider or an image spans the full width instead of floating inside the padding.
2 минутын өмнө шинэчлэгдсэн
Settled balance across all connected accounts, excluding pending transfers.
<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>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.
The default. A surface with a subtle edge.
Raised surface and shadow. Both, always.
Sunken. For context around content, not content itself.
One real link, stretched over the whole card.
Hover and focus both land on the card.
<div class="card-grid">
<article class="card" data-variant="outlined" data-size="sm"> … </article>
<article class="card" data-variant="elevated"> … </article>
<article class="card" data-variant="subtle"> … </article>
<!-- The card is NOT a link. It CONTAINS one. -->
<article class="card" data-interactive>
<div class="card__header">
<h4 class="card__title"><a class="card__link" href="#card" 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.
| Attribute | Values | Does |
|---|---|---|
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-size | sm, lg |
Padding and gap only. |
data-interactive | bare | Pointer cursor, hover and active fills, and a focus ring drawn on the card via
:focus-within. Establishes the containing block for
.card__link. |
| Property | Default | Controls |
|---|---|---|
--card-bg | var(--color-surface) | Fill. The hover and active fills are derived from it. |
--card-fg | var(--color-text) | Text colour. |
--card-border | var(--color-border-subtle) | Edge. |
--card-border-hover | var(--color-border) | Edge when interactive and hovered. |
--card-border-width | var(--border-width-1) | Edge width, and the amount the media radius is inset by. |
--card-divider | var(--color-border-subtle) | Header and footer rules. |
--card-radius | var(--radius-lg) | Corner radius. |
--card-padding | var(--space-6) | Padding, and the bleed the parts pull against. |
--card-gap | var(--space-5) | Space between parts. |
--card-shadow | var(--shadow-none) | Elevation. |
--card-bg-hover | derived from --card-bg | Hover fill. Re-pointing --card-bg moves it for free. |
--card-bg-active | derived from --card-bg | Active fill. |
--card-media-aspect | auto | On .card__media. Aspect ratio of the image or video inside. |
--card-grid-min | var(--size-card-min) | On .card-grid. Column width below which the grid drops a column. |
--card-grid-gap | var(--space-6) | On .card-grid. |
href, no
role="link", no tabindex, no click handler duplicating one. The
correct pattern is that the card contains one real <a> — normally
wrapping the title — carrying .card__link; that link is the only thing in the
tab order and the card shows the focus for it. Accessible name, context menu, middle-click
and "open in new tab" all keep working, and none of them survive a click handler on a div.position: relative to
come back out above the overlay..card__title a real heading. A card in a grid is something a
screen-reader user navigates by heading, and a <div> that merely looks like
a title is invisible to that.data-variant="elevated" sets both the raised surface and the shadow,
and that is not belt-and-braces. In light mode --color-surface and
--color-surface-raised are the same colour and the shadow carries the whole
effect; in dark mode the shadow is nearly invisible and the two surfaces differ by a full
ramp step. Colour alone is a no-op in light, shadow alone is a no-op in dark, and either on
its own ships a card that is flat in exactly one theme.Small status and count labels: order status, market state, account flags. Two independent
axes — data-variant chooses the colour family (what the badge means) and
data-emphasis chooses how loud it is.
<span class="badge">Neutral</span>
<span class="badge" data-variant="brand">Brand</span>
<span class="badge" data-variant="success">
<span class="badge__dot"></span><span lang="mn">Гуйвуулга баталгаажлаа</span>
</span>
<span class="badge" data-variant="warn">Pending review</span>
<span class="badge" data-variant="danger">Failed</span><span class="badge" data-emphasis="solid">Neutral</span>
<span class="badge" data-emphasis="solid" data-variant="brand">Brand</span>
<span class="badge" data-emphasis="solid" data-variant="success">Success</span>
<span class="badge" data-emphasis="solid" data-variant="warn">Warn</span>
<span class="badge" data-emphasis="solid" data-variant="danger">Danger</span><span class="badge" data-size="sm">Small</span>
<span class="badge" data-size="lg">Large</span>
<span class="badge" data-shape="pill" data-variant="brand">Pill</span>
<!-- Digits, not a translated string — so settled geometry is correct here. -->
<span class="badge" data-numeric>1</span>
<span class="badge" data-numeric>12</span>
<span class="badge" data-numeric data-variant="danger" data-emphasis="solid">1284</span>Two axes rather than one is a deliberate call. Folding them together would mean ten values —
brand, brand-solid, success,
success-solid, and so on — and there would then be no way to say "same meaning,
more emphasis" without knowing both halves of the name. With two axes a consumer flips emphasis
across a whole list without touching any of their meanings, and a new family costs one rule per
axis.
| Attribute | Values | Does |
|---|---|---|
data-variant |
neutral, brand, success, warn,
danger |
The colour family. Pairs the family's -subtle fill with its
-text foreground. |
data-emphasis | solid |
Swaps to the family's -solid / -on-solid pair and drops the
edge to transparent. |
data-size | sm, lg |
Padding, type step, gap and dot size together. No block size at any step. |
data-shape | pill |
Radius only. |
data-numeric | bare | The count badge: circular at one digit, growing from there, with tabular figures. |
| Property | Default | Controls |
|---|---|---|
--badge-bg | var(--color-surface-sunken) | Fill. |
--badge-fg | var(--color-text-muted) | Label colour, and the dot, which paints from currentColor. |
--badge-border | var(--color-border-subtle) | Edge. |
--badge-padding-inline | var(--space-3) | Inline padding. |
--badge-padding-block | var(--space-1) | Block padding, and half the count badge's minimum width. |
--badge-radius | var(--radius-sm) | Corner radius. |
--badge-font-size | var(--text-sm) | Type step. The count badge's circle tracks it. |
--badge-gap | var(--space-2) | Dot to label. |
--badge-dot-size | var(--space-3) | .badge__dot diameter. |
.badge__dot is decorative — it repeats meaning the label already carries and
the variant colour already signals. Keep it out of the accessible name: an empty
<span> with no text content, or aria-hidden="true" if it ever
carries any. Colour and the dot are never the only channel; the label is always present.<span>.-text on -solid, say — is not
safe, which is why every variant re-points both locals together and never one of them.white-space: nowrap,
no inline size, no maximum inline size and no text-overflow in the base rule. A
Mongolian status string is far longer than its Latin source, and a clipped status is a status
the user cannot read. data-numeric is the one exception, because its content is
digits rather than a translated string.An inline message: form errors, system notices, settlement warnings. Not a toast — an alert sits in the flow, next to the thing it is about, and stays until the condition clears. Three grid columns: icon, content, dismiss.
<div class="alert" data-variant="success" role="status">
<svg class="alert__icon" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="…"/></svg>
<div class="alert__title" 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>Four families. info is the brand family — an informational notice is the product
speaking in its own voice, so it does not need a fifth hue. An alert is perfectly legal with a
body and no title.
<!-- info and success are polite: role="status". -->
<div class="alert" data-variant="info" role="status">
<svg class="alert__icon" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="…"/></svg>
<div class="alert__body">Settlement runs at 16:00 local time.</div>
</div>
<!-- warn and danger interrupt: role="alert". Actions compose with .button-group. -->
<div class="alert" data-variant="warn" role="alert">
<svg class="alert__icon" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="…"/></svg>
<div class="alert__title">Your session expires in two minutes</div>
<div class="alert__body">Anything you have not submitted will be lost.</div>
<div class="alert__actions button-group">
<button class="button" type="button" data-size="sm" data-variant="outline">Stay signed in</button>
<button class="button" type="button" data-size="sm" data-variant="quiet">Sign out</button>
</div>
</div>The icon is placed in row 1 only, so it pins to the first line of the message and stays there while the body wraps to as many lines as the 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.
| Attribute | Values | Does |
|---|---|---|
data-variant |
info, success, warn, danger |
Fill to the family's -subtle, text to its -text, both edges
to its -border. Omitted is the neutral sunken alert. |
role | status, alert |
Markup, not styling, and urgency picks it. See below. |
| Property | Default | Controls |
|---|---|---|
--alert-bg | var(--color-surface-sunken) | Fill. |
--alert-fg | var(--color-text) | Text, and the icon via currentColor. |
--alert-border | var(--color-border-subtle) | The three ordinary edges. |
--alert-accent | var(--color-border-strong) | The leading accent edge. |
--alert-accent-width | var(--border-width-4) | Thickness of that edge. |
--alert-padding-block | var(--space-4) | Block padding. |
--alert-padding-inline | var(--space-5) | Inline padding. |
--alert-radius | var(--radius-md) | Corner radius. |
--alert-gap-inline | var(--space-4) | Icon and dismiss margins — the real column gaps. |
--alert-gap-block | var(--space-2) | Row gap between title, body and actions. |
danger and warn want
role="alert", which is assertive and interrupts — right for a failed transfer or
a settlement about to lapse. info and success want
role="status", which waits for a pause. Never put role="alert" on a
success message: assertive interruption for good news trains people to ignore the assertive
channel..alert and then fill it; injecting the whole element announces nothing in
several screen readers.aria-describedby, so it is reachable from the control..alert__icon is decorative — the variant colour and the message both already
carry the meaning. Mark it aria-hidden="true"..alert__dismiss must be a real <button type="button"> with
an accessible name, translated with the rest of the UI. Never a bare
<div> with a click handler, never an <a href="#">, and
never a bare × character as the name. It is a grid item in column 3 rather than an absolutely
positioned box, so it reserves its own space and can never sit on top of a title that grew to
two lines in translation..alert__dismiss never takes data-size="sm" — it is the primary
way out of the alert on a touchscreen, and the 40px it would resolve to sits below the
package's own 44px touch floor..alert__actions.<strong> inside an alert.
<strong> inherits --alert-fg, which measures 6.02:1 at worst
across all four variants in both themes, and it is bold — weight is the whole treatment.
Reaching for a solid role here would make it worse, not better: those are fill roles chosen
to carry -on-solid text, and on a subtle background they land well under
AA.Data tables: the OTC ledger, market lists, distribution reports. The file is thin on purpose.
reset.css already collapses borders and base.css already gives every
table its width, its cell padding, its gridline colour, its sunken header fill, its caption
styling and its right-aligned [data-numeric] cells. What the component layer adds
is what a data table needs and a prose table does not: a scroll container, density,
zebra, hover, a sticky header and an empty state.
| Reference | Төлбөрийн мэдээлэл | 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 |
<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 moves cell padding and nothing else — not the type scale, not the borders, not the
touch targets of controls inside cells. Compact is for a trader watching a ledger on a large
screen; comfortable is for the kiosk, where the row is also a tap target.
data-bordered opts back into the full gridline box for the cases that need it, a
comparison matrix or a report read column-wise.
| Market | Last | Change |
|---|---|---|
| ARDX / MNT | 3 480 | +1.2% |
| TAAY / MNT | 912 | -0.4% |
| Market | Last | Change |
|---|---|---|
| No transactions in the selected period. | ||
<table class="table" data-density="compact" data-bordered> … </table>
<!-- The empty state is one row inside tbody. colspan must match the real
column count: it cannot be computed without :has(), and a mismatched
span leaves a ragged edge. -->
<table class="table" data-density="comfortable">
<tbody>
<tr><td class="table__empty" colspan="3">No transactions in the selected period.</td></tr>
</tbody>
</table>| Attribute | Values | Does |
|---|---|---|
data-density |
compact, comfortable |
On .table. Re-points the two cell padding locals. Omitted is base. |
data-bordered | bare | On .table. Full gridline box instead of horizontal rules only. |
data-striped | bare | On .table. Zebra on even body rows. |
data-hoverable | bare | On .table. Row hover fill. |
data-sticky-header | bare | On .table. Sticks thead th to the nearest scrolling
ancestor at --z-raised. |
data-numeric | bare | On any th or td. End alignment and tabular figures come
from base.css; the component adds the wrapping ban. |
tabindex="0", role="region",
aria-labelledby | markup | Required on .table-wrap. See below. |
| Property | Default | Controls |
|---|---|---|
--table-border | var(--color-border-subtle) | Gridline colour. |
--table-stripe | var(--color-surface-sunken) | Zebra fill. |
--table-hover | var(--color-surface-hover) | Row hover fill. |
--table-cell-padding-block | var(--space-3) | Cell block padding. .table__empty re-points this on itself to buy its breathing room. |
--table-cell-padding-inline | var(--space-4) | Cell inline padding. |
overflow-x on a
<table> does nothing useful, so the table is wrapped in
.table-wrap. A scroll region that cannot be focused is a keyboard trap in
reverse, so the wrapper needs tabindex="0" to be arrow-scrollable,
role="region" so its name is announced, and
aria-labelledby pointing at the table's <caption>.<caption>, not a <div> above the table.
The native element is programmatically the table's accessible name, and it is what
aria-labelledby should point at. A div above the table is invisible to that, and
moving it inside the scroll container would make it scroll away from the table it names.[data-numeric] is the one place white-space: nowrap is
correct. A figure broken across two lines is not slow to read, it is misread: 1 250
000 wrapped after the second group reads as two numbers, and a minus sign left alone on line
one is a reconciliation error waiting to happen. Digits are also not translated, so the
bilingual rule that governs every other cell does not apply. Every other cell wraps, and when
the table as a whole no longer fits, .table-wrap scrolls it. Wrap first, scroll
second, truncate never — shrinking the type, hiding columns or truncating cells each hide a
number somebody is reconciling against a bank statement.<tr>. A row background
is painted beneath its cells, and base.css gives every <th>
an opaque sunken fill — so a <th scope="row"> would punch an unstriped,
unhovered hole through the middle of the row. Hover is declared after zebra on purpose:
identical specificity means source order decides, and hover must beat the stripe. Do not
reorder them.<th> has an opaque fill from
base.css. Make it transparent anywhere and the header text lands on top of
moving figures. It sits at --z-raised, not --z-sticky: it only has
to sit above the rows in its own table, and --z-sticky would also put it above
any menu a cell opens..table__cell--numeric class. "This cell holds a
figure" is a property of the data, not of this component, so a bare table in a rendered
report gets the same treatment and a server template can emit the attribute from the column
type without knowing which class the page uses.A tab strip and its panels. Variant, size, orientation and overflow are all data-attributes on
the .tabs root, so one attribute restyles the whole widget. The examples below are
static markup — arrow-key roving and panel switching are application JavaScript, and this page
ships none.
Өнөөдөр үүлэрхэг, зөөлөн салхитай.
Second panel.
Third panel.
<div class="tabs">
<div class="tabs__list" role="tablist">
<button class="tabs__tab" type="button" role="tab"
id="tb1" aria-selected="true" aria-controls="pb1" tabindex="0">
<svg class="tabs__icon" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="…"/></svg>
<span class="tabs__label" 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>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.
Underline is the default and has no rule of its own — the base locals are it.
enclosed joins the selected tab to the panel; pill is a segmented
control on a sunken track, which is what the kiosk wants.
The selected tab shares its fill with the panel and drops the edge between them.
Second panel.
Дахин тавтай морил
Second panel.
Third panel.
<div class="tabs" data-variant="enclosed"> … </div>
<div class="tabs" data-variant="pill" data-size="lg"> … </div>A vertical list gives every label the full inline size of the sidebar and lets it wrap at no cost. That is very often the right answer for Mongolian rather than a stylistic alternative.
Second panel.
Third panel.
Opt-in single-row scrolling strip, for chrome with a fixed block size that a second row would break.
Second panel.
Third panel.
Fourth panel.
<!-- The list must also carry aria-orientation, so arrow-key handling
matches what the user sees. That attribute is markup, not CSS. -->
<div class="tabs" data-orientation="vertical" data-size="sm">
<div class="tabs__list" role="tablist" aria-orientation="vertical"> … </div>
…
</div>
<div class="tabs" data-overflow="scroll"> … </div>| Attribute | Values | Does |
|---|---|---|
data-variant |
enclosed, pill |
Omitted is the underline strip. |
data-size | sm, lg |
Tab height, padding, type step, indicator thickness and panel padding. |
data-orientation | vertical |
Sidebar list. Moves the strip's rule and the indicator to the inline edge. |
data-overflow | scroll |
Single-row scrolling strip instead of a wrapping one. |
aria-selected | true, false |
On .tabs__tab. The selected colour and the indicator. |
aria-disabled="true" | on the tab | Dims it and suppresses both the hover and the press, while leaving it focusable. |
hidden | on the panel | Hides it. See the note below on why this works. |
| Property | Default | Controls |
|---|---|---|
--tabs-gap | var(--space-1) | Space between tabs. |
--tabs-border | var(--color-border-subtle) | The strip's rule. |
--tabs-border-width | var(--border-width-1) | Its thickness, and what the indicator and enclosed tabs overlap by. |
--tabs-indicator | var(--color-brand) | Selected indicator colour. |
--tabs-indicator-size | var(--border-width-2) | Indicator thickness. |
--tabs-indicator-inset | var(--space-0) | How far the indicator is inset from the tab's edges. |
--tabs-list-bg | transparent | Strip fill. The pill variant re-points it to a sunken track. |
--tabs-list-padding | var(--space-0) | Strip padding. |
--tabs-list-radius | var(--radius-none) | Strip radius. |
--tabs-tab-bg | transparent | Resting tab fill. |
--tabs-tab-bg-hover | var(--color-surface-hover) | Hover fill. |
--tabs-tab-bg-active | var(--color-surface-active) |
Press fill — the next step along the same axis as the hover, so rest → hover → press
moves in one direction. On a selected tab it is re-pointed to that tab's own fill taken
by --state-active-amount, so a press deepens the selected fill instead of
replacing it with a neutral. |
--tabs-tab-bg-selected | transparent | Selected fill. |
--tabs-tab-fg | var(--color-text-muted) | Resting label colour. |
--tabs-tab-fg-selected | var(--color-brand-text) | Selected label colour. |
--tabs-tab-border | transparent | Tab edge. The enclosed variant re-points it when selected. |
--tabs-tab-min-block-size | var(--size-touch) | Minimum tab height. |
--tabs-tab-padding-inline | var(--space-4) | Tab inline padding. |
--tabs-tab-padding-block | var(--space-3) | Tab block padding. |
--tabs-tab-radius | var(--radius-sm) | Tab radius. |
--tabs-font-size | var(--text-base) | Label type step. |
--tabs-panel-padding-block | var(--space-5) | Panel block padding. |
--tabs-panel-padding-inline | var(--space-0) | Panel inline padding. |
.tabs__list takes role="tablist" (plus
aria-orientation="vertical" when the root is vertical); each
.tabs__tab takes role="tab", an id,
aria-selected and aria-controls pointing at its panel; each
.tabs__panel takes role="tabpanel", tabindex="0" and
aria-labelledby pointing back at its tab.tabindex="0" on it,
tabindex="-1" on the rest — and arrow keys plus Home/End move between them. That
is application JavaScript. The CSS must not fight it, which is why nothing in the file sets
pointer-events, visibility, or order..tabs__icon is decorative and needs aria-hidden="true".
.tabs__badge must not be the only carrier of its meaning: put the number into
the tab's accessible name too, or a screen reader user never hears it.:hover
in the package sits inside @media (hover: hover), which never matches a touch
screen; :active never does, because it is the feedback a touch user gets
instead of hover, and reset.css suppresses the OS tap highlight. Without
it a tapped tab would acknowledge nothing at all. The selected tab presses too, so re-tapping
the tab you are already on gets an answer rather than a dead strip — and only its fill moves,
so the indicator on ::after still carries "selected" as a shape while the press
colour is on the tab. The enclosed variant keeps its border and dropped edge and the pill
variant keeps its --shadow-xs for the same reason.data-overflow="scroll" exists for chrome with a fixed height that a
second row would break.[hidden] guard. The panel rule is written
.tabs__panel:not([hidden]), and the :not() is load-bearing.
reset.css sets [hidden] { display: none }, but
layer order is compared before specificity and components sits
above reset — so a bare .tabs__panel { display: block } would beat
[hidden] outright and every panel in the widget would render at once. Any future
display declaration in the file needs the same guard.data-overflow="scroll" is deliberately unstyled; this package
leaves scrollbars to the application.Built on the native <dialog> element, opened with
showModal(). Not a div. The native element gives focus trapping, an inert
background, Escape to close, and top-layer rendering above every stacking context on the page —
four hard problems, all solved by the platform. Because it renders in the top layer,
--z-modal is never needed here.
<dialog class="dialog" id="new-order" aria-labelledby="dlg-title">
<form method="dialog">
<div class="dialog__header">
<h2 class="dialog__title" id="dlg-title" 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>The dialog above is a real <dialog> carrying the open
attribute, and a docs-only rule takes it out of the UA's absolute positioning so it can sit
in the flow of this page at all. In an application it is opened with
showModal() and renders in the top layer, over an inert background:
document.getElementById('new-order').showModal();Its buttons are type="button" here and do nothing on purpose: this copy is the
anatomy specimen, and a dismiss that worked would delete the example off the page until you
reloaded. The markup block below is the real thing — type="submit" inside a
<form method="dialog">, which closes the dialog with no JavaScript at all.
Open it for real under the size table opens a working copy.
Three size steps plus full for the kiosk, and two placements. The bottom sheet is
the right pattern on the phone target: it rises into the bottom third of the screen where the
thumb already is, rather than parking its actions at the top of a large display where they
cannot be reached one-handed.
| Attribute | Values | Does |
|---|---|---|
data-size |
sm, lg, full |
Maximum inline size, and padding at sm. full pins both
block sizes to the viewport for a fixed terminal. Omitted is medium. |
data-placement |
center, bottom |
center is the default, stated explicitly so an instance can be switched
back. bottom is the full-bleed sheet, rounded only on its leading block
edge. |
open | native | Set by showModal(). The grid display is scoped to it — see
below. |
| Property | Default | Controls |
|---|---|---|
--dialog-bg | var(--color-surface-raised) | Fill. |
--dialog-fg | var(--color-text) | Text colour. |
--dialog-border | var(--color-border) | Edge. |
--dialog-radius | var(--radius-lg) | Corner radius. |
--dialog-padding | var(--space-6) | Padding on the header, body and footer. The dialog box itself has none. |
--dialog-max-inline-size | var(--size-container-md) | Width ceiling. |
--dialog-shadow | var(--shadow-xl) | Elevation. |
--dialog-divider | var(--color-border-subtle) | Header and footer rules. |
--dialog-enter-translate | 0 var(--space-5) | Where the entry animation travels from. The bottom sheet re-points it so it travels further. |
showModal() moves focus into the dialog on open. Do not fight it with a
JavaScript focus() call.autofocus on the primary action is usually right, so Enter confirms.
Never autofocus a destructive action — a delete button sitting
under a finger already resting on Enter is a data-loss bug, not a shortcut. Focus the dismiss
or the safe action instead.aria-labelledby pointing at the
.dialog__title id, or aria-label where there is no visible
title.<form method="dialog"> and the platform closes the
dialog and reports the pressed button for free. Prefer a
<button formmethod="dialog"> for the dismiss control..dialog__dismiss is icon-only and must carry an accessible name..dialog__dismiss never takes data-size="sm" — on a bottom-sheet
dialog it is the primary exit on touch, and 40px sits below the package's own 44px touch
floor.display: grid is scoped to .dialog[open]. The
rule that hides a closed dialog is the UA's
dialog:not([open]) { display: none }, and any author declaration outranks a UA
one — so a bare .dialog { display: grid } renders every closed dialog on the
page. This is the easiest way to break a native dialog and it fails silently in review..dialog__body carries min-block-size: 0 because a grid
item's automatic minimum size is its content size, and without zeroing it the dialog would
grow past its maximum instead of the body scrolling.dvb, not vb or svb. On
mobile the URL bar shows and hides as the user scrolls; vb would tuck the footer
under the bar while it is showing, and svb would leave a permanent dead strip
once it hides.--duration-normal collapses to an instant under
prefers-reduced-motion. There is no exit animation: animating a dialog
out needs transition-behavior: allow-discrete or
@starting-style, both of which land in Firefox 129, above this package's floor.
The dialog closes instantly, which is correct and never reads as broken.A dropdown of commands — account menus, row actions, market filters. Positioned with ordinary
absolute positioning against a .menu-anchor wrapper, deliberately not with the
popover attribute or CSS anchor positioning: both sit above the browser floor, and
an unpositioned anchored menu has no graceful fallback — it lands in document flow.
<div class="menu-anchor">
<button class="button" type="button"
aria-haspopup="menu" aria-expanded="false" aria-controls="acct">…</button>
<div class="menu" id="acct" role="menu" hidden>
<div class="menu__group" role="group" aria-labelledby="acct-grp">
<div class="menu__label" id="acct-grp">Account</div>
<button class="menu__item" role="menuitem" type="button">
<span class="menu__label-text" 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>The menu above is shown open and un-positioned so it can sit in the flow of this page; a
real one is absolutely positioned below its anchor and toggled with the
hidden attribute. The trigger picks up its pressed fill automatically from
[aria-expanded="true"] — menu.css re-points
button.css's own --button-bg local rather than hard-coding a
colour, so an open trigger holds whatever pressed fill its own variant declared.
| Attribute or class | Values | Does |
|---|---|---|
data-placement | start, end |
On .menu. Which inline edge the menu aligns to against its anchor, using
logical insets so it flips correctly in RTL. |
data-variant | danger |
On .menu__item. Destructive commands, using the danger text role. |
.menu__group / .menu__label | — | 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. |
| Property | Resolves to | Controls |
|---|---|---|
--menu-min-inline-size | |
Floor, so a two-item menu does not collapse to the width of its shortest label. Capped
against --menu-inline-space, or an 18rem floor would be wider than a 320px
phone. |
--menu-inline-space | min(100%, calc(100vi - var(--space-7))) |
How much inline room the menu has, and the cap on both the floor and the ceiling. In
normal flow that is the containing block, so a menu inside a padded panel stops at the
panel's edge instead of hanging out of it. A menu inside a .menu-anchor is
out of flow and re-points this to the viewport term alone, because there
100% is only as wide as the trigger. Re-point it yourself if you position a
menu some other way. |
--menu-max-inline-size | |
Ceiling, which forces a long 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. |
aria-haspopup and aria-expanded; the menu needs
role="menu" and its items role="menuitem". Arrow-key navigation,
Home and End, Escape to close, and returning focus to the trigger are all application
JavaScript. The CSS is written not to fight any of it.role="menu" is the wrong
role. A menu is a set of commands. Navigation should be a plain <nav>
and a list — this is a common and genuinely harmful mistake, because the menu role changes how
a screen reader announces and navigates the whole thing.currentColor, not a glyph, so nothing leaks into the accessible name and there
is nothing for a translator to find..menu-anchor an anchor-name and replace four inset
declarations; nothing else changes.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.
Гуйвуулга баталгаажлаа
Төлбөрийн мэдээлэл амжилттай шинэчлэгдлээ.
<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>Neutral
Info
Гуйвуулга баталгаажлаа
Өнөөдөр үүлэрхэг, зөөлөн салхитай.
Утасны дугаар
| Attribute | Values | Does |
|---|---|---|
data-variant |
neutral, info, success, warn, danger |
On .toast. Re-points background, text and the accent edge to that
family's -subtle / -text / -border roles.
info borrows the brand family — there is deliberately no info ramp. |
data-placement |
top-center, bottom-end, bottom-center |
On .toast-region. Omitted is top-end. On the phone target, bottom
placement collides with the home indicator and sits under the thumb, so top-end is the
safer default and bottom-center is the deliberate choice when the toast is the primary
feedback for a thumb-driven action. |
data-entering / data-leaving | present or absent | The offset states. The resting state is visible — see below. |
aria-live and aria-atomic. Use
polite for ordinary toasts and assertive only for errors —
assertive interrupts whatever the screen reader is currently saying, which is rude for a
success message and correct for a failed payment.opacity: 0 at rest, a missed attribute toggle
ships an invisible notification — an unacceptable silent failure on a path that
tells someone their money moved.--duration-normal, so tokens.css collapses it automatically..toast__dismiss never takes data-size="sm" — on a bottom toast
it is the primary way to dismiss by hand, and 40px sits below the package's own 44px touch
floor.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.
<!-- 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.
<!-- 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>@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.
Дахин тавтай морил
The trigger's resting fill is a real colour here, so the hover and active mixes at the top of the file resolve to the sunken surface's own hover and active steps — in both themes, with nothing added.
<details class="collapse"> … </details> <!-- plain, the default -->
<details class="collapse" data-variant="bordered"> … </details>
<details class="collapse" data-variant="filled"> … </details>| Attribute or class | Values | Does |
|---|---|---|
data-variant |
bordered, filled |
On .collapse. Omitted is plain — transparent, no edge, no
radius. |
open | native, route 1 | On the <details>. Rotates the marker and drops the trigger's end
corners. Set by the user agent; never mirror it into a class. |
aria-expanded | true, false |
On .collapse__trigger, route 2. Required. This file
styles the attribute and nothing else — there is no .is-open class
anywhere, because a second source of truth will drift away from the one assistive tech
reads. |
aria-controls | an id |
On the trigger, route 2. Points at the region. |
aria-disabled="true" | on the trigger | Dims it and suppresses the hover fill, while leaving it focusable. |
aria-hidden="true" | on the marker | Required on .collapse__marker. It is decoration; the
state it depicts is already announced. |
hidden |
on the trigger, content or marker | Hides it outright. Guarded explicitly in the file — see the note under Accessibility and decisions. |
.collapse__label | — | 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. |
| Property | Resolves to | Controls |
|---|---|---|
--collapse-bg | transparent | Root fill. |
--collapse-fg | | Text colour. |
--collapse-border | transparent | Root edge colour. |
--collapse-border-width | | Root edge thickness. |
--collapse-radius | | Corner radius, on the root and on the trigger. |
--collapse-gap | | Column gap between the label and the marker. |
--collapse-padding-inline | | Inline padding on the trigger and the inner. |
--collapse-padding-block | | Block padding on the trigger, and the block-end padding of the inner. |
--collapse-font-size | | Trigger type step. |
--collapse-min-block-size | | Minimum trigger height. A minimum, never a fixed size — a two-line Cyrillic label grows the control instead of overflowing it. |
--collapse-trigger-bg | transparent | Resting trigger fill. |
--collapse-trigger-fg | | Trigger text colour. |
--collapse-trigger-bg-hover |
--collapse-trigger-bg mixed with --state-shade at |
Derived, not pointed at a surface role. A collapse does not know what it is sitting on, and in dark mode the raised surface is one ramp step lighter than the plain one — so a fixed hover token would go the wrong way on some grounds. Re-point the resting fill and both state fills follow. |
--collapse-trigger-bg-active |
the same mix at |
Pressed fill. |
--collapse-marker-color | |
Chevron colour. Muted text, not a border role — see below. |
--collapse-marker-size | |
Chevron box inline size. Its block size is this times the trigger's line height, so the box is exactly one line tall. |
<details> element is already exposed
as a disclosure button with its state, its accessible name and its keyboard handling
supplied by the engine, and it works before the JavaScript bundle lands or if it never lands
at all. Reach for route 2 only when something else genuinely owns the open state.auto block size is not
animatable, and all three ways round that were weighed against the package floor of
Chrome/Edge 123, Firefox 120, Safari 17.5. interpolate-size: allow-keywords is
Chrome 129 and ships in neither Firefox nor Safari — rejected.
transition-behavior: allow-discrete is the near miss: below the floor in Chrome
and Safari, but Firefox 129, nine versions short — rejected. What is used instead is
grid-template-rows: 0fr → 1fr on a one-row grid whose item clips its own
overflow, animatable since Chrome 107, Firefox 66 and Safari 16.<details> has its content hidden by the user
agent with display: none on an internal slot, and no author declaration can
reach inside that. ::details-content fixes exactly this and landed in Chrome
131, above the floor. So a native <details> opens
instantly and only its chevron rotates; route 2 is the one whose region slides. If
a smooth open matters more than free keyboard handling and no-JS behaviour, that is the
trade you are making, and it is why the chevron rotation is shared by both routes rather
than bundled into the animation.0fr track is applied only where the markup positively proves the region is
closed. Written the other way round, any region that lost its selector — a wrapper slipped
between trigger and region, a missing aria-expanded — would silently render at
zero height: invisible content, no error, nothing in the DOM to explain it. The worst
failure this way round is a section that does not animate.0fr clips, it does not remove. A region collapsed by
aria-expanded="false" is still in the accessibility tree and any control inside
it is still in the tab order. If the region holds focusable content, set hidden
on it as well once it is closed — which is why the file restates every
display selector with [hidden] attached. Layer order is compared
before specificity, so a plain .collapse__content { display: grid }
in the components layer beats reset.css's
[hidden] { display: none } outright and every collapsed region in route 2 would
stay on screen. Add a row to that guard whenever you add a display.align-items: center floats the marker in the middle of a two-line header; flex
with flex-start fixes that and breaks the one-line case, pinning everything to
the top whenever the touch minimum is taller than the content. Grid does both at once,
because align-content works on a grid and does not work on a single-line flex
container: align-content: center centres the row track in the free space the
touch minimum creates, and align-items: start puts each item at the top of that
track.currentColor. No
glyph, no icon font, no arrow in a content property — an arrow in a content
property is a string, and strings get sent to translators, machine-translated, or dropped by
a font missing that codepoint. Borders cannot be any of those things.--color-text-muted measures 7.98:1 light and 8.16:1 dark.
--color-border-subtle is documented as decorative and does not clear the bar;
the -border roles generally sit around 2.2:1 and are never right for a control
boundary or a state signal.transition-behavior, which is rejected above. Anything that must escape the
region's box — a menu, a popover — has to be portalled out of it. The inner's block-start
padding buys back what the clip would otherwise take from a focus ring on a control sitting
at the very edge of the region.:has() anywhere in this package. It would shorten several rules here, but
it would raise the documented Firefox floor from 120 to 121; sibling combinators and the
[open] attribute do the same work at the floor, at the cost of writing the
open-state rules twice — once per route.A group of collapses that reads as one control. This component owns exactly two classes,
.accordion and .accordion__item, and nothing else: the item itself,
the trigger, the label, the marker, the region and the inner all come from
collapse.css and are not redefined here. All the accordion does to them is
re-point collapse's own locals and add the corners that make the stack read as one block. Both
markup routes work inside it, and <details> is still the recommended
one — the accordion below is real, with no JavaScript behind it.
| Class | Defined in | Note |
|---|---|---|
.accordion | accordion.css |
The group. Owns the fill, the edge, the radius and the gap. |
.accordion__item | accordion.css |
Goes on the same element as .collapse, never on a
wrapper around it, and must be a direct child of the group. |
.collapse | collapse.css |
The item itself. |
.collapse__trigger | collapse.css |
The <summary> or the button. |
.collapse__label | collapse.css |
The label inside the trigger. |
.collapse__marker | collapse.css |
The chevron. Still needs aria-hidden="true". |
.collapse__content | collapse.css |
The animating region. |
.collapse__inner | collapse.css |
The clipped, padded contents. |
2 минутын өмнө шинэчлэгдсэн
A long label wraps to two lines and the chevron stays beside the first one.
Өнөөдөр үүлэрхэг, зөөлөн салхитай.
<!-- 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>data-exclusive carries no declarations. It changes behaviour,
not appearance, and a rule that existed only to be found by grep would be worse
than saying so here. It is a documented markup hook: it puts the attribute that describes
the widget on the widget, where a reviewer can see the intent in the DOM.
The behaviour comes from the native name attribute — give every
<details> in the group the same name and the engine closes the others.
Support at this package's floor is three of four: Chrome 120, Edge 120 and Safari 17.2 are
all under it, but Firefox shipped it in 130, ten major versions above the
120 floor. The failure is benign, which is why this is still the recommended route — Firefox
below 130 ignores an unknown attribute, so the group keeps working and simply allows more
than one item open. Nothing breaks, nothing is hidden, nothing shifts. If single-open is a
hard requirement rather than a preference, do not rely on name: in route 2 it
is application JavaScript setting aria-expanded="false" on the others. There is
no CSS-only exclusive accordion at this floor, and the radio-and-label trick sometimes
offered gives the group the wrong role and the wrong keyboard model.
bordered is the default and needs no rule — one boxed group with rules between
the rows. separated makes every item its own card with air between them;
flush keeps the rules and drops the outer box, for an accordion already sitting
inside a card or a dialog where a second edge would just be noise. Sizes step padding and the
type step only.
Each item takes the border, the radius and the fill; the group's own box goes
away. It re-points --collapse-radius rather than restating corner
rules, so collapse's own "an open trigger drops its end corners" rule keeps
working here with nothing added.
Дахин тавтай морил
Утасны дугаар
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 минутын өмнө шинэчлэгдсэн
<div class="accordion"> … </div> <!-- bordered, the default -->
<div class="accordion" data-variant="separated"> … </div>
<div class="accordion" data-variant="flush" data-size="sm"> … </div>
<div class="accordion" data-size="lg"> … </div>| Attribute | Values | Does |
|---|---|---|
data-variant |
separated, flush |
Omitted is bordered: one boxed group, rules between rows. |
data-size | sm, lg |
Item padding and type step. Not the trigger's minimum height — see below. |
data-exclusive | boolean | Documentation only, no declarations. Marks the group as single-open so a reader can see the intent in the DOM. |
name | route 1, on each <details> |
The native grouping that actually enforces single-open. Firefox 130 and up. |
hidden | on .accordion |
Hides the group. Guarded explicitly, for the same layer-order reason as collapse. |
| Property | Resolves to | Controls |
|---|---|---|
--accordion-bg | | Group fill. Transparent in both separated and flush. |
--accordion-border | | The group's edge, and the rule between two items. |
--accordion-border-width | | Both of those, and the amount the item's corner radius is inset by so its arc stays concentric with the group's. |
--accordion-radius | | Group radius, inherited by the first and last item and by their triggers. |
--accordion-gap | | Space between items. separated opens it up. |
--accordion-padding-inline | | Feeds --collapse-padding-inline on every item. |
--accordion-padding-block | | Feeds --collapse-padding-block. |
--accordion-font-size | | Feeds --collapse-font-size. |
<h3><button class="collapse__trigger">…</button></h3>
— so the accordion is navigable by heading. That is markup, not CSS, and it is the single
most valuable thing you can add to this component. Route 1 does not need it: a
<summary> is already exposed as a disclosure button, though a heading
around it is still welcome.aria-expanded and aria-controls in
route 2, and .collapse__marker still carries aria-hidden="true".
Nothing about being inside a group changes collapse's contract.plain variant. The
group owns the fill, the edge and the radius; a data-variant on the item would
draw a second box inside the first.--collapse-min-block-size is deliberately not stepped down at
sm. A disclosure header is a tap target on the kiosk and on the phone
at every size. The trigger's grid centres its row track inside whatever free space the touch
minimum creates, so keeping it costs nothing but a slightly airier small accordion..accordion__item and .collapse are single-class selectors, so a
bare .accordion__item rule would tie on specificity and lose on source order —
accordion.css sorts before collapse.css in the import list — and
every item would keep its standalone padding and radius. The combinator settles it, and it
also stops a nested accordion from inheriting its parent's item styling.filled if an instance opts
into it — so without that the hover fill would paint square into the group's rounded corner
on the first and last rows. The last trigger keeps its end corners only while its region is
shut; once open, a rounded trigger edge in the middle of the stack reads as a seam.--color-border-subtle, and that is correct
here and only here: it separates two rows of the same control, it is decoration
rather than a control boundary, and it is not the only signal for any state, so SC 1.4.11's
3:1 does not apply to it. The marker, which is a state signal, is held to that bar
in collapse.css.border-top-left-radius would not.A determinate or indeterminate progress indicator: uploads, batch settlement runs, multi-step
forms. The preferred markup is the native <progress> element, which carries
the value, the maximum and the progressbar role for free and needs no ARIA at all.
A div-based form exists for the two things <progress> cannot do — a
segmented bar, and a track that must contain real markup. Variant, size and the indeterminate
state are data-attributes on the track; the value never is. Value updates are application
JavaScript, so every bar on this page is static.
<!-- NATIVE — preferred. The semantics are not optional, so they
cannot be forgotten. -->
<div class="progress-group">
<label class="progress__label" for="up" 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>The fill is a background, and a background is invisible to assistive
technology. role="progressbar", aria-valuenow,
aria-valuemin and aria-valuemax are not decoration on top of a
working control — without them a screen reader announces an empty group and the value is
simply lost. The wrapper is identified by the role rather than by a data-attribute for the
same reason: the application has to set it anyway, so keying the styling off anything else
would create a second source of truth that will drift.
Indeterminate is the one case that differs: drop aria-valuenow
entirely and keep the minimum and maximum. An absent
aria-valuenow is what signals "indeterminate"; setting it to zero announces
"0 percent", which is a different and wrong claim.
Four fills and three thicknesses. Every variant points at --color-family
rather than the neighbouring -solid or -border roles, both of which
fail against this track — --color-brand-border measures 1.91 light and 2.41 dark,
and --color-warn-solid measures 2.64 in light mode because amber is light at every
usable step.
<!-- A bare progress with no .progress__label still needs an accessible
name, so these carry aria-label. In an application, prefer the group. -->
<progress class="progress" value="45" max="100" aria-label="…"></progress>
<progress class="progress" data-variant="success" value="100" max="100" aria-label="…"></progress>
<progress class="progress" data-variant="warn" value="80" max="100" aria-label="…"></progress>
<progress class="progress" data-variant="danger" data-size="lg" value="18" max="100" aria-label="…"></progress>
<progress class="progress" data-size="sm" value="62" max="100" aria-label="…"></progress>The same four fills against the same track, in both themes. The separation is lightness-led, not hue-led, which is the point of the choice: in OKLCH the track sits at L 93.6% in light and L 10.8% in dark, while every fill sits between L 42% and L 66% — 30 to 50 lightness points apart in either theme. Someone who cannot resolve hue still sees exactly where the fill ends.
A segmented bar is the first of the two reasons to reach for the div-based form: several
.progress__bar children share the row, each with its own
--progress-value and, where it helps, its own --progress-fill. The
wrapper still carries one set of ARIA describing the total.
<!-- SEGMENTED. One role, one set of aria-value* for the total. -->
<div class="progress" data-size="lg" role="progressbar" aria-labelledby="s-l"
aria-valuenow="85" aria-valuemin="0" aria-valuemax="100">
<span class="progress__bar" style="--progress-value: 55%"></span>
<span class="progress__bar" style="--progress-value: 20%; --progress-fill: var(--color-success)"></span>
<span class="progress__bar" style="--progress-value: 10%; --progress-fill: var(--color-warn)"></span>
</div>
<!-- INDETERMINATE, div form: NO .progress__bar child, and
aria-valuenow is dropped entirely rather than set to zero. -->
<div class="progress" data-indeterminate role="progressbar" aria-labelledby="i-l"
aria-valuemin="0" aria-valuemax="100"></div>
<!-- INDETERMINATE, native: no value attribute. -->
<progress class="progress" data-indeterminate data-size="lg"></progress>| Attribute or class | Values | Does |
|---|---|---|
data-variant |
success, warn, danger |
Re-points the fill. Omitted is brand. |
data-size | sm, lg |
Track thickness only. The radius stays fully rounded at every step, so it needs no size rule. |
data-indeterminate | boolean | Sweeping chunk painted by the track's own background. The div form must carry no
.progress__bar child while indeterminate, and must drop
aria-valuenow. |
value / max | native | On <progress>. Omit value for the native
indeterminate state. |
role="progressbar" | div form | Required. It is also the styling hook — the flex row that lays out the bars is scoped to it. |
aria-valuenow / -valuemin / -valuemax |
div form | Required, except aria-valuenow while indeterminate.
See the note above. |
.progress-group | — | 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__bar | div form | One fill. Its width is --progress-value, set on this element. |
hidden | on the track or the group | Hides it. Guarded explicitly, for the same layer-order reason as collapse. |
| Property | Resolves to | Controls |
|---|---|---|
--progress-track | |
Track fill. It measures 1.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-value | 0% |
Set per instance by the application, on the track or on a single bar:
style="--progress-value: 62%". A custom property rather than a
data-attribute because it is a continuous quantity, and an attribute selector cannot
express one. |
--progress-chunk | 35% |
Proportion of the track covered by the indeterminate chunk. |
<progress> carries the role
and the value without being asked, which is exactly why it is the better default — its
semantics cannot be forgotten. Reach for the div form only for a segmented bar or a track
that must contain markup.role="progressbar" with aria-valuenow,
aria-valuemin and aria-valuemax are not optional extras. Without
them there is nothing to announce.aria-valuenow entirely and keeps the
minimum and maximum. Zero is a claim about progress; absence is the claim that progress is
unknown.::-webkit-progress-value, ::-moz-progress-bar { … } is dropped in its entirety
by both engines — Gecko does not know the -webkit- pseudo, Blink does not know
the -moz- one — and the fill silently vanishes everywhere, leaving an empty
track that still reports 62 percent. There is no :is() escape either:
:is() is forgiving about its arguments, but pseudo-elements are not
valid inside it at all.base.css sets accent-color on progress, which
governs the UA-drawn bar and is deliberately not restated here. It stops applying the moment
appearance: none lands, which is precisely why the fill has to be re-declared
through those pseudo-elements rather than inherited. In Blink,
::-webkit-progress-bar is set transparent so the element's own box keeps
painting the track, the border, the radius and the indeterminate sweep — one place instead
of two that drift apart, and it makes Blink match what Gecko already does.aria-live="polite" on the
label, never on the track. A live region on the track re-fires on every
value change and floods the user with percentages.prefers-reduced-motion: reduce the sweep stops and leaves a
static centred marker, not a blank bar. --duration-loop is
deliberately outside the set that tokens.css collapses, because collapsing a
loop does not stop it — a sweep re-timed to a single millisecond is a strobing bar, which is
materially worse than the animation the user asked to be rid of. The loop is suppressed here
instead and the chunk is pinned to the centre of the track. It cannot be misread as a
determinate value, because a determinate fill always starts flush against the inline-start
edge and this one touches neither edge; the state itself is carried by the label text and by
the absent aria-valuenow, which is what assistive tech reads either way.infinite loop would teleport the chunk from the far edge back to the near one
once per cycle. The chunk is also symmetric about its centre, so it reads identically in a
right-to-left document — which matters because CSS gradients still have no logical direction
keyword and to right is the only thing available.inline-size: 100%. Every colour, length, radius, duration and easing curve
is a token.A scroll-snap container, and nothing else. The core of this component works with zero
JavaScript — swipe, trackpad, shift-wheel and keyboard scrolling are the browser's own
scrolling, and scroll-snap-type: inline mandatory is what makes each slide come to
rest in the right place. Where snap is unsupported it degrades to a plain scrolling row that
still shows every slide. Drag the row below; nothing on this page is wired up to it.
Slides are direct children of .carousel. There is no
.carousel__track, and its absence is a decision rather than an omission — see
Accessibility and decisions.
<!-- role, aria-roledescription and an accessible name are required: a
carousel with no name is announced as an unlabelled group. tabindex="0"
is required too — see the keyboard bullet under Accessibility. -->
<div class="carousel" data-slides="3"
role="group" aria-roledescription="carousel"
aria-label="Account summaries" tabindex="0">
<!-- Slides are DIRECT CHILDREN. There is no track element. The name
carries the position, because "slide" alone tells the user nothing. -->
<div class="card carousel__slide"
role="group" aria-roledescription="slide" aria-label="1 / 7">
<div class="card__header">
<h4 class="card__title" 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.
<div class="carousel" data-variant="peek" data-slides="2" …> … </div>
<div class="carousel" data-slides="auto" …> … </div>
<div class="carousel" data-variant="full" …> … </div>.carousel-frame is a separate block, the same relationship
.button-group has to .button — not a required wrapper. It exists for
exactly one reason: the prev/next controls have to be positioned against the scrollport, and
an absolutely positioned child of a scroll container scrolls away with the
content. Use .carousel on its own when there are no controls and no
dots.
<div class="carousel-frame">
<div class="carousel" data-variant="full"
role="group" aria-roledescription="carousel"
aria-label="Full-width gallery" tabindex="0"> … </div>
<!-- A sibling of the scrollport, not a child of it: an absolutely
positioned child would scroll away with the slides. The overlay is
pointer-events: none; the two buttons opt back in. -->
<div class="carousel__controls">
<button class="button carousel__prev" type="button"
data-icon-only data-shape="pill" aria-label="Previous slide">
<svg class="button__icon" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="…"/></svg>
</button>
<button class="button carousel__next" type="button"
data-icon-only data-shape="pill" aria-label="Next slide"> … </button>
</div>
<!-- Real buttons if they are clickable. A span with a click handler is not
focusable, not operable from the keyboard and announces nothing. -->
<div class="carousel__dots">
<button class="carousel__dot" type="button" aria-label="Slide 1 of 4" aria-current="true"></button>
<button class="carousel__dot" type="button" aria-label="Slide 2 of 4"></button>
…
</div>
</div>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.
The floor this package promises is Chrome/Edge 123, Firefox 120, Safari 17.5. These are not used, and a reader will ask why:
scroll-snap-stop: always — the property that stops one fast flick from
skipping three slides, and the natural companion to data-variant="full".
Firefox only shipped it in 139. Using it would move the documented floor for a behaviour a
large share of users would not get, and the failure is silent: it is a no-op, so nobody
notices until a report says "it skips slides". An application that knows its own
floor is higher can opt in from its own unlayered sheet in one line, which already
wins over this file: .carousel__slide { scroll-snap-stop: always; }::scroll-marker, ::scroll-marker-group and
::scroll-button() — these would be the true zero-JavaScript dots and arrows,
with no application code at all. Chromium-only and far above the floor, and there is
nothing to fall back to, so the dots would simply not exist in two of three engines.animation-timeline: scroll() / view() — would let a dot or a
progress rail track scroll position with no IntersectionObserver. Not in
Safari at the floor, and it degrades to a static element with no indication at all.| Attribute or class | Values | Does |
|---|---|---|
data-slides |
1, 2, 3, auto |
On .carousel. Sets --carousel-count, which is the N in the
sizing formula. auto drops the formula and sizes each slide to its own
content — the honest value for mixed-width media. |
data-variant |
peek, full |
On .carousel. Omitted is a plain N-up row. peek subtracts a
gutter so the next slide stays partly visible; full gives one slide the
whole scrollport. |
tabindex="0" | on .carousel |
Required, unless every slide contains a focusable element of its own. See the first bullet under Accessibility. |
aria-current="true" | on .carousel__dot |
The current dot. Signalled twice — brand fill and a widened dot — so it still reads in greyscale. Exactly one dot at a time; remove the attribute from the rest. |
aria-disabled="true" | on a control | Styled by .button, not by this file. Keeps the control focusable, which
is the point. |
.carousel-frame | — | 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. |
| Property | Default | Controls |
|---|---|---|
--carousel-gap |
var(--space-4) |
Space between slides, and the GAP term in the formula. |
--carousel-count | 1 |
The N. data-slides sets it; set it directly for an N the attribute does
not cover. |
--carousel-slide-min |
var(--size-column-min) |
The floor under a slide's share — the same floor the grid utilities use. Below it the carousel becomes a peek-and-scroll row rather than a squashed grid. |
--carousel-slide-size |
min(100%, max(SLIDE-MIN, (100% − (N − 1) × GAP) / N)) |
The whole formula, set once on the root and read by every slide's
flex-basis. |
--carousel-peek |
var(--space-8) |
Only on [data-variant="peek"]. The gutter that keeps the next slide
partly visible; it is subtracted inside the formula, which is restated in that rule
rather than folded into the base one. |
--carousel-snap-align | start |
Where a slide comes to rest against the scrollport. |
--carousel-padding-inline |
var(--space-4) |
Inline padding on the scrollport. full zeroes it. |
--carousel-padding-block |
var(--space-1) |
Block-axis room so a slide's shadow or focus ring is not clipped by the scrollport. There is no block size anywhere in the file — the carousel is exactly as tall as its tallest slide, in either language. |
--carousel-scroll-padding |
var(--carousel-padding-inline) |
scroll-padding-inline, so the snapped slide aligns to the padded edge
rather than the border edge. A separate local only so a consumer can decouple the
two. |
--carousel-radius |
var(--radius-lg) |
Slide radius. full re-points it to --radius-none. |
--carousel-frame-gap |
var(--space-4) |
On .carousel-frame. The space between the scrollport and the dot
row. |
--carousel-control-inset |
var(--space-3) |
On .carousel__controls. How far the two buttons sit in from the
scrollport's inline edges. |
--carousel-dot-size |
var(--space-3) |
The visible dot. One of the two fixed dimensions in the file, allowed because a dot is a glyph rather than a translated string. |
--carousel-dot-hit |
var(--space-4) |
Transparent hit area around the dot, clipped off the fill with
background-clip: content-box, so the target clears WCAG 2.2 SC 2.5.8
without putting a blob that size on screen. |
--carousel-dot-color |
var(--color-border-strong) |
Resting dot. Not a decorative -border role: the fill is the
control's entire visible boundary, so SC 1.4.11 applies and a role measuring around
2.2:1 would not clear 3:1. |
--carousel-dot-color-hover |
var(--color-text-muted) |
Hover fill. |
--carousel-dot-color-current |
var(--color-brand) |
Current fill, paired with the widened dot. |
tabindex="0" on .carousel, or
guarantee that every slide contains a focusable element of its own. With neither, a
keyboard-only user cannot scroll the carousel at all: the arrow keys have nothing to
act on and everything past the first screenful is simply unreachable. Chromium began focusing
keyboard-scrollable containers automatically only after the version this package
floors at, and the other engines do not do it, so it cannot be relied on. This is not a
nicety; it is the difference between usable and not..carousel takes role="group",
aria-roledescription="carousel" and a name via aria-label or
aria-labelledby — a carousel with no name is announced as an unlabelled group
and the user has no idea what it holds. Each .carousel__slide takes
role="group", aria-roledescription="slide" and a name that
carries position: aria-label="2 / 5", or
aria-labelledby pointing at a heading inside the slide..carousel__track. The
other common shape is a flex track nested inside a separate scrollport, and it buys nothing
here — snap works on any descendant of the scrollport and gap works on the
scroll container itself. It also costs something real: the track would be the flex container,
so scroll-padding on the scrollport and padding on the track become
two values a maintainer has to keep in sync by hand, and the first person to change one and
not the other gets a snap point that lands half a gutter off.<button>s. A
<span> with a click handler is not focusable, not operable from the
keyboard, not reachable by a switch or a screen reader, and announces nothing — and on the
kiosk it is the one thing a user is most likely to poke at. If they are purely an indicator
rather than a control, use a real list and mark the current item.inline mandatory, not proximity: a slide left
arbitrarily half-shown, with no peek gutter to explain it, reads as a rendering fault rather
than as content continuing. overscroll-behavior-inline: contain is what stops a
flick past the last slide from scrolling the page behind the carousel or firing the browser's
back gesture on a touch device.scroll-behavior: smooth is gated behind
prefers-reduced-motion: no-preference directly rather than through a duration
token, because a duration token is not what it is. A carousel that glides is precisely what
motion sensitivity is about: large, fast, off-axis movement across most of the viewport,
started by something other than the user's own finger. Under reduce the scroll
still happens, it just happens instantly. There is no autoplay in the file and there
cannot be one without JavaScript — an application that adds one must suppress it
under reduce itself, because a loop cannot be fixed by shortening it.data-variant="peek", which signals the same thing with layout instead of with
chrome.Scrollspy is a behaviour, not a component. The scroll position and the "which section am I in" calculation are entirely application JavaScript, and 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.
<!-- 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.
The fill used to be transparent, on the premise that colour and weight were
already two signals. Measured, that premise was false twice over: a transparent current fill
against a transparent non-current fill is 1.00:1 in both themes — not a weak
signal, no signal — and --color-brand-text sits on the same rung of the
lightness ladder as --color-text-muted (--l-700 light,
--l-300 dark): current measures 8.05 light / 8.17 dark against
--color-bg and non-current measures 8.00 / 8.14, so the two differ in chroma
alone and are all but identical in greyscale. --weight-semibold was carrying the
whole state by itself.
--scrollspy-link-bg-current is now --color-brand-subtle, which is
what the sidebar of this very page uses for its own current page link. Against the
non-current sibling's ground that measures 1.26:1 on --color-bg in light
and 1.21:1 in dark, 1.34:1 on --color-surface in light — small, but a
real luminance step where there was none, so it survives greyscale where the hue difference
does not. --color-brand-text on that fill is 6.37:1 light / 6.77:1 dark, so the
label stays comfortable.
Two things are worth knowing about its size. 1.2–1.3:1 is well under the 3:1 that SC 1.4.11
asks of a non-text signal carrying a state on its own — this fill does not carry it
on its own, the weight does, and the fill widens the margin. And on
--color-surface in dark mode it measures 1.00:1:
--color-brand-subtle is --color-brand-900 and
--color-surface is a neutral on the same rung, so on a raised ground in dark the
fill differs in hue alone. A nav inside a dialog or a card should re-point
--scrollspy-link-bg-current the way it already re-points
--scrollspy-link-bg-hover, or take data-variant="filled". Never put
the edge rule back.
data-variant="filled" gives the state an active block, and is the supported way to
buy a signal that clears 3:1 on its own — for a nav beside busy content, for a kiosk screen read
from a metre away, or wherever colour vision cannot be assumed. The fill is
--color-brand-solid and not --color-brand-subtle: the block is a
structural signal, so SC 1.4.11 applies and it has to clear 3:1. It does, at 5.72 light / 6.03
dark against --color-bg, while --color-brand-subtle measures
1.26 / 1.21 — which would be colour-alone dressed up as a shape if it were carrying the state
alone. In the default variant it is not; the weight is. That is the whole distinction between
the two variants.
<nav class="scrollspy" data-variant="filled" aria-labelledby="toc-h"> … </nav>The nested group indents with a rule, not with whitespace. Indentation only
reads as hierarchy while every item is one line, because the eye is comparing the start
positions of a clean column of first lines. The moment one title wraps — and a 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.
position: sticky sticks to the nearest ancestor scroll container,
not to the page. Put this nav inside any ancestor whose overflow is anything
other than visible — a layout wrapper carrying overflow-x: hidden to
tame some unrelated element is the usual culprit, and it is invisible when you read the
markup — and the nav sticks to that box instead. That box is not scrolling, so the
nav simply never sticks, and there is nothing the file can add to defend itself. The fix is
always to remove the ancestor's overflow. The two examples above demonstrate this by
accident: .example on this page carries overflow: hidden, so those
navs are sticky within their own example box and not within the page.
A second, quieter version: sticky also does nothing when the nav's own parent is no taller than the nav, because the parent's padding box is the entire sticky range. In a two-column grid the column track usually is taller; in a flex row where the nav is stretched to the row's height, it is exactly the same height and nothing moves.
The nav takes its own overflow-y because a long table of contents is taller than
the viewport, and overscroll-behavior-y: contain so that reaching its end does
not hand the remaining gesture back to the page. Its scrollbar is left unstyled, like every
other scrollbar in this package.
The JavaScript contract, precisely enough to implement from. None of it is in the CSS:
const nav = document.querySelector('.scrollspy');
const items = [...nav.querySelectorAll('.scrollspy__link')]
.map(link => ({ link, head: document.getElementById(link.hash.slice(1)) }))
.filter(item => item.head); // collect once, in document order
let pending = null;
function update() {
pending = null;
// A threshold line a fixed distance below the top of the viewport: the
// sticky page header's block-size, plus a little.
const line = header.offsetHeight + 24;
// The LAST heading whose top is above the line.
let current = items[0]; // clamp 1: nothing above it yet
for (const item of items) {
if (item.head.getBoundingClientRect().top <= line) current = item;
}
// clamp 2: at the bottom of the document the last section wins outright, or
// a short final section can never become current however far you scroll.
if (scrollY + innerHeight >= document.body.scrollHeight - 2) {
current = items[items.length - 1];
}
for (const item of items) {
if (item === current) item.link.setAttribute('aria-current', 'true');
else item.link.removeAttribute('aria-current'); // REMOVE, not "false"
}
// The nav scrolls itself, so the current link can sit off its own edge.
current.link.scrollIntoView({ block: 'nearest' });
}
// A TIMER, never requestAnimationFrame. See the warning below.
function onScroll() {
if (pending === null) pending = setTimeout(update, 100);
}
addEventListener('scroll', onScroll, { passive: true });
addEventListener('resize', onScroll, { passive: true });
addEventListener('visibilitychange', update); // one unconditional update
update(); // and one on loadAn IntersectionObserver on the headings is the usual wrong answer, and
it is unreliable. A heading is a few dozen pixels tall, so at most scroll positions
no heading intersects the viewport at all — the observer does not fire, and the nav
freezes on whatever was current when the last heading left the screen. When two headings
do intersect, the observer reports them in document order rather than by position,
which marked the wrong one. Observing whole sections instead has the mirror-image failure:
with long sections several intersect at once and the callback has to re-rank them anyway.
Reading getBoundingClientRect().top against a threshold line is one cheap pass
per event, needs no observer, and answers the question actually being asked — which heading
did I last scroll past — at every scroll position, including the ones where nothing
intersects.
requestAnimationFrame does not fire in a tab the compositor is not
painting — a background tab, a minimised or occluded window, some embedded webviews.
A scroll handler throttled through rAF therefore parks with a pending frame that never
arrives, and comes back stale, or never comes back at all if the pending flag is only cleared
inside the callback. Throttle with a timer instead — a trailing setTimeout of
about a tenth of a second is the same throttle in practice and keeps working regardless — and
run one unconditional update on visibilitychange.
| Attribute or class | Values | Does |
|---|---|---|
data-variant | filled |
On .scrollspy. Omitted is the default, which needs no rule of its own —
the base locals are it. filled re-points three locals and rounds all four
corners of the link. |
aria-current="true" | on .scrollspy__link |
The active link, and the only state this file styles. Exactly one at a time; remove
the attribute from the others rather than setting "false" — "false"
is valid and means "not current", so it is not wrong, but the selector does not match it
and a nav full of dead attributes is noise. |
aria-disabled="true" | on the link | A section that is not currently reachable. Dims it and suppresses both the hover and
the press, while leaving it focusable — never remove the href. |
role="list" |
on .scrollspy__list and .scrollspy__group |
Required, not optional. This file sets list-style: none,
and some screen readers stop announcing list semantics the moment a list loses its
markers. |
.scrollspy__group | — | One level of nesting, indented by a rule plus padding. One level only, deliberately. |
hidden |
on the nav, the list, a group or a link | Hides it. Each selector is restated with [hidden] attached, for the reason
recorded in the Tabs section. |
| Property | Default | Controls |
|---|---|---|
--scrollspy-inset |
var(--space-6) |
inset-block-start — how far below the top of the scrollport the nav
sticks. |
--scrollspy-gutter |
var(--space-8) |
Subtracted from 100dvb for the nav's own max block size, so it never runs
edge to edge. |
--scrollspy-spine |
var(--color-border-subtle) |
The nesting rule on .scrollspy__group. Decorative, so the 3:1 bar does
not apply. |
--scrollspy-spine-width |
var(--border-width-1) |
Its thickness. |
--scrollspy-link-fg |
var(--color-text-muted) |
Resting label colour. |
--scrollspy-link-fg-hover |
var(--color-text) |
Hover label colour. |
--scrollspy-link-fg-current |
var(--color-brand-text) |
Active label colour. filled flips it to
--color-brand-on-solid. |
--scrollspy-link-bg | transparent |
Resting fill. |
--scrollspy-link-bg-hover |
var(--color-surface-hover) |
Hover fill. Derived from --color-surface, which is what a sidebar nav
sits on. Inside a dialog or a card, re-point this to
--color-surface-raised-hover: in dark mode the raised surface is a full
step lighter, so a hover derived from the wrong ground moves darker when it
should move lighter. |
--scrollspy-link-bg-active |
var(--color-surface-active) |
Press fill — the next step along the same axis as the hover, so rest → hover → press
moves in one direction. On a current link it is re-pointed to that link's own fill
taken by --state-hover-amount, so a press deepens the current fill instead
of replacing it with a neutral. |
--scrollspy-link-bg-current |
var(--color-brand-subtle) |
Active fill. 1.26:1 light / 1.21:1 dark against --color-bg — a real
luminance step, but not a 3:1 one, and 1.00:1 against --color-surface in
dark. See the warning above. filled re-points it to
--color-brand-solid. |
--scrollspy-link-min-block-size |
var(--size-touch) |
A minimum, never a block size: this is tapped on the phone, and a wrapped two-line 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. |
<nav> needs an accessible name of its own, via
aria-labelledby pointing at a visually hidden heading or via
aria-label. A page with two unnamed navs gives the user two indistinguishable
"navigation" landmarks.role="list" on both the list and any nested group is required, not decoration.
reset.css only strips markers where role="list" has already made the
semantics explicit, and this file makes the same bargain — some screen readers stop announcing
list semantics the moment a list loses its markers.aria-current="true" on exactly one link and remove the attribute
from the rest. Do not set aria-current="false": it is a valid value meaning "not
current", so it is not wrong, but the selector does not match it and a nav full of dead
attributes is noise.--scrollspy-link-bg-current is
--color-brand-subtle, which measures 1.26:1 light / 1.21:1 dark against the
non-current sibling's ground — real, but under 3:1, and 1.00:1 on --color-surface
in dark. See the warning above for the full numbers. Where the current section has to be
findable at a glance, or where the block itself must clear 3:1, re-point that local or use
data-variant="filled".:hover
in the package sits inside @media (hover: hover), which never matches a touch
screen; :active never does, because it is the feedback a touch user gets
instead of hover, and reset.css suppresses the OS tap highlight. Without
it a tapped link would acknowledge nothing at all. The current link presses too — it is the
one a reader is most likely to tap — and only its fill moves, so
--weight-semibold still carries "current" in greyscale while the press colour is
on it.link.scrollIntoView({ block: "nearest" }). The nav scrolls itself, so
the current item can otherwise sit off its edge with nothing to show the reader where they
are.text-wrap: pretty and
overflow-wrap: break-word, with no nowrap, no ellipsis and no line
clamp. That wrapping is also what forces the nesting treatment to be a rule rather than an
indent.base.css underlines every <a>; the link removes it, because
inside a nav list the underline turns the column into a block of rules and buries the active
link in them.outline-offset so it is not
clipped by the nav's own overflow-y: auto. The carousel's slides and dots do the
same thing against the scrollport for the same reason.Figures that have to be read as money: a ledger column, an account balance, an order total,
an amount input. Everything on this page below is formatted live in your browser
by Intl.NumberFormat — the numbers are real output, not strings typed into the
markup, and they will change if you open this page under a different locale.
CSS cannot insert a thousands separator and cannot round a decimal. Those
are string operations, and @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.
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.
1. currencyDisplay defaults to "symbol", which for MNT
renders the ISO code, not the glyph. Measured on this machine:
| Option | Output |
|---|---|
currencyDisplay: 'symbol' (default) | MNT 1,250,000.5 |
currencyDisplay: 'code' | MNT 1,250,000.5 |
currencyDisplay: 'narrowSymbol' | ₮1,250,000.5 ← the one you want |
currencySign: 'accounting' | (MNT 98,765.4) — parentheses for negatives |
2. 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 driftIn this browser, right now: checking…
The root is a native <data> element. Its value attribute holds
the machine-readable number and the text node holds the formatted string — so the raw figure
survives copy-paste, scraping and testing, and the two can never drift because one is derived
from the other at render time.
1250000
1250000.5
1250000.456
0
-98765.4
<data class="money" value="1250000.5">₮1,250,000.5</data>
<!-- the text node is whatever Intl produced; value stays the raw number -->
<data class="money" data-sign="negative" value="-98765.4">(₮98,765.4)</data>data-sign takes positive, negative or zero
and colours the figure from the success, danger and muted text roles.
A negative figure distinguished only by being red is invisible to the most common
form of colour blindness, and invisible in a printed 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.
+₮1,250,000
(₮98,765.4)
₮0
+₮1,250,000
(₮98,765.4)
₮0
A table cell marked data-numeric already right-aligns and switches on tabular
figures — that lives in base.css, so a plain server-rendered table gets it without
knowing this component exists. .money composes on top for the sign and the
currency part.
| Огноо | Гүйлгээ | Дүн |
|---|---|---|
| 2026-08-24 | Шинэ захиалга үүсгэх | +₮1,250,000 |
| 2026-08-25 | Гуйвуулга баталгаажлаа | (₮98,765.4) |
| 2026-08-26 | Төлбөрийн мэдээлэл амжилттай шинэчлэгдлээ. | ₮0 |
| 2026-08-27 | Дансны үлдэгдэл | +₮1,151,234.6 |
Tabular figures align on the right edge, which is the same as aligning on the decimal mark
only while every row has the same number of decimal places. "Two decimals only when needed"
guarantees they do not. data-align="decimal" reserves the minor-unit space so the
marks line up anyway.
Inside a decimal-aligned column the application must emit a
.money__decimals element on every figure — empty when there are no
minor units — because CSS cannot synthesise an element that is not there. The alternative
was to pad every row to two decimals, which was rejected: the tögrög has no circulating
subunit, so .00 on every ledger row implies a precision the settlement does not
have.
The input root carries both classes, class="field money-field" — it composes
.field rather than rebuilding the label, affix group, focus ring and error
wiring, all of which are the parts most likely to drift on the accessibility details.
2 минутын өмнө шинэчлэгдсэн
<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>type="number"
It looks right and is wrong for money. It renders spinners nobody wants on a currency
amount, its parsing is locale-hostile, it silently rejects a pasted value that
contains grouping separators — which is exactly what a user copies out of a spreadsheet —
and valueAsNumber hands back a float. type="text" with
inputmode="decimal" gives the numeric keypad on the phone without any of that.
The ₮ lives in a .field__prefix, outside the editable value, so it can never be
selected, deleted or submitted.
| Attribute or class | Values | Does |
|---|---|---|
data-sign | positive, negative, zero |
Colours the figure. Must be paired with a sign character in the markup. |
data-size | sm, lg | Type step only. |
data-emphasis | strong, quiet |
A total row, or a secondary figure. Declared before data-sign on purpose so
sign wins the colour — sign is information, quiet is decoration. Do not reorder. |
data-align | decimal |
Reserves minor-unit space so mixed-precision rows align on the mark. |
.money__amount | — | 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. |
data-sign is reinforcement.<data value> keeps the machine-readable figure beside the human one. A
screen reader announces the text; anything parsing the page gets the number.aria-hidden and lives in the prefix, so it
is not read as part of the value. The field's own label says what the amount is for.blur, keep the raw value in a data- attribute or a hidden input,
and re-parse from that rather than from the displayed string.font-variant-numeric feature, not a font swap — the
system stack keeps its own shapes and only the advance widths are equalised, so the column
aligns without the figures changing character.A single, well-known accessibility pattern: an off-screen link that becomes visible on
keyboard focus and jumps past repeated navigation straight to the page's main content. No
parts — a single-purpose control, like .badge or .progress at
their simplest.
There is no live preview here. At rest the link sits transform: translateY(-200%)
off-screen, so a box that rendered it inline would show nothing — an empty box is not a
demonstration of anything, and forcing it permanently visible would misrepresent a component
whose entire job is to stay out of the way until it is needed. The markup, the same way
.card__media above gets a bare code sample instead of a live one:
<a class="skip-link" href="#main">Skip to content</a>You already have the means to. This very page ships one: it is the first focusable
element in <body>, immediately before the masthead. Press
Tab once from the top of this page — or Shift+Tab back to
it from anywhere else — and it drops into view reading "Skip to content". Activate it and
focus moves to <main id="main">, past the entire sidebar.
Must be the first focusable element in <body>, and its target id must
exist on the landmark it skips to — typically <main id="main">. The link
text is a short phrase sized to its own content, with no fixed width anywhere in the rule, so
a longer Cyrillic translation only makes the pill wider; there is nothing here for it to
clip against.
| Property | Default | Controls |
|---|---|---|
--skip-link-bg | var(--color-surface) | Fill once focused. |
--skip-link-fg | var(--color-brand-text) | Text colour. |
--skip-link-border | var(--color-border) | Edge. |
--skip-link-radius | var(--radius-md) | Corner radius. |
--skip-link-padding-block | var(--space-3) | Block padding. |
--skip-link-padding-inline | var(--space-4) | Inline padding. |
--skip-link-inset-block | var(--space-3) | Distance from the block-start edge once visible. |
--skip-link-inset-inline | var(--space-3) | Distance from the inline-start edge once visible. |
--skip-link-z | var(--z-tooltip) | Stacking, so it clears whatever else sits at the top of the page. |
:hover and deliberately no :active. This is
a keyboard-only surface: it sits off-screen until it receives focus, so no pointer or touch
user ever reaches it at rest to tap or press it in the first place — there is nothing here
that is ever "pressed". That matters more than it used to, now that
src/reset.css sets -webkit-tap-highlight-color: transparent
globally on html: any pressable surface that skips :active gets
no touch feedback at all. Here that omission is correct, not an oversight.:focus-visible is unconditional, not gated behind
@media (hover: hover) the way every :hover rule in this package
is. The guard exists to stop a :hover rule from latching onto a touch tap;
this file has no :hover rule for it to latch onto, so the guard has nothing to
protect against.transform: translateY(-200%), not a positional inset and not
visibility or display. The link keeps its box, so it stays in
focus order and reachable by assistive technology at every moment — only its paint
position moves on focus.The page header: a brand mark, a slot for whatever navigation or trailing actions the page
needs, and a spacer that pushes trailing content to the inline end. The one primitive every
top-level page in this package wants — the docs site you are reading and
examples/winsome.html both use it as-is.
<header class="masthead">
<a class="masthead__brand" href="#top">Brand</a>
<!-- Page-local nav classes and gap — .masthead does not style the links. -->
<nav class="u-flex u-gap-5" aria-label="Sections">
<a href="#balance">Balance</a>
<a href="#settings" 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.
position: sticky sticks to the nearest ancestor scroll container, not to the
page — see the sticky trap under Scrollspy for the full mechanism.
.example on this page carries overflow: hidden, so the masthead
above is sticky within its own example box and never within the page, by the same accident
as the two scrollspy demos. On the real page you are reading right now the masthead
is the one that is actually stuck: scroll, and it stays put, because nothing
between it and the document clips or scrolls independently.
| Property | Default | Controls |
|---|---|---|
--masthead-bg | var(--color-surface) | Fill. |
--masthead-fg | var(--color-text) | Text colour, inherited by .masthead__brand. |
--masthead-border | var(--color-border-subtle) | Block-end edge. |
--masthead-padding-block | var(--space-4) | Block padding. |
--masthead-padding-inline | var(--space-6) | Inline padding. |
--masthead-gap | var(--space-4) | Space between the brand, the nav and trailing content. |
--masthead-min-block-size | var(--space-9) | Floor, not a fixed height — a wrapped nav or a two-line brand grows past it. |
--masthead-z | var(--z-sticky) | Stacking while sticky. |
gap that adjacent taps stay separated by
space instead.min-block-size, never a fixed block-size: a two-line Cyrillic
brand or an overflowing nav grows the header rather than being cut off. Measured against a
masthead carrying long Mongolian nav labels, the nav block grows from about 27px for one
row to 106px wrapped, with zero overflow at a 320px viewport.position from unlayered application CSS,
which already beats every layer in this package..masthead does not style the nav's own links or a theme toggle placed in the
trailing slot — colour, hover state and gap for whatever sits in the row past the spacer
stay page-local CSS. It supplies only the row, the brand slot and the spacer.A responsive grid of self-contained comparison panels — most often 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.
Create a new project
Шинэ төсөл үүсгэх
Create a new project
Шинэ төсөл үүсгэх
<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.
| Property | Default | Controls |
|---|---|---|
--duo-min | var(--size-card-min) | Column width below which the grid drops to one column. |
--duo-gap | var(--space-5) | Space between panels. |
| Property | Default | Controls |
|---|---|---|
--duo-padding | var(--space-5) | Panel padding. |
--duo-radius | var(--radius-lg) | Corner radius. |
--duo-border | var(--color-border-subtle) | Edge. |
--duo-bg | var(--color-bg) | Fill. |
--duo-fg | var(--color-text) | Text colour. |
.duo__panel class is not optional. A bare child of
.duo gets no padding, border or background from this component — see the
markup contract above. This is the one thing every consumer of the old docs-only
.duo had to be updated for when it moved into the package.data-theme on a panel is not decoration — it invokes the package's real
subtree theming, so a panel marked data-theme="dark" genuinely resolves dark
tokens for its own background and text regardless of the page's own theme. It is not a
colour swatch standing in for the real thing..duo__label is a plain <span>, not a heading — it names
the panel visually (Light, Dark, a language name) but carries no landmark or heading
semantics of its own. Where the panels need to be findable in a screen reader's heading
list, wrap each panel's content in its own heading rather than relying on the label.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 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.
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 →