Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Accessibility

In this chapter, you will:

  • Rely on the labels WaterUI forces every control to carry
  • Hide a label visually without removing it from the accessibility tree
  • Override labels, roles, and states for custom widgets
  • Report disabled and hidden states correctly
  • Assert on the accessibility tree in cargo test

WaterUI does not treat accessibility as an annotation you add later. Its control constructors put the accessible name in the signature, so the common path already produces a labeled control. The work left to you is the part the type system cannot do: describing custom composites and hiding decoration.

The types live in waterui::accessibility; the modifiers are on ViewExt.

Labels are mandatory, visibility is not

button, slider, stepper, toggle, and field all take an impl IntoLabel as their first argument. Screen readers, voice control, switch control, and command palettes all read that label — a control without one is an anonymous widget that assistive technology users cannot reach.

Reach for the ergonomic free functions rather than Toggle::new(&binding) and TextField::new(&binding), which start with an empty label and rely on you remembering .label(...) afterwards.

Hiding the label is a separate, presentational decision. .hide_label() collapses the label’s rendered view to zero size while keeping the semantic text in the accessibility tree:

use waterui::prelude::slider::slider;
use waterui::prelude::*;

let progress = Binding::f64(0.5);

// Announced as "Playback position"; nothing is drawn next to the track.
slider("Playback position", &progress).hide_label()

.hide_label() is shorthand for .label_style(LabelDisplayMode::Hidden). The other modes — TitleAndIcon, TitleOnly, IconOnly, and the default Automatic — pick between title and icon without ever dropping the semantic text. Install a LabelDisplayMode in the environment to set the default for a whole subtree.

Icon-only controls

An icon-only button is a display mode, not a label-less button:

use waterui::prelude::*;

// Still announced as "Search".
button(label("Search").icon(search_icon()).icon_only())
    .action(run_search)

Label::icon takes any view, so an icon-pack crate works here. SystemIcon (via Label::system_icon) renders SF Symbols on Apple platforms and is intentionally unsupported on Android, Linux, and Web — for portable code prefer Label::icon with waterui-icons-lucide, waterui-icons-material-icon, or waterui-icons-fontawesome7.

When the visible content is not the spoken text

Label::new(semantic_text, content) is the general constructor: it takes arbitrary visual content plus the text assistive technology should announce.

use waterui::prelude::*;

let verified = Label::new(
    "Account, verified",
    hstack((text("Account"), verification_badge())),
);

button(verified).action(open_account)

A Label::new label owns its own layout, so the semantic-label builders (.icon(), .system_icon(), .leading(), .trailing(), .spacing(), .font()) panic on it — compose those inside content instead.

Overriding a label

ViewExt::a11y_label replaces the spoken label for any view. Reach for it when the view is not a control and its visual content does not describe it:

use waterui::prelude::*;

logo_image().a11y_label("Acme, home")

Keep it short and action-oriented, and leave out prefixes like “Button:” — the role already communicates that.

The label is reactive. Pass a signal and a label derived from app state stays current without rebuilding the subtree, the same way accessibility state does:

use waterui::prelude::*;

fn inbox(unread: &Binding<i32>) -> impl View {
    let label = unread.clone().map(|n| Str::from(format!("{n} unread messages")));
    button("Inbox").action(|| {}).a11y_label(label)
}

Roles

AccessibilityRole describes what a view is. Built-in controls set their own role; custom composites need one assigned:

CategoryRoles
InteractiveButton, Link, Checkbox, RadioButton, Switch, Slider
ContentText, Image, Header, Footer, Article
StructureNavigation, Main, Search, Section, Group
CollectionsList, ListItem, Tab, TabList, TabPanel
MenusMenu, MenuItem, MenuBar, MenuItemCheckbox, MenuItemRadio
FormsCombobox, Option, ProgressBar

Navigation containers do not attach landmark roles for you. If you build a sidebar, say so:

use waterui::accessibility::AccessibilityRole;
use waterui::prelude::*;

vstack((
    text("Menu").headline(),
    button("Home").action(go_home),
    button("Settings").action(go_settings),
))
.a11y_role(AccessibilityRole::Navigation)
.a11y_label("Main navigation")

States

AccessibilityState carries what a label and role cannot express. It is a const builder, so a state is cheap to construct:

use waterui::accessibility::AccessibilityState;

let state = AccessibilityState::new().expanded(Some(true)).busy(false);
FieldMeaning
disabledVisible but not interactive
selectedThe current selection within its group
checkedSome(true), Some(false), or None for mixed
expandedSome(true) / Some(false) for disclosure controls
busyLoading or processing
hiddenNot exposed to assistive technology

Attach a fixed state with .a11y_state(state), or a reactive one with .a11y_state_signal(signal):

use waterui::accessibility::AccessibilityState;
use waterui::prelude::*;

fn disclosure(expanded: &Binding<bool>, content: impl View) -> impl View {
    let state = expanded.map(|open| AccessibilityState::new().expanded(Some(open)));

    content.a11y_state_signal(state)
}

Disabled and hidden come for free

Two ViewExt modifiers already write state for you:

  • .disabled(signal) installs a disabled scope over the subtree. Controls render their platform disabled appearance, stop hit-testing, and report the disabled state to assistive technology. Nested scopes OR-combine, so an enclosing .disabled(true) cannot be undone by a child.
  • .visible(signal) sets hidden on the accessibility state as it fades the view out, so an invisible view is not announced.

.disabled(...) reaches every control, because no control implements disabled state itself: each one reads the scope in force at its own position. A menu Command carries the state as data instead — a menu is a list of records handed to the platform’s menu API, with no leaf environment to read — and its own flag is OR-combined with the enclosing scope when the command resolves.

Hiding decoration

Background patterns, dividers, and brand marks add noise to a screen reader. ViewExt::a11y_hidden(true) drops a view from the tree:

use waterui::prelude::*;

decorative_swirl().a11y_hidden(true)

When you have re-described a whole composite with one label, drop its children instead of hiding the container:

use waterui::accessibility::AccessibilityChildren;
use waterui::prelude::*;

hstack((star_icon(), text("4.8"), text("(120)")))
    .a11y_label("Rated 4.8 out of 5, 120 reviews")
    .a11y_children(AccessibilityChildren::ExcludeDescendants)

A custom control, end to end

A star rating needs a role and a label on the container and on each star. Note that the filled/empty glyph comes from a mapped signal fed into text — not from watch, which would rebuild the star subtree on every rating change and discard its state.

use waterui::accessibility::{AccessibilityRole, AccessibilityState};
use waterui::prelude::*;

fn star_rating(rating: &Binding<i32>, max: i32) -> impl View {
    hstack(
        (0..max)
            .map(|i| {
                let glyph = rating.map(move |r| if r > i { "★" } else { "☆" }).computed();
                let filled = rating.map(move |r| {
                    AccessibilityState::new().selected(r > i)
                });

                text(glyph)
                    .a11y_label(format!("Rate {} of {max}", i + 1))
                    .a11y_role(AccessibilityRole::Button)
                    .a11y_state_signal(filled)
                    .state(rating)
                    .on_tap(move |State(r): State<Binding<i32>>| r.set(i + 1))
            })
            .collect::<Vec<_>>(),
    )
    .a11y_role(AccessibilityRole::Group)
    .a11y_label("Rating")
}

The current value reaches the screen reader through each star’s selected state rather than through the container’s label. That is deliberate: AccessibilityLabel wraps a plain Str, so labels are fixed when the view is built. Only AccessibilityState has a reactive form (.a11y_state_signal(...)). When a value genuinely needs to be announced as it changes, use a real Slider — it reports its own value — instead of relabeling a custom composite.

Reduced motion

WaterUI does not ship a “prefers reduced motion” signal. Define a marker type, install it from your backend integration, and pick the animation from it:

use core::time::Duration;
use waterui::animation::Animation;
use waterui::prelude::*;

#[derive(Debug, Clone, Copy)]
struct PrefersReducedMotion(bool);

fn entrance(env: &Environment) -> impl View {
    let opacity = Binding::f32(0.0);
    let reduced = env.get::<PrefersReducedMotion>().is_some_and(|p| p.0);

    let animation = if reduced {
        Animation::linear(Duration::ZERO)
    } else {
        Animation::ease_in_out(Duration::from_millis(300))
    };

    text("Welcome")
        .opacity(opacity.with(animation))
        .on_appear(move || opacity.set(1.0))
}

A zero-duration animation applies the value immediately, so the same code path serves both preferences. This matters for users with vestibular disorders — wire your platform’s reduced-motion API into the environment rather than ignoring the preference.

Focus

ViewExt::focused drives both the visual focus ring and accessibility focus from one binding:

use waterui::prelude::*;

#[derive(Clone, PartialEq, Eq)]
enum Field {
    Name,
    Email,
}

let focus = Binding::container(None::<Field>);
let name = Binding::container(Str::from(""));

field("Name", &name).focused(&focus, Field::Name)

Setting focus to Some(Field::Name) moves VoiceOver or TalkBack focus to that field.

Testing the tree

waterui-testing drives views through the Hydrolysis accessibility tree, so an interaction test is an accessibility test. Query by role and label, then assert on the node:

use waterui::prelude::*;
use waterui_testing::{Role, SemanticApp};

fn submit_button() -> impl View {
    button("Submit").action(|| {}).disabled(true)
}

#[waterui::test(submit_button)]
fn submit_is_named_and_reports_disabled(app: &mut SemanticApp) {
    let element = app.query().role(Role::BUTTON).label("Submit").single();
    assert!(!element.node().enabled());
}

#[waterui::test(...)] expands to a plain #[test], so these run under the normal harness. If a component cannot be reached by role and label, that is a bug in the component, not a reason to skip the test.

Pair automated checks with the platform auditors before shipping: Accessibility Inspector on iOS and macOS, Accessibility Scanner on Android, VoiceOver (Cmd+F5), and Accerciser for AT-SPI on GTK. Ten minutes navigating your own app with a screen reader turned on finds things no assertion will.

What’s next

Your app is usable regardless of ability. In the next chapter you will make it readable regardless of language, with translation catalogs, CLDR plural rules, and locale-aware formatting.