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.
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
| Token | Value | Used by | What 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.
| Token | Value | Applied to | When 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.
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.
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.
--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:
| Selector | Guarded? | Why |
|---|---|---|
:hover | Yes | Latches on touch. This is the entire bug. |
:active | No | This 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-visible | No | Keyboard reachability is not a pointer question. A device with a touch screen and a keyboard is common, not exotic. |
:disabled, [aria-disabled] | No | A state of the control, not of the pointer. |
[aria-expanded], [aria-pressed], [aria-current] | No | Application state. Nothing to do with input hardware. |
--*-bg-hover and friends | No | The 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.
(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.
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.
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.
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.
| Unit | Measures | Reach 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. |
svb | The 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. |
dvb | The 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.
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.
- Test at 320px, not 375px. 375px is the iPhone SE and it hides a class of
overflow that 320px catches. Resize to 320 and look for a horizontal scrollbar on the
page — the ones on
.table-wrapand a scrolling tab strip are meant to be there. - Look at it after the Mongolian string wrapped, not before. Paste Хэрэглэгчийн тохиргоо in place of your English label and re-check. A layout that is correct at 320px in English has not been tested at 320px.
- Measure the tap target after the label wrapped.
. A control that clears 44px because of a fixed height is not passing — it is clipping. Check the computed block size in devtools, on the wrapped state. - Tap it on a real touch screen and then look at it. If any element is still
painted in its hover colour after your finger left, a
:hoverescaped the@media (hover: hover)guard. Devtools touch emulation reproduces this. - Tap it again and check that something happened. The platform tap
highlight is suppressed package-wide (see above), so a new
pressable surface with no
:activeof its own now acknowledges a tap with nothing at all. A guarded:hoverdoes not count — it never matches on touch. Every control you add needs a press state, and it must not be inside the guard. - Check the bottom sheet on a device with a home indicator. The footer buttons must be fully above it and fully tappable — the top half of a button clearing the indicator is not enough, because the indicator swallows the touch, not the pixels.
- Any fixed edge-anchored chrome of your own adds the safe tokens. A bottom
bar, a sticky header, a floating action button — each needs
calc(var(--your-gutter) + var(--safe-block-end)), added and not substituted. And the page needsviewport-fit=coverfor any of it to do anything. - Grep the diff for the forbidden three.
white-space: nowrap,text-overflow,line-clamp. Each one turns a bilingual layout problem into an invisible data-loss problem, and each one looks fine in the English review. - Grep it for a breakpoint. A
@media (max-width: …)in a component is the signal that amin()guard or a container query was the actual answer. In your own app CSS it may well be right; in anything shaped like a reusable component it is a guess.