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.
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
#1976DCclears AA by 0.0005 — and only against pure white. It backsS.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
#FA5500is 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 incontrast-table.mjs, which measures the club theme as well. - A
tokensprop 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 returnsincompletefor it, so no gate fails. Drive the child through its owntokens(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.tssetsph-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 setsph-club-theme=ghost, but it imports./previewstatically, 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#FA5500where Ghost's#1976DCwas expected). A change to ELB's palette therefore moves this gate's contrast results;contrast-table.mjsand 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>)onbackground-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-contrastdisable is honest and greppable; anincompleteis 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. Onembed-gallerythat 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.mjsfinds 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. WithCONTRAST=1it lists every pair below its WCAG AA bar.clip-sweep.mjsfinds content that is clipped and unreachable (overflow: hiddenwithscrollWidth > 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/IconChevronRightused 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 adir="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 (directionalIconis 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) orstagger.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.