# Hooks

**When to use:** Standalone React hooks the library uses internally and publishes for the same jobs in your own code: keyboard shortcuts, media queries, the mobile keyboard's viewport, scroll-edge detection, swipe-to-dismiss, copying to the clipboard, and the Escape / focus-trap / scroll-lock behaviour behind a custom overlay.

**Keywords:** hotkey, keybinding, keyboard shortcut, cmd+k, command palette, breakpoint, responsive, touch device, on-screen keyboard, focus trap, scroll lock, swipe

**Import:** `import { useHotkeys, formatHotkey, useMediaQuery, useCoarsePointer, useVisualViewportSize, useScrollEdges, useSwipeDismiss, useOverlay, useClipboard, tabbablesIn } from '@ahrowe/ui'`
**Types:** `import type { HotkeyMap, HotkeyHandler, UseHotkeysOptions, VisualViewportSize, ScrollEdges, ScrollEdgesOptions, UseSwipeDismissOptions, UseSwipeDismissResult, UseOverlayArgs, UseClipboardOptions, UseClipboardResult } from '@ahrowe/ui'`

---

## useHotkeys

Keyboard shortcuts for a whole app or for one element.

```tsx
import { useHotkeys } from '@ahrowe/ui';

function App() {
  const [isPaletteOpen, setPaletteOpen] = useState(false);

  useHotkeys({
    'mod+k': () => setPaletteOpen(true),
    'mod+shift+d': () => toggleDarkMode(),
    '?': () => setHelpOpen(true),
    Escape: () => setPaletteOpen(false),
  });
}
```

The map is read through a ref at event time, so an inline object literal is fine: no handler needs
`useCallback` and no combo needs `useMemo`.

**Combo syntax** — modifiers and the key joined by `+`, case-insensitive:

| Part | Means |
| ---- | ----- |
| `mod` | Cmd on Apple platforms, Ctrl everywhere else |
| `ctrl` / `control` | Ctrl |
| `meta` / `cmd` / `command` | Cmd (Win key) |
| `alt` / `option` | Alt |
| `shift` | Shift |
| the key | any `event.key` value, lower-cased: `k`, `1`, `escape`, `arrowup`, `enter` |

Aliases for keys that are awkward to write: `esc`, `space`, `plus`, `up`, `down`, `left`, `right`.

Two rules worth knowing:

- **Shift is only compared when the combo names it.** `'?'` matches, even though `?` is typed with
  Shift on most layouts.
- **The key is matched against the keycap as well as the event.** `'mod+1'` matches Ctrl+Shift+1,
  and `'alt+k'` matches on a Mac, where Alt+K produces `˚` rather than `k`.

**Options**

| Prop | Type | Default | Description |
| ---- | ---- | ------- | ----------- |
| `enabled` | `boolean` | `true` | Attach the listener at all. Suspends every combo at once |
| `target` | `RefObject<HTMLElement \| null>` | window | Scope the shortcuts to one element |
| `enableInFields` | `boolean` | `false` | Let a combo fire while a text field or `contenteditable` has focus. Only combos the field would otherwise consume are held back: a single character, or an editing key (`arrowup`, `home`, `backspace`, `enter`, `tab`, …). Anything carrying Ctrl, Cmd or Alt fires regardless, and so do `f2`, `escape` and the other keys a field does not use |
| `preventDefault` | `boolean` | `true` | Call `preventDefault()` on a match, so `mod+s` does not also open the browser's save dialog |
| `repeat` | `boolean` | `false` | Fire again for each repeat while the combo is held. Off, holding `mod+s` saves once; turn it on for keys that step through something |

Shortcuts keep firing while a `Modal` or `Drawer` is open, which is usually what a command palette
wants. Pass `enabled` for the ones that should not:

```tsx
useHotkeys({ n: createRecord }, { enabled: !isModalOpen });
```

A held combo fires once, where the browser's key repeat would save over and over or flicker a
toggle. The skipped repeats are still `preventDefault()`-ed. Where a repeat is the point, as with
arrows or zoom, ask for it, in a call of its own if the page also has actions:

```tsx
useHotkeys({ 'mod+s': save, '?': toggleHelp });
useHotkeys({ j: selectNext, k: selectPrevious }, { repeat: true });
```

### formatHotkey

The same combo written for the reader, so a hint beside a button cannot contradict what the binding
listens for:

```tsx
formatHotkey('mod+shift+k'); // '⇧⌘K' on an Apple platform, 'Ctrl+Shift+K' elsewhere
formatHotkey('f2'); // 'F2'
```

This is what `Menu`, `CommandPalette` and `Button` use to draw a shortcut, from the same string
`useHotkeys` binds, see [Menu.md](Menu.md) and [Button.md](Button.md). A combo it cannot parse is
handed back unchanged. The platform is read at call time, so a server-rendered page renders the
non-Apple spelling.

## useMediaQuery

`true` while the query matches, re-rendering on every change. Backed by `useSyncExternalStore`, so
it hydrates from the server's answer (`false`) instead of making the first client render disagree
with the server's HTML.

```tsx
const isWide = useMediaQuery('(min-width: 1024px)');
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
```

Prefer this over reading `window.innerWidth`: a resize listener re-renders on every pixel, a media
query only when the answer changes.

## useCoarsePointer

`true` when the primary pointing device is touch — `useMediaQuery('(pointer: coarse)')`.

```tsx
const isTouch = useCoarsePointer();
```

Use it for affordances, not for layout: a touchscreen laptop is a coarse pointer at desktop width.
For "is this a phone", ask for both, as `Presentation.Auto` does — see [ConfigProvider.md](ConfigProvider.md).

## useVisualViewportSize

Tracks `window.visualViewport`, so layout can react to the mobile on-screen keyboard opening.
`window.innerHeight` and CSS `vh` stay pinned to the full layout viewport and do not.

```tsx
const { height, offsetTop } = useVisualViewportSize();

<div style={{ height, transform: `translateY(${offsetTop}px)` }} />;
```

Falls back to `window.innerHeight` / `0` where the API is missing.

## useScrollEdges

Which edges of a scroll container currently hide content, so the boundary can be marked — most
often by fading it.

```tsx
const listRef = useRef<HTMLDivElement>(null);
const { hasStart, hasEnd } = useScrollEdges(listRef, { axis: 'vertical' });

<div ref={listRef} data-fade-top={hasStart} data-fade-bottom={hasEnd}>
  {items}
</div>;
```

| Option | Type | Default | Description |
| ------ | ---- | ------- | ----------- |
| `axis` | `'vertical' \| 'horizontal'` | `'vertical'` | Which axis to measure |
| `isEnabled` | `boolean` | `true` | Pass `false` while the element is hidden |

For the ready-made fade, use [ScrollFade.md](ScrollFade.md) instead.

## useSwipeDismiss

Swipe-to-dismiss on any element. Attaches its own pointer listeners to the node, so there is
nothing to spread but the ref.

```tsx
const { ref, isDragging } = useSwipeDismiss<HTMLDivElement>({
  onDismiss: () => remove(notification.id),
  axis: 'x',
});

<div ref={ref}>{notification.message}</div>;
```

| Option | Type | Default | Description |
| ------ | ---- | ------- | ----------- |
| `onDismiss` | `() => void` | — | Called once the swipe commits, after the exit transition, or at once without `animateExit` |
| `axis` | `'x' \| 'y'` | `'x'` | Which axis the drag tracks |
| `distanceThreshold` | `number` | `80` | px past which releasing commits, regardless of speed |
| `velocityThreshold` | `number` | `0.5` | px/ms past which releasing commits, regardless of distance |
| `mobileOnly` | `boolean` | `true` | Restrict the gesture to coarse pointers. On a mouse a drag is more likely a text selection |
| `disabled` | `boolean` | `false` | Attach no listeners at all |
| `onDrag` | `(offset: number) => void` | — | The element's offset in px whenever it changes: on every move, then `0` as it springs back or the exit offset once it commits. For something else that follows the drag, such as a backdrop fading out |
| `fade` | `boolean` | `true` | Fade the element out as it is dragged |
| `animateExit` | `boolean` | `true` | Slide the element off before calling `onDismiss`. `false` calls it the moment the swipe commits and leaves the element where it was let go, for a caller that animates the exit itself, as Lightbox flies its image into a thumbnail |

`isDragging` is `true` for the duration of a drag — use it to pause an auto-dismiss timer.

The hook moves and fades the element with inline styles, and removes them once a swipe that did not commit has sprung back, so the element's own stylesheet applies again. A swipe that starts cancels the element's CSS animations, because a finished `forwards` animation (an entrance) would otherwise hold its transform and opacity over the drag; an animation the element is given afterwards, such as an exit, still plays. A tap leaves them alone.

`ref` is a **callback ref**, not a ref object: the hook has to know when the node changes, and an
element unmounted and rendered again is a different node. Attach it with `ref={ref}` as above. To
keep a ref of your own on the same element, call both from one callback:

```tsx
<div
  ref={(node) => {
    ref(node);
    myRef.current = node;
  }}
/>
```

## useOverlay

The three behaviours every blocking overlay needs: Escape to dismiss, a focus trap, and a scroll
lock on the page behind it. `Modal`, `Drawer` and `Popover` are built on it, and they all share one
stack: a custom overlay using this hook nests correctly with theirs, so Escape closes only the
innermost layer and the page stays locked until the last one closes.

```tsx
function CustomOverlay({ isOpen, onClose, children }) {
  const panelRef = useRef<HTMLDivElement>(null);
  useOverlay({ isActive: isOpen, containerRef: panelRef, onEscape: onClose });

  if (!isOpen) return null;
  return (
    <div ref={panelRef} role="dialog" aria-modal="true">
      {children}
    </div>
  );
}
```

| Arg | Type | Default | Description |
| --- | ---- | ------- | ----------- |
| `isActive` | `boolean` | — | Everything below is set up while this is true |
| `containerRef` | `RefObject<HTMLElement \| null>` | — | The panel. Focus is trapped inside it |
| `onEscape` | `() => void` | — | Called when Escape is pressed while this is the topmost layer. Omit to not handle Escape |
| `trapFocus` | `boolean` | `true` | Keep Tab inside the panel, and restore focus on close |
| `lockScroll` | `boolean` | `true` | Prevent the page behind it from scrolling |

Reach for [Modal.md](Modal.md) or [Drawer.md](Drawer.md) first; this is for an overlay they do not cover.

## useClipboard

Writes text to the clipboard and reports how it went. For the usual icon-only copy control, use
[CopyButton.md](CopyButton.md), which is this hook plus the feedback and the screen-reader
announcement. Reach for the hook when the trigger is something else, such as a labelled `Button`:

```tsx
const { copy, copied } = useClipboard();

<Button icon={copied ? faCheck : faCopy} onClick={() => void copy(shareUrl)}>
  {copied ? 'Copied' : 'Copy link'}
</Button>;
```

`copy(text)` resolves `true` on success and `false` on failure, so it never needs a `try`:

```tsx
const ok = await copy(invoice.number);
showToast(ok ? 'Copied' : 'Could not copy', { type: ok ? 'success' : 'error' });
```

| Option | Type | Default | Description |
| ------ | ---- | ------- | ----------- |
| `timeout` | `number` | `2000` | How long `copied` stays `true` after a copy, in ms |

| Returns | Type | Description |
| ------- | ---- | ----------- |
| `copy` | `(text: string) => Promise<boolean>` | Writes `text`; a second copy restarts the timeout |
| `copied` | `boolean` | `true` for `timeout` ms after a successful copy |
| `error` | `Error \| null` | Why the last copy failed, until the next attempt |
| `reset` | `() => void` | Clears `copied` and `error` early |

It uses `navigator.clipboard.writeText` only. That API needs a secure context (`https://` or
`localhost`) and may be blocked inside an iframe; there it fails through `error` rather than falling
back to the deprecated `document.execCommand('copy')`.

## tabbablesIn

The tabbable descendants of an element, in DOM order. An element taken out of the tab order with
`tabindex="-1"` is skipped, whatever it is, so a roving-tabindex list reads as the one tab stop it
actually is.

```tsx
const focusables = tabbablesIn(panelRef.current);
focusables[0]?.focus();
```

## Also exported

Hooks documented with the thing they belong to:

- `useTheme` — the active theme, see [ThemeProvider.md](ThemeProvider.md)
- `useComponentDefaults`, `useLabels` — see [ConfigProvider.md](ConfigProvider.md)
- `useFormValidator` — see [FormValidator.md](FormValidator.md)
- `useFormValidatorGroup` — see [FormValidatorGroup.md](FormValidatorGroup.md)

## Migration from repeating hotkeys

Before 0.49, a held `useHotkeys` combo fired its handler for every repeat the browser sent. It now
fires once. Nothing fails to compile, so look for shortcuts that are meant to be held:

| Before | After |
|--------|-------|
| `useHotkeys({ arrowdown: next })`, relying on the repeat | `useHotkeys({ arrowdown: next }, { repeat: true })` |
| `if (event.repeat) return;` at the top of a handler | nothing: drop the check |
