Fix codebase audit regressions while preserving UI/UX behavior and adding review-driven hardening.
22 KiB
Accessibility Audit
Static review of src/components/ and src/views/ on 2026-04-23. Read-only pass; no browser/axe verification was performed. Findings focus on the primary interactive patterns (posting, replying, voting/subscribing, navigating threads, opening modals) over niche settings screens.
Summary
5chan leans heavily on non-semantic elements wrapped in role="button" plus tabIndex={0} and a hand-rolled onKeyDown that handles Enter/Space. This pattern is applied consistently enough to be keyboard-operable in most places, but it is brittle and leaks into cases where native <button> would trivially solve the problem. Eighty-nine uses of role='button' were found across the tree.
The most material issues are:
- Modals are not dialogs. None of the custom modals (
reply-modal,settings-modal,disclaimer-modal,directory-modal,create-board-modal,boards-bar-edit-modal,challenge-modal) setrole="dialog"/aria-modal="true"or label the dialog. Several also lack a focus trap and do not return focus to the trigger. Floating-UI-based popovers (post menu, tooltips) are the well-done exceptions. - Form controls have no programmatic labels. The primary post and reply forms render inputs with visible
<td>text or placeholders but no<label htmlFor>,<label>wrap, oraria-label/aria-labelledby. Screen readers announce these as unlabeled "edit text" fields. Exactly onehtmlForexists in the whole tree (boards-bar-edit-modal). - No landmarks, no h1, no skip link. There is no
<main>,<header>, or<nav>in the main app layout (only<footer>). No view except/blotterrenders an h1. There is no skip-to-content link. - Icon-only controls rely on
titlefor their accessible name. The close buttons on most modals are empty<button>elements styled as an X with only atitle='Close'.titleis not a reliable accessible name and is unusable on touch. - Nested interactive elements.
CatalogButtonandReturnButtonrender<button><Link>…</Link></button>, which is invalid HTML and confuses assistive tech and keyboard nav. <label>used without a form control (settings-modal categories) — labels wrap text only; the click target is the label itself, not an input, so the semantics are misleading.
Heading hierarchy is skipped in most views (jump straight to h2/h3). Images generally carry alt='' where decorative, which is correct; no missing alt was found. Keyboard traps were not observed.
Findings
Critical
- src/components/reply-modal/reply-modal.tsx:290-310 — Core reply surface. The outer
<animated.div>is not a dialog: norole="dialog",aria-modal="true",aria-labelledby, oraria-label. Fix:<div role="dialog" aria-modal="true" aria-labelledby="reply-modal-title">and put the title ("Reply to No. 12345") inside an element with that id. - src/components/reply-modal/reply-modal.tsx:313, 324, 333 — Name, link, and comment inputs have placeholders but no associated
<label>,aria-label, oraria-labelledby. Screen readers announce only "edit text". Fix: wrap each input in<label>with visible text or addaria-label={t('name')}/t('link')/t('comment'). - src/components/reply-modal/reply-modal.tsx:302-309 — Close button is an empty
<button>with onlytitle='close'.titleis not exposed as an accessible name reliably; touch users never see it. Fix:aria-label={t('close')}(and keep the visual X via CSS background). - src/components/post-form/post-form.tsx:152-165, 183-189, 207, 214-229, 258-263, 272-282 — The posting form (subject, name, comment, link, file, spoiler, board-select) has no label associations. Labels sit in adjacent
<td>cells but are not wired viahtmlFor/id. The<select>for board has no accessible name. Fix: give every input a stableidand add<label htmlFor={id}>{t('name')}</label>on the sibling<td>, or wrap the input in the label. Same for the<select>. - src/components/post-form/post-form.tsx:207 & src/components/reply-modal/reply-modal.tsx:333 — The comment
<textarea>(the primary text entry in the app) is unlabeled. Fix:aria-label={t('comment')}at minimum. - src/components/post-form/post-form.tsx (no
<form>wrapper) & reply-modal.tsx (no<form>wrapper) — Neither posting surface is inside a<form>, so Enter does not submit and assistive tech cannot navigate by form. Fix: wrap fields in a<form onSubmit={...}>with a submit button, even in the<table>layout. - src/components/disclaimer-modal/disclaimer-modal.tsx:34-65 — The backdrop is
role='button' tabIndex={0}and wraps the dialog content; the dialog itself has norole="dialog", noaria-modal, no focus trap, and no initial focus. Accept/Cancel are reachable only by tabbing through every control on the (backgrounded) page. Fix: move the backdrop to a non-focusable<div aria-hidden="true">handled viaonMouseDown; give the inner dialogrole="dialog" aria-modal="true" aria-labelledby="disclaimer-title"; set initial focus to the Accept button on open; restore focus to the trigger on close. - src/components/disclaimer-modal/disclaimer-modal.tsx:49, src/components/create-board-modal/create-board-modal.tsx:34, src/components/directory-modal/directory-modal.tsx:36, src/components/challenge-modal/challenge-modal.tsx:369 — Empty close
<button>withtitle='Close'only. No accessible name, no text. Fix: addaria-label={t('close')}. - src/components/settings-modal/settings-modal.tsx:106, 110, 114 — Settings overlay and close "button" are
<div>/<span>withrole='button'instead of real<button>. The modal itself also has norole="dialog"/aria-modal/aria-labelledby. Fix: use<button>elements and wrap the modal in<div role="dialog" aria-modal="true" aria-labelledby="settings-title">.
High
- src/components/board-buttons/board-buttons.tsx:83-86, 154-157 —
<button className='button'><Link to=...>{t('catalog')}</Link></button>and the same for Return. Nested interactive elements — invalid HTML, double-focusable, announced twice, and the button'sonClicknever fires because the Link swallows the click. Fix: drop the outer<button>and style the<Link>directly (<Link className='button' to=...>), or usenavigate()from a real<button>. - src/components/settings-modal/settings-modal.tsx:119-160 — Category toggles use
<label onClick={...}>without wrapping or associating any input. A<label>with no control is inert to assistive tech and has misleading semantics. Fix: replace with<button type="button" aria-expanded={showInterfaceSettings} aria-controls="interface-settings-panel" onClick={...}>. - src/components/comment-media/comment-media.tsx:73-86, 107-120, 123-136, 172-185, 217-233, 237-253, 321-335, 348-366, 387-405 —
<img role='button' tabIndex={0} onClick>for expanding/collapsing media. Keyboard works, butalt=''+role='button'gives an unnamed button to a screen reader. Fix: addaria-label={t('expand_image')}/t('collapse_image')on the interactive image, or wrap the<img alt=''>in a real<button aria-label=...>. - src/components/comment-media/comment-media.tsx:92-103 —
<video>withrole='button' tabIndex={0} onClick/onKeyDown. A<video>is not a button; hijacking its semantics confuses AT, and native video controls are already focusable. Fix: render the thumbnail as<img>+ a sibling/wrapper<button aria-label>instead of abusing the video element. - src/components/catalog-search/catalog-search.tsx:105-115 — Search input has no
<label>/aria-label, and the closing "✖" is a<span role='button'>with a literal Unicode glyph as its name. Fix:aria-label={t('search')}on the input;<button type='button' aria-label={t('close_search')}>✖</button>(or keep it a span but addaria-label). - src/components/style-selector/style-selector.tsx:32-40 — The theme
<select>has no label. Announced as "combo box" with no name. Fix:<label>{t('style')}:<select aria-label={t('style')}>(or pair with the{t('style')}label already rendered in footer.tsx viahtmlFor). - src/views/home/home.tsx:37-45 — Board-jump
<input>on home has no label, just a placeholder, and the<form>wraps it without any accessible name on either. Fix: addaria-label={t('enter_board_address')}on the input andaria-label={t('search_boards')}on the form. - src/components/post-desktop/post-desktop.tsx:1130-1141 — Thread "hide" toggle is a
<span role='button'>with no text content, noaria-label, and state conveyed only via a class name. Fix:aria-label={hidden ? t('unhide_thread') : t('hide_thread')}andaria-pressed={hidden}; prefer<button type='button'>. - src/components/post-desktop/post-desktop.tsx:1197-1209, post-mobile has the equivalent — "Show/hide omitted replies" toggle is a
<span role='button'>with no accessible name (icon-only via CSS). Fix: addaria-label={showOmittedReplies[cid] ? t('hide_omitted_replies') : t('show_omitted_replies')}andaria-expanded. - src/components/post-desktop/post-desktop.tsx:388-404 & src/components/post-mobile/post-mobile.tsx:315-331 — User ID highlight uses
<span role='button' tabIndex={0}>with only the truncated ID as visible text,title={t('highlight_posts')}, and inlinebackgroundColor/color. Contrast of the random user-ID colors is not guaranteed against the selected theme, andtitleis not a reliable accessible name. Fix:aria-label={t('highlight_posts_by_user', {id})}and verify colour-pair contrast in the palette generator (or render on a neutral chip). - src/components/post-desktop/post-desktop.tsx:430-444 & post-mobile 389-404 — The "reply-to-post" number (clicking a post number to quote it) is a
<span role='button'>ononMouseDown(notonClick) — mouse-only discoverability and a non-standard activation for keyboards. Fix: useonClick/keyboard handler consistent with the rest of the app, or a real<button>. - Modal focus management (all modals) — Most modals do not trap focus or return focus to the trigger on close.
reply-modalautofocuses the textarea on open (good) and listens for Escape (good), but still returns focus to<body>on close. Fix: storedocument.activeElementon open and.focus()it on close; add a focus trap (simplest:FocusTrapfrom@floating-ui/reactorfocus-trap-react).post-menu-desktopalready usesFloatingFocusManager— that pattern can be reused.
Medium
- src/components/comment-media/comment-media.tsx:266-280 — Mobile media "close" is a
<span className='button' role='button'>. Pure text button rendered as span. Fix: use<button className='button'>. - src/components/post-desktop/post-desktop.tsx:674-688 & 694-708 — Inline "close"/"open" toggles for expanded media use
<span role='button'>. Fix:<button type='button' className={styles.closeMedia}>. - src/components/boards-bar/boards-bar.tsx:226-244, 249-263, 265-279, 284-293, 396 — Five separate
<span role='button'>nav triggers ("...", "edit", "create board", toggle search). Visible text is present and keydown handlers exist, but each is a<span>duplicating what a<button>gives for free, and each has its own inlinestyle={{cursor:'pointer'}}that buttons don't need. Fix: convert to<button type='button' className={styles.temporaryButton}>. - src/components/board-blotter/board-blotter.tsx:45 — Expand/collapse trigger is a
<span role='button'>. Fix:<button type='button' aria-expanded={...}>. - src/components/catalog-filters/catalog-filters.tsx:154, 198, 258, 279, 293, 322 — Six
role='button'spans for filter controls (add, save, remove, move). Same pattern. Fix: real<button>s. - src/components/catalog-filters/highlight-color-picker/highlight-color-picker.tsx:63, 88, 121, 155, 171, 185 — Colour swatch pickers are
<div role='button'>with no accessible name other than the colour visually. Fix: render as<button type='button' aria-label={Highlight color ${color}} aria-pressed={isSelected}>. - src/components/post-desktop/post-menu-desktop/post-menu-desktop.tsx:228-269 & post-mobile equivalent — Items in the post-menu popover use
role='button'rather thanrole='menuitem', and the wrapper is notrole='menu'. Focus is trapped (good) and navigation works via Tab, but arrow-key menu semantics don't apply. Fix: use<button role='menuitem'>inside<div role='menu' aria-labelledby={headingId}>and wire arrow-key navigation, or stop calling it a menu. - src/components/markdown/markdown.tsx:95 — Spoiler-text reveal is
<span role='button'>. Fix:<button type='button' aria-expanded={revealed}>. - src/components/comment-content/comment-content.tsx:204, 246, 268 — Quote/backlink previews are
<span role='button'>. Fine functionally; would benefit from aria-label describing target post. Fix:aria-label={t('show_reply_preview', {no})}. - src/components/subscriptions-setting/subscriptions-setting.tsx:27, 64 —
role='button'rows for reordering/removing subscriptions. Fix: real<button>children witharia-label. - src/components/post-form/post-form.tsx:257-263 and reply-modal.tsx:367-370 — Spoiler checkbox is wrapped in
<label>(good) but the visible text is interleaved with[]punctuation that screen readers will read as part of the label ("[ Spoiler? ]"). Low-impact but untidy. Fix: put the brackets outside the<label>. - src/components/board-header/board-header.tsx:79-100 — The board title is rendered as a styled
<div>, not an<h1>. Every board page therefore has no page-level heading, which is the single most important landmark for screen-reader users. Fix: render the board title as<h1 className={styles.boardTitle}>. - Heading hierarchy —
src/views/home/home.tsxjumps straight toh2(noh1);src/views/faq/faq.tsx,src/views/rules/rules.tsx,src/views/pass/pass.tsx,src/views/not-found/not-found.tsx,src/views/not-allowed/not-allowed.tsx,src/views/archive/archive.tsxall start ath2/h4with noh1.src/views/blotter/blotter.tsx:23is the sole view with anh1. Fix: promote the top title of each view toh1and demote the subsequent levels. - No
<main>,<header>, or<nav>landmarks in the app shell (only<footer>insrc/components/footer/footer.tsx). Screen-reader users cannot skip to main content. Fix: wrap the primary route outlet in<main id="main-content">, the boards bar + board header in<header>/<nav aria-label={t('boards')}>, and add a visually-hidden<a href="#main-content" className={styles.skipLink}>{t('skip_to_content')}</a>as the first focusable element in the tree (commonly insrc/App.tsxor the top-level layout). - No skip-to-content link anywhere in the tree (grep confirms).
- src/components/comment-media/comment-media.tsx:137-138 & 234-235 —
<audio controls>/<video controls>inside a list of posts. Native controls are focusable but the element itself lacksaria-labeldescribing the media. Fix:<video aria-label={t('video_from', {host: getHostname(url)})}>. - src/views/home/home.tsx:46 —
<button className={styles.searchButton}>{t('go')}</button>— notype='submit', so it defaults tosubmitinside the<form>(works) but is brittle. Fix:type='submit'explicitly.
Low
- All modals (
disclaimer-modal,create-board-modal,directory-modal,boards-bar-edit-modal) — The backdrop is itselfrole='button' tabIndex={0}, meaning a keyboard user tabs into a full-screen "button" before reaching the dialog content. Fix: let backdrop handleonMouseDownonly and make itaria-hidden='true'with no tabindex/role; the Escape key should handle dismissal for keyboard users. - src/components/board-buttons/board-buttons.tsx:209-219 — Auto-update is a checkbox inside
<label className={isMobile ? 'button' : undefined}>. The checkbox hasaria-label={t('Auto')}but the label wraps both the input and the visible "Auto" text, which then gets announced twice. Fix: drop the redundantaria-label— the wrapping<label>already names the input. - src/components/post-form/post-form.tsx:154-165 — Display name input uses the user's previous value as
defaultValue, andplaceholder={!displayName ? 'anonymous' : undefined}— placeholder-as-label anti-pattern if a screen reader user clears the field. Fix: put "Name" as a real visible label (done if the htmlFor fix above is applied). - src/components/tooltip/tooltip.tsx — Tooltips use Floating UI's
useRole('tooltip'), which is correct, but the trigger wrapper is always a<span>regardless of whether the child is interactive. If the child is a non-focusable element, tooltip content is unreachable by keyboard. Fix: document thatTooltipchildren must themselves be focusable (or addtabIndex={0}on the span when the child isn't interactive). - src/components/comment-media/comment-media.tsx:170, 346, 385 —
<img src='assets/filedeleted-res.gif' alt='File deleted'>is correct. Note for the team: this is the right pattern (meaningful alt). Others (spoiler.png, sticky/closed icons) correctly usealt=''. No change needed; included as a positive reference point. - src/components/post-desktop/post-desktop.tsx:454, 459, 466 & post-mobile — Sticky/closed/archived indicator icons use
alt=''+title={t('sticky')}. Information is in thetitleonly, which screen readers don't reliably read. Fix: either usealt={t('sticky')}(meaningful icon) or pair the image with a visually-hidden<span>sibling. - src/components/settings-modal/advanced-settings/advanced-settings.tsx:178 —
<button onClick={() => setShowInfo(!showInfo)}>{showInfo ? 'X' : '?'}</button>— a question-mark help button with literally "?" as its name. Fix:aria-label={showInfo ? t('hide_info') : t('show_info')}andaria-expanded. - src/components/challenge-modal/challenge-modal.tsx:341-352 — Disabled inputs render the challenge subject/content/link (read-only display). Using disabled inputs for display removes them from the tab order, which is fine, but they will not be announced as labelled fields. Consider
<output>or read-only<pre>for clarity. Low-impact. - Touch targets —
styles.closeButton/styles.closeIconon modals are likely under 40×40 (CSS not fully reviewed), and thepostMenuBtn(▶) is small. Fix (follow-up): verify in browser with DevTools; enforce amin-width/min-height: 44pxon icon-only controls via a shared utility class. - Color contrast — Only obvious static-colour risks are user-ID chips (
src/components/post-desktop/post-desktop.tsx:400,post-mobile/post-mobile.tsx:327), whereuserIDBackgroundColoranduserIDTextColorcome from a generator. Cannot verify contrast statically. Fix (follow-up): in the palette generator, enforce WCAG AA (4.5:1) between the generated background and text, or always use a fixed text colour with a constrained background-luminance range. - src/components/post-mobile/post-mobile.tsx:806 —
<div onClick={unhide}>withoutrole='button'/tabIndex/onKeyDown. (div.postHiddenregion — keyboard can't unhide.) Fix: use a real<button>or add the standardrole/tabIndex/onKeyDowntrio. - src/components/boards-bar-edit-modal/boards-bar-edit-modal.tsx:62 — Directory-code input has
aria-label='Directory codes'hardcoded in English instead of usingt(...). Accessibility + i18n. Fix:aria-label={t('directory_codes')}.
Top 5 Actions
- Label every form control in the posting and reply surfaces.
post-form.tsxandreply-modal.tsxare the two most-used UIs in the app; their inputs (name, subject, comment, link, board-select, spoiler) are currently unlabeled for AT. AddhtmlFor/idpairs (oraria-label) and wrap both surfaces in a real<form>. This single change fixes the largest category of critical findings. - Promote modals to real dialogs. Add
role='dialog' aria-modal='true' aria-labelledby=...toreply-modal,settings-modal,disclaimer-modal,directory-modal,create-board-modal,boards-bar-edit-modal, andchallenge-modal. Add a focus trap (Floating UI'sFloatingFocusManageris already used for the post menu — reuse it) and restore focus to the trigger on close. Give every empty close-button anaria-label={t('close')}. - Add app-level landmarks + skip link + h1. In the top-level layout (likely
src/App.tsx): prepend a visually-hidden<a href='#main-content'>{t('skip_to_content')}</a>, wrap the route outlet in<main id='main-content'>, wrap the boards bar in<nav aria-label={t('boards')}>. Inboard-header.tsx, render the board title as<h1>, and in each view that starts ath2, promote the top title toh1. - Eliminate nested interactive elements in
board-buttons.tsx. Replace<button><Link>…</Link></button>(CatalogButton, ReturnButton) with either styled<Link className='button'>or<button onClick={() => navigate(...)}>. This is invalid HTML and currently makes those buttons keyboard-confusing. - Convert the large inventory of
<span role='button'>/<div role='button'>to real<button>s. Start with the most-used ones: hide-thread toggle (post-desktop.tsx:1130), show-omitted-replies (:1197), post-number quote trigger (:430,post-mobile.tsx:389), close-media (:674,:694),boards-bar.tsxedit/create/search triggers. Native<button>gives focus ring, Enter/Space activation, andclicksemantics for free — no more hand-rolledonKeyDownboilerplate. Keeprole='button'only where the element genuinely cannot be a<button>(e.g. the<img>click-to-expand incomment-media.tsx— which should additionally get anaria-label).