Custom React hooks exported from @ngrok/mantle/hooks.
const 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.
import { useBreakpoint } from "@ngrok/mantle/hooks";
function ResponsiveComponent() {
const breakpoint = useBreakpoint();
return <p>Current breakpoint: {breakpoint}</p>;
}const 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").
import { useIsBelowBreakpoint } from "@ngrok/mantle/hooks";
function ResponsiveSidebar() {
const isMobile = useIsBelowBreakpoint("md");
return isMobile ? <MobileNav /> : <DesktopNav />;
}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
}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 needs to forward a ref while also keeping an internal one.
import { forwardRef, useRef } from "react";
import { useComposedRefs } from "@ngrok/mantle/hooks";
const MyInput = forwardRef<HTMLInputElement>((props, forwardedRef) => {
const internalRef = useRef<HTMLInputElement>(null);
const ref = useComposedRefs(internalRef, forwardedRef);
return <input ref={ref} {...props} />;
});const [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.
import { useCopyToClipboard } from "@ngrok/mantle/hooks";
function CopyButton({ text }: { text: string }) {
const [copiedValue, copy] = useCopyToClipboard();
return <button onClick={() => copy(text)}>{copiedValue === text ? "Copied!" : "Copy"}</button>;
}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)} />;
}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.
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}</>;
}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
}, []);
}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>;
}const reduce = usePrefersReducedMotion();Returns true when the user has opted out of animations (i.e., prefers reduced motion). Defaults to true on the server to avoid animating before hydration.
import { usePrefersReducedMotion } from "@ngrok/mantle/hooks";
function AnimatedComponent() {
const reduce = usePrefersReducedMotion();
const duration = reduce ? 0 : 200;
return <div style={{ transitionDuration: duration + "ms" }} />;
}const 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").
import { useRandomStableId } from "@ngrok/mantle/hooks";
import type { PropsWithChildren } from "react";
function Tooltip({ children }: PropsWithChildren) {
const id = useRandomStableId("tooltip");
return <div id={id}>{children}</div>;
}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>;
}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" : ""} />;
}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
"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>
);
}