Browse all of Kata

Accessibility

Source docs/design/foundations/accessibility.mdMarkdown

On this page

Everything past contrast: screen readers, text scaling, reduced motion, and focus.

Part of the Konjo design language. The laws in the spine apply here unless this chapter contradicts them.

Contrast is settled in L1 and computed for you in the token tables. This chapter is the rest of it — the parts that are invisible when they work and humiliating when they don't.

The person this chapter is for is the same white belt in week two. They are not a separate audience. Half of what follows is also just quality: text that reflows, a control that announces what it does, an animation that stops when someone asked for animations to stop.

Where Konjo actually stands

Measured, not assumed, so the gap is known rather than guessed at:

Coverage
accessibilityLabel 843 uses across 250 files, against 980 pressables — broadly good
accessibilityRole 805 uses across 258 files — broadly good
accessibilityState 131 uses across 89 files — partial
accessibilityHint 8 uses across 5 files — effectively absent
Reduced motion 5 of the 19 files that animate honour it
Text scaling no file sets maxFontSizeMultiplier; nothing has been tested at 200%

Labelling is in decent shape. The three rows below it are the work.

Screen readers

Every pressable needs a label, and the label is what it does, not what it says. A chevron row labelled "chevron" is useless; the same row labelled "Ana Torres, brown belt" is the whole screen for someone who cannot see it. (Not "…, open profile" — the button role already said that, and repeating it is the noise the hint rule below warns about.)

  • accessibilityRole on everything interactive: button, link, header, image, switch, checkbox, radio, tab, search. The role is what tells the screen reader how to announce the element and what gestures apply to it.
  • accessibilityState for anything with a state: { selected } on a chip or tab, { checked } on a toggle, { disabled }, { expanded } on an accordion, and { busy: true } on a control in flight — a button relabelled "Submitting…" with a spinner has changed state in two visual channels and none an assistive technology reads. A selected chip that does not say it is selected is indistinguishable from an unselected one.
  • accessibilityHint only when the outcome is not obvious from the label. "Double tap to open" is noise — the role already said that. "Removes this student from tonight's roster" is worth the extra sentence. Under-using hints is a much smaller sin than over-using them, which is why 8 uses is closer to right than 800 would be.
  • Group a row into one stop. A list row with an avatar, a name, a belt dot, a status glyph and a chevron is one thing, not five. Put accessible={true} on the row and one accessibilityLabel that reads the whole thing in the order a person would say it. The tile grid depends on this: one glyph shows, but the label enumerates every status the tile carries, so nothing is visible-only.
  • Hide decoration. A glyph that repeats the adjacent text, a divider, a spacer image: accessibilityElementsHidden, importantForAccessibility="no-hide-descendants", or simply no label. Announcing it twice is worse than not announcing it.
  • Announce what changed silently. A toast, a validation failure, an "8 students checked in" counter that updates without navigation — a sighted user sees it and a screen-reader user gets nothing. Use AccessibilityInfo.announceForAccessibility() for the one-shot, or accessibilityLiveRegion="polite" on the region that mutates. Currently 3 uses in the whole app; nearly every async success and failure is silent.
  • Never type capitals to get uppercase. textTransform: 'uppercase' renders the same and reads correctly; a literal "ADD" is announced "A. D. D.". design-check enforces this as literal-caps.

Text scaling

React Native scales text by default — allowFontScaling is true unless you say otherwise — and iOS accessibility sizes reach roughly 310%, which turns 15pt body text into ~46pt. No file in Konjo sets maxFontSizeMultiplier, and no screen has been checked at large sizes. So this section is a specification, not a description.

The rules:

  • Never turn scaling off. allowFontScaling={false} is not a fix; it is opting a person out of the accommodation they asked the OS for.
  • Cap it where the layout genuinely cannot give, and only there. maxFontSizeMultiplier belongs on chrome with a hard geometric constraint — a tab bar label, a badge count inside a fixed dot, a chip that must stay on one line. Body copy, titles, form labels, empty states and error messages take whatever the person set.
  • Suggested caps, so this is not decided per-screen: tab labels 1.3, badge counts 1.4, chips and pills 1.6. Everything else uncapped.
  • No fixed-height container around text, ever. Height comes from the content. This is the single most common way scaling breaks a screen: a height: 44 row clips its own label at 150%. A minimum height is not a fixed height and is how you hit a target size or a density figure without breaking this law — minHeight: 44 on a row, never height: 44. Studio's dense 46 table row is a minimum for exactly this reason; see Studio metrics.
  • 11 of 25 type tokens carry a lineHeight; 14 do not — the generated tables name them. A fixed lineHeight scales with the font in RN, so it is safe — but an entry without one reflows unpredictably. Prefer a token that has one, and see the type chapter for which.
  • Test at three sizes, not one. 100%, 135%, 200%. Two of those will find something.

One exemption to the 11pt floor, stated so nobody "fixes" it: tabLabelActive and tabLabelInactive are 10pt. That matches the platform — iOS tab bar item titles are 10pt — and the tab bar is chrome the OS itself sizes. It is the only place under 11pt, and it needs the 1.3 cap above precisely because it starts small.

Reduced motion

14 of the 19 files that animate ignore the setting. The hook is useReducedMotion() from react-native-reanimated, already used correctly in ListFadeIn, ReflectPill, WizardProgressBar and WizardScaffold — copy one of those.

  • Reduced motion means replace, not delete. A slide becomes a cross-fade; a spring becomes an instant state change; a progress bar jumps to its value instead of counting up. The information still arrives. Removing the transition entirely often makes a screen harder to follow, not easier.
  • Anything that moves more than a few points, parallaxes, scales, or auto-plays is in scope. A colour change is not.
  • The one deliberate celebration — the promotion certificate — is also the one animation someone is most likely to want, so it degrades to a static presentation of the same certificate rather than to nothing.
  • Vestibular triggers are a real medical accommodation, not a preference. See motion for what is allowed to move at all.

Focus

RN-web is the surface CI actually walks, and it is the surface a keyboard reaches.

  • Focus is visible, always: 2px border.focus. One of the three sanctioned uses of borderWidth.emphasis 2 — see borders. Without it the app fails SC 2.4.13 outright, and forms become unusable by keyboard.
  • On a hero surface the ring flips to border.focusOnHero. border.focus is near-black on light and near-white on dark, which is correct on a page or a card and catastrophic on surface.hero: border.focus on surface.hero is 1.14:1 light and 1.00:1 dark — in dark it is the same hex as the surface. That covers every ink-filled control: Studio's navigation rail, a selected chip, a hero CTA, and any ring that lands inside an ink block rather than outside it. border.focusOnHero on surface.hero is 16.86:1 light and 14.72:1 dark. A ring with outline-offset ≥ 0 on a small ink control lands on the page behind it, and keeps border.focus. The test is what the ring is drawn on, not what it surrounds. This pair is now in the generator's law table, so a palette that breaks it fails the build rather than being published with a ✗ nobody reads.
  • Never remove the outline without replacing it with something at least as visible.
  • Focus order follows reading order. If a visual reorder puts the primary action above the fields, the DOM order has to say so too.
  • Opening a sheet moves focus into it; closing it returns focus to whatever opened it. A keyboard user who tabs into content behind a scrim is lost.

Targets

  • 44×44pt minimum (SC 2.5.5), or hitSlop making up the difference. touchTarget is the token; a 24pt glyph in a 44pt target is the normal shape.
  • Spacing between adjacent targets matters as much as their size — two 44pt buttons touching are one 88pt mistake.
  • Studio is pointer input and drops to 32pt; see the dialect.

Before you claim a screen is done

  • Every pressable has a role and a label that says what it does.
  • Anything with state announces its state.
  • Rows are one stop, not five, and decoration is hidden.
  • Async success and failure are announced, not just drawn.
  • Nothing is in a fixed-height box with text in it.
  • It survives 200% text.
  • Every animation checks useReducedMotion().
  • Focus is visible and lands in the right order.
  • Contrast computed, both themes.

The same contract on the web

Everything above is written as React Native props, because that is where most Konjo screens live. Konjo Studio is a web app, and a completeness test on a Studio table found the system contains exactly one ARIA attribute — which means the whole contract was being re-derived by whoever built each screen. That is how a safety rule quietly stops being followed.

The rules are identical. Only the API changes:

Mobile Web
accessibilityRole="button" a real <button>, or role="button"
accessibilityLabel aria-label, or a visually-hidden label
accessibilityHint aria-describedby pointing at the text
accessibilityState={{ checked }} aria-checked
accessibilityState={{ selected }} aria-selected, or aria-pressed on a toggle button
accessibilityState={{ disabled }} disabled, or aria-disabled when it must stay focusable
accessibilityState={{ expanded }} aria-expanded
accessibilityState={{ busy }} aria-busy
accessibilityElementsHidden aria-hidden="true"
accessibilityLiveRegion="polite" aria-live="polite"
AccessibilityInfo.announceForAccessibility() write into an aria-live="polite" region
accessibilityRole="header" a real heading element at the right level
testID data-testid — same kebab-case names, same rule: every interactive element and every screen root

Prefer the real element over the role. A <button> is focusable, keyboard-activatable and announced correctly with no attributes at all; role="button" on a <div> is three more things to get right and one of them will be missed.

One row of the table does not map, and inverts on the web. "Rows are one stop, not five" is a VoiceOver rotor idiom: on mobile a row is swiped to as a unit and its actions are rotor actions. On the web, a row containing a link, a status and a second link is correctly two tab stops — collapsing it into one hides the second action from every keyboard user. Group a row on the web only when the whole row does exactly one thing.

Cases the table above does not cover, and the web needs:

  • A partially-selected select-all is aria-checked="mixed". The moment one row of a filtered page is unchecked, a plain checked/unchecked box is lying about the state.
  • A sortable column header carries aria-sort on the sorted column only — ascending, descending — and the control inside it is a <button>, so Enter sorts.
  • A table is a real <table> with <thead>, <th scope="col"> and a <caption> that may be visually hidden. A grid of <div>s is unnavigable by screen reader, whatever ARIA is bolted to it.

Composite widgets, and the drag criterion

The contract above is written for controls on a page. A board, a grid, a tree and a toolbar are widgets, and two rules invert for them.

  • A composite widget is one tab stop, not one per child — roving tabindex, arrows to move within it. This is the one sanctioned exception to "tab reaches every control": a 40-row grid with 40 tab stops recreates the problem a skip link exists to solve. It applies to a widget, never to a list of links.
  • SC 2.5.7 Dragging Movements (WCAG 2.2 AA): anything you can drag must also be doable with a single pointer, without a path. Not a keyboard equivalent instead — as well. A "move to" control satisfies this, SC 2.1.1, and switch access in one control, and is usually a better interaction than the drag. See direct manipulation.
  • assertive is for the outcome a person is waiting on and cannot see — a drop landing, a submit failing. polite is for progress, including a gesture's in-flight messages, which repeat several times a second and will sometimes be coalesced away. A gesture therefore needs two regions, not one: polite for "Contacted, 4" on every arrow press, assertive for "Ana Torres moved from New to Contacted." A single region cannot serve both — politeness is read when the region is created, not per message — and the terminal message is the one that must never be dropped. See direct manipulation.

The page skeleton, which mobile does not have

Native apps have no landmarks and no bypass blocks; the web does, and Studio's shape makes both mandatory rather than nice.

  • One <main>, one <nav aria-label="…">, and a skip link. Studio's rail is ~25 links and precedes the content in DOM order on every screen, so without a bypass block a keyboard user tabs the entire navigation before reaching anything, on every page, forever (SC 2.4.1). The skip link is the first focusable element, visually hidden until focused, and it targets <main>'s id.
  • Headings are a real outline. <h1> is the page title, once. <h2> is every panel or card title, however small the card looks. Never skip a level to reflect visual size — the heading list is how a screen-reader user gets the shape of the page, and Studio's pages are mostly panels.
  • aria-current="page" on the active nav item, not just a colour and a fill.
  • A live region that is present at load is not role="alert". role="alert" interrupts, and it is for something that appeared because of an action. A standing banner that renders every morning with the page is a labelled <section>; announcing it as an alert on every page load trains people to dismiss the one time it matters.
  • One polite live region per page, not one per component. Six cards finishing their loads should announce once, not six times.
  • Decorative repeats are aria-hidden. A page eyebrow above an <h1> that says the same thing, and a rail item that is already aria-current, both announce twice otherwise.

The armed destructive control, on the web

This is the passage that matters most, because it exists to stop a blind person firing a destructive action they never confirmed — and it was written entirely as RN function calls.

On the web, arming must:

  • change the accessible name, not only the visible label, so re-focusing reads the armed state — aria-label="Really remove 3 students?";
  • carry aria-pressed="true" on the control, which is the web's closest true statement: this control is currently engaged;
  • announce itself by writing the consequence into an aria-live="polite" region — "Armed. Activate again to remove 3 students from the dojo";
  • announce the disarm when a scroll, another click, Escape or navigation cancels it.

Never rely on colour to carry armed. It is a state change, and colour alone fails SC 1.4.1 on every platform.