Hanzo GUI

FocusScope

Manage focus behavior within elements accessibly.

A utility component for managing keyboard focus within a container. Controls focus trapping, auto-focus behavior, and focus cycling for accessible interactive components.

Note that this is a web-only component, on native it is a no-op.

  • Trap focus within a container for modal-like behavior.
  • Auto-focus on mount and return focus on unmount.
  • Loop focus between first and last tabbable elements.
  • Prevent reflows during animations with focusOnIdle.

Installation

FocusScope is already installed in @hanzo/gui, or you can install it independently:

npm install @hanzogui/focus-scope

Usage

Wrap any content that needs focus management:

import { Button, FocusScope, XStack } from '@hanzo/gui'

export default () => (
  <FocusScope loop trapped>
    <XStack space="$4">
      <Button>First</Button>
      <Button>Second</Button>
      <Button>Third</Button>
    </XStack>
  </FocusScope>
)

Focus Trapping

Use trapped to prevent focus from escaping the scope:

import { Button, Dialog, FocusScope, XStack, YStack } from '@hanzo/gui'

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 space="$4">
            <Dialog.Title>Focused Content</Dialog.Title>
            <XStack space="$2">
              <Button>Cancel</Button>
              <Button>Confirm</Button>
            </XStack>
          </YStack>
        </FocusScope>
      </Dialog.Content>
    </Dialog.Portal>
  </Dialog>
)

Focus Looping

Enable loop to cycle focus between first and last elements:

import { Button, FocusScope, XStack } from '@hanzo/gui'

export default () => (
  <FocusScope loop>
    <XStack space="$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, FocusScope, XStack } from '@hanzo/gui'

export default () => (
  <FocusScope
    focusOnIdle={true} // Wait for idle callback
    // or focusOnIdle={200} // Wait 200ms
  >
    <XStack space="$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, FocusScope, XStack, YStack } from '@hanzo/gui'
import { useState } from 'react'

export default () => {
  const [trapped, setTrapped] = useState(false)

  return (
    <YStack space="$4">
      <Button onPress={() => setTrapped(!trapped)}>
        {trapped ? 'Disable' : 'Enable'} Focus Trap
      </Button>

      <FocusScope.Controller trapped={trapped} loop>
        <FocusScope>
          <XStack space="$4">
            <Button>Controlled</Button>
            <Button>Focus</Button>
            <Button>Behavior</Button>
          </XStack>
        </FocusScope>
      </FocusScope.Controller>
    </YStack>
  )
}

Function as Children

For advanced use cases, pass a function to get access to focus props:

import { FocusScope, View } from '@hanzo/gui'

export default () => (
  <FocusScope loop>
    {({ onKeyDown, tabIndex, ref }) => (
      <View
        ref={ref}
        tabIndex={tabIndex}
        onKeyDown={onKeyDown}
        padding="$4"
        borderWidth={1}
        borderColor="$borderColor"
      >
        Custom focus container
      </View>
    )}
  </FocusScope>
)

API Reference

FocusScope

PropTypeDefaultRequired
enabledbooleantrue-
loopbooleanfalse-
trappedbooleanfalse-
focusOnIdleboolean | number | { min?: number; max?: number }false-
onMountAutoFocus(event: Event) => void--
onUnmountAutoFocus(event: Event) => void--
forceUnmountbooleanfalse-
childrenReact.ReactNode | ((props: FocusProps) => React.ReactNode)--

FocusScope.Controller

Provides context-based control over FocusScope behavior:

PropTypeDefaultRequired
enabledboolean--
loopboolean--
trappedboolean--
focusOnIdleboolean | number--
onMountAutoFocus(event: Event) => void--
onUnmountAutoFocus(event: Event) => void--
forceUnmountboolean--

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

Usage in Other Components

Many Hanzo GUI components export FocusScope for advanced focus control:

import { Dialog, Popover, Select } from '@hanzo/gui'

// Available on:
<Dialog.FocusScope />
<Popover.FocusScope />
<Select.FocusScope />
// And more...

Accessibility

FocusScope follows accessibility best practices:

  • Manages tabindex appropriately for focus flow
  • Respects user's reduced motion preferences
  • Maintains focus visible indicators
  • Provides proper ARIA support when used with other components
  • Handles edge cases like disabled elements and hidden content

FocusScope is primarily designed for web platforms. On React Native, it renders children without focus management since native platforms handle focus differently.

Examples

import { Button, Dialog, FocusScope, Input, XStack, YStack } from '@hanzo/gui'

export default () => (
  <Dialog>
    <Dialog.Trigger asChild>
      <Button>Open Modal</Button>
    </Dialog.Trigger>

    <Dialog.Portal>
      <Dialog.Overlay />
      <Dialog.Content>
        <FocusScope trapped loop focusOnIdle={100}>
          <YStack space="$4" padding="$4">
            <Dialog.Title>User Details</Dialog.Title>
            <Input placeholder="Name" />
            <Input placeholder="Email" />
            <XStack space="$2">
              <Dialog.Close asChild>
                <Button variant="outlined">Cancel</Button>
              </Dialog.Close>
              <Button>Save</Button>
            </XStack>
          </YStack>
        </FocusScope>
      </Dialog.Content>
    </Dialog.Portal>
  </Dialog>
)

Custom Focus Container

import { Button, FocusScope, styled, XStack } from '@hanzo/gui'

const FocusContainer = styled(XStack, {
  borderWidth: 2,
  borderColor: 'transparent',
  borderRadius: '$4',
  padding: '$4',

  variants: {
    focused: {
      true: {
        borderColor: '$blue10',
        shadowColor: '$blue10',
        shadowRadius: 10,
        shadowOpacity: 0.3,
      },
    },
  },
})

export default () => (
  <FocusScope loop>
    {({ onKeyDown, tabIndex, ref }) => (
      <FocusContainer
        ref={ref}
        tabIndex={tabIndex}
        onKeyDown={onKeyDown}
        space="$4"
        focused
      >
        <Button>Action 1</Button>
        <Button>Action 2</Button>
        <Button>Action 3</Button>
      </FocusContainer>
    )}
  </FocusScope>
)

Last updated on

On this page