Utility Hooks
Preview: Playground
Import
import { useMediaQuery, useDebouncedValue, useLocalStorage, useClipboard, useInterval, useClickOutside, useHotkey } from '@marbleui/ui'Hooks
useMediaQuery(query: string): booleanSubscribes to a CSS media query (SSR-safe: false on the server). Re-subscribes only when `query` changes.
useDebouncedValue<T>(value: T, delay?: number): TValue that updates `delay` ms after the last change.
useLocalStorage<T>(key: string, initial: T): readonly [T, (next: T | ((prev: T) => T)) => void]localStorage-backed state for per-viewer conveniences. SSR-safe: the server and the hydrating render use `initial`, then the stored value. The setter takes a value or an updater `(prev) => next`, and every hook on the same key stays in sync, across components and across tabs. Storage can be unavailable (private mode): falls back to memory.
useClipboard(timeout?: number): { copy: (text: string) => Promise<void>; copied: boolean; }Copy text; `copied` resets after `timeout`.
useInterval(cb: () => void, ms: number | null): voidsetInterval that always calls the latest callback; pass null to pause.
useClickOutside<T extends HTMLElement>(ref: RefObject<T | null>, handler: () => void): voidCalls `handler` on pointer down outside `ref`.
useHotkey(combo: string, handler: (e: KeyboardEvent) => void, { allowInInputs }?: { allowInInputs?: boolean | undefined; }): voidGlobal keyboard shortcut like "mod+k" (mod = ⌘ on Mac, Ctrl elsewhere; also ctrl, meta, shift, alt). Modifiers must match exactly. Ignored while typing in inputs unless `allowInInputs`.
Usage notes
Small client-side hooks, exported for app code. None needs a provider.
useMediaQuery('(min-width: 768px)'): boolean that follows a media query;falseon the server and during hydration.useDebouncedValue(search, 300): the value, updated 300 ms (the default) after it last changed.useLocalStorage('sidebar-width', 280):[value, set]stored as JSON; keeps working in memory when storage is blocked.useClipboard():{ copy, copied };copiedstays true for 1.5 s after a successful copy.useInterval(tick, running ? 1000 : null): interval that always calls the latest callback;nullpauses it.useClickOutside(ref, close): calls the handler on pointer down outsideref.useHotkey('mod+k', openPalette): global shortcut wheremodis ⌘ on Mac and Ctrl elsewhere; ignored while typing in an input, textarea or contenteditable unless{ allowInInputs: true }.
useIsClient, useTheme and useOverlay are covered under ClientOnly, ThemeProvider and OverlayProvider.
useLocalStorage’s setter takes a value or an updater function. Hooks on the same key stay in sync within the page and across tabs. The server and the hydrating render useinitial, then the stored value, so there’s no hydration mismatch.useHotkeyrecognisesmod,ctrl,meta(orcmd),shiftandalt, and modifiers must match exactly:'ctrl+s'needs Ctrl, and'k'doesn’t fire with Ctrl or ⌘ held.useClickOutsidetreats clicks inside portalled menus and popovers as outside.
Example source · show· hide
import { useRef, useState } from 'react'
import { Button } from '../button'
import { Kbd } from '../kbd'
import { TextField } from '../text-field'
import { useClickOutside, useClipboard, useDebouncedValue, useHotkey, useInterval, useLocalStorage, useMediaQuery } from './hooks'
const Row = ({ name, children }: { name: string; children: React.ReactNode }) => <div className="flex items-center justify-between gap-4 px-4 py-3 text-sm"><code className="font-mono text-xs text-accent-ink">{name}</code><div className="flex items-center gap-2 text-fg">{children}</div></div>
function Demo() {
const wide = useMediaQuery('(min-width: 768px)')
const reduce = useMediaQuery('(prefers-reduced-motion: reduce)')
const [q, setQ] = useState('')
const dq = useDebouncedValue(q, 400)
const [count, setCount] = useLocalStorage('marbleui-demo-count', 0)
const { copy, copied } = useClipboard()
const [ticks, setTicks] = useState(0)
const [running, setRunning] = useState(false)
useInterval(() => setTicks((t) => t + 1), running ? 1000 : null)
const [hits, setHits] = useState(0)
useHotkey('mod+j', () => setHits((h) => h + 1))
const box = useRef<HTMLDivElement>(null)
const [outside, setOutside] = useState(0)
useClickOutside(box, () => setOutside((n) => n + 1))
return (
<div className="w-[30rem] divide-y divide-border rounded-xl border border-border bg-surface">
<Row name="useMediaQuery">{wide ? '≥ 768px' : '< 768px'} · reduced motion {reduce ? 'on' : 'off'}</Row>
<Row name="useDebouncedValue"><TextField aria-label="Type" value={q} onChange={setQ} className="w-32" /><span className="w-20 truncate text-fg-muted">“{dq}”</span></Row>
<Row name="useLocalStorage"><Button size="sm" variant="outline" onPress={() => setCount(count + 1)}>Count: {count}</Button></Row>
<Row name="useClipboard"><Button size="sm" variant="outline" onPress={() => copy('npm i @marbleui/ui')}>{copied ? 'Copied!' : 'Copy install'}</Button></Row>
<Row name="useInterval"><span className="tabular-nums">{ticks}s</span><Button size="sm" variant="ghost" onPress={() => setRunning(!running)}>{running ? 'Pause' : 'Start'}</Button></Row>
<Row name="useHotkey">Press <Kbd>⌘/Ctrl</Kbd> <Kbd>J</Kbd> · {hits}</Row>
<Row name="useClickOutside"><div ref={box} className="rounded-md border border-dashed border-border-strong px-2 py-1 text-xs">Click outside me · {outside}</div></Row>
</div>
)
}
export const styles = { Playground: Demo }Source: packages/ui/src/hooks/hooks.tsx
Catalog
- Category
- utilities
- Priority
- Extra
- Primitive
- custom
- React Aria
- usePress / useHover / useFocusRing
- Base UI
- useRender