Part 11 of 14 25 Jan 2026 By Raj Patil 30 min read

Part 11: Accessibility - Building for Everyone

Part 11 of the Freya Rust GUI series. Learn to make your applications accessible with proper roles, keyboard navigation, focus management, and screen reader support.

Intermediate #rust #freya #gui #accessibility #a11y #screen-reader #keyboard #tutorial
Building Native GUIs with Rust & Freya 11 / 14

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:

RoleDescription
ButtonClickable button
CheckBoxCheckbox control
SwitchToggle switch
TextInputText input field
SliderSlider control
LinkNavigation link
MenuItemMenu item
TabTab in a tab list
ImageImage element
HeadingSection heading
ListList container
ListItemList item
StatusStatus 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

  1. Tab through all interactive elements

    • All buttons, links, inputs should be reachable
    • Tab order should be logical
  2. Activate with Enter and Space

    • Buttons should respond to both
    • Links should respond to Enter
  3. Use arrow keys where appropriate

    • Lists, menus, tabs should support arrow navigation
  4. Escape closes things

    • Modals, dropdowns, menus should close with Escape
  5. Focus is visible

    • Always show a visible focus indicator

Screen Reader Testing

Test with screen readers on each platform:

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 ActionKeyboard Equivalent
ClickEnter or Space
HoverFocus (Tab)
DragArrow keys
Context menuShift+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:


Previous: Part 10: Theming ←

Next: Part 12: Routing →

In the next tutorial, we’ll explore routing - navigating between different views in your application.