@arag 0.1.0

Responsive & touch

This package ships zero viewport breakpoints and fits a 320px phone anyway. That is not restraint for its own sake — a viewport media query asks a question a component cannot answer. This page is what the system does instead, and where it hands the problem back to you on purpose.

The position

There is not one width-based media query in the shipped package. That is greppable, and it holds today:

# the only @media in src/ are (hover: hover) and (prefers-reduced-motion: …)
grep -rn '@media (' src/ | grep -vE 'hover: hover|prefers-reduced-motion'

The trailing ( is not decoration. Without it the command also matches the word @media where it appears inside prose comments — this package explains its own media queries in the files that use them, so a bare grep returns those explanations and makes a clean result look dirty. Match the opening paren of the condition and you match rules only.

Measured in a real browser at 320px and at 375px, across the home page and every page in these docs: no horizontal overflow anywhere, outside the two places that scroll on purpose — .table-wrap and the opt-in .tabs[data-overflow="scroll"] strip, both of which are declared overflow-x: auto because a ledger genuinely does not fit on a phone and scrolling it is the correct answer.

The argument for having no breakpoints is not that breakpoints are bad CSS. It is that a package cannot write a useful one. A viewport media query asks how wide is the screen. A component does not need to know that and cannot act on it correctly: what it needs to know is how much room was I given. Those two numbers are the same only in the one case where the component is the full width of the page, and every other case — a card inside a sidebar, a field row inside a dialog, a tab strip inside a kiosk panel — is a case where the viewport is wide and the component is not.

So the package ships nothing that depends on the viewport, and everything below is what falls out of that decision. Each thing sizes to its own content and to the container it was handed.

This is not "mobile first"

Mobile first is still breakpoint thinking — it just picks a different default and counts up. The position here is that there is no breakpoint at all, in either direction, because there is no width at which the correct layout changes for reasons the component can see. When a shape genuinely must change, the answer is a container query and it belongs in your CSS, not in the package. See When shape must change.

Intrinsic reflow, not breakpoints

One idiom does most of the work. Both reflowing grids in the package are the same shape, and the interesting part is the min():

/* src/components/field.css — .field-row */
--field-row-min: var(--size-column-min);

display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(var(--field-row-min), 100%), 1fr));
gap: var(--field-row-gap);
align-items: start;
/* src/components/card.css — .card-grid */
--card-grid-min: var(--size-card-min);

grid-template-columns: repeat(auto-fill, minmax(min(var(--card-grid-min), 100%), 1fr));

Why min(…, 100%) is load-bearing

A bare minmax(18rem, 1fr) declares a track floor that the grid is not allowed to go below. It is a floor, not a suggestion: if the container is narrower than the floor, the track stays at the floor and the grid overflows its container. --size-card-min is — 288px at a 16px root — and a 320px phone does not have 288px of usable inline size left once the page's own gutters are taken out of it. The grid overflows, horizontally, on the narrowest device in the fleet.

min(var(--card-grid-min), 100%) fixes that by making the floor conditional: the track wants 18rem, but never more than the full inline size of its container. On a wide container min() resolves to 18rem and the grid behaves exactly as the bare version would. On a narrow one it resolves to 100%, the column gives up and takes the whole row, and the grid stacks. That is the entire reflow mechanism, and it is a container-relative behaviour with no media query anywhere near it.

.field-row uses auto-fit and .card-grid uses auto-fill. That difference is deliberate rather than an inconsistency: a pair of fields should stretch to fill the row when there are only two of them, while a card grid should keep an empty track rather than blowing one card up to the full width of the page.

Two column tokens, not one

The two minimum-column tokens, and what each one is a minimum for.
TokenValueUsed byWhat it is sized against
--size-column-min .field-row, .u-grid-auto A form column: one label, one control, one help or error line. It has to hold a Mongolian label on two lines and a control that is still usable, and nothing wider.
--size-card-min .card-grid, .u-grid-cards A card column: a title, a paragraph of body copy and an action row. Prose wants a wider measure than a single control does, so it collapses later.

They are two tokens because they are two different judgments about the same axis. Collapsing them into one would mean either cards stacking earlier than they need to, or form columns staying side by side at a width where a wrapped Cyrillic label makes them unreadable. tokens.css says so at the declaration — before these existed both components were deriving the number by halving a container size, which is how you end up with a card column sized by an argument about page width.

Fluid type does the same job for the type scale

All seven steps of the type scale are clamp() with a vi slope, so the scale moves continuously with the viewport rather than jumping at a breakpoint. The slopes are derived, not eyeballed — tokens.css carries the formula:

/* Fluid type. 7 steps, clamp()-driven, anchored at 20rem and 96rem.
   Slopes are derived, not eyeballed: for a 16px root, 1vi = V/1600 rem, so
   hitting min at 320px and max at 1536px means
     slope = (max - min) / 0.76      intercept = min - 0.2 * slope
   … vi (inline viewport) rather than vw, so the scale stays correct if a
   consumer ever switches writing-mode. */
--text-base: clamp(1rem, 0.9671rem + 0.1645vi, 1.125rem);

The anchors are 320px and 1536px, which is the same 320px floor the layout is tested at — the scale bottoms out exactly where the narrowest supported device is, not somewhere above it. An earlier set of slopes topped out around 1230px, which meant the scale went flat two thirds of the way to the --size-container-2xl ceiling and the largest displays got type sized for a laptop. If you retune a min or a max, rerun both formulas.

Right now, in this browser at this window size: for body copy and at the top of the scale.

The bilingual rule does double duty

The narrow-screen behaviour a layout needs and the behaviour a Cyrillic translation needs are the same behaviour. Because nothing in this package may truncate, clamp lines or set white-space: nowrap — see Bilingual & Cyrillic for why — every label in the system already grows to a second line instead of clipping, and every control is already a min-block-size rather than a height. A component built to survive Хэрэглэгчийн тохиргоо where the design said User settings is a component that has already survived a 320px screen. The two constraints were not solved twice.

Touch targets

Two size tokens, and the difference between them is the whole of the policy.

The two control-height tokens.
TokenValueApplied toWhen it is right
--size-touch — 44px base.css on every button and input[type="button"|"submit"|"reset"]; .button, .tabs__tab, .menu__item, the .collapse trigger and .scrollspy links via their own locals The default. Anything a finger is expected to hit.
--size-control — 40px base.css on text inputs and select — and on a textarea only when it carries an explicit rows; a default textarea floors at instead, restated in base.css so it beats the shared control rule rather than shrinking a free-text Mongolian field to two lines. Also what data-size="sm" and data-variant="link" resolve to on .button, and what .tabs[data-size="sm"] resolves to Denser desktop and pointer-first surfaces, and text fields — which are targeted by a caret rather than tapped once, and which grow past 40px the moment their content needs the room.

Be honest about the standard: WCAG 2.2 SC 2.5.8 Target Size (Minimum) is a level AA criterion and it asks for 24 by 24 CSS pixels. 40px clears that with room to spare, and 44px clears it by nearly double. The 44px floor in this package is our rule, taken from the platform touch guidance, not a legal minimum — which matters, because it means data-size="sm" at 40px is not an accessibility failure and does not need to be treated as one. It is a density decision — and it now only holds where the pointer is precise enough to make it:

/* src/components/button.css */
.button[data-size="sm"] {
  --button-min-block-size: var(--size-control); /* 40px */
}

@media (pointer: coarse) {
  .button[data-size="sm"] {
    --button-min-block-size: var(--size-touch); /* 44px, restored */
  }
}

(pointer: coarse) reads the accuracy of the primary pointer, not whether a touchscreen merely exists — a mouse or trackpad keeps the 40px density, and a stylus, which is pointer: fine with no hover, correctly keeps it too. A finger is the case that cannot reliably land on 40px, and that is the case this restores the floor for. The question to ask about any given sm instance is still whether it will be tapped or clicked; the difference is that the button now answers that question itself instead of asking the author to remember it.

What the floor covers

The 44px floor is a policy for controls — a button, a dismiss control, a tab, a menu item: anything whose whole job is to be pressed. It is not a claim about every tappable thing on a page. An inline link sitting in a sentence or in a list of links — the sidebar you are reading this in, a footer, a paragraph of prose — is sized by its text, not by , and that is not an oversight: WCAG 2.5.8 exempts a target that is "in a sentence or block of text" from the Minimum criterion by name, because inflating a sentence's own words into 44px boxes is not a coherent request. A link row like this one relies on its own block padding and the spacing between rows to keep taps apart, not on each row filling a 44px box. Measure a link against this floor and it will read as a failure that it was never meant to pass — measure the row's padded hit area instead, or measure a button.

A floor, never a height

Every one of these is applied as min-block-size. Never height, never block-size. A fixed 44px control that is handed a two-line Mongolian label does not become 44px of legible button — it becomes a 44px box with its second line cut off, and it looks correct in the English review. The floor guarantees a minimum; the content decides the rest.

A press that dips under AA, on purpose

--color-text-muted on --color-surface-active measures 4.12:1 in dark mode — under WCAG 1.4.3's 4.5:1 for normal text. That is .button[data-variant="quiet"]'s own press fill, and the same pairing recurs in field.css, tabs.css and scrollspy.css: it is the package's standard quiet press, not an isolated miss, and it is left as it is. WCAG 1.4.3 governs text that is being read, and a press state exists only for as long as a finger is down — nobody reads a label while it is being pressed, so the criterion that fails does not describe the moment the failing value is on screen. Retuning --state-active-amount to fix it would move every :active fill in the package at once and require re-measuring all of them, for a state nobody reads. That cost is not being paid for this one. This is a statement about this one pairing, argued on its own terms — not a claim that every state in the package clears AA.

The dismiss controls are full size

The dismiss buttons on .alert, .dialog and .toast compose .button without data-size="sm", so they take the full --size-touch floor:

<button class="button alert__dismiss" data-variant="quiet" data-icon-only
        type="button" aria-label="Хаах"></button>

A small icon-only × in the corner is the conventional shape and it is the wrong one here. On touch, the dismiss control is the primary exit from an alert, a sheet or a toast — it is not a secondary affordance next to a bigger action, it is the thing the user reaches for first and, on a toast, the only thing they can reach at all. Shrinking the one control that has to be hit under time pressure, on the smallest screen, is exactly backwards. Note also the aria-label: it is translated with the rest of the UI, because a glyph is not an accessible name.

Hover is not a touch state

Every :hover in the package is behind @media (hover: hover). There are no exceptions, and it is worth knowing precisely which bug that fixes, because the guard looks like superstition until you have seen it.

On a touch screen there is no pointer to leave. The OS reports a tap as press-then-release with no persistent pointer position afterwards — but the :hover selector still matches on the tap, and the browser then has nowhere to move the hover state to. So it latches: the hover background stays painted on the tapped element until the user taps something else. The user taps a card, navigates, comes back, and one card in the grid is a different colour than the rest for no reason they can see. It reads as a selection state that they cannot clear.

/* src/components/button.css — the pattern, on the base rule's own locals */
.button {
  --button-bg: var(--color-surface);
  --button-bg-hover: var(--color-surface-hover);
  --button-bg-active: var(--color-surface-active);

  background-color: var(--button-bg);

  @media (hover: hover) {
    &:hover {
      background-color: var(--button-bg-hover);
      border-color: var(--button-border-hover);
    }
  }

  &:active {
    background-color: var(--button-bg-active);
  }
}

Wrapping the rule in @media rather than deleting it is the whole point, and it is safe here for a specific reason: @media nests inside @layer without changing specificity or layer order. The declarations inside the guard win or lose against everything else in exactly the same way they did outside it. The guard gates whether the block is ever reached; it cannot change which declaration wins when it is. That is what makes this a free addition rather than a cascade puzzle — and it is why the pattern can be applied file-wide without auditing every override.

What is deliberately not guarded:

Guarded and unguarded state selectors, and why.
SelectorGuarded?Why
:hoverYesLatches on touch. This is the entire bug.
:activeNoThis is the only press feedback a touch user gets. Guarding it would leave a tap with no visual response at all — the worst outcome of the set.
:focus-visibleNoKeyboard reachability is not a pointer question. A device with a touch screen and a keyboard is common, not exotic.
:disabled, [aria-disabled]NoA state of the control, not of the pointer.
[aria-expanded], [aria-pressed], [aria-current]NoApplication state. Nothing to do with input hardware.
--*-bg-hover and friendsNoThe custom property declarations stay in the base rule and in the variants. Only the :hover selector block moves inside the guard.

That last row is the one to get right when you write a variant. A variant re-points --button-bg-hover in its own flat block — no guard — and the single guarded :hover rule in the base picks it up. If you put the custom property declaration inside the media query instead, the token is undefined on touch and var(--button-bg-hover) falls back to nothing, which is a different bug in a harder place to find. Guard the selector, never the value.

Testing this

(hover: hover) reports the primary pointer. A laptop with a touch screen usually reports hover: hover and gets the hover styles, which is correct — the mouse is the primary device. Desktop devtools device emulation does change this media feature, so the guard is testable without a phone; a plain narrow browser window is not, because the mouse is still the primary pointer.

The tap highlight is suppressed — after, not before

WebKit and Blink paint their own press feedback on touch: a translucent block over the element's bounding box, in a colour nobody in this package chose, in the shape of the box rather than the shape of the control. It squares off a pill button, it overshoots a border-radius, and on a control whose own :active is a considered colour it lands on top of that colour and wins. So it goes:

/* src/reset.css — one declaration, on the document element */
html {
  -webkit-tap-highlight-color: transparent;
}

Three deliberate choices are packed into those three lines.

In reset.css, not base.css. That file's rule is that it only removes a default the browser added and never puts a colour in its place, and this passes on both counts — it deletes an overlay and adds nothing. It also needs no token, so the file's token-free constraint is not bent: transparent is one of the bare literals every file here is allowed. It sits beside -webkit-text-size-adjust, which is the same kind of declaration — a vendor-prefixed correction to a document default, on the document element.

On html, not on * and not on a list of controls. The property inherits. One declaration at zero specificity therefore reaches every element in the document, including every component added later, with nothing to keep in sync — and a consumer who wants the highlight back for a subtree writes one unlayered rule, which already beats every layer in this package.

WebKit and Blink only, and that is fine. The property is non-standard. Firefox paints no tap highlight at all, so the declaration is inert there rather than broken, and there is nothing to fall back to.

Why this was blocked for a release

Suppressing the highlight removes press feedback. On any surface that has no :active of its own, it removes the only press feedback, because the :hover that surface does have is behind @media (hover: hover) and never matches on touch. An audit found more gaps than the original report named, so the states were written first: .carousel__dot got a press, and so did the text-control family — the bare input/textarea/select group in base.css, .field__control, and .field__group, which takes the press on behalf of the control nested inside it.

Still outstanding: .tabs__tab and .scrollspy__link have no :active, so on a touch screen they now acknowledge a tap with nothing at all. Each needs one rule in the shape the rest of the package uses. Until they have it, they are the known cost of the line above.

The dot is the interesting one, because a colour shift is a weak signal on an element that is one --space-3 disc under a fingertip that covers it whole. It presses on two channels: the fill continues past the hover step in the same direction, and the visible disc shrinks from 8px to 6px. The shrink costs no layout — the box is border-box with an explicit size, so widening the dot's already-declared transparent border eats into the content box that background-clip paints, and the row does not move. A transform or a change to --carousel-dot-size would have reflowed the row, and the second would also have fought the width that [aria-current] uses to mark the current dot.

Safe areas

Four tokens, all of the same shape:

--safe-block-start: env(safe-area-inset-top, 0px);
--safe-block-end: env(safe-area-inset-bottom, 0px);
--safe-inline-start: env(safe-area-inset-left, 0px);
--safe-inline-end: env(safe-area-inset-right, 0px);

They exist because three edge-anchored surfaces were rendering underneath hardware. The bottom-sheet dialog (.dialog[data-placement="bottom"]), the full-screen kiosk dialog (.dialog[data-size="full"]) and the bottom toast placements all anchor to the physical bottom edge of the screen — which on a modern phone is occupied by the iOS home indicator or the Android gesture bar. The sheet's footer buttons were sitting under both.

Added to a design gutter, wherever there is one

/* src/components/toast.css — a real gutter to add to */
inset-block-end: calc(var(--toast-region-inset) + var(--safe-block-end));

/* src/components/dialog.css — no gutter here, so it stands alone */
padding-block-end: var(--safe-block-end);

The design gutter and the hardware inset are two different quantities, and where both exist both apply. A toast that used --safe-block-end in place of --toast-region-inset would sit flush against the gesture bar on a notched phone and flush against the screen edge on everything else. Added, the toast keeps its designed gutter and the hardware pushes it further in only where the hardware exists.

The dialog is the other case. Its box carries no padding of its own — .dialog is padding: var(--space-0), because the padding lives on .dialog__header, __body and __footer so the header can stay put while the body scrolls. There is no gutter to add to, so the inset stands alone.

The 0px fallback is what makes either form safe everywhere. env() with no safe area resolves to the fallback, so the declaration computes to 0px and every desktop and unnotched target renders byte-identically to what it rendered before these tokens existed. The no-op comes from the fallback inside the token, not from any arithmetic around it.

Do not pad the calc with a zero token

Writing calc(var(--space-0) + var(--safe-block-end)) to make the addition look symmetrical does not work, and fails in the most expensive possible way. --space-0 is unitless 0, so that expression adds a <number> to a <length> — invalid at computed-value time, which drops the whole declaration. The value it falls back to is the same 0px the rule produces correctly on an unnotched device, so it looks right in every browser you are likely to have in front of you and does nothing on the one device it was written for. This was written in this package and caught before it shipped — by measurement, not by reading. If there is no gutter, use the token on its own.

The full-screen dialog takes all four, including the inline pair the bottom sheet never needs — a phone rotated to landscape puts the notch on an inline edge instead of a block one, and a dialog that owns all four screen edges has to answer for all four.

Your side of the deal

env() is permanently 0 unless the page opts in. It only ever returns a real inset when the document declares:

<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">

Without viewport-fit=cover the browser letterboxes the page inside the safe area itself and these four tokens stay at zero — harmless, and doing nothing. Once you add it, the letterboxing stops for your whole page, and every piece of edge-anchored chrome you wrote — a fixed bottom bar, a sticky header, a full-bleed hero — becomes your responsibility to inset. The package handles its own components. It cannot handle yours. The tokens are exported for exactly that: use the same four.

The one compromise

This is the single place in the package where a logical name is put on a physically-named value. env() only knows top, right, bottom and left; it has no concept of writing mode, and there is no logical form of it to use instead. So the mapping is hard-coded: block-start is top, inline-start is left.

That holds for both languages this system serves — Latin and Mongolian Cyrillic are both horizontal-tb and both LTR — and it is wrong for nothing that exists today. It would be wrong for a vertical writing mode, and wrong for an RTL target, and in either case these four declarations would need remapping. tokens.css carries a ponytail: comment saying exactly that, so the tradeoff is recorded where the person who hits it will be standing. Everywhere else in the package, logical properties are real logical properties.

Viewport units on mobile

The package uses dvb. Not vh, not vb, not svb. The reasoning is in dialog.css at the declaration:

/* dvb, not vb or svb. On mobile the URL bar shows and hides as the user
   scrolls. vb (== lvb) measures the viewport with the bar HIDDEN, so a tall
   dialog would tuck its footer underneath the bar while it is showing;
   svb measures it with the bar SHOWN, leaving a permanent dead strip once
   it hides. dvb tracks the live value, so the footer is always reachable. */
max-block-size: calc(100dvb - var(--space-7));

The problem vh gets wrong is that on a mobile browser the viewport is not one number. The URL bar and the toolbar slide away as the user scrolls down and come back as they scroll up, so the visible area changes by 50–120px during ordinary use while 100vh stays fixed at the largest of those states. A dialog sized 100vh is therefore taller than the screen for as long as the browser chrome is showing, and the bottom of it — which is where the confirm button is — is underneath the toolbar and unreachable.

The three block-axis viewport units, and what each one measures.
UnitMeasuresReach for it when
lvb (same as vb, and vh)The viewport with browser chrome hidden — the largest state.You want a background or a hero to cover the screen in every state and do not mind it being taller than the visible area.
svbThe viewport with browser chrome shown — the smallest state.Content must be fully visible at all times and must not reflow. Costs a permanent dead strip once the chrome hides.
dvbThe live value, changing as the chrome moves.Everything in this package: dialogs, the bottom sheet, the menu ceiling, the scrollspy rail.

The tradeoff people hit with dvb is real: because it tracks the live value, an element sized in it relayouts while the URL bar animates. For a dialog that is exactly right — its body is what scrolls, so a changing ceiling just means the scrollport resizes and the footer stays reachable throughout. For something that must not move under the user's finger while they scroll — a full-bleed background, a fixed-position element being dragged, a canvas — that reflow is a visible jitter, and svb or lvb is the better answer. Both are inside the browser floor.

Note the axis: dvb and not dvh, and vi and not vw in the type scale, for the same reason every property in the package is logical.

When shape must change

Sometimes reflow is not enough and the shape has to change: the label moves from beside the control to above it, a table becomes a list of cards, a horizontal row becomes a stack with a different reading order. The package provides no mechanism for that. That is a decision, and it is worth stating plainly rather than leaving it to be discovered.

Three reasons, in order of weight.

No component in the current set of sixteen needs one. Each of the cases that would normally want a shape change already has an answer that does not: the tab strip wraps to a second row by default and scrolls only on explicit opt-in; the table scrolls inside a labelled role="region" scrollport with tabindex="0", which is the right answer for a ledger on a phone; .field-row and .card-grid collapse to a stack on their own through auto-fit. A mechanism with no caller is speculative infrastructure.

A package cannot know the container it will be dropped into. Any breakpoint shipped inside @layer components is a guess about your layout, made by someone who has not seen it — and a guess that is expensive to be wrong about, because unwinding a shape change you did not ask for is harder than adding the one you wanted.

It is the house position, stated in the source. src/utilities.css puts it at the top of the file: if it cannot be named, it is not needed — and if it is not here, write the rule in your own CSS, which is unlayered and therefore beats all of this anyway. That is not a brush-off. It is the actual mechanism: consumer CSS sits outside all six layers and wins against every one of them, so the rule you write needs no specificity trick and no !important to land.

The answer: a container query

Container queries are comfortably inside this package's browser floor — Chrome/Edge 123+, Firefox 120+ and Safari 17.5+ all support them, and light-dark() sets a higher bar than any of them. Nothing in src/ uses one (grep -rn '@container' src/ returns nothing), and that is what leaves the door open for you to declare the containment where it belongs: on your wrapper, in your CSS.

A worked example. A settings form where each row is a label beside its control on a wide panel, and a label above its control once the panel is narrow — the case .field-row does not cover, because it stacks fields, not label-and-control:

<div class="settings-panel">
  <div class="field settings-row">
    <label class="field__label" for="acct">
      Account balance / <span lang="mn">Дансны үлдэгдэл</span>
    </label>
    <input class="field__control" id="acct" type="text">
  </div>
</div>
/* Your CSS. Unlayered, so it beats all six @arag layers with no trick. */

.settings-panel {
  /* Makes the panel a query container on the inline axis. Nothing else
     changes; inline-size containment has no layout side effects. */
  container-type: inline-size;
  container-name: settings;
}

/* Wide panel: label beside control, on a track sized to the longer of the
   two languages rather than a number picked from the English one. */
.settings-row {
  display: grid;
  grid-template-columns: minmax(min(14rem, 100%), auto) minmax(0, 1fr);
  gap: var(--space-5);
  align-items: start;
}

/* Narrow panel: label above. The query asks the PANEL how wide it is,
   not the window — so this fires in a 380px sidebar on a 1920px display,
   which is exactly when it should. */
@container settings (max-width: 34rem) {
  .settings-row {
    grid-template-columns: minmax(0, 1fr);
    gap: var(--space-3);
  }
}

Three things to notice. The containment goes on the wrapper, never on the element being queried — an element cannot query itself. The threshold is in rem, so it moves with the user's root font size instead of fighting it. And the query fires on the panel's width: drop that same panel into a 380px sidebar on a 2560px monitor and it takes the narrow shape, which is the correct outcome and the one a @media query gets backwards.

That is the argument in one line. A container query asks how much room did this component get. A viewport media query asks how big is the screen. The first question has an answer the component can act on; the second one does not.

Still no truncation

A shape change is not a licence to clip. Whatever new shape you switch to still has to hold a Cyrillic label on two lines, still uses min-block-size rather than a height, and still has no nowrap in it. Changing shape at a threshold is fine; changing shape because something was about to overflow is a sign the first shape had a fixed dimension in it.

A checklist

Run this against any new component or page before it lands. Every item is something that has actually shipped broken.