Accessibility
sygnal-check has an accessibility lane, SYG701 to SYG708, SYG722, SYG730 and SYG731. It finds the markup mistakes that lock keyboard and screen-reader users out without any visible sign: a clickable <div>, a field without a label, an icon button with no name. Like the other checks, it reads your source, so it works the same in the editor, in CI and in the dev server.
Severity
Section titled “Severity”- Warning by default:
npx --no-install sygnal-checkprints the findings and exits with code 1, like any other warning. - Still a warning under
--strict. Strict mode checks the canonical forms; it doesn’t turn markup that already shipped into errors when you upgrade. - Error when you ask for it:
sygnal-check --a11y=error,check(paths, { a11y: 'error' }), orcheck: { a11y: 'error' }in the Vite plugin, where errors also open Vite’s error overlay. Use it in CI once an app is clean. - On in the dev server: the Vite plugin runs
sygnal-checkwhen it is installed, so the findings appear in the terminal and the browser console as you edit.
The checks are static only: markup that is built at runtime (spread props, a dynamic type or role) is skipped rather than guessed at.
To keep one finding on purpose, put // sygnal-ignore SYG70x on the line above it, or at the end of the line (see Suppressing a finding).
The rules
Section titled “The rules”Each example shows the flagged form as a comment, then the fix.
SYG701: click listener on a non-interactive element
Section titled “SYG701: click listener on a non-interactive element”The intent listens for clicks on a <div>, <span>, <li>, <p>, <img> or similar without a role and tabIndex. Mouse users can click it; keyboard users can’t reach it, and screen readers don’t announce it as something to activate. Render a button instead:
// Flagged:// function Card({ state }) { return <div className="card">{state.title}</div> }
function Card({ state }) { return <button type="button" className="card">{state.title}</button>}
Card.intent = ({ DOM }) => ({ OPEN: DOM.click('.card') })Card.model = { OPEN: (state) => ({ ...state, open: true }) }If the element must stay a <div>, give it role="button" and tabIndex={0}, and handle Enter and Space too:
import { xs } from 'sygnal'
function Card({ state }) { return <div className="card" role="button" tabIndex={0}>{state.title}</div>}
Card.intent = ({ DOM }) => ({ OPEN: xs.merge( DOM.click('.card'), DOM.keydown('.card').filter(e => e.key === 'Enter' || e.key === ' '), ),})Card.model = { OPEN: (state) => ({ ...state, open: true }) }The rule applies to a control’s element too (controls({ Card: 'div' }) listened to for clicks: declare it as 'button'). An element that contains a button, link or form field isn’t flagged, since a click on those bubbles up to the listener.
SYG702: form field without a label
Section titled “SYG702: form field without a label”An <input>, <select> or <textarea> has no accessible name, so a screen reader announces only “edit text”. Wrap it in a <label>, link a <label for> to its id (with uid(), below), or, for a field with no visible label, add aria-label:
// Flagged: <input className="search" type="search" value={state.query} />
function Search({ state }) { return <input className="search" type="search" aria-label="Search tasks" value={state.query} />}
Search.initialState = { query: '' }Search.intent = ({ DOM }) => ({ QUERY: DOM.input('.search').value() })Search.model = { QUERY: (state, query) => ({ ...state, query }) }A placeholder counts as a last-resort name, but it disappears as soon as the user types; prefer a visible label.
SYG703: image without alt text
Section titled “SYG703: image without alt text”Every <img> needs alt. Describe what the image shows, or use alt="" for a decorative image, which screen readers then skip:
// Flagged: <img src={state.avatarUrl} />
function Profile({ state }) { return ( <div> <img src={state.avatarUrl} alt={state.name} /> <img src="/divider.svg" alt="" /> </div> )}SYG704: link without href used as a button
Section titled “SYG704: link without href used as a button”An <a> without href is not focusable and is announced as plain text. Use a button for an action, and keep <a href> for navigation:
// Flagged: <a className="more">Show more</a> with DOM.click('.more')
function Comments({ state }) { return ( <div> <p>{state.shown} of {state.total} comments</p> <button type="button" className="more">Show more</button> </div> )}
Comments.intent = ({ DOM }) => ({ MORE: DOM.click('.more') })Comments.model = { MORE: (state) => ({ ...state, shown: Math.min(state.total, state.shown + 10) }) }SYG705: button without an accessible name
Section titled “SYG705: button without an accessible name”A button whose only content is an icon is announced as just “button”. Add aria-label, and hide the icon from screen readers:
// Flagged: <button type="button" className="close"><span>×</span></button>, no name for "×"...// ...and an icon with no text: <button type="button" className="close"><i className="icon-x" /></button>
function Banner({ state }) { return ( <div role="status"> {state.message} <button type="button" className="close" aria-label="Dismiss"> <i className="icon-x" aria-hidden="true" /> </button> </div> )}
Banner.intent = ({ DOM }) => ({ DISMISS: DOM.click('.close') })Banner.model = { DISMISS: (state) => ({ ...state, message: '' }) }Only literal content is checked: a button whose children include an expression ({state.label}) or a component isn’t flagged.
SYG706: positive tabIndex
Section titled “SYG706: positive tabIndex”tabIndex={1} and higher move the element ahead of everything else in the Tab order. Use tabIndex={0} (focusable in document order) or tabIndex={-1} (focusable only from code), and change the markup order to change the Tab order:
// Flagged: <div className="preview" tabIndex={1}>…</div>
function Preview({ state }) { return <div className="preview" tabIndex={0} aria-label="Preview">{state.html}</div>}SYG707: unknown ARIA attribute or invalid role
Section titled “SYG707: unknown ARIA attribute or invalid role”A misspelled aria-* attribute, or a role that isn’t a WAI-ARIA role (including abstract roles such as widget), is ignored by assistive technology. The message suggests the closest valid name:
// Flagged: aria-labeledby (a typo), role="widget" (abstract)
function Tabs({ state }) { return ( <div> <h2 id="tabs-title">Settings</h2> <div role="tablist" aria-labelledby="tabs-title"> {state.tabs.map(tab => <button type="button" role="tab" aria-selected={String(tab === state.current)}>{tab}</button>)} </div> </div> )}SYG708: label or ARIA reference to an id that isn’t rendered
Section titled “SYG708: label or ARIA reference to an id that isn’t rendered”A <label for>, aria-describedby or aria-labelledby names an id that no element has, so the label is attached to nothing. This usually comes from an id renamed on one side only. Inside a component, use uid() on both sides:
// Flagged: <input id="email" … /> with <label for="e-mail">
function EmailField({ state, uid }) { return ( <p> <label for={uid('email')}>Email</label> <input id={uid('email')} type="email" className="email" value={state.email} aria-describedby={uid('email-error')} /> {state.error && <span id={uid('email-error')}>{state.error}</span>} </p> )}
EmailField.initialState = { email: '', error: '' }EmailField.intent = ({ DOM }) => ({ EMAIL: DOM.input('.email').value() })EmailField.model = { EMAIL: (state, email) => ({ ...state, email, error: email.includes('@') ? '' : 'Enter an email address' }) }A uid('x') reference needs an element in the same component with id={uid('x')}; a literal id may be anywhere in the checked files.
SYG722: Menu, Select or Combobox without an accessible name
Section titled “SYG722: Menu, Select or Combobox without an accessible name”A Menu, Select or Combobox without label, aria-label or aria-labelledby. The part names its control from them (Menu’s trigger text is its label), so without one the menu button or combobox is announced without a name. Give it a visible label; when the label is elsewhere, point at it with aria-labelledby; for an icon-only menu trigger, use aria-label:
import { Select } from 'sygnal/ui/select'import { Menu } from 'sygnal/ui/menu'
// Flagged: <Select className="size" items={SIZES} />
function Sizes({ state }) { return ( <div> <Select className="size" label="Size" items={['S', 'M', 'L']} value={state.size} /> <Menu className="more" label="⋯" aria-label="More actions" items={['Duplicate', 'Delete']} /> </div> )}
Sizes.initialState = { size: null }Sizes.intent = ({ DOM }) => ({ SIZE: DOM.select('.size').events('value-change').detail() })Sizes.model = { SIZE: (state, size) => ({ ...state, size }) }SYG730: action reachable only by hovering
Section titled “SYG730: action reachable only by hovering”An intent action comes only from mouseenter or mouseover (also pointerenter / pointerover), nothing else sends it, and it changes the state. Keyboard users never hover, and touch screens hover only on a tap, so a tooltip, preview or menu shown this way is out of their reach. Trigger it from focus too, and close it on blur as on mouse leave:
import xs from 'xstream'
// Flagged: SHOW: DOM.mouseenter('.info'), with nothing else sending SHOW
function Shipping({ state, uid }) { return ( <p> <button type="button" className="info" aria-describedby={uid('tip')}>Shipping</button> {state.open && <span role="tooltip" id={uid('tip')}>Free over $50</span>} </p> )}
Shipping.initialState = { open: false }Shipping.intent = ({ DOM }) => ({ SHOW: xs.merge(DOM.mouseenter('.info'), DOM.focusin('.info')), HIDE: xs.merge(DOM.mouseleave('.info'), DOM.focusout('.info')),})Shipping.model = { SHOW: (state) => ({ ...state, open: true }), HIDE: (state) => ({ ...state, open: false }),}For a tooltip, tooltip() handles hover, focus and Escape for you. A hover listener whose entry changes no state (a prefetch) isn’t reported.
SYG731: toggled state shown only by a class
Section titled “SYG731: toggled state shown only by a class”A clicked element shows its on/off or selected state only through a class (className={{ on: state.starred }}), while the click’s reducer flips that field (starred: !state.starred) or, for a comparison such as state.filter === 'done', sets it. Sighted users see the change; a screen reader announces a plain button. Expose the state with the same condition:
// Flagged: <button className={{ star: true, on: state.starred }}> without aria-pressed
function Star({ state }) { return ( <button type="button" className={{ star: true, on: state.starred }} aria-pressed={state.starred}> Favorite </button> )}
Star.initialState = { starred: false }Star.intent = ({ DOM }) => ({ STAR: DOM.click('.star') })Star.model = { STAR: (state) => ({ ...state, starred: !state.starred }) }Use aria-selected for a tab, aria-expanded for a disclosure, aria-checked with role="switch", and aria-current="page" for the current link. Native controls that carry their own state (a checkbox, select, details) aren’t reported, and neither is a busy class ({ saving: state.saving }) that the click sets rather than toggles.
ARIA values in JSX
Section titled “ARIA values in JSX”An aria-* attribute set to true renders "true". Set to false, it renders "false" on the ARIA states that have a false value (aria-expanded, aria-hidden, aria-pressed, aria-checked, aria-selected, aria-disabled, aria-invalid, aria-current, aria-haspopup, aria-busy, aria-modal, aria-required, aria-readonly, aria-multiline, aria-multiselectable, aria-atomic, aria-grabbed); on any other aria-* (a label, an id reference, a number) false removes the attribute, so aria-describedby={hasError && uid('error')} doesn’t announce “false”. null and undefined remove it. Passed to a component, role, for, tabindex and aria-* are ordinary props: the component gets the value as written.
Ids with uid()
Section titled “Ids with uid()”Labels and ARIA references need ids, and ids must be unique on the page, which a literal id isn’t once the component is rendered twice. Every view gets a uid prop: uid() is an id for this component instance, and uid('email') an id derived from it. They are stable across renders and the same on the server and the client, so they are safe with SSR. See Forms for a full example. The checks understand uid(): SYG702 and SYG708 match for={uid('email')} with id={uid('email')}.
Suppressing a finding
Section titled “Suppressing a finding”A comment on the line above (or at the end of the line) suppresses one finding:
import { xs } from 'sygnal'
function Dialog({ state }) { return ( <div> <div className="backdrop" /> <div role="dialog" aria-modal="true" aria-label="Settings">{state.body}</div> </div> )}
Dialog.intent = ({ DOM }) => ({ CLOSE: xs.merge( // a mouse shortcut: keyboard users close the dialog with Escape // sygnal-ignore SYG701 DOM.click('.backdrop'), DOM.keydown('document').key().filter(key => key === 'Escape'), ),})Dialog.model = { CLOSE: { PARENT: () => 'closed' } }A backdrop that closes a dialog on click is a common case: the dialog itself is reachable, and keyboard users close it with Escape. Without codes, // sygnal-ignore suppresses every code on that line. To turn a rule off for the dev server, add it to the Vite plugin’s ignore list: sygnal({ check: { ignore: ['SYG705'] } }).
Related
Section titled “Related”- Error Reference: every SYG7xx code, its cause and its fix
- Diagnostics: running
sygnal-check - Forms: validation and server errors; Inputs, Labels and Focus: labels, controlled inputs and focus