Skip to main content

Hooks

Custom React hooks exported from @ngrok/mantle/hooks.

Overview

HookDescription
useBreakpointThe largest Tailwind breakpoint the viewport width matches, or "default" below 2xs.
useIsBelowBreakpointtrue while the viewport is narrower than the given breakpoint.
useCallbackRefReturns a memoized callback that always refers to the latest function passed to it.
useComposedRefsComposes multiple React refs into a single callback ref.
useCopyToClipboardCopies a string to the clipboard.
useDebounceReturns a debounced copy of a value that updates after changes pause.
useDebouncedCallbackCreates a debounced version of a callback function.
useFocusTrapKeeps keyboard focus inside an element while active: Tab wraps at both ends.
useInertSiblingsSets inert on every other child of an element's parent while active.
useIsHydratedReturns whether the component tree has been hydrated on the client.
useIsHydratedAfterPainttrue from the first animation frame after hydration, for CSS transition gates.
useIsomorphicLayoutEffectUses useLayoutEffect on the client and useEffect on the server.
useLocalStorageSSR-safe localStorage string state, synced across hook instances and tabs.
useSessionStorageSSR-safe sessionStorage string state for per-tab persistence.
useMatchesMediaQuerySubscribes to and returns the result of a CSS media query.
usePrefersReducedMotionReturns true when the user prefers reduced motion.
useScrollBehaviorReturns a ScrollBehavior that respects the user's reduced motion preference.
useSwipeToDismissMakes an element a swipe handle that drags a target toward one edge and dismisses it.
useInViewReturns true when a DOM element is visible within the viewport.
useUndoRedoGeneric undo/redo history stacks for snapshotting state.

useBreakpoint

const breakpoint = useBreakpoint();

The largest Tailwind breakpoint the viewport width matches, or "default" below 2xs. Shares one app-wide subscription to the min-width media queries.

import { useBreakpoint } from "@ngrok/mantle/hooks"; function ResponsiveComponent() {	const breakpoint = useBreakpoint();	return <p>Current breakpoint: {breakpoint}</p>;}

useIsBelowBreakpoint

const isMobile = useIsBelowBreakpoint("md");

true while the viewport is narrower than the given breakpoint. Accepts a TailwindBreakpoint ("2xs", "xs", "sm", "md", "lg", "xl", "2xl").

import { useIsBelowBreakpoint } from "@ngrok/mantle/hooks"; function ResponsiveSidebar() {	const isMobile = useIsBelowBreakpoint("md");	return isMobile ? <MobileNav /> : <DesktopNav />;}

useCallbackRef

const stableCallback = useCallbackRef(onChange);

Returns a memoized callback that always refers to the latest callback passed to the hook. Useful when passing a callback that may or may not be memoized to a child component without causing re-renders.

import { useCallbackRef } from "@ngrok/mantle/hooks"; function Example({ onChange }: { onChange?: (value: string) => void }) {	const stableOnChange = useCallbackRef(onChange);	// stableOnChange always calls the latest onChange}

useComposedRefs

const ref = useComposedRefs(internalRef, forwardedRef);

Composes multiple React refs (callback refs and RefObjects) into a single stable callback ref using useCallback. Useful when a component accepts a ref prop while also keeping an internal one.

import { useRef, type ComponentProps } from "react";import { useComposedRefs } from "@ngrok/mantle/hooks"; const MyInput = ({ ref: forwardedRef, ...props }: ComponentProps<"input">) => {	const internalRef = useRef<HTMLInputElement>(null);	const ref = useComposedRefs(internalRef, forwardedRef);	return <input ref={ref} {...props} />;};

useCopyToClipboard

const copy = useCopyToClipboard();

Returns a stable async function that copies a string to the clipboard. Uses the Clipboard API when available and falls back to a polyfill for older browsers. await the call to observe success; it throws when copying fails.

import { useCopyToClipboard } from "@ngrok/mantle/hooks";import { useState } from "react"; function CopyButton({ text }: { text: string }) {	const copy = useCopyToClipboard();	const [copied, setCopied] = useState(false); 	return (		<button			onClick={async () => {				await copy(text);				setCopied(true);			}}		>			{copied ? "Copied!" : "Copy"}		</button>	);}

useDebounce

const debouncedValue = useDebounce(value, { waitMs: 300 });

Returns a debounced copy of a value: it only updates after the value has stopped changing for waitMs milliseconds. Rapid successive changes collapse into a single trailing update. To debounce a function instead of a value, use useDebouncedCallback.

import { useDebounce } from "@ngrok/mantle/hooks";import { useState } from "react"; function Search() {	const [query, setQuery] = useState("");	const debouncedQuery = useDebounce(query, { waitMs: 300 });	// fetches keyed on debouncedQuery fire once per typing pause	const results = useSearchResults(debouncedQuery); 	return <input value={query} onChange={(event) => setQuery(event.target.value)} />;}

useDebouncedCallback

const debouncedFn = useDebouncedCallback(fn, { waitMs: 300 });

Creates a debounced version of a callback function. Delays execution until a period of inactivity has passed (options.waitMs). The debounced callback is stable and safe to use in dependency arrays.

import { useDebouncedCallback } from "@ngrok/mantle/hooks"; function SearchInput() {	const debouncedSearch = useDebouncedCallback((query: string) => fetchResults(query), {		waitMs: 300,	});	return <input onChange={(event) => debouncedSearch(event.target.value)} />;}

useFocusTrap

useFocusTrap(panelRef, { active: open && coversThePage });

Keeps keyboard focus inside the element ref points at while active is true (the default). On activation, when focus is outside the element, it moves focus to the element's first tab stop, else to the element itself, which gets tabindex="-1" for the trap's lifetime when it has none. An element that opens with a transition can hold nothing focusable in the commit that activates the trap, so the move retries for up to a second of frames until focus lands. While active, Tab on the last tab stop wraps to the first and Shift+Tab on the first wraps to the last, in document order. A child that prevents the default on Tab keeps it. On release the trap leaves focus where it is, so the caller decides where focus returns.

It traps the keyboard only. Focus that a pointer or a script moves out of the element stays out, so a menu, a popover, or a dialog that portals out of the element keeps working. Pair it with useInertSiblings to block pointer focus, the way AppLayout.SidePanel does when it covers the page. Flip active to engage and release the trap on one mounted element: a panel that covers the page at one viewport width and sits beside it at another keeps its DOM.

import { useFocusTrap, useInertSiblings, useMatchesMediaQuery } from "@ngrok/mantle/hooks";import { useRef, type ReactNode } from "react"; function CoverPanel({ open, children }: { open: boolean; children: ReactNode }) {	const panelRef = useRef<HTMLDivElement>(null);	const coversThePage = useMatchesMediaQuery("(width < 48rem)");	useInertSiblings(panelRef, { active: open && coversThePage });	useFocusTrap(panelRef, { active: open && coversThePage }); 	return (		<div ref={panelRef} role="region" aria-label="Assistant" inert={!open}>			{children}		</div>	);}
OptionTypeDefaultDescription
active?booleantrueWhether the trap holds focus. Flip it to engage and release the trap on one mounted element.

useInertSiblings

useInertSiblings(panelRef, { active: open && coversThePage });

Sets inert on every other child of the element's parent while active is true (the default), so nothing beside the element is clickable, focusable, or read by assistive technology. A sibling that mounts while the hook is active goes inert too. On release it clears inert from the siblings it set and leaves alone one that was inert already.

It reaches siblings only, not the rest of the document, so it fits a surface that covers one row of a layout in place: a panel that covers the card and the rail beside it, with a notice above the row still usable. Pair it with useFocusTrap to keep keyboard focus inside the element; the example above does both.

OptionTypeDefaultDescription
active?booleantrueWhether the siblings are inert. Flip it to make them inert and release them again.

useIsHydrated

const isHydrated = useIsHydrated();

Returns whether the component tree has been hydrated on the client. Returns false on the server and true after hydration on the client. Uses useSyncExternalStore to prevent hydration mismatches. To gate a CSS transition on a post-hydration state correction, use useIsHydratedAfterPaint instead. This hook flips in the same commit as the correction, so the transition runs.

import { useIsHydrated } from "@ngrok/mantle/hooks";import type { PropsWithChildren } from "react"; function ClientOnly({ children }: PropsWithChildren) {	const isHydrated = useIsHydrated();	if (!isHydrated) {		return <span style={{ visibility: "hidden" }}>Loading…</span>;	}	return <>{children}</>;}

useIsHydratedAfterPaint

const isHydrated = useIsHydratedAfterPaint();

Returns false on the server, in the hydration render, and in the mount commit, then true from the next animation frame.

Use it to gate a CSS transition that must not run on a post-hydration state correction. useIsHydrated flips in the hydration commit. A useLocalStorage or any other useSyncExternalStore correction lands in that same commit. CSS starts a transition from the commit's after-change style, so the transition runs. One frame later the correction has painted, so a gate keyed on this result opens on settled values. For every other client-only branch, useIsHydrated is the right hook: it costs no extra render.

import { useIsHydratedAfterPaint, useLocalStorage } from "@ngrok/mantle/hooks"; function Panel() {	const [state] = useLocalStorage("panel-state", "expanded");	const isHydrated = useIsHydratedAfterPaint();	// A persisted-collapsed panel snaps on load and animates on every later toggle.	return (		<div			data-state={state}			data-hydrated={isHydrated ? "" : undefined}			className="w-52 transition-[width] data-[state=collapsed]:w-13 not-data-hydrated:transition-none"		/>	);}

useIsomorphicLayoutEffect

useIsomorphicLayoutEffect(() => {	/* ... */}, [deps]);

Uses useLayoutEffect on the client and useEffect on the server. Avoids SSR warnings about useLayoutEffect doing nothing on the server.

import { useIsomorphicLayoutEffect } from "@ngrok/mantle/hooks"; function MeasureElement() {	useIsomorphicLayoutEffect(() => {		// safely measure DOM on client, no-op on server	}, []);}

useLocalStorage

const [value, setValue] = useLocalStorage("key", "default");

SSR-safe localStorage-backed string state. Server rendering returns the default and never touches localStorage. Values are JSON-encoded on write and decoded on read. The setter keeps every same-key hook instance in the tab in sync, and cross-tab changes arrive via the native storage event. Missing or corrupt entries resolve to the default instead of throwing.

Reads and writes never throw. If the browser denies storage access, the value is the default. If a write fails (a full origin, Safari private browsing, blocked site data), the hook keeps the value in memory, so it still advances until the page reloads. A later write that succeeds, or a storage event from another document, restores storage as the source of truth.

import { useLocalStorage } from "@ngrok/mantle/hooks"; function DateFormatPicker() {	const [dateFormat, setDateFormat] = useLocalStorage("dateFormat", "MMM d, y"); 	return (		<select value={dateFormat} onChange={(event) => setDateFormat(event.target.value)}>			<option value="MMM d, y">Aug 9, 2025</option>			<option value="y-MM-dd">2025-08-09</option>		</select>	);}

useSessionStorage

const [value, setValue] = useSessionStorage("key", "default");

The per-tab sibling of useLocalStorage with the same contract, backed by sessionStorage: values persist for the rest of the tab session and every same-key hook instance in the tab stays in sync.

import { useSessionStorage } from "@ngrok/mantle/hooks"; function MfaWarningBanner() {	const [dismissed, setDismissed] = useSessionStorage("mfa-warning-dismissed", "false"); 	if (dismissed === "true") {		return null;	} 	return (		<div role="alert">			Enable MFA to secure your account.			<button onClick={() => setDismissed("true")}>Dismiss</button>		</div>	);}

useMatchesMediaQuery

const matches = useMatchesMediaQuery("(prefers-color-scheme: dark)");

Subscribes to and returns the result of a CSS media query string. Uses window.matchMedia and useSyncExternalStore for concurrent rendering compatibility.

import { useMatchesMediaQuery } from "@ngrok/mantle/hooks"; function DarkModeDetector() {	const isDark = useMatchesMediaQuery("(prefers-color-scheme: dark)");	return <p>Dark mode: {isDark ? "Yes" : "No"}</p>;}

usePrefersReducedMotion

const reduce = usePrefersReducedMotion();

Returns true when the user has opted out of animations (i.e., prefers reduced motion). Defaults to true during SSR and before hydration: animations are suppressed until the real preference is known on the client.

import { usePrefersReducedMotion } from "@ngrok/mantle/hooks"; function AnimatedComponent() {	const reduce = usePrefersReducedMotion();	const duration = reduce ? 0 : 200;	return <div style={{ transitionDuration: duration + "ms" }} />;}

getPrefersReducedMotion

const reduce = getPrefersReducedMotion();

Imperatively reads the current prefers-reduced-motion preference. Useful in event handlers and plain functions where a hook cannot be called.

Returns true when the user has opted out of animations. Defaults to true in SSR environments (no DOM available), matching the conservative default of usePrefersReducedMotion.

import { getPrefersReducedMotion } from "@ngrok/mantle/hooks"; function shakeElement(element: HTMLElement) {	if (getPrefersReducedMotion()) {		return;	}	element.animate(/* ... */);}

useScrollBehavior

const behavior = useScrollBehavior(); // "smooth" | "auto"

Returns a ScrollBehavior ("auto" or "smooth") that respects the user's reduced motion preference. Returns "auto" when the user prefers reduced motion, otherwise "smooth".

import { useScrollBehavior } from "@ngrok/mantle/hooks"; function ScrollToTop() {	const behavior = useScrollBehavior();	return <button onClick={() => window.scrollTo({ top: 0, behavior })}>Back to top</button>;}

useSwipeToDismiss

useSwipeToDismiss(headerRef, { target: panelRef, onDismiss: () => setOpen(false) });

Makes the element handle points at a swipe handle that drags target toward one edge and calls onDismiss when the drag crosses threshold of the target's size or ends in a flick. A shorter drag springs back, and a drag the wrong way meets rubber-band resistance. The target's own transition on the property animates both the spring back and the slide the caller runs after onDismiss, so a motion-reduce: rule on the target turns them into cuts.

The target follows the pointer one to one: while the pointer is down the hook writes the property and transition: none on the target's inline style, and clears both on release. translate slides the target; flex-basis shrinks a flex item from its size toward nothing, the way a lane collapses. A tap on a control inside the handle stays a tap: the drag starts after 8px of travel along the direction, a gesture that travels further across it never starts, and a release after a drag swallows the click that follows it. Give the handle touch-action: pan-y (touch-pan-y) for a horizontal swipe, or pan-x for a vertical one, so the other axis still scrolls. AppLayout.SidePanelHeader is the handle for the side panel, with property picked at pointer down: translate while the panel floats and flex-basis while it sits in the lane.

import { useSwipeToDismiss } from "@ngrok/mantle/hooks";import { useRef, type ReactNode } from "react"; function FloatingPanel({	open,	setOpen,	children,}: {	open: boolean;	setOpen: (open: boolean) => void;	children: ReactNode;}) {	const panelRef = useRef<HTMLElement>(null);	const headerRef = useRef<HTMLDivElement>(null);	useSwipeToDismiss(headerRef, {		target: panelRef,		onDismiss: () => setOpen(false),	}); 	return (		<aside			ref={panelRef}			aria-label="Assistant"			data-state={open ? "open" : "closed"}			className="transition-[translate] duration-200 ease-out-strong motion-reduce:transition-none data-state-closed:translate-x-full"		>			<div ref={headerRef} className="touch-pan-y">				Assistant			</div>			{children}		</aside>	);}
OptionTypeDefaultDescription
onDismiss() => void—Called once when a released drag crosses the threshold or ends in a flick. The caller hides the target.
target?RefObject<HTMLElement | null>the handleThe element the drag moves and measures. Pass the panel when a header row is the handle.
direction?"left" | "right" | "up" | "down""right"The edge the target slides off. A drag the other way meets rubber-band resistance and never dismisses.
property?"translate" | "flex-basis" | (() => "translate" | "flex-basis")"translate"The CSS property the drag writes on the target. A function runs at pointer down, for a target that slides in one layout and collapses in another.
active?boolean | (() => boolean)trueWhether a drag can start. A function runs at pointer down, for a condition CSS owns.
threshold?number0.25The share of the target's size along the direction a drag must cross to dismiss, between 0 and 1.
flickVelocity?number0.4The speed toward the edge, in CSS pixels per millisecond, that dismisses a shorter drag as a flick. A pointer that rests for 100ms before the release has no speed.

useInView

React hook that tracks whether a DOM element is visible within the viewport (or a scrollable container). Returns true when the element is in view, otherwise false.

const isInView = useInView(ref);const isInView = useInView(ref, { once: true, amount: 0.5 });
import { useRef } from "react";import { useInView } from "@ngrok/mantle/hooks"; function FadeIn() {	const ref = useRef<HTMLDivElement>(null);	const isInView = useInView(ref); 	return <div ref={ref} style={{ opacity: isInView ? 1 : 0, transition: "opacity 0.5s" }} />;}

Trigger once on first entry:

import { useRef } from "react";import { useInView } from "@ngrok/mantle/hooks"; function AnimateOnce() {	const ref = useRef<HTMLDivElement>(null);	const isInView = useInView(ref, { once: true }); 	return <div ref={ref} className={isInView ? "animate" : ""} />;}

useInView options:

OptionTypeDefaultDescription
rootRefObject<Element | null>viewportRef to a scrollable container to use as the intersection root.
marginstring"0px"Expand or contract the root bounds. Same syntax as CSS margin (e.g. "10px", "10% 0px").
amount"some" | "all" | number"some"How much of the element must be visible. "some" = any part, "all" = fully visible, or a ratio between 0–1.
oncebooleanfalseStop observing after the element enters the viewport for the first time.
initialbooleanfalseInitial visibility state before the observer attaches.

Do not combine once: true with initial: true. With both set, the hook reports in view on mount and never observes the element.

useInView example

Live demo for the useInView hook. A scrollable container where the observed element transitions between in-view and out-of-view states as you scroll.

↓ scroll down

isInView: false

↑ scroll up

"use client"; import { cx } from "@ngrok/mantle/cx";import { useInView } from "@ngrok/mantle/hooks";import { useRef, type ComponentRef } from "react"; export function UseInViewDemo() {	const ref = useRef<ComponentRef<"div">>(null);	const isInView = useInView(ref, { amount: 0.5 }); 	return (		<div className="h-72 w-full overflow-y-scroll overscroll-contain">			<div className="flex flex-col items-center gap-4 px-8 py-6">				<p className="select-none font-mono text-sm text-muted">↓ scroll down</p>				<div className="h-60" />				<div					ref={ref}					className={cx(						"flex h-28 w-64 items-center justify-center rounded-xl border-2 font-mono text-sm transition-all duration-500",						isInView							? "scale-100 border-accent-600 bg-accent-600/10 text-accent-600 opacity-100"							: "scale-95 border-card-muted bg-gray-50 text-muted opacity-30",					)}				>					isInView: {String(isInView)}				</div>				<div className="h-60" />				<p className="select-none font-mono text-sm text-muted">↑ scroll up</p>			</div>		</div>	);}

useUndoRedo

const { canUndo, canRedo, push, undo, redo } = useUndoRedo<T>();

Generic undo/redo hook that maintains two history stacks (undo and redo). The hook does not own your application state — it helps you snapshot it. Call push(snapshot) before mutating state, then undo(current) or redo(current) return the snapshot to apply, or undefined when their stack is empty. Pushing a new snapshot clears the redo stack, matching standard editor semantics.

import { useUndoRedo } from "@ngrok/mantle/hooks";import { useState } from "react"; function TagEditor() {	const [tags, setTags] = useState<string[]>([]);	const { push, undo, redo, canUndo, canRedo } = useUndoRedo<string[]>(); 	function removeTag(tag: string) {		push(tags); // snapshot before mutation		setTags((previous) => previous.filter((entry) => entry !== tag));	} 	return (		<div>			<button				disabled={!canUndo}				onClick={() => {					const previous = undo(tags);					if (previous) {						setTags(previous);					}				}}			>				Undo			</button>			<button				disabled={!canRedo}				onClick={() => {					const next = redo(tags);					if (next) {						setTags(next);					}				}}			>				Redo			</button>			{tags.map((tag) => (				<button key={tag} onClick={() => removeTag(tag)}>					{tag} ✕				</button>			))}		</div>	);}