Focus Trap

Focus trap utility for constraining keyboard focus within a specific container. Essential for modal dialogs, sheets, and overlay components to provide proper keyboard navigation accessibility.

Install

Note: Dialog and Sheet components have focus trap built-in. You only need to manually use focus_trap() for custom modal-like components.

#Import

use gpui_kit::component::FocusTrapElement;
// Installed in ui/components. That directory is on the Slint include path, so import by file name:
import { FocusTrap } from "focus-trap.slint";
use iced::keyboard;
use iced::widget::{
use iced::{Alignment, Element, Length, Shadow, Subscription, Task, Theme, Vector};

#Usage

#Basic Focus Trap

let container_handle = cx.focus_handle();

v_flex()
    .child(Button::new("btn1").label("Button 1"))
    .child(Button::new("btn2").label("Button 2"))
    .child(Button::new("btn3").label("Button 3"))
    .focus_trap("trap1", &container_handle)
// Pressing Tab will cycle: btn1 -> btn2 -> btn3 -> btn1
// Focus will not escape to elements outside this container
import { Theme }
from "theme.slint";
import { GroupBox, GroupBoxVariant }
from "section.slint";
import { FocusTrap }
from "focus-trap.slint";
import { Button }
from "button.slint";
import { Input }
from "input.slint";
export component FocusTrapExample inherits VerticalLayout {
    horizontal-stretch: 1;
    padding: Theme.page-padding(Theme.viewport);
    spacing: 24px;
    alignment: start;
    FocusTrap {
        Input {
            placeholder: "First field";
        }
        Input {
            placeholder: "Second field";
        }
        Button {
            text: "Submit";
        }
    }
}
// The Slint example's own region: two fields and an action, stacked at the
// reference's 12px gap inside its 16px inset and one-pixel edge. The region
// names both fields, so `Tab` wraps second -> first inside it.
let basic = body_box(
    column![
        FocusTrap::new()
            .focusable(BASIC_FIELDS[0].clone())
            .child(
                Input::new(BASIC_FIELDS[0].clone())
                    .placeholder("First field")
                    .value(app.first.clone())
                    .on_input(Message::First),
            )
            .focusable(BASIC_FIELDS[1].clone())
            .child(
                Input::new(BASIC_FIELDS[1].clone())
                    .placeholder("Second field")
                    .value(app.second.clone())
                    .on_input(Message::Second),
            )
            .child(Button::new("Submit").on_press(Message::Submitted)),
        muted_line(
            "The frame, the gap and the inset are the reference's. iced binds no key, so \
             the subscription above drives Tab between these two fields and wraps it here.",
        ),
    ]
    .spacing(8.0),
);

#Multiple Focus Traps

GPUINo GPUI version of this example yet.
SlintThis example is GPUI-specific. The Slint usage near the top of the page covers this component.
// Two regions sharing a row, each naming its own field. The region that
// holds the focused id is the one `Tab` steps through, so the two traps
// stay independent; a button is not focusable in iced, so each one holds
// its single field.
let multiple = body_box(
    column![
        row![
            FocusTrap::new()
                .focusable(AREA_ONE_FIELDS[0].clone())
                .child(
                    Input::new(AREA_ONE_FIELDS[0].clone())
                        .placeholder("Area 1")
                        .value(app.area_one.clone())
                        .on_input(Message::AreaOne),
                )
                .child(Button::new("Continue").on_press(Message::Submitted)),
            FocusTrap::new()
                .focusable(AREA_TWO_FIELDS[0].clone())
                .child(
                    Input::new(AREA_TWO_FIELDS[0].clone())
                        .placeholder("Area 2")
                        .value(app.area_two.clone())
                        .on_input(Message::AreaTwo),
                )
                .child(Button::new("Cancel").on_press(Message::Submitted)),
        ]
        .spacing(GAP),
        muted_line(
            "Two traps, side by side. Each holds its own field, so Tab stays in the region \
             it started in; a button is not focusable in iced 0.14."
        ),
    ]
    .spacing(8.0),
);

You can have multiple independent focus trap areas in your application. Each trap operates independently:

let trap1_handle = cx.focus_handle();
let trap2_handle = cx.focus_handle();

v_flex()
    .gap_4()
    // First focus trap area
    .child(
        h_flex()
            .gap_2()
            .child(Button::new("trap1-1").label("Area 1 - Button 1"))
            .child(Button::new("trap1-2").label("Area 1 - Button 2"))
            .child(Button::new("trap1-3").label("Area 1 - Button 3"))
            .focus_trap("trap1", &trap1_handle)
    )
    // Second focus trap area
    .child(
        h_flex()
            .gap_2()
            .child(Button::new("trap2-1").label("Area 2 - Button 1"))
            .child(Button::new("trap2-2").label("Area 2 - Button 2"))
            .focus_trap("trap2", &trap2_handle)
    )
SlintThis example is GPUI-specific. The Slint usage near the top of the page covers this component.

#Focus Trap with Dialog

Dialog components have focus trap built-in automatically. You don’t need to manually add focus_trap():

window.open_dialog(cx, |dialog, _, _| {
    dialog
        .title("Settings")
        .child(
            v_flex()
                .gap_3()
                .child(Button::new("save").label("Save"))
                .child(Button::new("cancel").label("Cancel"))
                .child(Button::new("reset").label("Reset"))
        )
    // Dialog internally uses focus_trap()
    // Tab navigation automatically cycles: save -> cancel -> reset -> save
})
SlintThis example is GPUI-specific. The Slint usage near the top of the page covers this component.

#Focus Trap with Sheet

Sheet components also have focus trap built-in automatically:

window.open_sheet(cx, |sheet, _, _| {
    sheet
        .title("Filter Options")
        .child(
            v_flex()
                .gap_2()
                .child(Checkbox::new("option1").label("Option 1"))
                .child(Checkbox::new("option2").label("Option 2"))
                .child(Button::new("apply").label("Apply Filters"))
        )
    // Sheet internally uses focus_trap()
    // Focus automatically cycles within the sheet panel
})
SlintThis example is GPUI-specific. The Slint usage near the top of the page covers this component.

#Custom Modal with Focus Trap

struct CustomModal {
    container_handle: FocusHandle,
}

impl CustomModal {
    fn new(cx: &mut App) -> Self {
        Self {
            container_handle: cx.focus_handle(),
        }
    }
}

impl Render for CustomModal {
    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        div()
            .absolute()
            .inset_0()
            .flex()
            .items_center()
            .justify_center()
            .child(
                v_flex()
                    .gap_4()
                    .p_6()
                    .bg(cx.theme().background)
                    .rounded(cx.theme().radius_lg)
                    .shadow_lg()
                    .border_1()
                    .border_color(cx.theme().border)
                    .child("This is a modal dialog")
                    .child(
                        h_flex()
                            .gap_2()
                            .child(Button::new("ok").primary().label("OK"))
                            .child(Button::new("cancel").label("Cancel"))
                    )
                    .focus_trap("modal", &self.container_handle)
            )
    }
}
SlintThis example is GPUI-specific. The Slint usage near the top of the page covers this component.
// The page's own custom-modal shape: a dimmed full-size backdrop with the
// region centered on it, here turned into a panel by the style closure. Its
// two fields are what the trap holds, and `Tab` cycles them rather than
// reaching the page behind.
let custom_modal = body_box(
    column![
        Button::new("Open Modal").on_press(Message::OpenModal),
        muted_line(
            "A custom modal: the region is the panel, over a dimmed backdrop, and Tab \
             cycles its two fields."
        ),
    ]
    .spacing(8.0),
);

let modal_panel = FocusTrap::new()
    .style(|theme: &Theme| {
        let tokens = Tokens::of(theme);
        Style::resolve(tokens).fill(tokens.popover).shadow(Shadow {
            color: tokens.shadow,
            offset: Vector::new(0.0, 4.0),
            blur_radius: if tokens.dark { 8.0 } else { 4.0 },
        })
    })
    .child(
        text("Delete workspace?")
            .size(theme::FONT_SIZE_LG)
            .font(iced::Font {
                weight: iced::font::Weight::Semibold,
                ..iced::Font::DEFAULT
            })
            .style(|theme: &Theme| text::Style {
                color: Some(Tokens::of(theme).foreground),
            }),
    )
    .child(
        text("Type the workspace name to confirm. Escape and the backdrop both dismiss it.")
            .size(theme::FONT_SIZE_SM)
            .style(|theme: &Theme| text::Style {
                color: Some(Tokens::of(theme).muted_foreground),
            }),
    )
    .focusable(MODAL_FIELDS[0].clone())
    .child(
        Input::new(MODAL_FIELDS[0].clone())
            .placeholder("Workspace name")
            .value(app.modal_name.clone())
            .on_input(Message::ModalName),
    )
    .focusable(MODAL_FIELDS[1].clone())
    .child(
        Input::new(MODAL_FIELDS[1].clone())
            .placeholder("Reason")
            .value(app.modal_reason.clone())
            .on_input(Message::ModalReason),
    )
    .child(
        row![
            Button::new("Cancel")
                .outline()
                .width(Length::Fill)
                .on_press(Message::CloseModal),
            Button::new("Delete")
                .variant(button::Variant::Danger)
                .width(Length::Fill)
                .on_press(Message::CloseModal),
        ]
        .spacing(8.0),
    );

// The layer, or nothing at all while the modal is closed.
let modal_layer: Element<'_, Message> = if app.modal_open {
    // The panel captures the press, so a click on its own body never reaches
    // the backdrop: iced captures only for a `mouse_area` that has a message.
    let panel = container(mouse_area(modal_panel).on_press(Message::StayOpen))
        .width(Length::Fill)
        .max_width(MODAL_MAX_WIDTH);
    let centered = container(panel)
        .width(Length::Fill)
        .height(Length::Fill)
        .padding([0.0, PAGE])
        .center_x(Length::Fill)
        .center_y(Length::Fill);

    let backdrop = mouse_area(
        container(Space::new().width(Length::Fill).height(Length::Fill))
            .width(Length::Fill)
            .height(Length::Fill)
            .style(|theme: &Theme| container::Style {
                background: Some(Tokens::of(theme).overlay.into()),
                ..container::Style::default()
            }),
    )
    .on_press(Message::CloseModal);

    // A `Stack` sizes from its first child, so a covering layer needs the
    // explicit fill the module comment names.
    Stack::new()
        .push(backdrop)
        .push(centered)
        .width(Length::Fill)
        .height(Length::Fill)
        .into()
} else {
    Space::new().into()
};

#Nested Focus Traps

Focus traps support nesting. When multiple traps are active, the innermost trap takes precedence:

let outer_handle = cx.focus_handle();
let inner_handle = cx.focus_handle();

div()
    .child(
        v_flex()
            .gap_4()
            .p_4()
            .border_1()
            .border_color(cx.theme().border)
            .child(Button::new("outer-1").label("Outer Button 1"))
            .child(
                // Inner trap takes precedence when focused
                h_flex()
                    .gap_2()
                    .p_4()
                    .bg(cx.theme().accent.opacity(0.1))
                    .child(Button::new("inner-1").label("Inner Button 1"))
                    .child(Button::new("inner-2").label("Inner Button 2"))
                    .focus_trap("inner", &inner_handle)
            )
            .child(Button::new("outer-2").label("Outer Button 2"))
            .focus_trap("outer", &outer_handle)
    )
SlintThis example is GPUI-specific. The Slint usage near the top of the page covers this component.

#Conditional Focus Trap

You can conditionally apply focus trapping based on application state:

struct ModalView {
    is_modal: bool,
    container_handle: FocusHandle,
}

impl Render for ModalView {
    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let content = v_flex()
            .gap_2()
            .child(Button::new("btn1").label("Button 1"))
            .child(Button::new("btn2").label("Button 2"))
            .child(Button::new("btn3").label("Button 3"));

        if self.is_modal {
            // Apply focus trap when in modal mode
            content.focus_trap("conditional", &self.container_handle)
                .into_any_element()
        } else {
            // Normal behavior without focus trap
            content.into_any_element()
        }
    }
}
SlintThis example is GPUI-specific. The Slint usage near the top of the page covers this component.

#How It Works

The focus trap system consists of three key components:

  1. FocusTrapContainer: Wraps any container element and registers it as a focus trap area
  2. FocusTrapManager: Global state manager that tracks all active focus traps
  3. Root Integration: The Root view intercepts Tab/Shift-Tab events and enforces focus cycling

When Tab or Shift-Tab is pressed:

  1. Root detects if the currently focused element is inside a focus trap
  2. If yes, it calculates the next focusable element within the same trap
  3. If focus would escape the trap, it cycles back to the beginning (Tab) or end (Shift-Tab)
  4. This prevents focus from leaving the trapped container

#Built-in Focus Trap Components

The following components have focus trap functionality built-in and don’t require manual focus_trap() calls:

  • Dialog - Modal dialogs automatically trap focus (see dialog.rs:437)
  • Sheet - Side panels automatically trap focus (see sheet.rs:197)

#Accessibility Notes

  • Focus trapping is essential for modal dialogs and overlays to meet WCAG accessibility guidelines
  • Always provide a way to close or dismiss trapped focus areas (ESC key, close button)
  • The first focusable element in the trap should receive focus when the trap is activated
  • Use focus traps sparingly - only for truly modal interactions
  • Ensure keyboard navigation order is logical within the trapped area

#See Also

#API Reference

#FocusTrap

Keeps keyboard focus cycling inside a region. Mirrors GPUI Component's `FocusTrap`. Slint FocusScope does not expose a full tab ring; Tab still walks the children, and Escape calls `escaped`.

NameKindTypeDefault
enabledinbooltrue
escapedcallback()