{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "idle-timeout",
  "title": "Idle Timeout",
  "description": "An inactivity timeout with a warning before it fires: it watches for the user going away, puts up a \"you will be signed out in 1:59\" dialog with a live countdown, lets them stay with one click, and keeps every open tab agreeing about when the session actually ends. Reach for it on any screen behind a session that expires on its own: banking, brokerage and payments, health records and patient portals, insurance and claims, payroll and HR, government and tax filing, admin consoles, CRM and EHR, anything under a PCI DSS, HIPAA, SOC 2 or internal policy that mandates an idle logout, and any internal tool where a shared or unattended machine is a real possibility. Common asks it answers: \"react idle timer\", \"react-idle-timer alternative\", \"session timeout warning react\", \"auto logout after inactivity react\", \"inactivity timeout react hook\", \"detect user inactivity react\", \"useIdleTimeout\", \"warn user before session expires\", \"session expiry countdown dialog\", \"keep me signed in prompt\", \"next.js auto logout\", \"idle detection react\", \"cross tab session timeout\", \"BroadcastChannel session sync\", \"logout user after 15 minutes of inactivity\", \"shadcn session timeout dialog\", \"stay signed in modal\", \"useIdle hook\", \"useIdleTimer\", \"idle timer hook typescript\", \"auto logout react hook\", \"log out inactive users\", \"sign out after 15 minutes idle\", \"session timeout modal with countdown\", \"warn before auto logout\", \"idle timeout remix\", \"idle timeout vite react\", \"react router session timeout\", \"HIPAA automatic logoff react\", \"PCI DSS 15 minute inactivity timeout\", \"SOC 2 session timeout requirement\", \"timer stops running in background tab\", \"setTimeout throttled when tab is hidden\", \"countdown wrong after tab was in the background\", \"session expired while tab was inactive\", \"logout all tabs at once\", \"sign out other tabs react\", \"sync logout across tabs\", \"自動ログアウト react\", \"無操作 タイムアウト\", \"セッションタイムアウト 警告\", \"放置 ログアウト 実装\", \"タブ間 ログアウト 同期\". The reason to install one rather than write a setInterval is that counting elapsed time is the wrong shape for this problem, and it fails in the exact situation the component exists for. A browser throttles a background tab's timers to once a minute or slower, so a counter that decrements per tick falls behind real time by however long the tab was hidden — and it falls behind in the dangerous direction, reading high. The dialog claims two minutes of grace when the session died ninety seconds ago, and the user clicks \"stay signed in\" on a session the server has already dropped. Everything here derives from one timestamp instead: when the person was last seen. Nothing is accumulated, so nothing can drift; the state is read off the clock at every wake, whenever the tab becomes visible again, on focus, and on a bfcache restore, which is what turns \"the counter was frozen while you were away\" into \"the counter was right all along\". Timers are also never armed for the whole wait, because a delay over 2**31-1 ms silently wraps and fires immediately — a session measured in days would sign the user out on page load — so the wake is capped and the deadline re-read, which covers the overflow, a laptop waking from sleep, and an NTP correction with one rule. The second thing a page-local timer cannot do is see the other tabs. Someone with the app open in three tabs is working in one of them; the other two observe no events at all and each will announce, on its own authority, that the session has expired — so whichever tab they return to has signed them out of work that was never idle. Activity is therefore broadcast, over BroadcastChannel where it exists and a localStorage write where it does not, since the storage event fires in the other tabs, which is exactly the audience. What travels is the moment a person was last seen rather than a computed deadline, because two tabs may be configured with different timeouts and the sighting is the fact; the merge rule is to take the later one, since a tab that saw activity saw a person while a tab that saw none is only reporting that nothing happened in front of it. Signing out is broadcast the same way, so one tab ending the session ends it everywhere. The broadcast is throttled: a message per mousemove would be a storm across every open tab, and being a few seconds stale about a timeout measured in minutes changes nothing. Passive activity deliberately does not dismiss the warning. Once the prompt is up, only a real answer puts the deadline back — a dialog that a mouse move clears cannot be read at all, because reaching for its button clears it, and the question the warning asks is whether a person is still there, which a trackpad brushed by a sleeve does not answer. Before the prompt appears, every configured event counts, listened for passively and in the capture phase so that a component calling stopPropagation on its own pointer events does not make its part of the page look deserted, and so that scrolling inside a pane — a scroll event does not bubble — still counts as being alive. What it does not claim to be is enforcement. Hidden-tab timers are throttled, a machine asleep through the deadline signs out when it wakes rather than on time, and any of it can be turned off from a console. The server's session lifetime is the security boundary; this is the courtesy that stops people losing work to it, and the two are meant to be kept in step. Official shadcn/ui has nothing in this area: across all sixty-three components, inactivity, auto-logout, session, BroadcastChannel, visibilitychange, localStorage, expiry, heartbeat and deadline are all zero hits — the three idle matches belong to attachment's upload state and the timeout matches to toast's own dismiss queue, neither of which is a session. Within pulld it is the counterpart to duration-input, which is the control for configuring an auto-logout window: the number that component produces is the timeoutMs this one counts down. It is distinct from countdown, which runs to a fixed public moment like a sale ending, where this deadline moves every time the user does something; from network-status, which also re-reads on visibilitychange but to re-probe a connection; and from unsaved-changes-guard, which interrupts a departure the user chose, where this one interrupts an absence nobody chose. The dialog is an alertdialog because it interrupts rather than being asked for, and focus lands on \"stay signed in\" rather than on \"sign out now\", so an Enter press already on its way to the page cannot end the session; Escape does the same as staying. The countdown is a role=\"timer\" with live updates off, and a separate polite region carries the time at widening intervals — every minute, then thirty and ten seconds, then each of the final five — because a per-second announcement is a barrier rather than an aid, each one cutting off the last. useIdleTimeout() is exported for a dialog of your own, along with the small pure pieces it is built from, every colour is a shadcn token so it follows light and dark, and the whole thing is one file with no dependency beyond the icon.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/idle-timeout.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Clock } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/** Where a session stands relative to its deadline. */\nexport type IdleState = \"active\" | \"prompted\" | \"idle\"\n\n/**\n * The whole component derives from one number — the timestamp the session ends at — and every\n * question is answered by comparing it to `Date.now()`. Nothing counts elapsed time by adding up\n * ticks, because a tick counter is wrong in exactly the situation this component exists for.\n *\n * A background tab has its timers throttled to once a minute or slower, so a counter that\n * decrements per tick falls behind real time by however long the tab was hidden. The number on\n * screen then reads *high*: it claims two minutes of grace when the session died ninety seconds\n * ago. A deadline cannot drift, because it is not being counted — it is being looked at.\n */\nexport function idleStateAt(\n  deadlineMs: number,\n  nowMs: number,\n  promptBeforeMs: number\n): IdleState {\n  const remaining = deadlineMs - nowMs\n  if (remaining <= 0) return \"idle\"\n  if (remaining <= promptBeforeMs) return \"prompted\"\n  return \"active\"\n}\n\n/** Longest a timer is ever armed for. See `nextWakeDelay`. */\nexport const MAX_WAKE_MS = 30_000\n/** Cadence of the countdown while the warning is on screen. */\nexport const PROMPT_TICK_MS = 1_000\n\n/**\n * How long to sleep before looking at the clock again.\n *\n * The timer is never armed for the whole wait, even when the whole wait is known. Three separate\n * things break a long `setTimeout`, and capping it fixes all three at once:\n *\n *   - **Delays above 2³¹−1 ms (about 24.8 days) overflow** and fire immediately, so a session\n *     timeout set in days signs the user out the instant the page loads.\n *   - **The wall clock moves.** A machine that sleeps, an NTP correction, a user changing the\n *     system clock — a timer armed for the old distance now lands in the wrong place, while a\n *     deadline re-read after a short sleep is only ever out by that sleep.\n *   - **Throttling is invisible from inside.** A hidden tab's wake comes late; waking often and\n *     re-reading means late wakes are noticed rather than accumulated.\n *\n * The cost of the cap is one wake every thirty seconds, which is less work than a single mouse\n * move. While the warning is up the cadence is a second, because a countdown is being rendered.\n */\nexport function nextWakeDelay(\n  state: IdleState,\n  remainingMs: number,\n  promptBeforeMs: number\n): number {\n  if (state === \"idle\") return 0\n  if (state === \"prompted\") return PROMPT_TICK_MS\n  // Land on the moment the warning is due, unless that is further off than the cap.\n  return Math.max(50, Math.min(remainingMs - promptBeforeMs, MAX_WAKE_MS))\n}\n\n/**\n * The value a screen reader should hear, or null once the remaining time is not worth saying again.\n *\n * A countdown that announces every second is not an accessibility feature — it is a barrier, since\n * the announcement of one second interrupts the announcement of the last and nothing else on the\n * page can be heard. Announcements are made when this value changes: every minute while there are\n * minutes left, then at thirty and ten seconds, then each of the final five.\n */\nexport function announceStep(remainingMs: number): number {\n  const seconds = Math.ceil(remainingMs / 1000)\n  if (seconds <= 5) return seconds\n  if (seconds <= 10) return 10\n  if (seconds <= 30) return 30\n  return Math.ceil(seconds / 60) * 60\n}\n\n/** mm:ss, or h:mm:ss once there is an hour to show. */\nexport function formatRemaining(remainingMs: number): string {\n  const total = Math.max(0, Math.ceil(remainingMs / 1000))\n  const hours = Math.floor(total / 3600)\n  const minutes = Math.floor((total % 3600) / 60)\n  const seconds = total % 60\n  const pad = (n: number) => String(n).padStart(2, \"0\")\n  return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`\n}\n\n/** The sentence a screen reader hears when the announced step changes. */\nexport function spokenRemaining(remainingMs: number): string {\n  const seconds = announceStep(remainingMs)\n  if (seconds >= 60) {\n    const minutes = Math.round(seconds / 60)\n    return `Signing out in ${minutes} minute${minutes === 1 ? \"\" : \"s\"}`\n  }\n  return `Signing out in ${seconds} second${seconds === 1 ? \"\" : \"s\"}`\n}\n\n/** Events that mean a person is still there. Attached passively, in the capture phase. */\nexport const DEFAULT_ACTIVITY_EVENTS = [\n  \"pointerdown\",\n  \"keydown\",\n  \"wheel\",\n  \"touchstart\",\n  \"mousemove\",\n  \"scroll\",\n] as const\n\nconst ACTIVITY_THROTTLE_MS = 1_000\nconst BROADCAST_THROTTLE_MS = 5_000\n\nexport interface IdleTimeoutOptions {\n  /** Idle time allowed before the session ends, in milliseconds. */\n  timeoutMs: number\n  /** How long before the deadline the warning appears (default 60000). */\n  promptBeforeMs?: number\n  /** Runs once when the deadline passes. Sign out here. */\n  onIdle: () => void\n  /** Runs when the warning opens. */\n  onPrompt?: () => void\n  /** Runs when the session is extended, including when another tab extended it. */\n  onActive?: () => void\n  /** Which events count as activity (default `DEFAULT_ACTIVITY_EVENTS`). */\n  events?: readonly string[]\n  /** Stop watching entirely — pass true while signed out, or the timer runs on the login screen. */\n  disabled?: boolean\n  /** Key shared by every tab of the same app. Set it per signed-in user, not per app. */\n  channelName?: string\n  /** Turn off tab-to-tab agreement (default false — it is on). */\n  crossTab?: boolean\n}\n\nexport interface IdleSession {\n  /** Where the session stands as of the render that produced this. */\n  state: IdleState\n  /**\n   * Milliseconds until sign-out, clamped at zero and accurate as of this render. A render happens\n   * every second while the warning is up, and roughly every thirty seconds before that.\n   */\n  remainingMs: number\n  /** Push the deadline out and tell the other tabs. This is what the \"stay signed in\" button calls. */\n  extend: () => void\n  /** End it now, here and in every other tab. */\n  signOutNow: () => void\n}\n\n/**\n * What tabs tell each other. The message carries *when the user was last seen*, not when the\n * session ends: the two tabs may have been configured with different timeouts, and the moment a\n * person was at the keyboard is the fact, while the deadline is only each tab's opinion of it.\n */\ntype SyncMessage = { type: \"active\"; at: number } | { type: \"idle\" }\n\n/**\n * Watches for the user going away, warns before the session ends, and keeps every tab agreeing\n * about when that is.\n *\n * The second half is the part a page-local timer cannot do. A person with the app open in three\n * tabs is working in one of them; the other two see no events at all, and each will announce on\n * its own authority that the session has expired. Whichever one the user comes back to has already\n * signed them out of work that was never idle. So activity is broadcast: `BroadcastChannel` where\n * it exists, a `localStorage` write where it does not (the `storage` event fires in the *other*\n * tabs, which is exactly the audience). The merge rule is to take the later of the two sightings,\n * because a tab seeing activity has seen a person, while a tab that has seen none is only reporting\n * that nothing happened in front of it — which is not evidence that nothing happened.\n *\n * **Passive activity does not dismiss the warning.** Once the prompt is up, only `extend()` —\n * a real answer — puts the deadline back. Two reasons, and both matter: a warning that a mouse\n * move clears cannot be read, because reaching for its button clears it; and the question the\n * warning asks is whether a person is still there, which a trackpad brushed by a sleeve does not\n * answer. Before the prompt, every event in `events` counts.\n *\n * What this cannot do is enforce anything. Timers in a hidden tab are throttled, a laptop that\n * sleeps through the deadline signs out when it wakes rather than on time, and any of it can be\n * turned off from the console. **The server's session lifetime is the security boundary; this is\n * the courtesy that stops people losing work to it.** Keep the two in step — a prompt that offers\n * to extend a session the server has already dropped is worse than no prompt.\n */\nexport function useIdleTimeout({\n  timeoutMs,\n  promptBeforeMs = 60_000,\n  onIdle,\n  onPrompt,\n  onActive,\n  events = DEFAULT_ACTIVITY_EVENTS,\n  disabled = false,\n  channelName = \"idle-timeout\",\n  crossTab = true,\n}: IdleTimeoutOptions): IdleSession {\n  // A prompt window longer than the timeout would mean the warning is up before the session\n  // starts; clamping keeps the states in the order they are named.\n  const promptWindow = Math.min(promptBeforeMs, timeoutMs)\n\n  const [state, setState] = React.useState<IdleState>(\"active\")\n  // Re-rendering is what makes the countdown move, and the value held is the second being shown —\n  // not a counter — so that a re-evaluation inside the same second is not a re-render. `evaluate`\n  // runs on every wake, every peer message and every return to the tab, not only on ticks. What is\n  // rendered still comes from the clock; this only decides when to look.\n  const [, setSecondShown] = React.useState(0)\n\n  /**\n   * When the user was last seen. Zero until the mount effect starts the clock, which keeps the\n   * server and the first client render equal.\n   *\n   * The deadline is derived from this rather than stored, so that changing `timeoutMs` mid-session\n   * lands where it should — a session shortened from thirty minutes to five ends five minutes after\n   * the last keystroke, not five minutes after the setting changed.\n   */\n  const lastSeenRef = React.useRef(0)\n  /** The deadline `onIdle` has already fired for, so it fires once however often we re-evaluate. */\n  const firedForRef = React.useRef(0)\n  const lastBroadcastRef = React.useRef(0)\n  const timerRef = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined)\n  // `undefined` rather than `null` for \"no channel\", matching `timerRef` — one spelling for absent.\n  const channelRef = React.useRef<BroadcastChannel | undefined>(undefined)\n  const storageCounterRef = React.useRef(0)\n\n  // Callbacks and the current state are read through refs so that the listeners are installed once.\n  // Rebuilding them on every state change would mean re-attaching a mousemove listener from inside\n  // the handler for a mousemove.\n  const callbacksRef = React.useRef({ onIdle, onPrompt, onActive })\n  callbacksRef.current = { onIdle, onPrompt, onActive }\n  const stateRef = React.useRef<IdleState>(state)\n  stateRef.current = state\n\n  /** Sends to the other tabs, by whichever transport this browser has. */\n  const post = React.useCallback(\n    (message: SyncMessage) => {\n      if (!crossTab) return\n      if (channelRef.current) {\n        channelRef.current.postMessage(message)\n        return\n      }\n      try {\n        // `storage` does not fire when the value is unchanged, so a counter rides along to make\n        // every write distinct — two extends landing on the same millisecond are still two events.\n        window.localStorage.setItem(\n          channelName,\n          JSON.stringify({ ...message, n: ++storageCounterRef.current })\n        )\n      } catch {\n        // Private browsing, disabled site data, a full quota. Tab sync is a nicety; the local\n        // timeout still works, and there is nothing useful to tell the user here.\n      }\n    },\n    [channelName, crossTab]\n  )\n\n  /**\n   * Looks at the clock, moves the state to match, and arms the next wake. Everything routes through\n   * here — the timer, the activity listeners, a message from another tab, the tab becoming visible\n   * — so there is exactly one place where a deadline turns into a state.\n   */\n  const evaluate = React.useCallback(() => {\n    if (timerRef.current !== undefined) clearTimeout(timerRef.current)\n    timerRef.current = undefined\n    if (disabled || lastSeenRef.current === 0) return\n\n    const now = Date.now()\n    const deadline = lastSeenRef.current + timeoutMs\n    const remaining = deadline - now\n    const next = idleStateAt(deadline, now, promptWindow)\n\n    if (next !== stateRef.current) {\n      stateRef.current = next\n      setState(next)\n      if (next === \"prompted\") callbacksRef.current.onPrompt?.()\n      if (next === \"active\") callbacksRef.current.onActive?.()\n    } else if (next === \"prompted\") {\n      setSecondShown(Math.ceil(remaining / 1000))\n    }\n\n    if (next === \"idle\") {\n      if (firedForRef.current !== deadline) {\n        firedForRef.current = deadline\n        callbacksRef.current.onIdle()\n      }\n      return\n    }\n\n    timerRef.current = setTimeout(evaluate, nextWakeDelay(next, remaining, promptWindow))\n  }, [disabled, promptWindow, timeoutMs])\n\n  // Reached through a ref by everything that is not the timer itself. Depending on `evaluate`\n  // directly would put `promptBeforeMs` into the clock effect's dependencies, and changing how\n  // early the warning appears would silently restart the session it is warning about.\n  const evaluateRef = React.useRef(evaluate)\n  evaluateRef.current = evaluate\n\n  const extend = React.useCallback(() => {\n    const now = Date.now()\n    lastSeenRef.current = now\n    lastBroadcastRef.current = now\n    post({ type: \"active\", at: now })\n    evaluateRef.current()\n  }, [post])\n\n  const signOutNow = React.useCallback(() => {\n    // Backdating the sighting by a whole timeout is what \"the deadline is now\" is spelled as when\n    // the deadline is derived; it needs no second flag, and it stays true if `timeoutMs` changes.\n    lastSeenRef.current = Date.now() - timeoutMs\n    post({ type: \"idle\" })\n    evaluateRef.current()\n  }, [post, timeoutMs])\n\n  // --- the clock -------------------------------------------------------------------------------\n  // Started in an effect rather than at render, so the server and the first client render agree,\n  // and restarted when the length of the session changes.\n  React.useEffect(() => {\n    if (disabled) {\n      if (timerRef.current !== undefined) clearTimeout(timerRef.current)\n      timerRef.current = undefined\n      lastSeenRef.current = 0\n      stateRef.current = \"active\"\n      setState(\"active\")\n      return\n    }\n    // Only the first run starts the clock. A later run — `timeoutMs` changed, or React mounting the\n    // effect twice as it does in development — re-derives the deadline from the same sighting\n    // rather than handing the user a fresh session for having changed a setting.\n    if (lastSeenRef.current === 0) {\n      lastSeenRef.current = Date.now()\n      firedForRef.current = 0\n    }\n    evaluateRef.current()\n    return () => {\n      if (timerRef.current !== undefined) clearTimeout(timerRef.current)\n      timerRef.current = undefined\n    }\n  }, [disabled, timeoutMs])\n\n  // --- activity --------------------------------------------------------------------------------\n  React.useEffect(() => {\n    if (disabled) return\n\n    const onActivity = () => {\n      // The warning is answered, not brushed away. See the note on the hook.\n      if (stateRef.current !== \"active\") return\n      const now = Date.now()\n      // `mousemove` arrives dozens of times a second and a deadline is measured in minutes, so a\n      // second of granularity costs nothing and saves the work.\n      if (now - lastSeenRef.current < ACTIVITY_THROTTLE_MS) return\n      lastSeenRef.current = now\n      // Peers are told at a coarser cadence than the sighting moves: being a few seconds stale\n      // about a timeout of minutes changes nothing, and a channel message per mouse move would be\n      // a broadcast storm across every open tab.\n      if (now - lastBroadcastRef.current >= BROADCAST_THROTTLE_MS) {\n        lastBroadcastRef.current = now\n        post({ type: \"active\", at: now })\n      }\n      // No `evaluate()` here: the state cannot have changed (we are active, and the deadline only\n      // moved further away), and the armed timer re-reads the deadline when it fires.\n    }\n\n    // Capture, because a component that calls `stopPropagation` on its own pointer events would\n    // otherwise make its part of the page look deserted — and because `scroll` does not bubble\n    // from a scrolling element at all, so a bubble-phase listener on the document misses every\n    // scroll that happens inside a pane.\n    const options = { passive: true, capture: true } as const\n    for (const type of events) document.addEventListener(type, onActivity, options)\n    return () => {\n      for (const type of events) document.removeEventListener(type, onActivity, options)\n    }\n  }, [disabled, events, post])\n\n  // --- waking up -------------------------------------------------------------------------------\n  // A hidden tab's timers are throttled to a minute or worse, so its idea of the state is stale by\n  // however long it was hidden. Re-reading the deadline the moment the tab is looked at again is\n  // what turns \"the counter was frozen\" into \"the counter was right all along\".\n  React.useEffect(() => {\n    if (disabled) return\n    const recheck = () => evaluateRef.current()\n    document.addEventListener(\"visibilitychange\", recheck)\n    window.addEventListener(\"focus\", recheck)\n    // Restoring from the back/forward cache resumes a page whose timers were suspended entirely.\n    window.addEventListener(\"pageshow\", recheck)\n    return () => {\n      document.removeEventListener(\"visibilitychange\", recheck)\n      window.removeEventListener(\"focus\", recheck)\n      window.removeEventListener(\"pageshow\", recheck)\n    }\n  }, [disabled])\n\n  // --- the other tabs --------------------------------------------------------------------------\n  React.useEffect(() => {\n    if (disabled || !crossTab) return\n\n    const receive = (message: SyncMessage) => {\n      if (message.type === \"idle\") {\n        lastSeenRef.current = Date.now() - timeoutMs\n        evaluateRef.current()\n        return\n      }\n      // Later wins. A tab reporting activity has seen a person; a tab that has not is only\n      // reporting that nothing happened in front of it, which is not evidence of absence.\n      if (message.at > lastSeenRef.current) {\n        lastSeenRef.current = message.at\n        evaluateRef.current()\n      }\n    }\n\n    if (typeof BroadcastChannel !== \"undefined\") {\n      const channel = new BroadcastChannel(channelName)\n      channelRef.current = channel\n      const onMessage = (event: MessageEvent<SyncMessage>) => receive(event.data)\n      channel.addEventListener(\"message\", onMessage)\n      return () => {\n        channel.removeEventListener(\"message\", onMessage)\n        channel.close()\n        channelRef.current = undefined\n      }\n    }\n\n    const onStorage = (event: StorageEvent) => {\n      if (event.key !== channelName || !event.newValue) return\n      try {\n        receive(JSON.parse(event.newValue) as SyncMessage)\n      } catch {\n        // Someone else's write under the same key. Ignoring it is the whole handling.\n      }\n    }\n    window.addEventListener(\"storage\", onStorage)\n    return () => window.removeEventListener(\"storage\", onStorage)\n  }, [channelName, crossTab, disabled, timeoutMs])\n\n  // Read from the deadline at render rather than kept in state. Holding it in state would mean a\n  // re-render every thirty seconds to update a number that, before the warning, nothing displays —\n  // and it would still be stale for any render that happened between those. Derived from the clock,\n  // it is right whenever it is looked at.\n  const remainingMs =\n    lastSeenRef.current === 0\n      ? timeoutMs\n      : Math.max(0, lastSeenRef.current + timeoutMs - Date.now())\n\n  return { state, remainingMs, extend, signOutNow }\n}\n\nexport interface IdleTimeoutProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"title\" | \"children\">,\n    IdleTimeoutOptions {\n  title?: React.ReactNode\n  /** Takes the live remaining time so the sentence can carry the number. */\n  description?: (remaining: string) => React.ReactNode\n  /** The safe choice, which is the one that starts focused. */\n  stayLabel?: string\n  /** Leaving now, deliberately. Omit to render only the one button. */\n  signOutLabel?: string | null\n}\n\n/**\n * The warning itself: \"you will be signed out in 1:59\", a button to stay, and a button to go.\n *\n * It renders nothing until the prompt is due, so it costs a listener and a thirty-second timer to\n * leave mounted for the life of a signed-in layout. Put one at the root of that layout — one\n * instance per tab, not one per screen, or every copy runs its own clock and broadcasts over the\n * others.\n *\n * The dialog is an `alertdialog` because it interrupts rather than being asked for, and focus lands\n * on **stay signed in**: the destructive button on a dialog nobody asked for is answered by\n * whatever key was already on its way to the page. Escape does the same as staying. The countdown\n * is a `role=\"timer\"`, which is not announced as it changes, and a separate polite region carries\n * the time at the intervals `announceStep` picks — a per-second announcement is a barrier rather\n * than an aid, because each one cuts off the last.\n */\nexport function IdleTimeout({\n  timeoutMs,\n  promptBeforeMs,\n  onIdle,\n  onPrompt,\n  onActive,\n  events,\n  disabled,\n  channelName,\n  crossTab,\n  title = \"Still there?\",\n  description = (remaining) => `You will be signed out in ${remaining}.`,\n  stayLabel = \"Stay signed in\",\n  signOutLabel = \"Sign out now\",\n  className,\n  onKeyDown: onKeyDownProp,\n  ...props\n}: IdleTimeoutProps) {\n  const { state, remainingMs, extend, signOutNow } = useIdleTimeout({\n    timeoutMs,\n    promptBeforeMs,\n    onIdle,\n    onPrompt,\n    onActive,\n    events,\n    disabled,\n    channelName,\n    crossTab,\n  })\n\n  const open = state === \"prompted\"\n  const dialogRef = React.useRef<HTMLDivElement>(null)\n  const stayRef = React.useRef<HTMLButtonElement>(null)\n  const titleId = React.useId()\n  const descriptionId = React.useId()\n\n  React.useEffect(() => {\n    if (!open) return\n    const restore = document.activeElement\n    stayRef.current?.focus()\n    return () => {\n      // Whatever the user was on when the warning cut in is where they belong afterwards.\n      if (restore instanceof HTMLElement && restore.isConnected) restore.focus()\n    }\n  }, [open])\n\n  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    onKeyDownProp?.(event)\n    if (event.defaultPrevented) return\n    if (event.key === \"Escape\") {\n      event.preventDefault()\n      extend()\n      return\n    }\n    if (event.key !== \"Tab\") return\n    // Modal: Tab stays inside the dialog.\n    const focusables = Array.from(\n      dialogRef.current?.querySelectorAll<HTMLElement>(\"button:not([disabled])\") ?? []\n    )\n    if (focusables.length === 0) {\n      event.preventDefault()\n      return\n    }\n    const first = focusables[0]\n    const last = focusables[focusables.length - 1]\n    const active = document.activeElement\n    if (event.shiftKey && (active === first || active === dialogRef.current)) {\n      event.preventDefault()\n      last.focus()\n    } else if (!event.shiftKey && active === last) {\n      event.preventDefault()\n      first.focus()\n    }\n  }\n\n  if (!open) return null\n\n  const remaining = formatRemaining(remainingMs)\n\n  return (\n    <div\n      role=\"presentation\"\n      className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4\"\n    >\n      {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}\n      <div\n        {...props}\n        ref={dialogRef}\n        role=\"alertdialog\"\n        aria-modal=\"true\"\n        aria-labelledby={titleId}\n        aria-describedby={descriptionId}\n        tabIndex={-1}\n        onKeyDown={onKeyDown}\n        className={cn(\n          \"w-full max-w-md rounded-xl border bg-background text-foreground shadow-2xl outline-none\",\n          className\n        )}\n      >\n        <div className=\"flex gap-3 p-5\">\n          <Clock className=\"mt-0.5 h-5 w-5 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n          <div className=\"space-y-1.5\">\n            <h2 id={titleId} className=\"text-sm font-medium\">\n              {title}\n            </h2>\n            <p id={descriptionId} className=\"text-sm text-muted-foreground\">\n              {description(remaining)}\n            </p>\n            <p\n              role=\"timer\"\n              aria-live=\"off\"\n              className=\"pt-0.5 text-2xl font-semibold tabular-nums tracking-tight\"\n            >\n              {remaining}\n            </p>\n            {/*\n              Re-rendered every second, announced far less often: `spokenRemaining` returns the same\n              string for every tick inside a step, React leaves the text node alone when it has not\n              changed, and a live region announces on mutation rather than on render.\n            */}\n            <span className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\">\n              {spokenRemaining(remainingMs)}\n            </span>\n          </div>\n        </div>\n        <div className=\"flex justify-end gap-2 border-t px-5 py-3\">\n          {signOutLabel === null ? null : (\n            <button\n              type=\"button\"\n              onClick={signOutNow}\n              className=\"inline-flex h-9 items-center rounded-md border px-3 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n            >\n              {signOutLabel}\n            </button>\n          )}\n          <button\n            ref={stayRef}\n            type=\"button\"\n            onClick={extend}\n            className=\"inline-flex h-9 items-center rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n          >\n            {stayLabel}\n          </button>\n        </div>\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
