Hooks

garn's utility hooks — the small pieces of stateful logic the components share, packaged so you don't re-roll them. They're copy-in, exactly like the components: add one with garn add <hook> and it lands in your lib/ folder — no runtime dependency on a garn package. Behavior hooks (focus, keyboard, ARIA) live in the Radix primitives the components are built on; these are the UI-agnostic utilities on top.

Runtime theming

Wrap your app in <ThemeProvider> and drop <ThemeScript> in your <head> (no theme flash on first paint). It applies color mode (.dark), density ([data-density]), and CSS-var overrides to <html> and persists each — then useColorMode / useDensity read and set them. A theme customizer drives the same provider via useTheme().setVars({ "--primary": … }) — a flat CSS-var map — so there's one theming mechanism, not two.

app/layout.tsx
import { ThemeProvider, ThemeScript } from "@garn/ui";

export default function RootLayout({ children }) {
  return (
    <html suppressHydrationWarning>
      <head><ThemeScript /></head>
      <body>
        <ThemeProvider defaultMode="system">{children}</ThemeProvider>
      </body>
    </html>
  );
}

useControllableState

garn add use-controllable-state

Controlled/uncontrolled state in one hook — the value + defaultValue + onChange primitive every garn control uses. When `value` is provided the state is controlled (the hook never stores its own copy); otherwise it tracks `defaultValue` locally. `onChange` fires on every setter call in both modes.

useControllableState<T>(opts: { value: T | undefined; defaultValue: T; onChange?: (value: T) => void }): readonly [T, (value: T) => void]
usage
const [value, setValue] = useControllableState({
  value: valueProp,        // controlled when defined
  defaultValue: false,     // used when uncontrolled
  onChange: onValueChange, // fires in both modes
});

useClipboard

garn add use-clipboard

Copy text to the clipboard with a self-resetting `copied` flag. It flips only on a real success — a missing Clipboard API (insecure context / SSR) or a rejected write is swallowed — so the UI never announces a copy that didn't happen. `timeout` defaults to 1500ms.

useClipboard(opts?: { timeout?: number }): { copied: boolean; copy: (text: string) => Promise<void> }
usage
const { copied, copy } = useClipboard();

<button onClick={() => copy("cus_123")}>
  {copied ? "Copied" : "Copy"}
</button>

useAnnounce

garn add use-announce

Announce a message to a shared screen-reader live region — one polite + one assertive region for the whole app, created lazily. Any component can give SR feedback (copy, save, error) without rendering its own `aria-live` span. garn's Badge and DataList copy buttons use it.

useAnnounce(): (message: string, priority?: "polite" | "assertive") => void announce(message, priority?)
usage
const announce = useAnnounce();

announce("Copied to clipboard");         // polite
announce("Upload failed", "assertive");  // interrupts

useMediaQuery

garn add use-media-query

Subscribe to a CSS media query and re-render when it changes. It starts `false` and syncs in an effect so SSR and the first client render agree (no hydration mismatch), and stays `false` where `matchMedia` is unavailable. The listener is cleaned up on unmount / query change.

useMediaQuery(query: string): boolean
usage
const isMobile = useMediaQuery("(max-width: 767px)");

useDebouncedValue

garn add use-debounced-value

Debounce a fast-changing value — returns it only after it has stopped changing for `delay` ms. The primitive for async search: feed a Combobox's `inputValue` through it so the fetch fires once typing settles, not on every keystroke.

useDebouncedValue<T>(value: T, delay: number): T
usage
const [inputValue, setInputValue] = React.useState("");
const query = useDebouncedValue(inputValue, 300);

React.useEffect(() => {
  // fires once typing settles, not per keystroke
  fetchResults(query);
}, [query]);

useLocalStorage

garn add use-local-storage

State backed by localStorage — reads/writes JSON under `key`, falling back to `initialValue`. SSR-safe (the first render returns `initialValue`, then it hydrates in an effect) and swallows writes where storage is unavailable. The setter takes a value or an updater, like `useState`.

useLocalStorage<T>(key: string, initialValue: T): readonly [T, (value: T | ((prev: T) => T)) => void]
usage
const [collapsed, setCollapsed] = useLocalStorage("sidebar", false);

useResizeObserver / useElementSize

garn add use-resize-observer

Observe an element's size with a ResizeObserver, without hand-rolling the observer + cleanup each time. `useResizeObserver` runs a callback on every resize (the latest callback is always used); `useElementSize` is the ergonomic wrapper returning the live content-box size. Both no-op where ResizeObserver is unavailable. Powers Textarea's `autoResize`.

useResizeObserver<T>(ref, (entry: ResizeObserverEntry) => void): void useElementSize<T>(): readonly [ref, { width: number; height: number }]
usage
const [ref, { width }] = useElementSize<HTMLDivElement>();

return <div ref={ref}>{Math.round(width)}px wide</div>;

useEventListener

garn add use-event-listener

Subscribe to an event with automatic cleanup. The listener always calls the latest handler (kept in a ref), so it never re-subscribes on every render and never goes stale — you can drop the `useCallback` + dependency array. Defaults to `window`; pass `options.target` for `document`.

useEventListener<K extends keyof WindowEventMap>(type: K, handler: (e) => void, options?): void
usage
useEventListener("keydown", (e) => {
  if ((e.metaKey || e.ctrlKey) && e.key === "k") open();
});

useIsomorphicLayoutEffect

garn add use-isomorphic-layout-effect

useLayoutEffect in the browser, useEffect on the server — run pre-paint DOM reads/writes (measuring, auto-sizing) without React's "useLayoutEffect does nothing on the server" warning.

useIsomorphicLayoutEffect(effect, deps?): void
usage
useIsomorphicLayoutEffect(() => {
  el.style.height = "auto";
  el.style.height = el.scrollHeight + "px";
}, [value]);

useColorMode

garn add use-color-mode

Read and set the color mode ("light" | "dark" | "system"). `resolvedMode` is the applied light/dark after resolving "system" against the OS preference. Requires a <ThemeProvider> ancestor (see Runtime theming above).

useColorMode(): { mode; resolvedMode; setMode; toggle } // inside <ThemeProvider>
usage
const { resolvedMode, toggle } = useColorMode();

<button onClick={toggle}>
  {resolvedMode === "dark" ? "Light" : "Dark"} mode
</button>

useDensity

garn add use-density

Read and set the global density ("compact" | "default" | "spacious"). Writes `[data-density]` on <html>; the token layer re-declares control + spacing tokens under it, so every control resizes. Requires a <ThemeProvider> ancestor.

useDensity(): { density; setDensity } // inside <ThemeProvider>
usage
const { density, setDensity } = useDensity();
// wire to any control, e.g. a Select of compact / default / spacious

useDisclosure

garn add use-disclosure

Boolean open/close state for overlays — the isOpen + open / close / toggle / setOpen primitive. The setters are stable (their identity never changes), so they're safe in dependency arrays and as props to memoized children. Radix owns each overlay's internal state; reach for this when you need to drive `open` from the outside.

useDisclosure(initial = false): { isOpen; open; close; toggle; setOpen }
usage
const { isOpen, open, close } = useDisclosure();

<Dialog open={isOpen} onOpenChange={(v) => (v ? open() : close())}>
  ...
</Dialog>

usePrefersReducedMotion

garn add use-prefers-reduced-motion

`true` when the user has asked the OS to minimize motion (prefers-reduced-motion: reduce) — gate non-essential animation and transitions behind it. Built on useMediaQuery, so it inherits the same SSR safety: starts `false` and syncs after mount.

usePrefersReducedMotion(): boolean
usage
const reduced = usePrefersReducedMotion();

<div className={reduced ? "" : "animate-fade-in"}>...</div>

useSessionStorage

garn add use-session-storage

State backed by sessionStorage — the per-tab twin of useLocalStorage. Scoped to the tab session (cleared when the tab closes), so it's right for wizard progress or a scroll position that shouldn't outlive the visit. Same SSR-safe contract: first render returns `initialValue`, then it hydrates in an effect.

useSessionStorage<T>(key: string, initialValue: T): readonly [T, (value: T | ((prev: T) => T)) => void]
usage
const [step, setStep] = useSessionStorage("checkout-step", 0);

useIntersectionObserver / useInView

garn add use-intersection-observer

Observe when an element crosses the viewport (or a `root`) with an IntersectionObserver — the primitive for lazy-loading, reveal-on-scroll, and infinite-scroll sentinels. `useIntersectionObserver` runs a callback on every entry (the latest callback is always used); `useInView` is the ergonomic wrapper returning a ref + boolean. Both no-op where IntersectionObserver is unavailable.

useIntersectionObserver<T>(ref, (entry: IntersectionObserverEntry) => void, options?): void useInView<T>(options?): readonly [ref, inView: boolean]
usage
const [ref, inView] = useInView<HTMLDivElement>({ rootMargin: "200px" });

return <div ref={ref}>{inView ? <Chart /> : <Skeleton />}</div>;

useClickOutside

garn add use-click-outside

Call a handler when a pointer press lands outside the referenced element — dismiss-on-outside-click for custom popovers, menus, and panels. (Radix overlays already do this; reach for it when you're building your own surface.) Listens for pointerdown on document so it fires before focus moves; pass `{ enabled: false }` to suspend it.

useClickOutside<T>(ref, handler: (e: PointerEvent) => void, options?: { enabled?: boolean }): void
usage
const ref = React.useRef<HTMLDivElement>(null);
useClickOutside(ref, () => setOpen(false));

<div ref={ref}>...</div>

useThrottle

garn add use-throttle

Throttle a fast-changing value — emits at most once per `ms` (leading edge immediate, latest coalesced onto the trailing edge). Unlike useDebouncedValue (which waits for a pause and never settles under a continuous stream), this advances on a fixed cadence — right for scroll / resize / drag readouts.

useThrottle<T>(value: T, ms: number): T
usage
const [scrollY, setScrollY] = React.useState(0);
const throttled = useThrottle(scrollY, 100);
// advances at most every 100ms during a continuous scroll

useDebouncedCallback

garn add use-debounced-callback

Debounce a callback — a stable function that defers `fn` until it stops being called for `ms`, then invokes the latest `fn` with the most recent arguments. The imperative complement to useDebouncedValue for search-as-you-type requests or autosave. Stable identity (safe in deps / as a handler); the pending call is cancelled on unmount.

useDebouncedCallback<A extends unknown[]>(fn: (...args: A) => void, ms: number): (...args: A) => void
usage
const save = useDebouncedCallback((draft: string) => {
  persist(draft);
}, 500);

<textarea onChange={(e) => save(e.target.value)} />

useHover

garn add use-hover

Track whether the pointer is over an element — attach the returned ref and read the boolean. Uses pointerenter / pointerleave (covers mouse + pen and ignores bubbling), so `hovered` reflects the ref'd element only. Cleans up on unmount.

useHover<T>(): readonly [ref, hovered: boolean]
usage
const [ref, hovered] = useHover<HTMLButtonElement>();

<button ref={ref}>{hovered ? "Release to delete" : "Delete"}</button>

useLongPress

garn add use-long-press

Fire a handler after the user presses and holds for `threshold` ms (default 500) — press-and-hold affordances (touch context actions, hold-to-confirm). Spread the returned props onto the target; the press is cancelled if the pointer lifts, leaves, or the gesture is cancelled before the threshold. The timer is cleared on unmount.

useLongPress(handler: (e) => void, options?: { threshold?: number }): { onPointerDown; onPointerUp; onPointerLeave; onPointerCancel }
usage
const handlers = useLongPress(() => openContextMenu(), { threshold: 600 });

<button {...handlers}>Hold to open menu</button>

useScrollLock

garn add use-scroll-lock

Lock body scroll while `locked` is true — the classic modal/drawer background-lock. Sets overflow: hidden on document.body and pads by the scrollbar's width so the page doesn't shift when the scrollbar disappears. The previous values are captured and restored on unlock and on unmount. SSR-safe.

useScrollLock(locked: boolean): void
usage
const [open, setOpen] = React.useState(false);
useScrollLock(open);
// body scroll frozen (scrollbar gap compensated) while open