Skip to content

Focus and scroll

Manage focus traps, focus restoration, and scroll locking with @diffgazer/keys.

Info:

Try the Focus Trap demo to see focus trapping in action.

The three focus and scroll hooks on this page are independent of KeyboardProvider. Recipes that also call useScope or useKey still need the provider shown below.

useFocusTrap

Traps Tab focus inside a container element. Useful for modals, dialogs, and any overlay where Tab should cycle through the overlay's controls instead of escaping to the page behind it.

tsx
import { useRef } from "react";
import { useFocusTrap } from "@diffgazer/keys";

function Modal({ open, onClose }: { open: boolean; onClose: () => void }) {
  const containerRef = useRef<HTMLDivElement>(null);
  useFocusTrap(containerRef, { enabled: open });

  if (!open) return null;
  return (
    <div ref={containerRef} tabIndex={-1}>
      <input placeholder="Name" />
      <button onClick={onClose}>Close</button>
    </div>
  );
}

API

ts
function useFocusTrap(
  containerRef: RefObject<HTMLElement | null>,
  options?: UseFocusTrapOptions,
): void;

interface UseFocusTrapOptions {
  initialFocus?: RefObject<HTMLElement | null>;
  restoreFocus?: boolean;   // default: true
  enabled?: boolean;        // default: true
}

Independence from KeyboardProvider

useFocusTrap intercepts Tab/Shift+Tab with a capture-phase keydown listener on the container's document while the trap is active, and uses a capture-phase focusin listener there to recapture escaped focus. It doesn't use KeyboardContext at all. This means:

  • It works without KeyboardProvider in the tree
  • It doesn't interfere with @diffgazer/keys's scope system
  • It doesn't care about the active scope

This separation is intentional. Focus trapping is a DOM concern. Keyboard shortcuts are an application concern. They shouldn't be coupled.

Initial focus behavior

When the trap activates, it focuses an element in this priority order:

  1. initialFocus.current if provided and non-null
  2. First focusable element inside the container
  3. The container itself (as a fallback)
tsx
const closeRef = useRef<HTMLButtonElement>(null);

useFocusTrap(containerRef, {
  initialFocus: closeRef,
});

// Close button gets focus when the trap activates
return (
  <div ref={containerRef}>
    <input placeholder="Name" />
    <button ref={closeRef}>Close</button>
  </div>
);

What counts as focusable

The trap uses the same focusable-element helper as the navigation utilities:

plaintext
a[href],
area[href],
button:not([disabled]),
select:not([disabled]),
textarea:not([disabled]),
input:not([type="hidden"]):not([disabled]),
iframe,
object,
embed,
audio[controls],
video[controls],
[contenteditable]:not([contenteditable="false"]),
details > summary:first-of-type,
[tabindex]:not([disabled])

Programmatic focus targets with negative tabIndex are focusable and can be used for initialFocus. Disabled controls are excluded. Links and areas without href are excluded.

Tab cycling

Most Tab presses between tabbable elements use browser-default behavior. The trap intercepts Tab in these cases:

  • Tab on the last tabbable element wraps to the first
  • Shift+Tab on the first tabbable element wraps to the last
  • Focus sitting on a non-tabbable element inside the container (for example a tabIndex={-1} panel) moves to the nearest tabbable element in the Tab direction
  • Crossing a native radio group whose checked peer is not the next tab stop focuses that adjacent stop directly, so the trap matches native radio-group tab semantics

Outside those cases you get native tab ordering for free, and the trap steps in to prevent focus from leaving the container.

Dynamic content

Tabbable elements are re-queried on every Tab press. If you conditionally render a button or input inside the trap, it's picked up immediately -- no need to notify the trap or re-initialize anything.

tsx
<div ref={containerRef}>
  <input placeholder="Name" />
  {showExtra && <input placeholder="Email" />}
  <button>Submit</button>
</div>

Focus restoration

When the trap deactivates (unmount or enabled becomes false), it restores focus to whatever element was focused before the trap activated. This is on by default.

tsx
// User clicks a button -> modal opens -> trap captures activeElement (the button)
// User closes modal -> trap restores focus to the button

useFocusTrap(containerRef, {
  restoreFocus: false, // disable if you handle focus yourself
});

The modal pattern

The three hooks you'll typically combine for a modal:

tsx
import { useRef, useState } from "react";
import {
  KeyboardProvider,
  useFocusTrap,
  useKey,
  useScope,
  useScrollLock,
} from "@diffgazer/keys";

function App() {
  const [open, setOpen] = useState(false);

  return (
    <KeyboardProvider>
      <div inert={open || undefined}>
        <button onClick={() => setOpen(true)}>Open modal</button>
      </div>
      <Modal open={open} onClose={() => setOpen(false)} />
    </KeyboardProvider>
  );
}

function Modal({ open, onClose }: { open: boolean; onClose: () => void }) {
  const ref = useRef<HTMLDivElement>(null);

  const scope = useScope("modal", { enabled: open });
  useFocusTrap(ref, { enabled: open });
  useScrollLock({ enabled: open });

  useKey("Escape", onClose, { scope });

  if (!open) return null;
  return (
    <div
      ref={ref}
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
      tabIndex={-1}
    >
      <h2 id="modal-title">Modal title</h2>
      <button onClick={onClose}>Close</button>
    </div>
  );
}
  • useScope("modal") isolates keyboard shortcuts to the modal
  • useFocusTrap keeps Tab inside the modal
  • useScrollLock prevents the page from scrolling behind it

All three clean up on unmount or when open becomes false.


useFocusRestore

Captures the currently focused element before temporary UI opens, then restores focus when it closes. Use it when a component owns its own open/close lifecycle and is not already using a primitive such as Dialog or CommandPalette that restores focus for you.

tsx
import { useEffect, useId, useRef, useState } from "react";
import { useFocusRestore } from "@diffgazer/keys";

function TemporaryPanel() {
  const [open, setOpen] = useState(false);
  const panelRef = useRef<HTMLDivElement>(null);
  const panelId = useId();
  const panelTitleId = `${panelId}-title`;
  const focusRestore = useFocusRestore();

  useEffect(() => {
    if (open) panelRef.current?.focus();
  }, [open]);

  function openPanel() {
    focusRestore.capture();
    setOpen(true);
  }

  function closePanel() {
    setOpen(false);
    focusRestore.restore();
  }

  return (
    <>
      <button aria-controls={panelId} aria-expanded={open} onClick={openPanel}>
        Open panel
      </button>
      {open && (
        <div
          ref={panelRef}
          id={panelId}
          role="dialog"
          aria-labelledby={panelTitleId}
          tabIndex={-1}
        >
          <h2 id={panelTitleId}>Temporary panel</h2>
          <button onClick={closePanel}>Close</button>
        </div>
      )}
    </>
  );
}

useFocusRestore is stack-aware. If a dialog opens another temporary surface, closing the nested surface restores focus inside the parent before the parent restores focus back to its trigger.


useScrollLock

Prevents scrolling on an element by setting overflow: hidden. Reference-counted so multiple locks on the same element don't conflict.

tsx
import { useScrollLock } from "@diffgazer/keys";

function Modal({ open }: { open: boolean }) {
  useScrollLock({ enabled: open }); // locks document.body
  return open ? <div>...</div> : null;
}

API

ts
interface UseScrollLockOptions {
  target?: RefObject<HTMLElement | null>;
  enabled?: boolean;  // default: true
}

function useScrollLock(options?: UseScrollLockOptions): void;

Default target

If target is omitted, the lock applies to document.body. When target is supplied but its current is null, nothing is locked until the ref is populated.

tsx
// These are equivalent:
useScrollLock();
useScrollLock({ enabled: true });

// Lock a specific scrollable container:
const panelRef = useRef<HTMLDivElement>(null);
useScrollLock({ target: panelRef, enabled: showOverlay });

Reference counting

Multiple components can lock the same element without fighting over overflow. The hook uses a module-level WeakMap<Element, number> to track lock counts.

plaintext
Component A locks body    -> count: 1, overflow set to "hidden"
Component B locks body    -> count: 2, no style change
Component B unlocks body  -> count: 1, no style change
Component A unlocks body  -> count: 0, overflow restored to original value

The original overflow value is captured when the first lock is applied and restored when the last lock is released. This means if the element had overflow: auto before, it gets overflow: auto back -- not an empty string.

WeakMap for cleanup

The WeakMap means if the element is removed from the DOM and garbage collected, its lock count goes with it. No manual cleanup, no memory leaks.

Multiple locks example

A common case: a modal and a nested confirmation dialog both lock scroll.

tsx
function Modal({ open }: { open: boolean }) {
  useScrollLock({ enabled: open });

  return open ? (
    <div>
      <p>Modal content</p>
      {showConfirm && <ConfirmDialog />}
    </div>
  ) : null;
}

function ConfirmDialog() {
  useScrollLock(); // second lock on body

  return (
    <div>
      <p>Are you sure?</p>
      <button>Yes</button>
    </div>
  );
}

When ConfirmDialog unmounts, the body stays locked because Modal still has an active lock. When Modal unmounts, the body's overflow is restored.