Marble UI
← Components

Utility Hooks

Preview: Playground

Import

import { useMediaQuery, useDebouncedValue, useLocalStorage, useClipboard, useInterval, useClickOutside, useHotkey } from '@marbleui/ui'

Hooks

useMediaQuery(query: string): boolean

Subscribes to a CSS media query (SSR-safe: false on the server). Re-subscribes only when `query` changes.

useDebouncedValue<T>(value: T, delay?: number): T

Value 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): void

setInterval that always calls the latest callback; pass null to pause.

useClickOutside<T extends HTMLElement>(ref: RefObject<T | null>, handler: () => void): void

Calls `handler` on pointer down outside `ref`.

useHotkey(combo: string, handler: (e: KeyboardEvent) => void, { allowInInputs }?: { allowInInputs?: boolean | undefined; }): void

Global 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; false on 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 }; copied stays true for 1.5 s after a successful copy.
  • useInterval(tick, running ? 1000 : null): interval that always calls the latest callback; null pauses it.
  • useClickOutside(ref, close): calls the handler on pointer down outside ref.
  • useHotkey('mod+k', openPalette): global shortcut where mod is ⌘ 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 use initial, then the stored value, so there’s no hydration mismatch.
  • useHotkey recognises mod, ctrl, meta (or cmd), shift and alt, and modifiers must match exactly: 'ctrl+s' needs Ctrl, and 'k' doesn’t fire with Ctrl or ⌘ held.
  • useClickOutside treats clicks inside portalled menus and popovers as outside.
Example source · show
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