Custom React hooks exported from @ngrok/mantle/hooks.
1const breakpoint = useBreakpoint();Returns the current responsive breakpoint based on the viewport width. Uses a singleton subscription to a set of min-width media queries and returns the largest matching breakpoint.
1import { useBreakpoint } from "@ngrok/mantle/hooks";2 3function ResponsiveComponent() {4 const breakpoint = useBreakpoint();5 return <p>Current breakpoint: {breakpoint}</p>;6}1const isMobile = useIsBelowBreakpoint("md");Returns true if the current viewport width is below the specified breakpoint. Accepts a TailwindBreakpoint ("2xs", "xs", "sm", "md", "lg", "xl", "2xl").
1import { useIsBelowBreakpoint } from "@ngrok/mantle/hooks";2 3function ResponsiveSidebar() {4 const isMobile = useIsBelowBreakpoint("md");5 return isMobile ? <MobileNav /> : <DesktopNav />;6}1const 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.
1import { useCallbackRef } from "@ngrok/mantle/hooks";2 3function Example({ onChange }: { onChange?: (value: string) => void }) {4 const stableOnChange = useCallbackRef(onChange);5 // stableOnChange always calls the latest onChange6}1const ref = useComposedRefs(internalRef, forwardedRef);Composes multiple React refs (callback refs and RefObjects) into a single stable callback ref using useCallback. Useful when a component needs to forward a ref while also keeping an internal one.
1import { forwardRef, useRef } from "react";2import { useComposedRefs } from "@ngrok/mantle/hooks";3 4const MyInput = forwardRef<HTMLInputElement>((props, forwardedRef) => {5 const internalRef = useRef<HTMLInputElement>(null);6 const ref = useComposedRefs(internalRef, forwardedRef);7 return <input ref={ref} {...props} />;8});1const [copiedValue, copy] = useCopyToClipboard();Copies a string to the clipboard. Returns a tuple of the last copied value and a copy function. Includes a fallback for older browsers.
1import { useCopyToClipboard } from "@ngrok/mantle/hooks";2 3function CopyButton({ text }: { text: string }) {4 const [copiedValue, copy] = useCopyToClipboard();5 return <button onClick={() => copy(text)}>{copiedValue === text ? "Copied!" : "Copy"}</button>;6}1const 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.
1import { useDebouncedCallback } from "@ngrok/mantle/hooks";2 3function SearchInput() {4 const debouncedSearch = useDebouncedCallback((query: string) => fetchResults(query), {5 waitMs: 300,6 });7 return <input onChange={(event) => debouncedSearch(event.target.value)} />;8}1const 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.
1import { useIsHydrated } from "@ngrok/mantle/hooks";2import type { PropsWithChildren } from "react";3 4function ClientOnly({ children }: PropsWithChildren) {5 const isHydrated = useIsHydrated();6 if (!isHydrated) {7 return <span style={{ visibility: "hidden" }}>Loading…</span>;8 }9 return <>{children}</>;10}1useIsomorphicLayoutEffect(() => {2 /* ... */3}, [deps]);Uses useLayoutEffect on the client and useEffect on the server. Avoids SSR warnings about useLayoutEffect doing nothing on the server.
1import { useIsomorphicLayoutEffect } from "@ngrok/mantle/hooks";2 3function MeasureElement() {4 useIsomorphicLayoutEffect(() => {5 // safely measure DOM on client, no-op on server6 }, []);7}1const 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.
1import { useMatchesMediaQuery } from "@ngrok/mantle/hooks";2 3function DarkModeDetector() {4 const isDark = useMatchesMediaQuery("(prefers-color-scheme: dark)");5 return <p>Dark mode: {isDark ? "Yes" : "No"}</p>;6}1const 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.
1import { usePrefersReducedMotion } from "@ngrok/mantle/hooks";2 3function AnimatedComponent() {4 const reduce = usePrefersReducedMotion();5 const duration = reduce ? 0 : 200;6 return <div style={{ transitionDuration: duration + "ms" }} />;7}1const 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.
1import { getPrefersReducedMotion } from "@ngrok/mantle/hooks";2 3function shakeElement(element: HTMLElement) {4 if (getPrefersReducedMotion()) {5 return;6 }7 element.animate(/* ... */);8}1const id = useRandomStableId("prefix");Generates a random, stable ID. Similar to useId, but produces an ID that is safe for use in CSS selectors and as element IDs. Accepts an optional prefix (defaults to "mantle").
1import { useRandomStableId } from "@ngrok/mantle/hooks";2import type { PropsWithChildren } from "react";3 4function Tooltip({ children }: PropsWithChildren) {5 const id = useRandomStableId("tooltip");6 return <div id={id}>{children}</div>;7}1const 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".
1import { useScrollBehavior } from "@ngrok/mantle/hooks";2 3function ScrollToTop() {4 const behavior = useScrollBehavior();5 return <button onClick={() => window.scrollTo({ top: 0, behavior })}>Back to top</button>;6}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.
1const isInView = useInView(ref);2const isInView = useInView(ref, { once: true, amount: 0.5 });1import { useRef } from "react";2import { useInView } from "@ngrok/mantle/hooks";3 4function FadeIn() {5 const ref = useRef<HTMLDivElement>(null);6 const isInView = useInView(ref);7 8 return <div ref={ref} style={{ opacity: isInView ? 1 : 0, transition: "opacity 0.5s" }} />;9}Trigger once on first entry:
1import { useRef } from "react";2import { useInView } from "@ngrok/mantle/hooks";3 4function AnimateOnce() {5 const ref = useRef<HTMLDivElement>(null);6 const isInView = useInView(ref, { once: true });7 8 return <div ref={ref} className={isInView ? "animate" : ""} />;9}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
↑ scroll up
1"use client";2 3import { cx } from "@ngrok/mantle/cx";4import { useInView } from "@ngrok/mantle/hooks";5import { useRef, type ComponentRef } from "react";6 7export function UseInViewDemo() {8 const ref = useRef<ComponentRef<"div">>(null);9 const isInView = useInView(ref, { amount: 0.5 });10 11 return (12 <div className="h-72 w-full overflow-y-scroll overscroll-contain">13 <div className="flex flex-col items-center gap-4 px-8 py-6">14 <p className="select-none font-mono text-sm text-muted">↓ scroll down</p>15 <div className="h-60" />16 <div17 ref={ref}18 className={cx(19 "flex h-28 w-64 items-center justify-center rounded-xl border-2 font-mono text-sm transition-all duration-500",20 isInView21 ? "scale-100 border-accent-600 bg-accent-600/10 text-accent-600 opacity-100"22 : "scale-95 border-card-muted bg-gray-50 text-muted opacity-30",23 )}24 >25 isInView: {String(isInView)}26 </div>27 <div className="h-60" />28 <p className="select-none font-mono text-sm text-muted">↑ scroll up</p>29 </div>30 </div>31 );32}