Accessibility
Labels, reduced motion and contrast — what views do for you and what to set.
A label that follows the state
Every view exposes one accessible element with an image role. Its name is your label (accessibilityLabel in SwiftUI, contentDescription on Android), else the FX Spec's accessibility.name or name, else the pattern. Pass an empty label when the visual is purely decorative and the same information is elsewhere on screen.
The name follows the state it shows: "Coach" while idle, "Coach, listening" while the agent listens. The words for each state come from your labels (this is how you translate them), then the FX Spec's accessibility.states, then the built-in words for the voice states.
State changes are spoken
When the state changes, the view tells screen reader users, politely, without interrupting them: an aria-live region on the web, VoiceOver on iOS, TalkBack on Android. So a conversation doesn't turn into a stream of announcements, the rule is strict and the same on every platform:
- a state is spoken only after it has held for 1 second, and at most once every 3 seconds;
- only when its words differ from the last ones spoken;
- never for the view's first state, and never for
idle.
A conversation that flips between listening and speaking every second says nothing until it settles. announce: false turns it off (the FX Spec's accessibility.announce sets the default). On iOS and Android, haptics: true adds a light tap when the agent starts listening, the "your turn" moment; never with reduced motion on.
import { mount } from "@sinua/web";
import { LocalMicVoiceSource } from "@sinua/voice/mic";
const canvas = document.querySelector<HTMLCanvasElement>("#assistant")!;
// The accessible name follows the state: "Coach", then "Coach, listening"…
// State changes are spoken politely; `announce: false` turns that off.
export const fx = mount(canvas, {
pattern: "glowing",
voice: new LocalMicVoiceSource(),
label: "Coach",
labels: { listening: "Koç dinliyor", speaking: "Koç konuşuyor" }, // your words, per state
announce: true,
});
| Option | Web (mount, <sinua-view>, React) | iOS | Android | React Native |
|---|---|---|---|---|
| words per state | labels | labels: | labels = | labels |
| speak state changes (on) | announce | announce: | announce = | announce |
| a tap when listening starts (off) | — | haptics: | haptics = | haptics |
derive the state from rules (on) | rules | rules: | rules = | rules |
One-shot effects are announced too, right away, since they're events.
Reduced motion
Views follow the system setting: prefers-reduced-motion on the web, Reduce Motion on iOS, Remove animations on Android. With it on, a view draws a single still frame. A connected voice is the exception: the visual still reacts to it, gently (up to 30 redraws a second), because the cue carries information. Override with reducedMotion: "always" or "never".
Colour and contrast
Ink visuals mirror their lightness on dark themes, so they keep contrast on either background. With a fixed brand colour (color.mode: "fixed"), check contrast on both themes yourself. Don't rely on colour alone to carry meaning: pair a state change with a label, or with a pattern that changes shape.
Reduced motion:
import { mount } from "@sinua/web";
const canvas = document.querySelector<HTMLCanvasElement>("#orb")!;
// "auto" (default) follows prefers-reduced-motion: a still pose instead of motion.
// With a voice attached it still redraws (up to 30 Hz): the voice cue is information.
export const fx = mount(canvas, { pattern: "working", reducedMotion: "auto" });
// "always" / "never" override the system setting, e.g. from an in-app preference:
fx.update({ reducedMotion: "always" });
The accessibility label:
import { mount } from "@sinua/web";
const canvas = document.querySelector<HTMLCanvasElement>("#orb")!;
// The canvas gets role="img" and this name. Default: the spec's `name`, else the pattern.
export const fx = mount(canvas, { pattern: "listening", label: "Assistant is listening" });
// Purely decorative (text next to it already says it): an empty label hides it.
fx.update({ label: "" });