Accessibility

The WCAG AA bar every component on the site ships to: the standards, the axe and contrast gates and their blind spots, RTL and reduced motion.

2026-11-07

Standards

Every component in the four component packages — @v4/ghost-core, @v4/ghost-content, @v4/ghost-video and @v4/ghost-basketball — ships to WCAG 2.1 A/AA. The target is a real one — the axe gate in the Storybook story tests fails a build on a violation — but the gate has blind spots that only manual measurement covers, so the standard is upheld by convention as much as by tooling. This reference states the conventions; Gates & tooling covers what the automated checks do and do not see.

Semantic HTML and WAI-ARIA patterns

Reach for the native element first; add ARIA only to fill the gap the platform leaves. Each interactive component follows the WAI-ARIA Authoring Practices pattern for its role — a disclosure, a tablist with roving tabindex, a modal dialog with a focus trap — rather than an ad-hoc set of attributes.

A named landmark needs a distinct accessible name per instance on a page. <section aria-labelledby> becomes a region landmark, and a story like AllColourSets renders three or four of them on one page, which axe fails with landmark-unique. Give each instance its own heading rather than dropping the landmark — real pages carry one, and the story is the artefact. The same applies to any component whose root is a named <nav> / <section> / <aside>. When a page composes several such blocks, each must still be distinctly named, and that is a content decision the composing component cannot make for the consumer.

Do not use display: contents to make a composed child the flex item of a list row — engine support for preserving list semantics through it has been patchy, and the whole point of the <ul> / <li> structure is that the rail announces as "list, N items". Use display: flex on the row; the count stays assertable with getAllByRole("listitem").

aria-disabled, not the disabled attribute

The house disabled pattern uses aria-disabled rather than the HTML disabled attribute, so the control stays keyboard-discoverable and screen-reader announceable while being semantically unavailable:

<button
  aria-disabled={isDisabled || undefined}
  onClick={isDisabled ? undefined : onClick}
>

This has a testing consequence worth knowing: Playwright's toBeDisabled() treats aria-disabled="true" as disabled and so is blind to the exact distinction the pattern exists to express. Assert the DOM property and the attribute separately — see Component e2e.

Icon slots and screen readers

Decorative slot content — icons, crests, sponsor marks — sits in an aria-hidden well so it is not announced. A slot that carries meaning gets an accessible name instead. The directionalIcon mechanism for mirroring inline-axis glyphs under RTL is covered in RTL & motion.

Colour sets and contrast expectations

Theming is by colour set, not an onDark boolean — a component takes colourSet?: ColourSet, drawn from the ten-key ramp (default, inverse, base1, base2, colourSet1–colourSet6; a component may support a subset), and each set specifies its own text, link, background, border and fill colours. Every set is expected to meet AA (4.5:1 for normal text, 3:1 for large text and UI) on the surface it is authored against. Two facts make that expectation fragile in practice, and both are this site's to watch:

  • The Ghost brand blue #1976DC clears AA by 0.0005 — and only against pure white. It backs S.Color.Text.Default.Emphasis, category-link labels and tertiary button labels, and measures 4.5005:1 on #ffffff. On every off-white it fails: 4.27 on neutral-100 #f8f9fa, 3.79 on neutral-200. A theme that darkens its page background even slightly takes every default-way link, kicker and tertiary button label below AA at once — this is one token, not a set of unrelated component defects.
  • The Euro League brand orange #FA5500 is about 3.3:1 on white — it fails AA as body text and passes only as large text or a UI component. It is a brand-token decision for the client, not a component defect, and it is one of the colours the club layer applies across the library.
  • Know which theme a measurement used. This repo ships the Euro League club theme, and the Storybook UI shows it by default; the Playwright e2e pins the Ghost contract (localStorage["ph-club-theme"] = "ghost"), while the Vitest story suite — which sets the same flag too late — renders the club theme. So the axe gate measures Euro League's colours, and a change to ELB's palette can turn story tests red; a contrast problem also shows up on the running app (CONTRAST=1 node scripts/page-audit.mjs) or in contrast-table.mjs, which measures the club theme as well.
  • A tokens prop reaches only its own component's contract. An override that repaints a band while a composed child keeps resolving its original way can produce genuinely invisible text — black on black — and axe returns incomplete for it, so no gate fails. Drive the child through its own tokens (they are real data props), or drop that part of the demo. Do not recolour around it.

Record the measured figures per component in its accessibility.mdx, using the tool rather than transcribing — see Gates & tooling.

The per-component accessibility.mdx

Every component ships a companion {name}.accessibility.mdx alongside its stories, nested in the sidebar under the component. It documents the WAI-ARIA pattern, ARIA attributes, keyboard interaction, screen-reader behaviour, focus management, motion, and colour & contrast — with the contrast pairs measured, not assumed. The content is derived from the actual .component.tsx / .css.ts / .types.ts, not from assumptions; omit sections that do not apply.

The /component-accessibility skill runs a static WCAG A/AA review of a component — semantic HTML, ARIA, keyboard/focus, screen-reader text, contrast structure, motion — and applies the safe fixes.

Accessibility is not a ticket criterion

Because the bar is assumed across the product and enforced by these gates, a ticket's acceptance criteria never restate it — no screen-reader, landmark, ARIA, focus or contrast criteria (rule 7 of .claude/rules/tickets/acceptance-criteria.md). A keyboard interaction that is part of the feature itself ("Pressing Escape closes the panel") is still a criterion.

Gates & tooling

Two automated checks defend the accessibility bar at component level — the axe gate in the Storybook story tests, and the contrast-table.mjs measurement tool — and two browser sweeps check the running app. The single most important thing to know about them is where the axe gate goes silent — a green a11y run does not mean contrast was checked.

The axe gate

Component stories set:

parameters: {
  a11y: {
    test: "error";
  }
}

Every story is a real-browser test (see Stories & visual testing), so axe runs against the rendered component and an axe violation fails the run. This catches missing labels, landmark-unique collisions, invalid ARIA, role misuse and the contrast pairs axe can compute. Token and reference pages may opt the test out.

Two things decide what the gate actually sees:

  • It measures the club theme. .storybook/vitest.setup.ts sets ph-club-theme=ghost, but the preview it imports statically has already read the flag, so the colours axe checks are Euro League's (the setup sets ph-club-theme=ghost, but it imports ./preview statically, and that import — which reads the flag for the toolbar's initial value — runs before the setup's body; measured 29 Sep 2026: ELB's #FA5500 where Ghost's #1976DC was expected). A change to ELB's palette therefore moves this gate's contrast results; contrast-table.mjs and the browser sweeps below measure the club theme too.
  • It reads final motion states. The same setup file marks the document data-motion-final, so the motion spec's entrances stand down and axe never measures text mid-fade (held at opacity 0). Motion itself is tested by the e2e — see RTL & motion.

axe returns incomplete over any background-image — never a pass, never a violation

axe cannot compute a contrast ratio when any ancestor paints a background-image — including a gradient, and including a fully transparent one. For those nodes it returns incomplete. And test: "error" fails only on violations, so an incomplete node sails straight through: the story reports green having checked nothing there.

This is not theoretical. Measured across the components that paint a background-image (125 stories swept with axe-core directly):

axe contrast result nodes
checks that PASSED 465
checks returning incomplete (unchecked) 220
violations reported 6

Eleven components were affected when this was measured, in the ghost-core and ghost-content campaigns — the article-card family, card-promo-rich, card-promo-simple, card-category, card-gateway, embed-gallery, image-with-overlay, the two calendar overlays (add-to-calendar-page, overlay-add-to-calendar), overlay-subscription-barrier and base-page-error. The list is not exhaustive: @v4/ghost-video and @v4/ghost-basketball — whose team and player heroes put text over photography and cut-outs — were never swept the same way. Roughly a third of the contrast checks in those components are silently skipped, and behind embed-gallery's 54 skipped checks sat four sub-AA pairs that no gate had ever evaluated.

Rules that follow:

  • Never read a green a11y gate as "contrast is fine" on a component that paints a gradient or image behind text. Measure those pairs by hand with the tool below and record the figures.
  • Watch for a transparent gradient used only to keep a token live. linear-gradient(<transparent>, <transparent>) on background-image — painted so a Figma fill binding stays club-overridable — costs the entire contrast gate for that subtree. That trade must be a decision, not a side effect.
  • A story-level color-contrast disable is honest and greppable; an incomplete is neither. Prefer the explicit disable with a recorded reason over a silently unevaluated check.

The contrast tool

apps/storybook/scripts/contrast-table.mjs renders a story in a real browser and emits the markdown contrast table for the accessibility MDX. Use it rather than writing ratios by hand — five consecutive batches shipped correct components with wrong figures in their docs (a 2.116:1 pair called "the tightest pairing", two "comfortably over 4.5:1" claims that measure exactly 4.500), and correcting them one batch at a time did not stop it recurring.

# From apps/storybook, with Storybook running
node scripts/contrast-table.mjs --story ghost-content-organisms-embedgallery--all-colour-sets
node scripts/contrast-table.mjs --component embedgallery      # every story
# [--width 1280] [--url http://localhost:6006] [--min 4.5] [--json]

What it does that hand-measurement gets wrong:

  • It recovers the pairs axe skips. Where a gradient or image ancestor makes axe return incomplete, it hides the glyphs, screenshots the element, and samples the composited centre pixel — the actual backdrop a reader sees. On embed-gallery that moved the result from 38-of-38 unmeasurable to 48-of-48 measured, surfacing 7 failing pairs behind a green gate.
  • It composites, so a scrim carrying an opacity token reports the real ratio, not the naive one (one figure read 3.9:1 by hand and 4.16:1 composited).
  • It applies the right threshold. WCAG large text is 18.66px bold or 24px — 12px/700 and 14px/700 are not large text, a mistake made in the docs more than once.
  • It flags "exactly on the threshold" separately from "passes", because a pair on 4.500 with zero headroom is what a re-skinning club needs told.

Text over consumer imagery: report the guaranteed floor, not the fixture

Sampling the pixel behind a card title measures the mock image, not the design — doing exactly that once reported 82 false "failures" across the promo cards, every one an artefact of a light placehold.co placeholder. What the design actually guarantees over imagery is the scrim. So for text over an <img> or url() background, the tool hides the imagery, forces the surface behind the scrim to white, and samples that: the worst case the design can promise over the brightest possible content, independent of whatever photo an editor uploads.

One edge the tool's imagery guard does not catch: a component that paints nothing itself, over an ancestor's image. TeamIndicator's background is fully transparent — on its own story it measures 19.9:1, but inside a news card it reports 1.4:1 over the fixture. Before filing an alarming ratio, check the component's own background; if it is transparent, ask what the design guarantees behind it (usually a parent-owned scrim) and measure that. A number that varies with the mock is not evidence about the component — in either direction.

Browser sweeps on the running app

The story gates score components in isolation. Two scripts sweep a RUNNING app (the web app on :3000 by default — set BASE for another) at several viewport widths, scrolling a screenful at a time:

node scripts/page-audit.mjs [path …]            # add CONTRAST=1 for every pair below AA
node scripts/clip-sweep.mjs [path …]
  • page-audit.mjs finds defects that exist only in the browser: text the same colour as the surface behind it, zero-size components, broken images, page and child overflow, console errors. It reads the real paint stack, not the ancestor chain — a sibling band or an absolutely positioned scrim is what is actually behind the text, and an ancestor walk reports legible copy as white-on-white. With CONTRAST=1 it lists every pair below its WCAG AA bar.
  • clip-sweep.mjs finds content that is clipped and unreachable (overflow: hidden with scrollWidth > clientWidth) — the shape of a bound width shipped inside a narrower column.

These are the only gates that see contrast as the club ships it, on real pages. Detail: Browser sweeps.

Where to record the figures

The measured pairs go in the component's {name}.accessibility.mdx, generated from the tool's output. The /component-accessibility skill runs the static WCAG review that structures that page.

RTL & motion

Two accessibility concerns that are engineered into the component layer from the first line rather than retrofitted: right-to-left support, and respecting a user's request for reduced motion. Both have one house mechanism; use it.

Logical properties from day one

Every component uses CSS logical properties wherever direction matters — marginInlineStart not marginLeft, paddingInlineEnd not paddingRight, insetInlineStart not left, borderStartStartRadius not borderTopLeftRadius. Physical block properties (marginBlockStart, top/bottom) are fine; physical left/right are not. Layout, spacing and scroll logic then mirror for free under dir="rtl" — far cheaper than retrofitting if the site ever adds a right-to-left locale.

Ghost 2.0 ships no RTL artboards, so mirroring behaviour is an engineering decision made against the WAI/W3C i18n guidance, not a design hand-off. The token layer anticipates it — tokens.json carries a complete P.RTL.* typography ramp and fontFamilies.rtl — but the component sets are authored LTR-only. Revisit when design ships RTL artboards.

Logical properties mirror the layout, not the artwork

An arrow or chevron drawn pointing right keeps pointing right under dir="rtl", so a "next" paddle, a "back" button or a drill-in arrow points the LTR way on an RTL page. There is one house mechanism for this — the directionalIcon utility — and there is deliberately no blanket [dir="rtl"] svg { transform: scaleX(-1) }, because that would flip logos, play triangles, ticks and search glasses.

// packages/ghost-core/src/utils/directional-icon.css.ts — the whole rule
export const directionalIcon = style({
  selectors: { "&:dir(rtl)": { transform: "scaleX(-1)" } },
});

Apply it at the call site, on the element that is (or wraps) the glyph, together with data-directional="true":

import { directionalIcon } from "../../utils/directional-icon.css";

<span className={classnames(styles.iconGlyph, directionalIcon)} data-directional="true">
  {direction === "next" ? <IconChevronRight /> : <IconChevronLeft />}
</span>;

The rules that govern it:

  • Opt-in per icon slot, never blanket. Only glyphs whose meaning is an inline-axis direction may mirror — IconArrowLeft / IconArrowRight / IconChevronLeft / IconChevronRight used to express navigation. Block-axis glyphs (IconArrowUp, IconChevronDown — back-to-top, disclosure chevrons) and every semantic glyph must not. The test is "does this glyph mean inline-start/inline-end?", not "does it look asymmetric?".
  • The decision is declared, not inferred — same house style as lgRootWidth / lgOrientation. data-directional="true" makes it visible in the DOM for inspection and assertions.
  • :dir(rtl), not [dir="rtl"] &. It reads the element's own resolved directionality, so a dir="ltr" island nested in an RTL page correctly does not flip. Verified identical in Chromium, Firefox and WebKit, on HTML elements and <svg>, so consumers may put it straight on an icon passed into a slot (directionalIcon is exported from @v4/ghost-core).
  • Put it on a symmetric box. Flipping a wrapper also flips its padding, radius and border — choose the glyph itself or a wrapper that is symmetric on the inline axis. And do not stack it on an element that already has a transform (the later declaration wins outright).
  • Documented exception: image-with-overlay's gradient is deliberately physical, because the underlying Token Studio value is a physical 270deg ramp. It carries a comment and an e2e asserting it does not flip. Any physical-by-design case must do the same.

Guard rails: packages/ghost-core/src/utils/directional-icon.test.tsx asserts the declaration is present/absent (jsdom has no layout engine), and apps/e2e/tests/ghost-core/directional-icon.e2e.ts asserts the computed matrix(-1, 0, 0, 1, 0, 0) at dir="rtl" and none at dir="ltr". It runs on both full Chromium projects and is in the cross-browser risk subset, so WebKit and Firefox run it too.

transform-origin has no logical keyword

A scaled progress fill, or any element driven by transform, grows from a physical edge — left/right are not logical, so it fills the wrong way under RTL. Use the same :dir(rtl) mechanism:

selectors: { "&:dir(rtl)": { transformOrigin: "right center" } }

A [dir="rtl"] & descendant rule would not respect a nested LTR island; the element's own resolved directionality does.

Motion follows the ELB motion spec

What moves, and with which values, is decided by design: the EuroLeague Motion Spec names every pattern (P-REVEAL, P-CASCADE, P-HOVER, P-COUNT, P-MENU and the rest). Where code and spec disagree, the spec wins. The rules are .claude/rules/frontend/motion.md; the digest of every pattern and value is .claude/reference/motion/elb-motion-spec.md; the how-to is the Motion guide. The ones that matter for accessibility:

  • Reduced motion is a complete, instant page. Not shorter animations: the final state, immediately, every loop stopped. Server markup IS the final state; script arms an entrance before the first paint and plays it, so nothing is hidden without script. A hook that animates a value (a count-up, a reorder) returns the final value at once under prefers-reduced-motion: reduce.
  • Once per visit. Entrances and count-ups play the first time they are seen; scrolling back or a back navigation never replays them. A data change (a new season, sort or view) is a new entrance.
  • Nothing waits for motion. Live scores, sort and filter results are in the DOM immediately; motion only decorates their arrival.
  • Never animated: ads, layout properties (width, height, top, margin), logos (fade at full strength or mask only), a repeating blink, or anything above three flashes a second. No scroll-jacking, custom scroll physics or parallax on content. Tables never count and never wobble — a row hover tints at once, and a changed number cross-fades.
  • Budgets are measured: at most 12 concurrent animations on mobile and 24 on desktop, CLS below 0.1, transform and opacity only. A cascade stops staggering after ten items (CASCADE_MAX_ITEMS) or stagger.maxTotal (400ms); the rest arrive together.
  • Every animation names its pattern in a comment (// P-CASCADE — …), the way a theme leaf names its Token Studio binding.

Reduced motion via the motion helpers

Component CSS never writes raw transition / animation strings. It spreads createTransition() / createAnimation() from the @v4/ghost-core/theme motion helpers. Both reference the club-overridable motion.* contract tokens and bake in the prefers-reduced-motion: reduce override automatically:

export const button = recipe({
  base: {
    cursor: "pointer",
    ...createTransition(["background-color", "border-color"]),
  },
});

The motion.* contract (packages/ghost-core/src/theme/default.theme.ts) is the spec's vocabulary, never literals in a component:

Group Leaves
duration fast 150ms, normal 200ms, base 200ms, moderate 300ms, slow 500ms, shimmer 1000ms, countUp 800ms, barFill 600ms, chartDraw 800ms, liveFlash 1200ms, pageTransition 250ms, sheetExit 250ms, imageSettle 600ms, skeletonDelay 300ms, reorder 450ms
easing linear, standard, enter, exit, pop, emphasized
stagger cards, list, chips, widgets, maxTotal, rounds
distance reveal, revealSM, hoverLift, menuDrop, drillPush, tabSlide, nudge
scale hoverImage, hoverPlay, press, settle

Euro League re-tunes the tree at override level 2 in packages/tokens-euro-league/src/motion.ts (today only duration.slow, 400ms), so a tuning pass changes one value and every component follows. Besides the two builders, the helpers export motionTokens (the whole tree, for a rule the builders do not cover), MOTION_EASING (every easing name resolved to its variable) and cardHoverMotion() (the P-HOVER / P-PRESS card lift and photo zoom, transform only and none of it under reduced motion). The entrance and count-up hooks live in packages/ghost-core/src/motion/.

Only GPU-composited properties animate (transform, opacity); layout-triggering properties (width, height, top, margin) never do. A value that changes continuously — a progress fill following scroll — is scaled with transform: scaleX(), never sized with an inline width, so it stays off the layout path.

Testing both sides of a motion

Every pattern is tested from both sides:

  • unit — the hook's final state, and its reduced-motion branch returning the final state at once;
  • e2e — one frame after the trigger, getAnimations() on the element lists the expected animation with the token's duration; under emulated reduced motion it lists none and the element is already final (apps/e2e/helpers/motion.ts);
  • story — one that shows the motion with a replay control, and a play-less twin for the e2e to drive.

The Vitest story suite and visual snapshots read final states: .storybook/vitest.setup.ts marks the document data-motion-final, and the entrance hooks stand down, so a play function never meets a card held at opacity 0 and axe never measures text mid-fade. The e2e never sets the mark, because it is where the motion is tested.

Never let your own @media clobber the reduced-motion guard

The helper's return value carries an '@media' key holding the reduced-motion override. Spreading the helper and then declaring your own '@media' key in the same object literal replaces it wholesale — the guard silently disappears and the component animates for users who asked it not to. Nothing fails; the only symptom is the missing guard. When a rule needs both, merge them by hand:

...createTransition(["color"], { duration: "fast" }),
"@media": {
  "(prefers-reduced-motion: reduce)": { transition: "none" },
  [mediaUp("lg")]: { /* … */ },
},

Grep any .css.ts that both spreads a motion helper and declares '@media' whenever you touch it. If a manual transition is ever unavoidable, it must carry the (prefers-reduced-motion: reduce) block by hand.