Skip to content

Quality · the automated half of a11y

What a machine can check

Accessibility has two halves. One needs a person and a browser; the other is decidable from markup, and that half should never be shipped broken. This page runs the mechanical half over 498 page documents in the finished build — the same files the server sends — and reports what it finds, including the findings that were real and have been fixed.

Documents scanned

498

every page document in the finished build

Open findings

0

clean at this commit

Editorial a11y ≥ 95

266/289

the per-asset score kept in the catalog

Found and fixed

7

repairs recorded below, not hidden

Next fallbacks skipped

7

error shells, not pages: they carry no lang and no content

The 14 checks, and the count for each

  • an <img> with no alt attribute0
  • a button with no text, aria-label or title, outside an aria-hidden subtree0
  • a link with no text and no accessible name0
  • a form control with no label, aria-label or explicit label association0
  • two elements sharing an id in one document0
  • a heading that jumps down more than one level0
  • a document with no lang attribute on <html>0
  • a focusable control inside an aria-hidden subtree — hidden from the tree but still reachable by Tab0
  • an aria-labelledby/describedby/controls/… pointing at an id that is not in the document0
  • an operable role (button, switch, slider…) on an element that cannot take focus — no tabindex, not a native control, not inside a closed popup0
  • an aria-expanded/checked/selected/pressed/current value the spec does not allow0
  • a role that requires a value property (slider, tab, switch…) without one0
  • a data table with no caption and no aria-label — a grid without a sentence0
  • a link or button whose text is just "click here" — the purpose must be in the link0

A control that is inside a aria-hidden subtree is exempt from the name rules (decorative mock controls are not interactive for anybody) — but being focusable inside one is a finding of its own: hidden from the accessibility tree and still in the tab order fails both ways. A control with an explicit <label for> is satisfied by that association, and a control inside a closed popup is exempt from “cannot take focus”, because a hidden subtree is not rendered at all. The pass implements the rules, not a count of tags. This page reads the directory live, so the 7 documents it skipped hereare whatever Next had cached by the time you asked: _global-error.html, changelog/2026-01-01-an-entry-that-does-not-exist.html, collections/3d-tilt.html, collections/cursor-magnetic.html, collections/dev-terminal.html, collections/micro-interactions.html, collections/signature-motion.html. The measurement applies the same rule at build time and publishes the count it skipped, and probing a URL that does not exist is enough to add one — which is how a removed changelog slug ended up counted as a page, and this page ended up reporting it as missing a language attribute.

Motion, counted per module

The split above, broken down by the module each scene lives in. animate counts every scene that drives motion; guarded counts only the JavaScript-driven ones that name the preference — CSS-only motion is stopped by the stylesheet, so it has no branch to count.

Motion, counted per module — data table
modulescenesanimateguardedfirst unguarded
scenes-17.tsx261813
scenes/set-01.tsx29194
scenes/set-02.tsx17104
scenes/set-03.tsx17125
scenes/set-04.tsx16132
scenes/set-05.tsx15121
scenes/set-06.tsx16103
scenes/set-07.tsx26260
scenes/set-08.tsx2564
scenes/set-09.tsx2500
scenes/set-10.tsx2543
scenes/set-11.tsx25104
scenes/set-12.tsx25112
scenes/set-13.tsx2610
scenes/set-14.tsx522

15 modules · 318 scenes · 154 animate — the same numbers as the split above, generated from the same audit.

The motion contract

Three rules every demo scene promises, written down so a scene is added the same way it is reviewed. The full contract lives in the repository, and the two automated halves are named with it:

  • A scene that loops declares a branch. A scene that drives motion from JavaScript without naming the preference fails check:demos.
  • Reduced motion never removes content that only animation revealed. The reduced version is the same content, still or instant — never an empty frame.
  • The branch is named in the scene's own body. A shared stylesheet rule is a net, not the scene's answer: the code that moves names the preference next to the loop it guards.

The stylesheet rule itself is asserted in the built CSS by check:exports. The contract is docs/motion-contract.md, and the template every scene imports from is scene-kit.tsx.

Focus ring against the surfaces it sits on

The ring globals.css draws is rgba(139,92,246,.9). Against each flat surface it can sit on, that ring blends to a colour whose contrast against the surface is computed here. The 3:1 line is the non-text contrast a focus indicator should clear. Glass panels and gradients are a rendering question, not a token, so they stay on the hand-check list rather than pretending a flat average is the glass.

Focus ring against the surfaces it sits on — data table
surfacering · surface ratioclears 3:1
page background4.05:1yes
panel3.9:1yes
raised surface3.78:1yes

Forced-colors mode

Windows high contrast replaces the palette, so the theme's translucent violet can vanish against a forced background.globals.css now carries a @media (forced-colors: active) block that hands the focus ring to Highlight and the primary chrome to CanvasText. This page reads the built stylesheet, not the source file, so the sentence below is about what actually shipped: present in .next/static/chunks/2-zetnxeeufsp.css.

Target size — candidates, not measurements

A class name is not a measurement. 64 interactive controls in the scene source carry no min-height/min-width/size utility, so they are candidates for the hand check, not failures. The hand check decides whether the control is actually below the touch-target line at 200% zoom; this list is where that check starts.

  • scenes-17.tsx · button
  • scenes-17.tsx · button
  • scenes-17.tsx · button
  • scenes-17.tsx · a
  • scenes-17.tsx · button
  • scenes-17.tsx · button
  • scenes/set-02.tsx · button
  • scenes/set-03.tsx · button
  • scenes/set-03.tsx · input
  • scenes/set-03.tsx · button
  • scenes/set-04.tsx · button
  • scenes/set-04.tsx · button
  • scenes/set-04.tsx · button
  • scenes/set-04.tsx · button
  • scenes/set-04.tsx · button
  • scenes/set-04.tsx · button
  • scenes/set-06.tsx · button
  • scenes/set-06.tsx · button
  • scenes/set-06.tsx · button
  • scenes/set-06.tsx · button
  • scenes/set-06.tsx · button
  • scenes/set-06.tsx · button
  • scenes/set-08.tsx · input
  • scenes/set-08.tsx · button
  • … and 40 more

Behaviour declarations, and the code behind them

A catalog record that declares a behaviour now has to have the code. keyboard means a key or focus handler, or a native control that is keyboard-operable without one; drag means a pointer, mouse or touch handler, or a native range thumb. 51 records declare keyboard (all with evidence) and 17 declare drag (all with evidence). check:demos fails the moment a declaration loses its handler.

Accessible-name fixtures

A name passes a linter if it exists; these fixtures assert it is the right name. 30 of the most interactive scenes store the accessible name their primary control should have, and the built embed markup is compared against it — check:demos fails on drift. All 30 match the built markup.

Accessible-name fixtures — data table
sceneexpected accessible name
file-drop-zoneUpload a build spec
combo-boxSearch the component library
star-ratingRate this component
tag-inputRemove motion
slider-ticksReveal threshold percentage
quantity-stepperDecrease quantity
reading-dotsNext section
pulse-graphPause the pulse
bezier-drawerControl point 1
chart-scrubberLower fidelity bound
scroll-pinThree-step story. Arrow keys move between steps; Escape releases pinning.
reorder-listReorder Write the brief. Arrow keys move it, Home and End send it to either end.
split-paneResize the two panels
command-paletteSearch the whole library
radio-pillsChoose a plan
toggle-label-stackWeekly digest
ripple-dotsPage sections
success-burstCreate account (demo)
share-sheetShare this asset
search-walkPrevious match
morph-iconsPlay icon, currently showing Pause
split-button-menuMore deploy actions
pagination-ellipsisPagination
tabs-indicatorSections
disclosure-listCan I use Motif assets in commercial projects?
fullscreen-overlay-menuOpen menu
comparison-sliderComparison slider
newsletter-band-tiersEmail address
template-galleryFilter templates
preloader-handoffRun the hand-off

DOM order vs visual order

Scene source is scanned for the utilities that reorder visually — order-*, flex-row-reverse and the rest — and a scene that uses one must explain its reading order in its own body. 1 use found, 0 without an explanation.

  • src/components/demos/scenes/set-13.tsx · flex-row-reverse · reading order explained

The sentences the live regions announce

So the hand check knows what it is listening for. The toast scene announces these in an aria-live="polite" region, and the live-region lab announces these through role="status" / role="alert".

toast scene

  • Build passed · 3.2s
  • Wipe Reveal copied to clipboard
  • Changed 3 theme tokens — undo?
  • New run log: 3/3 models clean
  • ✓ Tokens restored

live-region lab

  • ✓ Snippet copied
  • ⏳ Loading 4 of 12 surfaces
  • ✕ Payment failed — card declined
  • ✦ 3 updates are waiting for you

Fixed-height inventory for the 200% zoom check

The preview frames reserve fixed pixel heights, so a 200% zoom check starts from a list rather than a blank page:

  • src/components/cards.tsx · minHeight 160px
  • src/components/cards.tsx · minHeight 0px
  • src/components/cards.tsx · minHeight 168px
  • src/components/asset-detail.tsx · h-[420px]
  • src/components/lazy-demo.tsx (default) · 168px

RTL: a dry run on two scenes

No browser exists in this build, so this is a source scan, not a render: two scenes were chosen and their physical-direction tokens listed. Those tokens will not mirror under dir="rtl" — the logical equivalents are -start/-end, text-start, rounded-s/e. The other 316 scenes are untested in RTL.

split-button-menu · SplitButtonMenu

right-0 · rounded-r-none · rounded-l-none · border-l · text-left

parallax-layered-scene · ParallaxLayeredScene

left-[14%] · left-[68%] · left-[42%] · left-[24%] · left-[82%] · left-[55%] · left-1/2 · translate-x-1/2 · left-[16%]

What this pass found, and what changed

A green dashboard that has always been green is usually a dashboard nobody ran. These are the real findings from the runs of this pass, kept on the page because the fixes are the evidence:

  • batch 78the demo scene rendered inside component previews was an <h1>133 pages affected
  • batch 79the logo gradient id "mf-logo" appeared three times per page (header, footer, press kit)134 pages affected
  • batch 79every remaining heading in a demo scene, and the mock hero headline inside card previews40 pages affected
  • batch 79panel titles that rendered directly under a page h1 as h36 pages affected
  • batch 79controls whose only visible label was a span (lab sliders, selects, the command palette input)4 pages affected
  • batch 83a role="switch" span in the admin settings demos and two demo scenes whose aria-controls pointed at a panel that only existed while open7 pages affected
  • batch 82this pass counted a stale document from an earlier build as a page, and reported a missing lang attribute against a changelog slug that no longer existed

What this page does not check

  • Focus order and keyboard reachability. Deciding whether Tab reaches every control, and in a sensible order, needs a running browser. 154 scenes move at all: 107 through CSS, which the stylesheet collapses site-wide, and 47 from JavaScript, which no stylesheet can reach — every one of those 47 now names the preference and check:demos fails if any stops. What a branch does at runtime is still a hand check like everything else on this list.
  • Contrast in context. Token-level contrast is computed on /quality from the real colour pairs, but whether a specific element over a gradient is legible is a rendering question.
  • Screen-reader output. Nothing here proves what a screen reader will say; an accessible name can still be a confusing one. That is what the guides at the keyboard walk exist for.
  • What the demo scenes do at runtime. Their markup is no longer exempt — the rules above run over the scenes like any other markup, which is how the closed listbox behind an aria-controls was caught. What no static pass can see is behaviour: where focus goes when a sheet closes, what a live region announces, whether a drag has a keyboard equivalent. The demo harness compares the behaviour list each scene it knows about declares (drag, click, keyboard, type, scroll) with the list that scene's catalog record publishes — two declarations against each other, which is a weaker thing than an assertion about the code. The checklist below is for the rest.

The rules also run a second time over HTTP. npm run check:a11y:served fetches every page the sitemap lists plus the 2 server-rendered pages the sitemap cannot list — 647 URLs in this build, including the 289 component pages rendered on demand, which have no HTML on disk to scan — reports any URL that did not answer instead of skipping it, and fails on the same findings. The counts above come from the disk pass.

The export harness and npm run check:a11y run the same rules from a separate process over the same files, and fail on any finding — so this page cannot report green while the served HTML is not. The three counted different documents once; the rule for what is a page now has one home, in the same module the three read.

What a machine cannot decide here: the manual checklist

Seven checks, in the order a reviewer should run them. Each one names the automated half that does exist, so the list is what is left rather than a pile of work someone already did. None of these has been run in a browser by this project. The build has no browser in it, no session has been recorded, and this page will not imply otherwise.

  1. 01Tab from the top of a page to the end, with no mouseautomated half: every control's name and role

    Put the cursor in the address bar, then Tab. Note every stop the way the keyboard-walk guide does: where am I, what is this, what will it do? The scenes with the most to lose are the ones that reimplement dragging, so walk one of those rather than the homepage.

    Fails when a stop you cannot see, a widget you cannot leave with Tab or Escape, or a stop announced as nothing.

    Where/components/reorder-list/components/split-pane/components/zoom-lens/components/chart-scrubber

  2. 02Close an overlay while focus is inside itautomated half: focusable controls inside aria-hidden subtrees, in the built state only

    Open one of the overlay scenes, Tab to a control inside it, then press Escape. Focus has to be somewhere visible afterwards — normally the trigger — and the overlay must not be left in the document as an aria-hidden wrapper whose controls are still in the tab order. This is the case the static rule cannot see: the markup was correct when it was built, and the failure happens at runtime.

    Fails when focus falls to the document body (the next Tab restarts at the top), or the closed overlay is still reachable — hidden from the tree and tabbable at once.

    Where/components/sheet-menu/components/command-palette/components/fullscreen-overlay-menu/components/share-sheet

  3. 03Check where focus returns after a dialog closesnothing automated

    Continue from the step above: press Tab once without looking. The next stop should be the control that opened the overlay.

    Fails when the reader loses their place and walks the page again to find what they were doing.

  4. 04Look at the focus ring against the surface it sits onautomated half: token-level contrast on /quality — a colour pair, not a ring on a gradient

    Walk the same pages on a dark display and check that every stop has a ring you can actually see. The scenes draw their own rings, and several of them sit on gradients or glass panels.

    Fails when a ring that is drawn but invisible over the surface behind it, which reads as no focus at all.

    Where/quality

  5. 05Listen to what the screen reader says, not just that it canautomated half: that a name exists — nothing about whether it means anything

    Run one scene under a screen reader (or an axe/Playwright aria snapshot), trigger it and listen. The live-region and toast scenes are where the words are the whole point: does the announcement arrive, and is it the sentence a person needs?

    Fails when a message that never announces because the region mounted with its text already in it, or a name that passes the checker and confuses a listener.

    Where/components/live-region-demo/components/toast-stack

  6. 06Set reduced motion and open the animated scenesautomated half: every scene that drives motion from JavaScript names the preference — 47 of 47, checked by check:demos — and the other 107 scenes move only through CSS, which the stylesheet collapses site-wide (154 scenes move in total)

    Turn on the system preference and walk the animated half of the catalog. The part that moves has to stop or become instant; a scene that only slows down has not honoured it.

    Fails when animation that keeps running, or content that disappears because it only ever appeared through animation.

    Where/components/orbit-deck/components/pulse-loader/components/text-cycle

  7. 07Zoom to 200% and use only touch-sized targetsnothing automated

    Set the browser to 200% zoom and check every control a finger has to hit, then repeat at 375 px wide. Nothing should be clipped, overlap or scroll sideways.

    Fails when targets smaller than a fingertip, or text that only fits because nobody zoomed it.

Related: the audit register, the craft excerpts and the crawl surface and the index of every check.

The a11y score, in plain terms

  • Keyboard path — the demo is reachable and operable without a pointer (behaviors recorded per entry).
  • Names & roles — controls carry accessible names; operable roles carry their required properties.
  • State announced — expanded, checked, selected and live changes speak in the page’s own words.
  • Structure — landmarks, one h1, no skipped heading levels, captions on data tables.
  • Motion — a declared reduced-motion branch; loops honour the preference globally.
  • Contrast — token pairs measured, rings measured against the surfaces they sit on.

A score is a judgement with a rubric attached — argue with the line items, not the number.

The alt inventory

The markup pass walks every built document and reports img-no-alt findings; the catalog ships no raster imagery, so the live count is the number you saw at the top of this page. Decorative SVGs are aria-hidden by convention; informative ones carry their own label.