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". 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.
pnpm dlx shadcn@latest add "https://pulld.pages.dev/r/idle-timeout.json"