Skip to content

src/feedback - 9 files

Feedback

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.

Alert

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.

src/feedback/alert.css, adds 1,742 B gzip

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

An alert that arrives after an action

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.

src/feedback/alert.css, no extra bytes

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.

Show codeHide code

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.

Toast

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.

src/feedback/toast.css, adds 2,338 B gzip

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.

Show codeHide code

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.

Popover

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.

src/feedback/popover.css, adds 2,104 B gzip

Filters

Show codeHide code

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.

Tooltip

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.

src/feedback/tooltip.css, adds 1,772 B gzip
Saves without publishing Last synced 4 minutes ago
Show codeHide code

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.

Progress

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.

src/feedback/progress.css, adds 1,609 B gzip

Rebuilding index

Reticulating, length unknown

Show codeHide code

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.

Spinner

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.

src/feedback/spinner.css, adds 898 B gzip
Loading results
Show codeHide code

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.

Skeleton

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.

src/feedback/skeleton.css, adds 1,219 B gzip
Show codeHide code

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.