FocusScope

Manage keyboard focus trapping and roving navigation accessibly

Components for managing keyboard focus: FocusScope for trapping and containing focus within modals and dialogs, and RovingFocusGroup for arrow-key roving tabindex navigation across composite widgets.

These are web-only components. On native they render their children with no keyboard navigation management.

Features

  • Focus trapping for modal dialogs and overlays

  • Composite-widget arrow navigation with one tab stop

  • Autofocus on mount, focus restoration on unmount

  • Focus looping between first and last tabbable elements

  • Focus-inert content with noFocus

  • Animation reflow prevention with focusOnIdle

Installation

FocusScope and RovingFocusGroup ship in their own packages:

yarn add @tamagui/focus-scope @tamagui/roving-focus

Focus trapping (FocusScope)

Wrap any content that needs focus containment or trapping:

import { Button, XStack } from 'tamagui'
import { FocusScope } from '@tamagui/focus-scope'
export default () => (
<FocusScope loop trapped>
<XStack gap="4">
<Button>First</Button>
<Button>Second</Button>
<Button>Third</Button>
</XStack>
</FocusScope>
)

In v3, FocusScope renders a display: contents wrapper so it does not add an extra layout box around its children.

Trapped focus

Use trapped to prevent focus from escaping the scope (ideal for modals, dialogs, and sheets):

import { Button, Dialog, XStack, YStack } from 'tamagui'
import { FocusScope } from '@tamagui/focus-scope'
export default () => (
<Dialog>
<Dialog.Trigger asChild>
<Button>Open Dialog</Button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay />
{/* key used by AnimatePresence to animate */}
<Dialog.Content key="content">
<FocusScope trapped>
<YStack gap="4">
<Dialog.Title>Focused Content</Dialog.Title>
<XStack gap="2">
<Button>Cancel</Button>
<Button>Confirm</Button>
</XStack>
</YStack>
</FocusScope>
</Dialog.Content>
</Dialog.Portal>
</Dialog>
)

Zero focus mode

Use noFocus to disallow focus entirely while the scope is active: focus can land neither inside nor outside the scope. Any element that receives focus is immediately blurred, so document.activeElement settles on document.body. This takes precedence over trapped and auto-focus behavior:

import { Button, Input, XStack } from 'tamagui'
import { FocusScope } from '@tamagui/focus-scope'
export default () => (
<FocusScope noFocus>
<XStack gap="4">
<Input placeholder="Cannot be focused" />
<Button>Cannot be focused either</Button>
</XStack>
</FocusScope>
)

Focus looping

Enable loop to cycle focus between first and last elements:

import { Button, XStack } from 'tamagui'
import { FocusScope } from '@tamagui/focus-scope'
export default () => (
<FocusScope loop>
<XStack gap="4">
<Button>First</Button>
<Button>Second</Button>
<Button>Last</Button>
{/* Tab from "Last" goes to "First" */}
</XStack>
</FocusScope>
)

Animation-friendly focusing

Use focusOnIdle to prevent reflows during animations:

import { Button, XStack } from 'tamagui'
import { FocusScope } from '@tamagui/focus-scope'
export default () => (
<FocusScope focusOnIdle={true} // Wait for idle callback // or focusOnIdle={200} // Wait 200ms >
<XStack gap="4">
<Button>Animated</Button>
<Button>Content</Button>
</XStack>
</FocusScope>
)

Advanced control with FocusScopeController

Use the controller pattern for managing focus from parent components:

import { Button, XStack, YStack } from 'tamagui'
import { FocusScope, FocusScopeController } from '@tamagui/focus-scope'
import { useState } from 'react'
export default () => {
const [trapped, setTrapped] = useState(false)
return (
<YStack gap="4">
<Button onPress={() => setTrapped(!trapped)}>
{trapped ? 'Disable' : 'Enable'} Focus Trap
</Button>
<FocusScopeController trapped={trapped} loop>
<FocusScope>
<XStack gap="4">
<Button>Controlled</Button>
<Button>Focus</Button>
<Button>Behavior</Button>
</XStack>
</FocusScope>
</FocusScopeController>
</YStack>
)
}

FocusScope props

Props

  • enabled

    boolean

    Default: 

    true

    Whether focus management is enabled

  • loop

    boolean

    Default: 

    false

    When true, tabbing from last item will focus first tabbable and shift+tab from first item will focus last tabbable

  • trapped

    boolean

    Default: 

    false

    When true, focus cannot escape the focus scope via keyboard, pointer, or programmatic focus

  • noFocus

    boolean

    Default: 

    false

    Zero focus mode: while active, focus is allowed neither inside nor outside the scope. Anything that receives focus is immediately blurred. Takes precedence over trapped and auto-focus. Web only

  • focusOnIdle

    boolean | number | { min?: number; max?: number }

    Default: 

    true

    When true, waits for idle before focusing using requestIdleCallback. When a number, waits that many ms. Object sets a lower and upper bound. Helps to prevent reflows during animations, as focusing inputs easily blocks main thread.

  • onMountAutoFocus

    (event: Event) => void

    Event handler called when auto-focusing on mount. Can be prevented

  • onUnmountAutoFocus

    (event: Event) => void

    Event handler called when auto-focusing on unmount. Can be prevented

  • forceUnmount

    boolean

    Default: 

    false

    If unmount is animated, you want to force re-focus at start of animation not after

  • children

    React.ReactNode

    Content to apply focus management to

  • FocusScopeController props

    Provides context-based control over FocusScope behavior:

    Props

  • enabled

    boolean

    Override enabled state for all child FocusScope components

  • loop

    boolean

    Override loop state for all child FocusScope components

  • trapped

    boolean

    Override trapped state for all child FocusScope components

  • noFocus

    boolean

    Override noFocus (zero focus mode) for all child FocusScope components

  • focusOnIdle

    boolean | number

    Override focusOnIdle behavior for all child FocusScope components

  • onMountAutoFocus

    (event: Event) => void

    Override onMountAutoFocus handler for all child FocusScope components

  • onUnmountAutoFocus

    (event: Event) => void

    Override onUnmountAutoFocus handler for all child FocusScope components

  • forceUnmount

    boolean

    Override forceUnmount behavior for all child FocusScope components

  • The FocusScope component automatically inherits props from the nearest FocusScopeController, with controller props taking precedence over direct props.

    Roving focus (RovingFocusGroup)

    The roving tabindex pattern manages focus across a group of interactive elements (like toolbars, tab lists, or radio groups) so that the entire group acts as a single tab stop. Arrow keys move focus between items within the group, and pressing Tab moves focus out of the group.

    import { Button, XStack } from 'tamagui'
    import { RovingFocusGroup } from '@tamagui/roving-focus'
    export default () => (
    <RovingFocusGroup orientation="horizontal" loop>
    <XStack gap="2">
    <RovingFocusGroup.Item asChild>
    <Button>First</Button>
    </RovingFocusGroup.Item>
    <RovingFocusGroup.Item asChild>
    <Button>Second</Button>
    </RovingFocusGroup.Item>
    <RovingFocusGroup.Item asChild>
    <Button>Third</Button>
    </RovingFocusGroup.Item>
    </XStack>
    </RovingFocusGroup>
    )

    Orientation

    Set orientation to "horizontal" or "vertical" to control which arrow keys navigate:

    import { Button, YStack } from 'tamagui'
    import { RovingFocusGroup } from '@tamagui/roving-focus'
    export default () => (
    // Up/Down arrows navigate, Left/Right are ignored
    <RovingFocusGroup orientation="vertical">
    <YStack gap="2">
    <RovingFocusGroup.Item asChild>
    <Button>Option 1</Button>
    </RovingFocusGroup.Item>
    <RovingFocusGroup.Item asChild>
    <Button>Option 2</Button>
    </RovingFocusGroup.Item>
    <RovingFocusGroup.Item asChild>
    <Button>Option 3</Button>
    </RovingFocusGroup.Item>
    </YStack>
    </RovingFocusGroup>
    )

    Looping navigation

    Enable loop to wrap focus from the last item back to the first:

    <RovingFocusGroup loop orientation="horizontal">
    {/* items */}
    </RovingFocusGroup>

    Controlled tab stop

    Control which item is the current tab stop via currentTabStopId and onCurrentTabStopIdChange:

    import { Button, XStack } from 'tamagui'
    import { RovingFocusGroup } from '@tamagui/roving-focus'
    import { useState } from 'react'
    export default () => {
    const [currentId, setCurrentId] = useState<string | null>('item-2')
    return (
    <RovingFocusGroup currentTabStopId={currentId} onCurrentTabStopIdChange={setCurrentId} >
    <XStack gap="2">
    <RovingFocusGroup.Item tabStopId="item-1" asChild>
    <Button>First</Button>
    </RovingFocusGroup.Item>
    <RovingFocusGroup.Item tabStopId="item-2" asChild>
    <Button>Second (default)</Button>
    </RovingFocusGroup.Item>
    <RovingFocusGroup.Item tabStopId="item-3" asChild>
    <Button>Third</Button>
    </RovingFocusGroup.Item>
    </XStack>
    </RovingFocusGroup>
    )
    }

    Non-focusable items

    Give an item a negative tabIndex to make it non-focusable and skip it during keyboard navigation:

    <RovingFocusGroup.Item tabIndex={-1} asChild>
    <Button disabled>Disabled</Button>
    </RovingFocusGroup.Item>

    RovingFocusGroup props

    Props

  • orientation

    "horizontal" | "vertical"

    The orientation of the group. Determines which arrow keys are used for navigation (left/right vs up/down).

  • dir

    "ltr" | "rtl"

    The reading direction. When set to rtl, left and right arrow keys are reversed.

  • loop

    boolean

    Default: 

    false

    When true, keyboard navigation will loop from last to first and vice versa.

  • currentTabStopId

    string | null

    The controlled id of the current tab stop.

  • defaultCurrentTabStopId

    string

    The default id of the current tab stop for uncontrolled usage.

  • onCurrentTabStopIdChange

    (tabStopId: string | null) => void

    Callback when the current tab stop changes.

  • onEntryFocus

    (details: RovingFocusEntryDetails) => void

    Callback when focus enters the group via keyboard. Call details.cancel() to prevent the default focus behavior.

  • asChild

    boolean

    Default: 

    false

    When true, renders as a Slot, merging props onto the child element.

  • RovingFocusGroup.Item props

    Props

  • tabStopId

    string

    A unique identifier for this item. Auto-generated if not provided.

  • tabIndex

    number

    Default: 

    0

    Items with a negative tabIndex are skipped during keyboard navigation.

  • active

    boolean

    Default: 

    false

    Whether this item is considered active. Active items receive focus priority when the group is entered.

  • asChild

    boolean

    Default: 

    false

    When true, renders as a Slot, merging props onto the child element.

  • Keyboard navigation

    KeyAction
    TabMove focus into/out of the group
    Arrow Left/RightMove focus between items (horizontal orientation)
    Arrow Up/DownMove focus between items (vertical orientation)
    Home / Page UpMove focus to first item
    End / Page DownMove focus to last item
    Key
    Tab
    Action
    Move focus into/out of the group
    Key
    Arrow Left/Right
    Action
    Move focus between items (horizontal orientation)
    Key
    Arrow Up/Down
    Action
    Move focus between items (vertical orientation)
    Key
    Home / Page Up
    Action
    Move focus to first item
    Key
    End / Page Down
    Action
    Move focus to last item

    Internal usage in Tamagui

    FocusScope and RovingFocusGroup power accessibility throughout Tamagui:

    Both components are primarily designed for web platforms. On React Native, they render children directly without web focus management since native platforms manage focus and touch interaction natively.