Utility
FocusTrap
A utility component that traps keyboard focus within its children, preventing focus from escaping to elements outside the trap.
Demo
Features
- Traps Tab and Shift+Tab navigation within boundaries
- Automatic focus on activation
- Returns focus on deactivation
- Handles edge cases (no focusable elements)
- Programmatic focus control methods
- Activation/deactivation callbacks
Installation
bash
dotnet add package SummitUIAnatomy
Import the component and wrap your content:
razor
@using SummitUI.Components.Utilities
<FocusTrap IsActive="true">
<div class="su-trap-container">
<input type="text" class="su-input" />
<button class="su-button">Submit</button>
</div>
</FocusTrap>API Reference
FocusTrap
| Property | Type | Default | Description |
|---|---|---|---|
| ChildContent | RenderFragment? | null | Content to render within the trap |
| IsActive | bool | true | Whether trap is active |
| AutoFocus | bool | true | Auto-focus first element when activated |
| ReturnFocus | bool | true | Return focus to previously focused element on deactivation |
| OnActivated | EventCallback | - | Callback when trap activates |
| OnDeactivated | EventCallback | - | Callback when trap deactivates |
Public Methods
| Method | Returns | Description |
|---|---|---|
| FocusFirstAsync() | ValueTask | Manually focus the first focusable element |
| FocusLastAsync() | ValueTask | Manually focus the last focusable element |
How It Works
Focus Trapping Mechanism
- When activated, the component identifies all focusable elements within its boundaries
- It intercepts Tab and Shift+Tab key presses
- When Tab is pressed on the last focusable element, focus moves to the first
- When Shift+Tab is pressed on the first focusable element, focus moves to the last
- This creates a circular focus loop within the trapped area
Focusable Elements
The following elements are considered focusable:
-
<a>withhrefattribute -
<button>(not disabled) -
<input>(not disabled, nottype="hidden") -
<select>(not disabled) -
<textarea>(not disabled) -
Elements with
tabindex>= 0 -
Elements with
contenteditable
Edge Cases
- No focusable elements:
If there are no focusable elements, the trap container itself receives focus (if it has
tabindex="-1") - Single focusable element: Focus stays on that element
- Dynamically added elements: New focusable elements are automatically included in the trap
Examples
Basic Dialog
razor
@code {
private bool showDialog = false;
}
@if (showDialog)
{
<div class="su-dialog-overlay">
<FocusTrap IsActive="true" AutoFocus="true">
<div class="su-dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title">
<h2 id="dialog-title" class="su-dialog-title">Confirm Action</h2>
<p class="su-dialog-description">Are you sure you want to proceed?</p>
<div class="su-dialog-actions">
<button class="su-button su-button-secondary" @onclick="@(() => showDialog = false)">Cancel</button>
<button class="su-button" @onclick="HandleConfirm">Confirm</button>
</div>
</div>
</FocusTrap>
</div>
}
<button class="su-button" @onclick="@(() => showDialog = true)">Open Dialog</button>
@code {
private void HandleConfirm()
{
// Handle confirmation
showDialog = false;
}
}Modal Form
A form inside a modal with focus trapping.
razor
@code {
private bool showModal = false;
private string name = "";
private string email = "";
}
@if (showModal)
{
<div class="su-modal-overlay" @onclick="@(() => showModal = false)">
<FocusTrap IsActive="true" AutoFocus="true" ReturnFocus="true">
<div class="su-modal" @onclick:stopPropagation="true" role="dialog" aria-modal="true">
<h2 class="su-modal-title">Sign Up</h2>
<form @onsubmit="HandleSubmit" class="su-form">
<div class="su-form-field">
<label for="name" class="su-label">Name</label>
<input type="text" id="name" @bind="name" class="su-input" required />
</div>
<div class="su-form-field">
<label for="email" class="su-label">Email</label>
<input type="email" id="email" @bind="email" class="su-input" required />
</div>
<div class="su-form-actions">
<button type="button" class="su-button su-button-secondary" @onclick="@(() => showModal = false)">
Cancel
</button>
<button type="submit" class="su-button">Submit</button>
</div>
</form>
</div>
</FocusTrap>
</div>
}
<button class="su-button" @onclick="@(() => showModal = true)">Open Sign Up</button>Controlled Activation
Toggle the focus trap on and off.
razor
@code {
private bool isTrapActive = false;
}
<div class="su-control-panel">
<button class="su-button" @onclick="@(() => isTrapActive = !isTrapActive)">
@(isTrapActive ? "Deactivate" : "Activate") Focus Trap
</button>
</div>
<FocusTrap IsActive="@isTrapActive"
OnActivated="HandleActivated"
OnDeactivated="HandleDeactivated">
<div class="su-trap-container">
<h3 class="su-trap-title">Focus Trap Area</h3>
<input type="text" class="su-input" placeholder="First input" />
<input type="text" class="su-input" placeholder="Second input" />
<button class="su-button">Action Button</button>
</div>
</FocusTrap>
@code {
private void HandleActivated()
{
Console.WriteLine("Focus trap activated");
}
private void HandleDeactivated()
{
Console.WriteLine("Focus trap deactivated");
}
}Programmatic Focus Control
Manually focus the first or last element.
razor
@code {
private FocusTrap? focusTrap;
}
<FocusTrap @ref="focusTrap" IsActive="true" AutoFocus="false">
<div class="su-trap-container">
<input type="text" class="su-input" placeholder="First" />
<input type="text" class="su-input" placeholder="Middle" />
<input type="text" class="su-input" placeholder="Last" />
</div>
</FocusTrap>
<div class="su-controls">
<button class="su-button" @onclick="FocusFirst">Focus First</button>
<button class="su-button" @onclick="FocusLast">Focus Last</button>
</div>
@code {
private async Task FocusFirst()
{
if (focusTrap != null)
{
await focusTrap.FocusFirstAsync();
}
}
private async Task FocusLast()
{
if (focusTrap != null)
{
await focusTrap.FocusLastAsync();
}
}
}Sidebar Navigation
A mobile sidebar with focus trapping.
razor
@code {
private bool sidebarOpen = false;
}
<button class="su-button" @onclick="@(() => sidebarOpen = true)" aria-expanded="@sidebarOpen">
Open Menu
</button>
@if (sidebarOpen)
{
<div class="su-sidebar-overlay" @onclick="@(() => sidebarOpen = false)">
<FocusTrap IsActive="true" AutoFocus="true" ReturnFocus="true">
<nav class="su-sidebar" @onclick:stopPropagation="true" role="navigation">
<button class="su-close-btn" @onclick="@(() => sidebarOpen = false)"
aria-label="Close menu">
X
</button>
<a href="/home" class="su-nav-link">Home</a>
<a href="/about" class="su-nav-link">About</a>
<a href="/services" class="su-nav-link">Services</a>
<a href="/contact" class="su-nav-link">Contact</a>
</nav>
</FocusTrap>
</div>
}Nested Focus Traps
Handle nested dialogs with multiple focus traps.
razor
@code {
private bool showInnerDialog = false;
}
<FocusTrap IsActive="true">
<div class="su-outer-dialog">
<h2 class="su-dialog-title">Outer Dialog</h2>
<input type="text" class="su-input" placeholder="Outer input" />
<button class="su-button" @onclick="@(() => showInnerDialog = true)">Open Inner</button>
@if (showInnerDialog)
{
<FocusTrap IsActive="true" AutoFocus="true">
<div class="su-inner-dialog">
<h3 class="su-dialog-title">Inner Dialog</h3>
<input type="text" class="su-input" placeholder="Inner input" />
<button class="su-button" @onclick="@(() => showInnerDialog = false)">Close</button>
</div>
</FocusTrap>
}
</div>
</FocusTrap>Common Use Cases
| Use Case | Configuration |
|---|---|
| Modal dialog | IsActive="true" AutoFocus="true" ReturnFocus="true" |
| Dropdown menu | IsActive="@isOpen" AutoFocus="true" ReturnFocus="true" |
| Sidebar navigation | IsActive="@isOpen" AutoFocus="true" ReturnFocus="true" |
| Inline editor | IsActive="@isEditing" AutoFocus="true" ReturnFocus="true" |
| Toast/notification | IsActive="false" (usually not needed) |
Styling
CSS Example
Common styles for dialogs and overlays used with FocusTrap:
css
.su-dialog-overlay {
position: fixed;
inset: 0;
background: rgb(var(--su-foreground) / 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.su-dialog {
background: rgb(var(--su-card));
padding: 24px;
border-radius: 8px;
border: 1px solid rgb(var(--su-border));
box-shadow: 0 4px 20px rgb(var(--su-foreground) / 0.2);
max-width: 400px;
width: 100%;
}
.su-dialog-title {
margin-top: 0;
color: rgb(var(--su-foreground));
font-weight: 600;
}
.su-dialog-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 24px;
}
.su-trap-container {
border: 2px solid rgb(var(--su-primary));
padding: 20px;
border-radius: 8px;
}
.su-trap-container[data-focus-trap-active="true"] {
border-color: rgb(var(--su-primary));
}Accessibility
Best Practices
- Always use with modals/dialogs: Focus trapping is essential for modal accessibility
- Provide an escape route: Always include a way to close/deactivate the trap (close button, Escape key)
-
Use with
role="dialog": Combine with proper ARIA roles for screen readers -
Return focus: Keep
ReturnFocusenabled to maintain user context
Screen Reader Considerations
- -
The focus trap should be used in conjunction with
aria-modal="true"for dialogs - -
Include
aria-labelledbyoraria-labelon the dialog container - - Announce the dialog opening/closing to screen readers if needed
Accessible Dialog Example
razor
<FocusTrap IsActive="true" AutoFocus="true" ReturnFocus="true">
<div role="dialog"
class="su-dialog"
aria-modal="true"
aria-labelledby="dialog-title"
aria-describedby="dialog-description">
<h2 id="dialog-title" class="su-dialog-title">Delete Item</h2>
<p id="dialog-description" class="su-dialog-description">
Are you sure you want to delete this item? This action cannot be undone.
</p>
<button class="su-button su-button-secondary" @onclick="Cancel">Cancel</button>
<button class="su-button su-button-destructive" @onclick="Delete">Delete</button>
</div>
</FocusTrap>