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.
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-stateControlled/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.
const [value, setValue] = useControllableState({
value: valueProp, // controlled when defined
defaultValue: false, // used when uncontrolled
onChange: onValueChange, // fires in both modes
});useClipboard
garn add use-clipboardCopy 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.
const { copied, copy } = useClipboard();
<button onClick={() => copy("cus_123")}>
{copied ? "Copied" : "Copy"}
</button>useAnnounce
garn add use-announceAnnounce 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.
const announce = useAnnounce();
announce("Copied to clipboard"); // polite
announce("Upload failed", "assertive"); // interruptsuseMediaQuery
garn add use-media-querySubscribe 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.
const isMobile = useMediaQuery("(max-width: 767px)");useDebouncedValue
garn add use-debounced-valueDebounce 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.
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-storageState 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`.
const [collapsed, setCollapsed] = useLocalStorage("sidebar", false);useResizeObserver / useElementSize
garn add use-resize-observerObserve 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`.
const [ref, { width }] = useElementSize<HTMLDivElement>();
return <div ref={ref}>{Math.round(width)}px wide</div>;useEventListener
garn add use-event-listenerSubscribe 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("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") open();
});useIsomorphicLayoutEffect
garn add use-isomorphic-layout-effectuseLayoutEffect 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(() => {
el.style.height = "auto";
el.style.height = el.scrollHeight + "px";
}, [value]);useColorMode
garn add use-color-modeRead 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).
const { resolvedMode, toggle } = useColorMode();
<button onClick={toggle}>
{resolvedMode === "dark" ? "Light" : "Dark"} mode
</button>useDensity
garn add use-densityRead 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.
const { density, setDensity } = useDensity();
// wire to any control, e.g. a Select of compact / default / spacioususeDisclosure
garn add use-disclosureBoolean 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.
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.
const reduced = usePrefersReducedMotion();
<div className={reduced ? "" : "animate-fade-in"}>...</div>useSessionStorage
garn add use-session-storageState 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.
const [step, setStep] = useSessionStorage("checkout-step", 0);useIntersectionObserver / useInView
garn add use-intersection-observerObserve 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.
const [ref, inView] = useInView<HTMLDivElement>({ rootMargin: "200px" });
return <div ref={ref}>{inView ? <Chart /> : <Skeleton />}</div>;useClickOutside
garn add use-click-outsideCall 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.
const ref = React.useRef<HTMLDivElement>(null);
useClickOutside(ref, () => setOpen(false));
<div ref={ref}>...</div>useThrottle
garn add use-throttleThrottle 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.
const [scrollY, setScrollY] = React.useState(0);
const throttled = useThrottle(scrollY, 100);
// advances at most every 100ms during a continuous scrolluseDebouncedCallback
garn add use-debounced-callbackDebounce 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.
const save = useDebouncedCallback((draft: string) => {
persist(draft);
}, 500);
<textarea onChange={(e) => save(e.target.value)} />useHover
garn add use-hoverTrack 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.
const [ref, hovered] = useHover<HTMLButtonElement>();
<button ref={ref}>{hovered ? "Release to delete" : "Delete"}</button>useLongPress
garn add use-long-pressFire 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.
const handlers = useLongPress(() => openContextMenu(), { threshold: 600 });
<button {...handlers}>Hold to open menu</button>useScrollLock
garn add use-scroll-lockLock 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.
const [open, setOpen] = React.useState(false);
useScrollLock(open);
// body scroll frozen (scrollbar gap compensated) while open