Skip to content

Early previewhamur is not on npm yet. The components are being built in the open, follow along on GitHub.

OverlaysReact

Focus Trap

Keeps keyboard focus inside a region while it is active, for an overlay or an editing area that is not a dialog.

Rendered by React 19.3.1

Loading React…
import * as React from 'react'
import { FocusTrap } from '@hamur/react/focus-trap'

export default function FocusTrapExample() {
  const [open, setOpen] = React.useState(false)
  return (
    <div className="stack">
      <button className="button" type="button" onClick={() => setOpen(true)}>
        Open panel
      </button>
      {open ? (
        <React.Fragment>
          <div className="backdrop" />
          <FocusTrap
            className="focus-trap-panel"
            role="group"
            aria-label="Quick note"
            onActiveChange={(active: boolean) => { if (!active) setOpen(false) }}
          >
            <h2 className="dialog-title">Quick note</h2>
            <p className="muted">Tab stays in this panel and the page behind it is inert.</p>
            <label className="field">
              <span className="select-label">Title</span>
              <input className="input" />
            </label>
            <div className="actions">
              <button className="button" type="button" onClick={() => setOpen(false)}>
                Cancel
              </button>
              <button className="button" type="button" onClick={() => setOpen(false)}>
                Save
              </button>
            </div>
          </FocusTrap>
        </React.Fragment>
      ) : null}
    </div>
  )
}
Not measured yetTests passing in React
Not measured yetSize
Not measured yetMemory
Not measured yetSpeed
[N] commits

Installation

npm i @hamur/react

Not on npm yet. The package goes out under the chosen name.

Anatomy

Import the component and assemble its parts:

<FocusTrap />

Examples

Inline

An address edited in place with modal={false}: Tab wraps inside the form while the page stays usable, and Escape cancels.

FocusTrapInline.tsx
import * as React from 'react'
import { FocusTrap } from '@hamur/react/focus-trap'

export default function FocusTrapInlineExample() {
  const [editing, setEditing] = React.useState(false)
  const [address, setAddress] = React.useState('12 Harbour Street, Leith')
  const [draft, setDraft] = React.useState(address)
  return (
    <div className="stack">
      <div className="row focus-trap-summary">
        <span>{address}</span>
        <button
          className="button"
          type="button"
          onClick={() => {
            setDraft(address)
            setEditing(true)
          }}
        >
          Edit address
        </button>
      </div>
      <FocusTrap
        className="focus-trap-region"
        modal={false}
        active={editing}
        onActiveChange={setEditing}
        hidden={!editing}
      >
        <form
          className="stack"
          onSubmit={(event) => {
            event.preventDefault()
            setAddress(draft)
            setEditing(false)
          }}
        >
          <label className="field">
            <span className="select-label">Address</span>
            <input
              className="input"
              value={draft}
              onChange={(event) => setDraft(event.target.value)}
            />
          </label>
          <div className="actions">
            <button className="button" type="button" onClick={() => setEditing(false)}>
              Cancel
            </button>
            <button className="button" type="submit">
              Save
            </button>
          </div>
        </form>
      </FocusTrap>
      <p className="muted">
        While editing, Tab wraps inside the form; the rest of the page still takes a click.
      </p>
    </div>
  )
}

Inside

A note panel whose formatting toolbar sits outside its element and joins the Tab cycle through inside.

FocusTrapInside.tsx
import * as React from 'react'
import { FocusTrap } from '@hamur/react/focus-trap'

export default function FocusTrapInsideExample() {
  const [open, setOpen] = React.useState(false)
  const [bold, setBold] = React.useState(false)
  const [italic, setItalic] = React.useState(false)
  const toolbarRef = React.useRef<HTMLDivElement>(null)
  return (
    <div className="stack">
      <button className="button" type="button" onClick={() => setOpen(true)}>
        Write a note
      </button>
      {open ? (
        <div className="focus-trap-editor">
          <div ref={toolbarRef} className="toolbar" role="toolbar" aria-label="Format">
            <button
              className="toolbar-button"
              type="button"
              aria-pressed={bold}
              onClick={() => setBold(!bold)}
            >
              Bold
            </button>
            <button
              className="toolbar-button"
              type="button"
              aria-pressed={italic}
              onClick={() => setItalic(!italic)}
            >
              Italic
            </button>
          </div>
          <FocusTrap
            className="focus-trap-region"
            inside={[toolbarRef]}
            onActiveChange={(active) => {
              if (!active) setOpen(false)
            }}
          >
            <label className="field">
              <span className="select-label">Note</span>
              <textarea
                className="input focus-trap-textarea"
                style={{
                  fontWeight: bold ? 600 : undefined,
                  fontStyle: italic ? 'italic' : undefined,
                }}
              />
            </label>
            <div className="actions">
              <button className="button" type="button" onClick={() => setOpen(false)}>
                Done
              </button>
            </div>
          </FocusTrap>
        </div>
      ) : null}
    </div>
  )
}

Nested

A settings panel that opens a confirm panel inside it; Escape closes the inner one first, then the outer.

FocusTrapNested.tsx
import * as React from 'react'
import { FocusTrap } from '@hamur/react/focus-trap'

export default function FocusTrapNestedExample() {
  const [open, setOpen] = React.useState(false)
  const [confirming, setConfirming] = React.useState(false)
  return (
    <div className="stack">
      <button
        className="button"
        type="button"
        onClick={() => {
          setConfirming(false)
          setOpen(true)
        }}
      >
        Open settings
      </button>
      {open ? (
        <FocusTrap
          className="focus-trap-region"
          role="group"
          aria-label="Settings"
          onActiveChange={(active) => {
            if (!active) setOpen(false)
          }}
        >
          <h2 className="dialog-title">Settings</h2>
          <label className="row">
            <input type="checkbox" defaultChecked />
            Email me a weekly summary
          </label>
          <div className="actions">
            <button className="button" type="button" onClick={() => setConfirming(true)}>
              Reset all
            </button>
            <button className="button" type="button" onClick={() => setOpen(false)}>
              Close
            </button>
          </div>
          {confirming ? (
            <FocusTrap
              className="focus-trap-region"
              role="group"
              aria-label="Reset all settings?"
              onActiveChange={(active) => {
                if (!active) setConfirming(false)
              }}
            >
              <p className="muted">Reset all settings? Escape closes this panel first.</p>
              <div className="actions">
                <button className="button" type="button" onClick={() => setConfirming(false)}>
                  Keep
                </button>
                <button
                  className="button danger"
                  type="button"
                  onClick={() => setConfirming(false)}
                >
                  Reset
                </button>
              </div>
            </FocusTrap>
          ) : null}
        </FocusTrap>
      ) : null}
    </div>
  )
}

API reference

7 props across 1 parts, in React syntax. className, style, render are listed once, under Shared props.

FocusTrapFocusTrap

Keeps keyboard focus inside its region while active: Tab and Shift+Tab wrap at the edges, Escape asks to deactivate, and under modal the rest of the page is inert. Focus returns where it was on deactivation. Renders a <div>.

FocusTrap props
PropTypeDefault
activeWhether the trap is active.boolean—
defaultActiveWhether the trap is initially active.booleantrue
onActiveChangeEvent handler called when the trap asks to deactivate: on Escape, with reason escape-key.((active: boolean, eventDetails: { reason: "escape-key"; event: KeyboardEvent; cancel: () => void; allowPropagation: () => void; isCanceled: boolean; isPropagationAllowed: boolean; trigger: Element | undefined; }) => void)—
modalWhether the rest of the page is inert while the trap is active.booleantrue
insideElements that belong to the trap though they are outside its element (a toolbar button, aFocusTrapInsideItem[] | (() => FocusTrapInsideItem[])—
initialFocusDetermines the element to focus when the trap activates.boolean | React.RefObject<HTMLElement | null> | ((interactionType: FocusTrapInteractionType) => boolean | void | HTMLElement | null)true
finalFocusDetermines the element to focus when the trap deactivates.boolean | React.RefObject<HTMLElement | null> | ((interactionType: FocusTrapInteractionType) => boolean | void | HTMLElement | null)true

Shared props

Every part that renders an element takes these.

Shared props
PropType
classNameCSS class applied to the element, or a function that returns a class based on the component's state.string | ((state: State) => string | undefined)
styleStyle applied to the element, or a function that returns a style object based on the component's state.React.CSSProperties | ((state: State) => React.CSSProperties | undefined)
renderAllows you to replace the component's HTML element with a different tag, or compose it with another component.React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, State>

Keyboard and accessibility

Keyboard

Keyboard
KeysDoesPart
TabOn the last control, moves to the firstFocusTrap
ShiftTabOn the first control, moves to the lastFocusTrap
TabFrom the last control, moves to an inside element, and from it to the firstFocusTrap
TabShiftTabWith nothing tabbable inside, keeps focus on the regionFocusTrap
EscapeCalls onActiveChange with false and the reason escape-keyFocusTrap
EscapeDeactivates an uncontrolled trap and returns focusFocusTrap
EscapeIn a nested trap, deactivates only the inner oneFocusTrap
EscapeIn a trap inside a dialog, deactivates the trap and leaves the dialog openFocusTrap

Accessibility

What the parts set
PartSetsSo that
FocusTrapno role, no name, no aria-modalthe region is announced as what it holds; the app gives it a role and a name where it needs one
FocusTraptabindex="-1" and [data-active] while activethe region can hold focus itself when nothing inside is tabbable
FocusTrapa visually hidden focus guard as its first and last child while active, with [data-base-ui-focus-guard]Tab out of either edge, and a screen reader's virtual cursor, land back inside
FocusTrapinert and [data-base-ui-inert] on the rest of the page while modal is on, live regions left outthe page behind cannot be focused, pressed or read, and announcements still reach the reader

Focus order

  1. Activating moves focus to the first element with an autofocus attribute, else the first tabbable element, else the region itself (the region after a touch), or to initialFocus; focus already inside stays where it is.
  2. Tab and Shift+Tab move through the region's controls, then the inside elements in their order, and wrap at either end.
  3. When the focused control is removed, hidden or disabled, focus moves to the region without scrolling.
  4. Deactivating returns focus to the element focused at activation, or to finalFocus, without scrolling; under modal={false}, focus the user moved outside stays there.