LearnStyling and warp
Accessibility
An accessible app is one that works for someone using a keyboard, a screen reader, a switch, voice control, 200% zoom, forced colours or reduced motion. Most of that is decided by small things: whether a control is a real <button>, whether a field has a name, where focus goes when a dialog closes.
weft splits the work. warp's components handle the parts that are hard to get right and easy to get wrong, like keyboard patterns and focus, and weft check catches the common mistakes in the markup you write yourself. What's left is what no library can do for you: the words, the structure, and the order things happen in.
A washed coffee with notes of lemon and black tea.
Roasted on Monday, shipped on Tuesday.
Unopened bags can be returned within 30 days.
import { state } from '@weft-js/core'import { Button, Card, Menu, Modal, Row, Stack, Tabs, Text } from '@weft-js/warp'type Tab = 'details' | 'shipping' | 'returns'const TABS = [ { value: 'details' as const, label: 'Details' }, { value: 'shipping' as const, label: 'Shipping' }, { value: 'returns' as const, label: 'Returns' },]const COPY: Record<Tab, string> = { details: 'A washed coffee with notes of lemon and black tea.', shipping: 'Roasted on Monday, shipped on Tuesday.', returns: 'Unopened bags can be returned within 30 days.',}export default function Keyboard() { const tab = state<Tab>('details') const confirm = state(false) return ( <Card title="Yirgacheffe"> <Stack gap="related"> <Tabs label="Product" value={tab.value} options={TABS} onChange={next => (tab.value = next)}> {value => <Text>{COPY[value]}</Text>} </Tabs> <Row gap="tight"> <Menu label="Share" trigger={{ emphasis: 'outline', tone: 'neutral' }} items={[{ label: 'Copy link' }, { label: 'Email' }]} /> <Button emphasis="soft" onClick={() => (confirm.value = true)}>Subscribe</Button> </Row> </Stack> <Modal open={confirm} size="sm" title="Subscribe monthly?" footer={<Button data-close="">Done</Button>}> <Text subtle>Focus moved into this dialog. Escape closes it and puts focus back on Subscribe.</Text> </Modal> </Card> )}Try this: Click Details, then use only the keyboard: the arrows move between tabs, Tab reaches Share, and Escape closes what you open.
What warp does for you
Real elements
A warp Button is a <button>. Input is an <input> with a <label>. Modal is a native <dialog> opened with showModal(). Switch and Checkbox are real checkboxes. Building on native elements means the browser and assistive technology already know what each thing is: Enter and Space press a button, a disabled control leaves the tab order, and a form posts its fields with no script. This is why, in a warp app, weft check suggests the warp component when it finds a bare <button> or <select> (D005): the component gives you the same element with the behaviour around it finished.
Names
Every control needs an accessible name, the words a screen reader says for it. warp makes the name part of the API, so you can't forget it:
- Fields take a required
label. TypeScript reports a field without one. - An icon-only
ButtonorToggletakeslabel, which becomes itsaria-label. Tablerequires acaption;captionHiddenhides it on screen but keeps it for screen readers.Tabs,SegmentedandRadioGrouprequire alabelfor the group.<Surface>requires alabel, ordecorativeif the canvas is only ornament (X009).
A field's hint and error are tied to it with aria-describedby, and error sets aria-invalid, so a screen reader hears the message with the field instead of having to find it.
import { derived, state } from '@weft-js/core'import { Button, Card, Input, Stack } from '@weft-js/warp'export default function Labels() { const email = state('') const tried = state(false) const problem = derived(() => (tried.value && !email.value.includes('@') ? 'Enter an email address, like ana@example.com.' : undefined)) const submit = (e: Event) => { e.preventDefault() tried.value = true } return ( <Card title="Newsletter"> <form onSubmit={submit} noValidate> <Stack gap="related"> {/* label is required: it is the field's accessible name. error sets aria-invalid and is read with the field. */} <Input label="Email" type="email" hint="One email a month." error={problem.value} value={email.value} onInput={e => (email.value = e.currentTarget.value)} /> <Button type="submit">Sign up</Button> </Stack> </form> </Card> )}Try this: Press Sign up with the field empty. The message appears under the field, and a screen reader hears it with the field.
Keyboard
Composite widgets follow the WAI-ARIA Authoring Practices patterns, so they work the way screen reader and keyboard users expect:
| Component | Keys |
|---|---|
Menu | Enter, Space or ↓ opens at the first item, ↑ at the last; ↑/↓ wrap; Home/End jump; typing a letter jumps to an item; Escape closes and returns to the trigger; Tab closes and moves on |
Tabs | ←/→ move between tabs and show each one; Home/End jump; one tab stop for the whole list; right-to-left flips the arrows |
Segmented, RadioGroup | the arrows move the choice; one tab stop |
Modal, Drawer | Tab stays inside; Escape closes |
Table | sortable headers are buttons; the filter is a labelled search field |
Each component's page on the site has its own keyboard table, for example Menu and Tabs.
The "one tab stop" pattern is roving focus: Tab enters the group once, the arrow keys move inside it, and Tab leaves. A toolbar of twelve buttons costs a keyboard user one Tab, not twelve. warp-core exports it as roving for your own markup, as Using warp shows.
Focus management
Where focus goes after something opens or closes is the part most often missed:
Modalmoves focus inside when it opens: to the element withautofocus, or else to the title. When it closes, by any path (Escape, the backdrop, adata-closebutton, or your code setting the box tofalse), focus goes back to whatever opened it.Menuputs focus on the first or last item when it opens, and back on its trigger when it closes.- Overlays stack. Escape closes the topmost one only, so a menu inside a dialog closes before the dialog does.
- Toasts from a mounted
Toasterare announced through a live region (role="status", orrole="alert"for a critical one) without stealing focus.
The look
- Contrast. Every text and fill pair in warp's tokens meets WCAG contrast at every dial stop, light and dark, and a seed or token override that breaks one fails the build (D010).
- Focus ring. Every interactive component draws a visible focus ring from
--warp-color-focus, on:focus-visible. - Targets. On a touch screen, controls are at least 44 pixels tall.
- Forced colours. warp redraws its parts in system colours when Windows' high-contrast mode is on.
- Motion. Under
prefers-reduced-motion(ordata-motion="reduce"), an animation runs at most once, nothing loops but an opacity pulse, and the action icons hold still.
warp's own test suite runs axe-core over every component in a real browser at all twelve combinations of expression, theme and motion, so these hold for the components you import.
What you still own
Structure
Screen reader users move through a page by its headings and landmarks, so those have to be right:
- Headings in order. One
h1per page, thenh2for sections,h3inside those. A jump fromh1toh3is D035.Headingtakeslevelfor the outline and picks the look from it, so you never choose a level for its size. - Landmarks. Put the page's main content in
<main>, navigation in<nav>with anaria-labelif there's more than one, and use<header>,<footer>and<aside>for what they are.Stack as="main"and the other layout primitives'asprop give you these elements. - Language.
<html lang>tells a screen reader which voice to use. weft writes it from your config'ssite.locale; a hand-written<html>without one is D039.
import { Heading, Stack, Text } from '@weft-js/warp'export function OrdersPage() { return ( <Stack as="main" gap="section"> <Heading level={1}>Orders</Heading> <Stack as="section" gap="related" aria-labelledby="today"> <Heading level={2} id="today">Today</Heading> <Text>12 orders to ship.</Text> </Stack> </Stack> )}Words
No component can write these for you:
- Labels that say what. "Email" not "Input 1"; "Delete project" not "Delete" when there are several things to delete.
- Errors that say how to fix it. "Enter an email address, like ana@example.com" beats "Invalid".
- Alt text that says why the image is there. A product photo's
altnames the product; a decorative swirl getsalt=""so it's skipped. An<img>with noaltat all is D032, because a screen reader then reads the file name. - Link text that makes sense alone. Screen reader users often list a page's links. Ten links that say "here" are no help.
Your own markup
Whatever you build from plain elements is yours to get right. The commonest mistake is a click handler on something that isn't a control:
import { state } from '@weft-js/core'import { Button, Text } from '@weft-js/warp'export function Reveal() { const open = state(false) return ( <> <Button emphasis="link" aria-expanded={open.value} onClick={() => (open.value = !open.value)}> Shipping details </Button> {open.value && <Text>Roasted on Monday, shipped on Tuesday.</Text>} </> )}A <div onClick> in its place works with a mouse and with nothing else: it can't be focused, Enter does nothing, and a screen reader doesn't say it's clickable. That's D033. A real button gets all three, and aria-expanded tells a screen reader whether the details are showing.
What happens after an action
When something on the page changes because of what the user did, think about where their focus and attention are:
- After deleting a row, focus shouldn't vanish with it. Move it to the next row, or to the list's heading.
- After saving, a toast (
toast.show({ title: 'Saved' })) announces the result without moving focus. - After loading more results, keep focus where it was; announce the count if it matters.
The diagnostics
weft check reads your markup and CSS for the mistakes it can see. These run in every app, warp or not:
| Code | Catches |
|---|---|
| D030 | An <input>, <select> or <textarea> with no label |
| D031 | A button or link with no accessible name: an icon only, or empty |
| D032 | An <img> with no alt |
| D033 | onClick on an element that isn't interactive: mouse only |
| D034 | A control inside a control, like a <button> in an <a> |
| D035 | A heading level that jumps (h1 to h3) |
| D036 | The focus ring removed with nothing in its place (outline: none) |
| D037 | A positive tabindex, which reorders the page's Tab order |
| D039 | <html> with no lang |
| D011 | A literal text and background colour pair below 4.5:1 |
A few more catch accessibility problems in other places: X005, X006 and X008 are text that doesn't grow with zoom or a viewport that blocks pinch zoom; X009 is an unnamed canvas surface; X041 is per-character spans a screen reader would spell out letter by letter. Each code has a page under Diagnostics with the fix.
Each message names the WCAG criterion it's about:
D031 6: <Button> has no name a screen reader can read: only an icon, or nothing (WCAG 4.1.2).weft fix applies the ones with a safe fix, like turning a positive tabindex into 0. A fix that changes an element, like a <div onClick> into a button, comes back as a suggested edit.
Testing it
Query by role and name
@weft-js/core/test has role, label and text queries and nothing else: no test ids, no CSS selectors. That's on purpose. A test that finds a button by its role and name fails when the button loses its name, so every test you write checks accessibility a little:
import { state } from '@weft-js/core'import { render, screen, user } from '@weft-js/core/test'import { Button, Text } from '@weft-js/warp'import { expect, test } from 'vitest'function Favourite() { const on = state(false) return ( <> <Button emphasis="ghost" icon="star" label="Favourite" aria-pressed={on.value} onClick={() => (on.value = !on.value)} /> <Text>{on.value ? 'Saved to favourites' : 'Not saved'}</Text> </> )}test('the star is a named toggle', async () => { render(Favourite) const star = screen.getByRole('button', { name: 'Favourite' }) expect(star.getAttribute('aria-pressed')).toBe('false') await user.click(star) expect(star.getAttribute('aria-pressed')).toBe('true') expect(screen.getByText('Saved to favourites')).toBeTruthy()})If someone drops label="Favourite", getByRole('button', { name: 'Favourite' }) finds nothing and the test fails, which is exactly what a screen reader user would have found.
Test the keyboard path
user.tab() and user.keyboard() drive the keyboard. Check that Tab reaches each control in a sensible order and that the keys do what they say:
import { render, screen, user } from '@weft-js/core/test'import { Button, Input, Stack } from '@weft-js/warp'import { expect, test } from 'vitest'function SignIn() { return ( <form> <Stack gap="related"> <Input label="Email" type="email" /> <Input label="Password" type="password" /> <Button type="submit">Sign in</Button> </Stack> </form> )}test('Tab goes email, password, then the button', async () => { render(SignIn) await user.tab() expect(document.activeElement).toBe(screen.getByRole('textbox', { name: 'Email' })) await user.tab() expect(document.activeElement).toBe(screen.getByLabelText('Password')) await user.tab() expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Sign in' }))})Testing covers render, user and the rest.
Use it yourself
Automated tests can't hear a page. Before a release, spend five minutes on each of these:
- Keyboard only. Unplug the mouse. Can you reach and use everything? Can you always see where focus is?
- A screen reader. VoiceOver (⌘F5 on a Mac), NVDA on Windows, or TalkBack on Android. Move by headings, then by form controls. Does each thing say what it is?
- 200% zoom and a narrow window. Does the text grow, and does anything get cut off?
- The dials.
?theme=dark&motion=reduce&expression=quietin the address bar sets them for one page (withdialsFromUrlfrom@weft-js/warp-core, if your app calls it), or set them with the dial attributes.
Recap
- warp's components are native elements with names, keyboard patterns and focus management built in, and their tokens pass contrast at every dial stop.
- You own the structure (headings, landmarks,
lang), the words (labels, errors, alt text) and anything built from plain elements. weft checkreports the mistakes it can see as D030–D039 and D011, each with its WCAG criterion.- Find elements by role and name in tests, so a lost name fails a test. Then use the app with a keyboard and a screen reader.
Try it
Challenge 1
A clickable card
This card opens a product when clicked, but weft check reports D033 and a keyboard user can't open it. Fix it without changing how it looks to a mouse user.
<!-- example: skip the challenge's starting code, which has D033 on purpose -->
export function ProductCard(props: { name: string; onOpen: () => void }) { return ( <div class="product" onClick={props.onOpen}> {props.name} </div> )}Show the solution
import { Card } from '@weft-js/warp'export function ProductCard(props: { name: string; onOpen: () => void }) { return <Card title={props.name} onClick={props.onOpen} />}A Card with onClick renders as a real <button>, so it's focusable, Enter and Space open it, and a screen reader announces it as a button named after its title. If the card goes to another page, give it href instead, and it becomes a link.
Challenge 2
Name the search
This search field has a placeholder but no label, so a screen reader announces only "search field".
<!-- example: skip the challenge's starting code, which has D030 on purpose -->
export function SiteSearch() { return ( <form role="search"> <input type="search" placeholder="Search products" /> </form> )}Show the solution
import { Search } from '@weft-js/warp'export function SiteSearch() { return ( <form role="search"> <Search label="Search products" /> </form> )}label is required on warp's fields, so the name can't be left out. A placeholder isn't a name: it disappears as soon as you type, and many screen readers don't read it.
Challenge 3
Prove the dialog gives focus back
Write a test for this component: open the dialog with the button, close it with Done, and check that focus is back on the button that opened it.
import { state } from '@weft-js/core'import { Button, Modal, Text } from '@weft-js/warp'export function Terms() { const open = state(false) return ( <> <Button emphasis="outline" tone="neutral" onClick={() => (open.value = true)}>Read the terms</Button> <Modal open={open} title="Terms" footer={<Button data-close="">Done</Button>}> <Text>Be kind.</Text> </Modal> </> )}Show the solution
import { state } from '@weft-js/core'import { render, screen, user } from '@weft-js/core/test'import { Button, Modal, Text } from '@weft-js/warp'import { expect, test } from 'vitest'function Terms() { const open = state(false) return ( <> <Button emphasis="outline" tone="neutral" onClick={() => (open.value = true)}>Read the terms</Button> <Modal open={open} title="Terms" footer={<Button data-close="">Done</Button>}> <Text>Be kind.</Text> </Modal> </> )}test('closing the terms puts focus back on the button', async () => { render(Terms) const opener = screen.getByRole('button', { name: 'Read the terms' }) await user.click(opener) await user.click(screen.getByRole('button', { name: 'Done' })) expect(document.activeElement).toBe(opener)})The test reads like what a keyboard user does, and it finds both buttons by role and name. If a later change broke focus return, or dropped the button's text, this test would say so.