Accessibility
Accessibility (often abbreviated as “a11y”) ensures your applications can be used by everyone, including people with disabilities. Freya provides built-in accessibility support through AccessKit.
[!NOTE] Why Accessibility Matters
- ~15% of the world’s population has some form of disability
- Many users rely on keyboard navigation (not just mouse)
- Screen readers help blind and visually impaired users
- Accessible apps often have better UX for everyone
- It’s often a legal requirement
Core Concepts
Accessibility Roles
Every interactive element should have an appropriate role that describes what it is:
rect()
.a11y_role(AccessibilityRole::Button)
Common roles:
| Role | Description |
|---|---|
Button | Clickable button |
CheckBox | Checkbox control |
Switch | Toggle switch |
TextInput | Text input field |
Slider | Slider control |
Link | Navigation link |
MenuItem | Menu item |
Tab | Tab in a tab list |
Image | Image element |
Heading | Section heading |
List | List container |
ListItem | List item |
Status | Status message |
Focusable Elements
Make elements keyboard-focusable:
rect()
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
[!IMPORTANT] Focus is Essential Without focus, users cannot interact with elements using only a keyboard. All interactive elements must be focusable.
Focus Management
use_focus Hook
The use_focus hook provides focus management:
fn focusable_component() -> impl IntoElement {
let focus = use_focus();
rect()
.a11y_id(focus.a11y_id())
.a11y_focusable(true)
.on_pointer_down(move |_| {
focus.request_focus();
})
.child(label().text("Click to focus"))
}
Checking Focus Status
fn styled_button() -> impl IntoElement {
let focus = use_focus();
let focus_status = use_focus_status(focus);
let border_color = match focus_status() {
FocusStatus::Keyboard => Color::BLUE, // Focused via Tab
FocusStatus::Pointer => Color::GREEN, // Focused via click
FocusStatus::Not => Color::GRAY, // Not focused
};
rect()
.a11y_id(focus.a11y_id())
.a11y_focusable(true)
.border(Border::new().fill(border_color).width(2.0))
.padding(12.0)
.child(label().text("Focusable"))
}
Focus Status Types
enum FocusStatus {
Keyboard, // Focused via Tab navigation
Pointer, // Focused via mouse/touch click
Not, // Not focused
}
Programmatic Focus Control
let focus = use_focus();
// Request focus
focus.request_focus();
// Remove focus
focus.request_unfocus();
// Check if focused
if focus.is_focused() {
println!("Element is focused");
}
// Check if focused by keyboard
if focus.is_focused_with_keyboard() {
println!("Focused via Tab key");
}
Labels and Descriptions
Accessible Name (Alt Text)
Provide accessible names for elements, especially icons:
rect()
.a11y_role(AccessibilityRole::Button)
.a11y_focusable(true)
.a11y_alt("Save document") // Screen reader reads this
.on_press(|_| save())
.child(SaveIcon::new())
Labeling Inputs
Always associate labels with inputs:
rect()
.direction(Direction::Vertical)
.gap(4.0)
.child(
label()
.text("Username")
.font_weight(FontWeight::Medium)
)
.child(
Input::new(username)
.placeholder("Enter your username")
)
Accessibility Attributes
a11y_id
Unique identifier for the accessibility tree:
let focus = use_focus();
rect()
.a11y_id(focus.a11y_id())
a11y_role
Semantic role of the element:
.a11y_role(AccessibilityRole::Button)
a11y_focusable
Whether the element can receive keyboard focus:
.a11y_focusable(true)
a11y_auto_focus
Automatically focus when the element mounts:
Input::new(value)
.auto_focus(true)
a11y_alt
Alternative text description for screen readers:
.a11y_alt("Close dialog")
a11y_builder
Custom AccessKit node configuration:
.a11y_builder(|builder| {
builder.set_toggled(Toggled::True);
})
Keyboard Navigation
Tab Navigation
Focusable elements are automatically included in tab order:
// These will be in tab order
Button::new().child("First")
Button::new().child("Second")
Input::new(value).placeholder("Third")
Arrow Key Navigation
For lists and custom components:
fn accessible_list() -> impl IntoElement {
let items = use_state(|| vec!["Item 1", "Item 2", "Item 3"]);
let focused_index = use_state(|| 0);
rect()
.direction(Direction::Vertical)
.a11y_role(AccessibilityRole::List)
.on_key_down(move |e| {
match &e.key {
Key::Named(NamedKey::ArrowDown) => {
focused_index.with_mut(|i| {
*i = (*i + 1).min(items().len() - 1);
});
}
Key::Named(NamedKey::ArrowUp) => {
focused_index.with_mut(|i| {
*i = i.saturating_sub(1);
});
}
Key::Named(NamedKey::Enter) => {
println!("Selected: {}", items()[focused_index()]);
}
_ => {}
}
})
.children(items.read().iter().enumerate().map(|(i, item)| {
accessible_list_item(i, item, i == focused_index())
}))
}
Activation (Enter/Space)
Elements should respond to Enter and Space:
rect()
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.on_key_down(|e| {
if Focus::is_pressed(&e) {
// Focus::is_pressed checks for Enter or Space
perform_action();
}
})
.on_pointer_down(|_| {
perform_action();
})
Custom Accessible Components
Accessible Checkbox
fn custom_checkbox(checked: State<bool>) -> impl IntoElement {
let focus = use_focus();
let focus_status = use_focus_status(focus);
rect()
.a11y_id(focus.a11y_id())
.a11y_role(AccessibilityRole::CheckBox)
.a11y_focusable(true)
.a11y_builder(|builder| {
builder.set_toggled(if checked() {
Toggled::True
} else {
Toggled::False
});
})
.width(Size::px(24.0))
.height(Size::px(24.0))
.border(Border::new()
.width(2.0)
.fill(if focus_status().is_focused() {
Color::BLUE
} else {
Color::GRAY
})
)
.corner_radius(4.0)
.main_align_center()
.cross_align_center()
.on_pointer_down(move |_| checked.toggle())
.on_key_down(move |e| {
if Focus::is_pressed(&e) {
checked.toggle();
}
})
.child(if checked() {
Some(label().text("✓").font_weight(FontWeight::Bold))
} else {
None
})
}
Accessible Icon Button
fn icon_button(icon: Icon, label: &str, on_press: impl Fn() + 'static) -> impl IntoElement {
let focus = use_focus();
let focus_status = use_focus_status(focus);
rect()
.a11y_id(focus.a11y_id())
.a11y_role(AccessibilityRole::Button)
.a11y_focusable(true)
.a11y_alt(label)
.width(Size::px(44.0))
.height(Size::px(44.0))
.background(if focus_status().is_focused() {
Color::from_rgb(230, 230, 230)
} else {
Color::TRANSPARENT
})
.corner_radius(8.0)
.main_align_center()
.cross_align_center()
.on_pointer_down(move |_| on_press())
.on_key_down(move |e| {
if Focus::is_pressed(&e) {
on_press();
}
})
.child(icon)
}
Built-in Components
Freya’s built-in components include accessibility by default:
Button
Button::new()
.on_press(|_| do_action())
.child("Click me")
// Automatically includes:
// - AccessibilityRole::Button
// - Keyboard focusable
// - Enter/Space activation
Input
Input::new(value)
.placeholder("Enter text")
// Automatically includes:
// - AccessibilityRole::TextInput
// - Keyboard focusable
// - Text input support
Checkbox & Switch
Checkbox::new().selected(checked)
Switch::new().toggled(enabled)
// Automatically includes:
// - Correct accessibility role
// - Toggled state announced
Testing Accessibility
Keyboard Testing Checklist
-
Tab through all interactive elements
- All buttons, links, inputs should be reachable
- Tab order should be logical
-
Activate with Enter and Space
- Buttons should respond to both
- Links should respond to Enter
-
Use arrow keys where appropriate
- Lists, menus, tabs should support arrow navigation
-
Escape closes things
- Modals, dropdowns, menus should close with Escape
-
Focus is visible
- Always show a visible focus indicator
Screen Reader Testing
Test with screen readers on each platform:
- Windows: NVDA (free), Narrator (built-in)
- macOS: VoiceOver (built-in)
- Linux: Orca (built-in in GNOME)
Focus Indicator Test
fn visible_focus_test() -> impl IntoElement {
let focus = use_focus();
let focus_status = use_focus_status(focus);
rect()
.a11y_id(focus.a11y_id())
.a11y_focusable(true)
.border(Border::new()
.width(if focus_status().is_focused() { 3.0 } else { 1.0 })
.fill(if focus_status().is_focused() {
Color::BLUE
} else {
Color::GRAY
})
)
.child(label().text("I have visible focus!"))
}
Best Practices
1. Use Semantic Roles
Choose the most appropriate role for each element:
// Good
rect().a11y_role(AccessibilityRole::Button)
// Bad - generic role
rect().a11y_role(AccessibilityRole::Generic)
2. Ensure Keyboard Accessibility
Every mouse interaction must have a keyboard equivalent:
| Mouse Action | Keyboard Equivalent |
|---|---|
| Click | Enter or Space |
| Hover | Focus (Tab) |
| Drag | Arrow keys |
| Context menu | Shift+F10 or Menu key |
3. Provide Labels
Icons and images need accessible names:
rect()
.a11y_alt("Settings") // Screen reader announces this
.child(SettingsIcon::new())
4. Maintain Visible Focus
Always show clear focus indicators:
.border(Border::new()
.fill(if is_focused { Color::BLUE } else { Color::GRAY })
.width(if is_focused { 2.0 } else { 1.0 })
)
5. Announce Dynamic Changes
Use appropriate roles for status updates:
rect()
.a11y_role(AccessibilityRole::Status)
.child(label().text("File saved successfully"))
6. Test with Real Tools
Don’t assume accessibility - test with actual screen readers and keyboard-only navigation.
Summary
In this tutorial, you learned:
- Accessibility roles - Describing what elements are
- Focus management - use_focus, focus status, programmatic control
- Labels and descriptions - Alt text for screen readers
- Keyboard navigation - Tab order, arrow keys, activation
- Custom accessible components - Building with a11y in mind
- Testing - Keyboard and screen reader testing
Previous: Part 10: Theming ←
Next: Part 12: Routing →
In the next tutorial, we’ll explore routing - navigating between different views in your application.