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.
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 containerimport { 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
// 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)
)#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
})#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
})#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)
)
}
}// 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)
)#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()
}
}
}#How It Works
The focus trap system consists of three key components:
- FocusTrapContainer: Wraps any container element and registers it as a focus trap area
- FocusTrapManager: Global state manager that tracks all active focus traps
- Root Integration: The Root view intercepts Tab/Shift-Tab events and enforces focus cycling
When Tab or Shift-Tab is pressed:
- Root detects if the currently focused element is inside a focus trap
- If yes, it calculates the next focusable element within the same trap
- If focus would escape the trap, it cycles back to the beginning (Tab) or end (Shift-Tab)
- 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
- Root View System - Manages focus trap behavior at the window level
- Dialog - Uses focus trap automatically
- Sheet - Uses focus trap automatically
- focus-trap-react - Similar concept for React applications
#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`.
| Name | Kind | Type | Default |
|---|---|---|---|
enabled | in | bool | true |
escaped | callback | () |