Hooks

useIdle

A user-idle detection hook that flips a boolean after a period of no pointer, keyboard, wheel, or touch activity.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

export interface UseIdleOptions {
  /** Activity events to listen for, attached to `window` as passive. Passing this replaces the default list wholesale. */
  events?: string[]
  /** Initial idle value at mount. Default `false` — treated as "activity just happened". */
  initialState?: boolean
}

const DEFAULT_EVENTS = ["pointermove", "pointerdown", "keydown", "wheel", "touchstart"]

// high-frequency events (pointermove/wheel) reach handleActivity on every tick; a

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-idle.json

Prompt

Build a React + TypeScript "useIdle" hook (no dependencies beyond React;
browser event listeners + timers only).

Contract
- `useIdle(timeout?: number, options?: { events?: string[]; initialState?:
  boolean }): boolean`.
- `timeout` defaults to 60000ms (60s).
- `options.events` defaults to `["pointermove", "pointerdown", "keydown",
  "wheel", "touchstart"]`, attached to `window` with `{ passive: true }`;
  passing a custom array replaces the default list entirely.
- `options.initialState` is the idle value used for the very first render
  (default `false` — a fresh mount counts as "just active").
- Returns a single boolean: `true` once `timeout` ms have elapsed with none
  of `events` firing, `false` otherwise.

Behavior
- On mount, schedule a timer for `timeout` ms that flips the return value to
  `true` when it fires.
- Any of `events` firing resets the timer: clear the pending timeout, flip
  the value back to `false` if it was `true`, and schedule a fresh `timeout`
  ms timer.
- Throttle the reset path: `pointermove`/`wheel` can fire dozens of times a
  second, and clearing + rescheduling a timer on every single one is wasted
  work. Compare timestamps instead — only the first event after a ~1s
  throttle window actually resets the timer; events inside that window are
  dropped outright (not merely debounced). The idle flip can therefore land
  up to ~1s later than the mathematically exact last-event instant, an
  acceptable trade for cutting timer churn during continuous mouse/wheel
  movement.
- `document`'s `visibilitychange` is wired in separately from `events` and
  cannot be turned off through `options.events`: switching back to the tab
  (`visibilityState === "visible"`) counts as one activity event and resets
  the timer through the same throttled path. Switching away (`hidden`) is
  deliberately a no-op — it does NOT force an early idle flip. The already
  scheduled timer keeps counting down and fires naturally when it elapses
  (background tabs may get their timers throttled by the browser, and that
  drift is accepted rather than special-cased).
- `timeout` or `options.events` changing after mount tears down the old
  timer/listeners and rebuilds them, without losing the currently displayed
  idle/active value.
- Every state update happens inside an event listener or timer callback —
  never synchronously during render or in a bare mount effect — so the hook
  is SSR-safe.
- On unmount, clear the pending timeout and remove every listener
  (`events` + `visibilitychange`); nothing fires after the consumer is gone.

Rendering & styling
- The hook renders nothing itself and owns no visual state. Consumers own
  all UI: swap an icon/label/opacity when the returned boolean flips, using
  semantic tokens (`text-muted-foreground`, `border`, `grayscale`) for any
  idle styling, and respect `prefers-reduced-motion` for any transition.

Customization levers
- `timeout` — how long without activity before flipping to idle; shorter
  for "hide the cursor", longer for "warn before logout".
- `options.events` — narrow the activity signal (e.g. `["keydown"]` only) or
  widen it (add `"scroll"`, `"click"`) depending on what should count as
  "the user is here".
- `options.initialState` — start already idle (e.g. a kiosk screen that
  should show its idle state immediately) instead of the default active.
- Cross-tab idle sync (all open tabs agree the user stepped away) is
  intentionally NOT built in — it would need a shared heartbeat through
  `localStorage`/`BroadcastChannel`, a meaningfully bigger and more
  failure-prone feature than this hook's single-tab timer.
- The browser's native Idle Detection API (`IdleDetector`) is also
  intentionally NOT used by default: it requires an explicit permission
  prompt and is Chromium-only, a much heavier ask than a plain event
  listener for the vast majority of "dim the UI after inactivity" cases.

Concepts

  • Throttled reset, not debounced clear — high-frequency events (pointermove, wheel) don't each clear + reschedule the timer; a timestamp comparison drops everything inside a 1s window and only the first event past it actually resets, trading a little precision for a lot less timer churn.
  • Visibility as a one-way activity signal — coming back to the tab counts as activity and resets the timer, but leaving the tab never forces an idle flip; the pending timeout is left to expire on its own, so idle detection stays driven by one mechanism instead of two competing ones.
  • Boolean-only contract — the hook exposes no imperative reset()/pause(); every actor (mouse, keyboard, tab visibility) resets state the same way, through the same event listeners, keeping the state machine to two nodes instead of a bespoke API surface.
  • Not a security boundary — idle here is purely a client-observed signal; it disappears on a page refresh or a spoofed system clock, so it should drive UX (warnings, dimming, paused polling) and never gate anything a server should be the source of truth for.

On This Page