Draft saved locally
It will publish when you are back online.
Import failed on row 214
The region column was empty. Fix that row and upload the file again.
All 1,204 rows imported
Show codeHide code
src/feedback - 9 files
Four different things get confused with each other constantly. An alert sits inline next to the thing it is about. A banner is page level. A toast is transient. A modal interrupts. Choosing the wrong one is an accessibility defect, not a style preference, because the ARIA that goes with each is different.
Every component below is the running thing, not a picture of one. Press the controls under each example: the modal opens for real, toasts stack in a live region, the bar fills, the skeletons are replaced. The panel under each example is read out of the example itself, so the markup you copy is the markup that produced what you just operated.
The markup panels below are read out of the live examples by site.js. With scripting off the examples still render and the panels stay empty.
An inline message attached to the thing it is about: a failed field, a result, a caveat. The role decides whether the message interrupts, so it is not decoration.
Draft saved locally
It will publish when you are back online.
Import failed on row 214
The region column was empty. Fix that row and upload the file again.
All 1,204 rows imported
The three above are present at page load and carry no role at all. This one is different: the container is on the page from the start and empty, and the message is put into it when the reader does something. That is the only ordering a screen reader announces reliably, because the accessibility tree needs a live region to diff against before the content lands in it.
Empty until you press Run the import. The container being here already is the whole point of the example, and an empty container is what that looks like.
An alert present at page load carries no role, or
role="note". One inserted in response to an action and
urgent carries role="alert", which is assertive and cuts
off whatever the screen reader is saying. One inserted but not urgent carries
role="status". Never put
role="alert" on markup present at load: it fires on first
render and interrupts the page introduction with a message the reader has no context
for.
A page-level notice: consent, a service message, an account state.
role="region" plus a name is what lets a screen reader
user find it and skip past it. An unnamed region is not exposed as a landmark at all.
The close button here really dismisses the notice; the control under the card puts it
back so the example can be run more than once.
Scheduled maintenance on Sunday between 02:00 and 04:00 UTC. Reports will be read only.
On the page.
A banner that appears in response to something the reader just did should be
announced: use role="status" instead, and keep the element
in the DOM from page load so the live region exists before its content does.
--bottom is fixed to the viewport, so give the page an
equivalent bottom padding while it is shown. Dismissal is the consumer's: the close
button is a button and nothing more until a handler removes the notice, which is what
the control under this example does.
Transient, and the hardest of the four to get right. Fire several: they stack in the
region in arrival order, each one runs its own dismissal timer, and hovering or
focusing a toast pauses that timer so it cannot expire while it is being read. The
timers come from
src/behaviour/toast.js, which this page loads.
Both regions are already on the page. They are fixed to the viewport edges, so they occupy nothing here until a toast arrives: press a button and watch the bottom of the screen, then the top.
Both regions are in the DOM, empty, from page load: a live region created at the same moment as its content is usually not announced, because the accessibility tree has nothing to diff against. Failures go to the second region because polite announcements wait for the reader to stop typing, and an error that arrives silently is an error they act against. Do not put both severities in one region. The third button raises a toast with a control in it, and that one never auto-dismisses at all: a message that can be acted on and disappears on a timer is a control the reader was not given time to reach.
Fire three on a 320px screen and the stack takes the lower half of it, because the region is pinned to the bottom edge, which is the reachable half of a phone. That is the component behaving correctly and it is also the argument against using a toast for anything the reader has to act on: while the stack is up it is covering the page it is reporting about. Keep the copy to a line or two, and put anything the reader must answer in a dialog.
A native dialog opened with
showModal(), never with the
open attribute and never a div. Setting
open="" renders the same markup non-modally, with no
backdrop, no inert background and no focus trap, which looks identical and is not a
modal. Open it: Tab is trapped inside, Escape closes it, and focus returns to the
button you pressed. None of that is written on this page, it is what the element does.
Not opened yet. The trigger reports aria-expanded="false".
aria-labelledby pointing at the title is required: a
dialog with no accessible name is announced only as "dialog". The trigger and the
header close are wired by
src/behaviour/dialog.js, which adds the three things the
platform does not do: opening from markup, keeping
aria-expanded in step, and holding the page behind still
while the dialog is open. Scroll the page, open the dialog, and try to scroll again.
Built on the popover attribute, which supplies light
dismiss, Escape to close, top-layer painting and the trigger relationship without a
line of script. This example has no JavaScript behind it at all, including the button
that closes it: press outside it, press Escape, or press Apply.
Controls belong in here and never in a tooltip: a tooltip cannot be reached by
keyboard, so anything interactive in one is unreachable. Use auto and not
popover="manual" unless the panel must survive a click
elsewhere: manual gives up light dismiss and Escape, and then keyboard users have no
way out. An open popover lives in the top layer, so its containing block is the
viewport: positioning it against a DOM ancestor is not possible in any browser, and
anchor positioning is the only CSS route to a true dropdown. Under 48em this one is a
bottom sheet; at 48em and above it is a centred panel, and where anchor positioning is
supported it is pinned under its trigger instead.
A real element referenced by aria-describedby, not a
title attribute: title is not shown on focus, is not
announced consistently, and cannot be styled. Three ways in, all of them live here:
point at it, Tab to it, or tap it on a touch screen. Escape hides the one showing
without moving your focus.
The tooltip element stays in the DOM at all times and is hidden with
visibility, never
display: none, because a node referenced by
aria-describedby is still read while hidden and removing
it from the box tree is what breaks the description. Never put anything focusable
inside a tooltip: that case is the popover above. The touch path is a real rule rather
than a borrowed one - a tap does not match
:focus-visible, so the file carries a plain
:focus reveal behind
(hover: none) instead of relying on the sticky
:hover an engine may or may not leave behind a finger.
Dismissible under SC 1.4.13 is the one third CSS cannot reach, and
src/behaviour/tooltip.js is the handler for it.
Two markups, because a determinate bar and a busy indicator are different things to a screen reader and only one of them has a value. Start the upload: the native element and the ARIA one are driven from the same number, and the third bar is the indeterminate case, which never has one.
Rebuilding index
Reticulating, length unknown
Idle at 0 per cent.
For the indeterminate case, omit aria-valuenow entirely: a
progressbar with no current value is what tells assistive technology the length of the
wait is unknown. Sending aria-valuenow="0" announces as
nought per cent and stays there. The fraction is driven through a custom property
rather than an inline width, because width is a layout property and cannot be animated
under this framework's rules; scaleX gives the same result
on the compositor. The readout beside the controls is a
role="status" of its own, because a bar that moves
silently tells a screen reader user nothing.
A graphic with no text, so the waiting state has to be carried by ARIA or it is invisible to a screen reader. Better still, pair it with a visible label, which serves everyone. Press Publish to run the second row for real.
Publishing the draft
Under prefers-reduced-motion the animation name is
cancelled outright rather than clamped, because the global clamp would leave an
infinite animation iterating thousands of times a second for no visible benefit and
real battery cost. The Publish button here stays enabled while the work runs, and the
waiting state is carried by the row beside it. That is deliberate: do not disable a
button on click without moving focus first, because focus on a disabled element is
dropped to the body and the keyboard user loses their place. The third specimen is the
button's own loading state, which is paint only and therefore ships with
disabled and
aria-busy in its markup.
Decoration standing in for content that has not arrived, so it is hidden from
assistive technology and the region it fills announces that it is busy. Without
aria-busy a screen reader reads the region as empty and
moves on. Load the card to watch the swap that has to happen in one update.
318 accounts, 41 of them added this week. The last full reconciliation finished at 04:12 UTC and matched the ledger to the penny.
Set aria-busy="false" and replace the skeletons in the
same update, which is what the Load control does here. A skeleton left in place after
the data arrives is announced to nobody and waited on by everybody. Sizes are in em so
a skeleton line matches the type it stands in for.