From 54baf3029ff336b1b255ba34904c71e05f57206a Mon Sep 17 00:00:00 2001 From: Tony Ko Date: Thu, 16 Jul 2026 14:59:28 -0400 Subject: [PATCH] fix(a11y): give the marquee drift a control and name the landmarks An AODA Level A pass found the marquee failing 2.2.2 Pause, Stop, Hide: the rows autostart, run 80s, and offered no way to stop them. The hover:paused they carried is mouse-only, so it reached neither keyboard nor touch, and hover is not a mechanism under the criterion. The reduced-motion path added earlier only serves readers who set the preference; 2.2.2 asks for a control for everyone. Marquee becomes a client component holding the paused state and driving data-[paused=true] on the track. Its children still arrive from the server organisms, so the cards stay server-rendered and only the atom ships JS. The control is hidden under reduced motion, where the row is already a scroller with no drift to stop. Landmark names come from the call sites rather than the component: every route carried two unlabelled navs, which a reader listing them cannot tell apart. Content baked into a component would break the reusability rule. The missing h1s the same pass found are not addressed here. --- docs/01-standards/accessibility.md | 2 + src/app/design/page.tsx | 4 +- src/components/atoms/marquee.tsx | 84 ++++++++++++++++++------ src/components/organisms/global-menu.tsx | 2 +- src/components/templates/footer.tsx | 2 +- src/components/templates/header.tsx | 2 +- 6 files changed, 70 insertions(+), 26 deletions(-) diff --git a/docs/01-standards/accessibility.md b/docs/01-standards/accessibility.md index ae8b5ad..eb2dce7 100644 --- a/docs/01-standards/accessibility.md +++ b/docs/01-standards/accessibility.md @@ -5,3 +5,5 @@ The floor, not a cleanup pass. 1. **WCAG 2.0 AA (AODA) is the baseline:** semantic landmarks (`header`/`main`/`footer`/`nav`/`section`), one `h1` per page, accordion and nav keyboard-operable with correct ARIA, focus trapped in the nav overlay while open, visible `ring` focus states, decorative art `aria-hidden`. 2. **Respect `prefers-reduced-motion`**: ambient and entrance animations pause or reduce; every motion moment has a static equivalent, and nothing is readable only through motion. 3. **Contrast is checked in every theme scope**: AA contrast for text tokens (`muted-foreground` on `background` included) in the base theme and inside any inverse-color section. +4. **Self-starting motion carries a control**: anything that begins moving on its own and runs past a few seconds (the marquee drift) pairs with a keyboard-operable pause. A hover pause is not one: it reaches neither keyboard nor touch, and the reduced-motion path only serves the readers who set the preference. +5. **Each landmark of a kind is named**: a page with more than one `nav` labels each (`Primary`, `Footer`, `Menu`), since an unnamed pair is indistinguishable to the reader listing them. The name is passed at the call site, never baked into the component. diff --git a/src/app/design/page.tsx b/src/app/design/page.tsx index ac784f7..299023b 100644 --- a/src/app/design/page.tsx +++ b/src/app/design/page.tsx @@ -515,11 +515,11 @@ const DesignPage = () => {

Nav

-

Menu

- +

Emblem

diff --git a/src/components/atoms/marquee.tsx b/src/components/atoms/marquee.tsx index f59e5a9..cb7ddab 100644 --- a/src/components/atoms/marquee.tsx +++ b/src/components/atoms/marquee.tsx @@ -1,45 +1,87 @@ -import { forwardRef, type HTMLAttributes } from 'react' +'use client' + +import { forwardRef, useState, type HTMLAttributes } from 'react' +import { PauseIcon, PlayIcon } from 'lucide-react' +import { Button } from '@/components/atoms/button' +import { Icon } from '@/components/atoms/icon' import { cn, cva, type VariantProps } from '@/utils/theme' const styles = { - // the drift is the only thing bringing offscreen entries into view, so under reduced - // motion the row hands that job to the reader as a plain scroller - root: cva([ + root: cva('relative w-full'), + viewport: cva([ 'w-full overflow-hidden', 'motion-reduce:overflow-x-auto motion-reduce:overscroll-x-contain' ]), - track: cva(['animate-marquee flex w-max', 'hover:paused', 'motion-reduce:animate-none'], { - variants: { - direction: { - left: '', - right: 'direction-reverse' + track: cva( + [ + 'animate-marquee flex w-max', + 'hover:paused data-[paused=true]:paused', + 'motion-reduce:animate-none' + ], + { + variants: { + direction: { + left: '', + right: 'direction-reverse' + } + }, + defaultVariants: { + direction: 'left' } - }, - defaultVariants: { - direction: 'left' } - }), + ), group: cva('flex shrink-0 items-stretch gap-8 pr-8'), - // the duplicate exists only to hide the loop's seam; scrolling it would just repeat - duplicate: cva('motion-reduce:hidden') + duplicate: cva('motion-reduce:hidden'), + control: cva(['absolute top-4 right-4 z-10', 'text-muted-foreground', 'motion-reduce:hidden']) } type MarqueeRef = HTMLDivElement -type MarqueeProps = HTMLAttributes & VariantProps +type MarqueeProps = HTMLAttributes & + VariantProps & { + /** Accessible label for the control that stops the drift. */ + pauseLabel?: string + /** Accessible label for the control once the drift is stopped. */ + resumeLabel?: string + } const Marquee = forwardRef((props, ref) => { // props - const { direction, children, className, ...rest } = props + const { + direction, + pauseLabel = 'Pause scrolling', + resumeLabel = 'Resume scrolling', + children, + className, + ...rest + } = props + + // hooks + const [paused, setPaused] = useState(false) + + // render vars + const handleToggle = () => setPaused((prev) => !prev) // jsx return (
-
-
{children}
-
- {children} +
+
+
{children}
+
+ {children} +
+
) }) diff --git a/src/components/organisms/global-menu.tsx b/src/components/organisms/global-menu.tsx index ae84be3..bb93578 100644 --- a/src/components/organisms/global-menu.tsx +++ b/src/components/organisms/global-menu.tsx @@ -67,7 +67,7 @@ const GlobalMenu = (props: GlobalMenuProps) => {
- +

{'// Menu //'}

diff --git a/src/components/templates/footer.tsx b/src/components/templates/footer.tsx index 4486d70..7d18a10 100644 --- a/src/components/templates/footer.tsx +++ b/src/components/templates/footer.tsx @@ -48,7 +48,7 @@ const Footer: FC = (props) => {
© Tony Ko {year}
-
diff --git a/src/components/templates/header.tsx b/src/components/templates/header.tsx index b3ae0fb..c00e502 100644 --- a/src/components/templates/header.tsx +++ b/src/components/templates/header.tsx @@ -60,7 +60,7 @@ const Header: FC = (props) => {
-