Warns before an idle session expires and lets the user stay signed in, so a timeout doesn't silently lose unsaved work.
StopsLogged out and lost unsaved work
"use client"
import * as React from "react"
import { SessionTimeout } from "@/components/ui/session-timeout"
export function Demo() {
const [signedIn, setSignedIn] = React.useState(false)
const [note, setNote] = React.useState("Demo timings: idle for 3 s and the warning opens, 15 s before sign-out.")
return (
<div className="w-full max-w-sm space-y-3 rounded-xl border bg-background p-4 text-sm">
<div className="flex items-center justify-between gap-2">
<p className="font-medium">Expense report</p>
<span className="text-xs text-muted-foreground">{signedIn ? "Signed in as Priya" : "Signed out"}</span>
</div>
<textarea
aria-label="Expense notes"
defaultValue="Client dinner with Acme, 12 Oct. Taxi from airport, receipts attached…"
rows={3}
className="w-full resize-none rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-input/30"
/>
<p aria-live="polite" className="text-xs text-muted-foreground">
{note}
</p>
<button
type="button"
onClick={() => {
setSignedIn((value) => !value)
setNote(signedIn ? "You signed out." : "Now keep your mouse and keyboard still for 3 s.")
}}
className="h-9 rounded-md bg-primary px-4 font-medium text-primary-foreground hover:bg-primary/90 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
{signedIn ? "Sign out" : "Sign in and go idle"}
</button>
<SessionTimeout
disabled={!signedIn}
timeout={18_000}
warnBefore={15_000}
announceAt={[15, 10, 5]}
channel="bollard-ui-demo-session"
onKeepAlive={() =>
new Promise<void>((resolve) =>
setTimeout(() => {
setNote("Session extended. Your draft is safe.")
resolve()
}, 500),
)
}
onSignOut={() => {
setSignedIn(false)
setNote("You signed out.")
}}
onExpire={() => {
setSignedIn(false)
setNote("Signed out after inactivity.")
}}
/>
</div>
)
}Installation
The files land in your project. They're yours to edit.
Install the dependencies
Copy and paste the following code into
components/uisession-timeout.tsx307 lines"use client" // Bollard UI — session-timeout · MIT licence · https://bollard-ui.vercel.app/components/session-timeout import * as React from "react" import NumberFlow, { NumberFlowGroup } from "@number-flow/react" import { LoaderCircle, LogOut, Timer } from "lucide-react" import { motion, useReducedMotion } from "motion/react" import { cn } from "@/lib/utils" type Message = { type: "activity"; at: number } | { type: "signout" } export interface SessionTimeoutProps extends Omit<React.ComponentProps<"dialog">, "children" | "open" | "onCancel" | "title"> { /** Idle time before the session ends, in ms. */ timeout?: number /** How long before expiry the warning opens, in ms. */ warnBefore?: number /** Extend the session on the server. Return a promise to show progress; throw to keep the warning. */ onKeepAlive: () => void | Promise<void> /** Sign out now: the user chose to, or another tab signed out. */ onSignOut: () => void /** Called when the idle time runs out. Defaults to `onSignOut`. */ onExpire?: () => void /** BroadcastChannel name shared by every tab of the app. */ channel?: string /** Seconds left at which screen readers hear the countdown. */ announceAt?: number[] /** Pause tracking, e.g. while signed out. */ disabled?: boolean /** Dialog title. */ title?: string /** Dialog description. */ description?: string /** Label of the stay-signed-in button. */ stayLabel?: string /** Label of the sign-out button. */ signOutLabel?: string /** Read to screen readers; `{seconds}` is filled in. */ announcement?: string /** Shown when `onKeepAlive` rejects. */ errorText?: string } const ACTIVITY = ["pointerdown", "pointermove", "keydown", "wheel", "scroll", "touchstart"] as const /** "1:05" for 65 s. */ export function formatClock(ms: number) { const total = Math.max(0, Math.ceil(ms / 1000)) return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}` } /** * Warns before an idle session ends so nobody loses a half-filled form to a * silent logout. Activity in any tab keeps every tab alive, a backgrounded * tab can't wake up "alive" after it has expired, and screen readers hear the * countdown at a few moments instead of every second. */ export function SessionTimeout({ timeout = 15 * 60_000, warnBefore = 60_000, onKeepAlive, onSignOut, onExpire, channel = "bollard-ui-session", announceAt = [60, 30, 10], disabled = false, title = "Still there?", description = "You'll be signed out for inactivity.", stayLabel = "Stay signed in", signOutLabel = "Sign out", announcement: announcementText = "Your session ends in {seconds} seconds.", errorText = "Couldn't extend your session. Try again.", className, ...props }: SessionTimeoutProps) { const [left, setLeft] = React.useState<number>() // ms remaining, only while warning const [announcement, setAnnouncement] = React.useState("") const [pending, setPending] = React.useState(false) const [failed, setFailed] = React.useState(false) const dialogRef = React.useRef<HTMLDialogElement>(null) const lastActive = React.useRef(0) const bus = React.useRef<BroadcastChannel | null>(null) const callbacks = React.useRef({ onSignOut, onExpire, announceAt, announcementText }) const titleId = React.useId() const descriptionId = React.useId() const warning = left !== undefined const reduceMotion = useReducedMotion() React.useEffect(() => { callbacks.current = { onSignOut, onExpire, announceAt, announcementText } }) const touch = React.useCallback((at = Date.now()) => { lastActive.current = at bus.current?.postMessage({ type: "activity", at } satisfies Message) }, []) React.useEffect(() => { if (disabled) return bus.current = typeof BroadcastChannel === "undefined" ? null : new BroadcastChannel(channel) let warningOpen = false let spoken = -1 touch() const check = () => { const remaining = lastActive.current + timeout - Date.now() if (remaining <= 0) { setLeft(undefined) setAnnouncement("") warningOpen = false lastActive.current = Infinity // stop checking until remounted bus.current?.postMessage({ type: "signout" } satisfies Message) ;(callbacks.current.onExpire ?? callbacks.current.onSignOut)() return } warningOpen = remaining <= warnBefore setLeft(warningOpen ? remaining : undefined) const seconds = Math.ceil(remaining / 1000) if (warningOpen && seconds !== spoken && (spoken === -1 || callbacks.current.announceAt.includes(seconds))) { spoken = seconds setAnnouncement(callbacks.current.announcementText.replace("{seconds}", String(seconds))) } if (!warningOpen && spoken !== -1) { spoken = -1 setAnnouncement("") } } let throttled = 0 const onActivity = () => { // The warning needs a deliberate answer, and an ended session stays ended. if (warningOpen || lastActive.current === Infinity || Date.now() - throttled < 1000) return throttled = Date.now() touch() } const onVisibility = () => { if (document.hidden) return check() // an expired background tab must not be revived by coming back to it onActivity() } if (bus.current) { bus.current.onmessage = (event: MessageEvent<Message>) => { if (event.data.type === "activity") { lastActive.current = Math.max(lastActive.current, event.data.at) check() } else { lastActive.current = Infinity setLeft(undefined) callbacks.current.onSignOut() } } } ACTIVITY.forEach((name) => window.addEventListener(name, onActivity, { passive: true, capture: true })) document.addEventListener("visibilitychange", onVisibility) const id = setInterval(check, 1000) return () => { clearInterval(id) ACTIVITY.forEach((name) => window.removeEventListener(name, onActivity, { capture: true })) document.removeEventListener("visibilitychange", onVisibility) bus.current?.close() bus.current = null setLeft(undefined) // disabling mid-warning must close it setFailed(false) } }, [channel, disabled, timeout, touch, warnBefore]) React.useEffect(() => { const dialog = dialogRef.current if (!dialog) return if (warning && !dialog.open) dialog.showModal() if (!warning && dialog.open) dialog.close() }, [warning]) const stay = async () => { if (pending) return setFailed(false) setPending(true) try { await onKeepAlive() touch() setLeft(undefined) } catch { setFailed(true) } finally { setPending(false) } } const signOut = () => { lastActive.current = Infinity setLeft(undefined) bus.current?.postMessage({ type: "signout" } satisfies Message) onSignOut() } const fraction = left === undefined ? 0 : Math.min(1, left / warnBefore) const seconds = left === undefined ? 0 : Math.max(0, Math.ceil(left / 1000)) const urgent = warning && seconds <= 10 return ( <dialog ref={dialogRef} data-slot="session-timeout" data-urgent={urgent || undefined} role="alertdialog" aria-labelledby={titleId} aria-describedby={descriptionId} onCancel={(event) => { event.preventDefault() // Escape means "I'm still here", never a silent logout void stay() }} className={cn( "m-auto w-[calc(100%-2rem)] max-w-sm rounded-xl border bg-popover p-0 text-popover-foreground shadow-xl backdrop:bg-black/50", className, )} {...props} > <div className="p-5"> <div className="flex items-center gap-3"> {/* The ring drains with the time left, so the deadline reads without counting digits. */} <span className="relative flex size-10 shrink-0 items-center justify-center rounded-full bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-400"> <svg aria-hidden viewBox="0 0 40 40" className="absolute inset-0 -rotate-90"> <motion.circle cx="20" cy="20" r="18.5" fill="none" strokeWidth="3" strokeLinecap="round" className={urgent ? "stroke-red-500" : "stroke-amber-500"} initial={false} animate={{ pathLength: fraction }} transition={{ duration: reduceMotion ? 0 : 1, ease: "linear" }} /> </svg> <motion.span className="flex" animate={urgent && !reduceMotion ? { rotate: [0, -12, 12, -8, 0] } : { rotate: 0 }} transition={urgent && !reduceMotion ? { duration: 0.5, repeat: Infinity, repeatDelay: 0.5 } : { duration: 0 }} > <Timer aria-hidden className="size-5" /> </motion.span> </span> <div> <h2 id={titleId} className="font-medium"> {title} </h2> <p id={descriptionId} className="text-sm text-muted-foreground"> {description} </p> </div> </div> <p aria-hidden className={cn( "mt-4 flex items-baseline justify-center text-4xl font-semibold tabular-nums transition-colors", urgent && "text-red-600 dark:text-red-400", )} > {warning && ( <NumberFlowGroup> <NumberFlow value={Math.floor(seconds / 60)} trend={-1} animated={!reduceMotion} /> <span>:</span> <NumberFlow value={seconds % 60} trend={-1} format={{ minimumIntegerDigits: 2 }} digits={{ 1: { max: 5 } }} animated={!reduceMotion} /> </NumberFlowGroup> )} </p> <p className="sr-only" aria-live="assertive"> {announcement} </p> {failed && ( <p role="alert" className="mt-3 text-sm text-destructive"> {errorText} </p> )} </div> <div className="flex justify-end gap-2 border-t bg-muted/40 px-5 py-3"> <button type="button" onClick={signOut} className="inline-flex h-9 items-center gap-1.5 rounded-md px-3 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > <LogOut aria-hidden className="size-4" /> {signOutLabel} </button> <button type="button" autoFocus onClick={() => void stay()} disabled={pending} className="inline-flex h-9 items-center gap-1.5 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-70 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > {pending && <LoaderCircle aria-hidden className="size-4 animate-spin" />} {stayLabel} </button> </div> </dialog> ) }Update the import paths to match your project setup.
It uses the
cn()helper from@/lib/utils, which every shadcn/ui project already has.
What it catches
- Idle time approaching the timeout (warns warnBefore ms ahead)
- Activity in another tab, which resets the timer (BroadcastChannel)
- Sign-out in another tab, followed here
- Keep-alive failures, shown while the warning stays open
Props
| Prop | Type | Default |
|---|---|---|
timeoutIdle time before the session ends, in ms. | number | 15 * 60_000 |
warnBeforeHow long before expiry the warning opens, in ms. | number | 60_000 |
onKeepAlive* (required)Extend the session on the server. Return a promise to show progress; throw to keep the warning. | () => void | Promise<void> | — |
onSignOut* (required)Sign out now: the user chose to, or another tab signed out. | () => void | — |
onExpireCalled when the idle time runs out. Defaults to `onSignOut`. | () => void | — |
channelBroadcastChannel name shared by every tab of the app. | string | "bollard-ui-session" |
announceAtSeconds left at which screen readers hear the countdown. | number[] | [60, 30, 10] |
disabledPause tracking, e.g. while signed out. | boolean | false |
titleDialog title. | string | "Still there?" |
descriptionDialog description. | string | "You'll be signed out for inactivity." |
stayLabelLabel of the stay-signed-in button. | string | "Stay signed in" |
signOutLabelLabel of the sign-out button. | string | "Sign out" |
announcementRead to screen readers; `{seconds}` is filled in. | string | "Your session ends in {seconds} seconds." |
errorTextShown when `onKeepAlive` rejects. | string | "Couldn't extend your session. Try again." |
* required. Also accepts the native props of <dialog> (className, aria-*, data-*…), passed through to it.
Accessibility
- Native <dialog> with role=alertdialog, labelled and described
- Stay signed in receives focus (autoFocus)
- Countdown is spoken only from announceAt seconds, assertively; errors use role=alert
- Ring and digit animation respect reduced motion
Only what the source does. Test with your own screen reader and content before shipping.
Limitations
This is a UX guard rail. Enforce on the server: Expire sessions on the server and extend them only through an authenticated keep-alive.
- The client timer is advisory; the server session can still expire on its own clock
- Cross-tab sync needs BroadcastChannel
- Does not save work; pair with autosave
What it stops
Logged out and lost unsaved work
How people describe it
Also known as
session timeout · idle timeout · auto logout · stay signed in · countdown · net banking · jwt expiry · keep me logged in