Skip to content

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.

  • Warning by default: npx --no-install sygnal-check prints 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' }), or check: { 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-check when 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).

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.

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.

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>
)
}
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) }) }

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.

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 }) }

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.

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.

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')}.

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'] } }).