Focustrap
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 SummitUI

Anatomy

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

  1. When activated, the component identifies all focusable elements within its boundaries
  2. It intercepts Tab and Shift+Tab key presses
  3. When Tab is pressed on the last focusable element, focus moves to the first
  4. When Shift+Tab is pressed on the first focusable element, focus moves to the last
  5. This creates a circular focus loop within the trapped area

Focusable Elements

The following elements are considered focusable:

  • <a> with href attribute
  • <button> (not disabled)
  • <input> (not disabled, not type="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 ReturnFocus enabled to maintain user context

Screen Reader Considerations

  • - The focus trap should be used in conjunction with aria-modal="true" for dialogs
  • - Include aria-labelledby or aria-label on 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>
An unhandled error has occurred. Reload X