Changing content
When a line or a small box changes (a hint, an error, a label, a pressed chip, a count), nothing around it moves, even right after a click. Its place is kept for its longest form before it is needed.
A few pixels is enough to make a page feel like it pops. Google's layout shift score forgives a shift in the half second after a click or a key; v2 does not, because that is exactly when someone is looking.
This is about small changes in place. Opening a section, a drawer or a side panel is a change someone asked for, and it may move the page.
Hints, errors and status lines
The place under a control is always there, empty or not, and as tall as its longest message: two lines for a hint or an error (--ds-slot-hint), one for a status line (--ds-slot-status). Both count lines of the text they hold, so they follow its size in every context.
Write the message to fit in two lines at the narrowest width the field appears. A longer explanation belongs in a popover, not under the control.
In a tool's compact panels (Contexts › Density) the place is one line, so the message is a few words. A field there that never has anything to say leaves the place out.
A place that is always there is also the right home for role="alert" or aria-live: a live region that exists before its message is read reliably, and one added with its message often is not.
Do
That code has ended.
Don't
That code has ended.
Do: the place under the field is there before the error, so Join holds still. Don't: the error is added, and pushes Join down.
.field-error {
min-height: var(--ds-slot-hint); /* two lines, there before any error */
}
<input class="ds-input" id="code" aria-describedby="code-error">
<p class="field-error" id="code-error" role="alert"></p>
Labels that change
A button that goes from Start to Stop, or from Copy to Copied, and a tag that goes from Idle to Running, hold every label in a .ds-hold. The held place is as wide as the widest label, so the button or tag keeps one width and nothing beside it moves. Show one label and hide the rest with hidden; inside a held place a hidden label keeps its space and is not read.
Do
IdleRunningDon't
IdleRunningDo: each label is held, so the button and the tag keep one width. Don't: the text is swapped, and Reset moves.
<button class="ds-button" type="button">
<span class="ds-hold">
<span>Copy</span>
<span hidden>Copied</span>
</span>
</button>
Pressed and current
A state changes color, never weight or size. A semibold label is wider than a regular one, so a chip that turns semibold when pressed moves every chip after it. A pressed chip is ink, as every selected state is.
Where a current item is semibold (the rail, the top bar's areas), keep its words to one line, so the heavier weight cannot wrap it.
Do
Don't
Do: pressed turns the chip ink. Don't: pressed turns it semibold, which is wider, and the chips after it move.
Things that come and go
A Retry button that appears after a failure, a "Saving…" beside a button, an icon that shows a state: each keeps its space while it is hidden. Put it in a .ds-hold on its own and hide it with hidden; it keeps its place, cannot be reached by Tab, and is not read.
Never hide one with opacity: 0, which leaves it focusable and read aloud.
Do
Don't
Do: Retry's place is kept while it is hidden. Don't: Retry appears from nowhere, and Cancel moves.
<span class="ds-hold"> <button class="ds-button" type="button" hidden>Retry</button> </span>
Numbers that update
IBM Plex Sans and IBM Plex Mono already draw every figure the same width, so a value set in either keeps its width when its digits change. Archivo's figures are proportional: a number set in Archivo that updates takes font-variant-numeric: tabular-nums.
A number that can gain a digit holds its widest form too: a hidden "8,888" in a .ds-hold ds-hold--end beside the live value, so its last figure stays put. A number in a table or beside a bar gets a column wide enough for its longest value.
Do
9848,888 tokens
Don't
984 tokens
Do: tabular figures, with the widest form held. Don't: proportional figures, nothing held, and the word after the number moves.
.count {
font-family: var(--ds-font-display);
font-variant-numeric: tabular-nums;
}
<span class="count ds-hold ds-hold--end"><span>984</span><span hidden>8,888</span></span> tokens
The page itself
- The scrollbar: v2 keeps its room while a page is too short to scroll (
scrollbar-gutter: stableonhtml), so a page that starts to scroll never jumps sideways. Nothing to do; overlay scrollbars take no room. - Images and video carry
widthandheight, or anaspect-ratio, so their box is there before the file arrives. - A small value that is still loading holds its line: its place is kept, empty or with a dash, until the value arrives.
How it is checked
By measuring, not by eye. npm run test:ui-smoke runs scripts/test-stability.mjs, which records the box of every element on the page, makes one change, records them again and compares, to a hundredth of a pixel, at 1280 and 375 px wide.
It makes every change on this page: around each "Do" the page must hold still, and each "Don't" must move, which proves the check can see a shift. It also presses every control on Components › Controls, switches light and dark, marks each section in "On this page", steps the presentation bar through every slide, and lets a short page start to scroll.