Session Expiry
A sign-out warning that arms at a threshold before an injected expiry instant — live countdown, one-shot refresh, a closed state that says why, and cross-tab dismissal over BroadcastChannel.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/session-expiry.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "SessionExpiry" component: the panel that
appears shortly before a login session dies, with a live countdown, a refresh
button, an immediate sign-out, a closed state, and cross-tab coordination.
lucide-react for icons, the native BroadcastChannel for sync, no date library,
no timer library.
Contract
- forwardRef<HTMLDivElement>, props extend Omit<React.HTMLAttributes
<HTMLDivElement>, "children"> and add:
expiresAt: string | number | Date (the absolute instant the session dies),
now?: string | number | Date (reference instant for the FIRST PAINT only),
warnBeforeSeconds?: number (default 120),
onExtend?: () => void | Promise<string | number | Date | void>,
onSignOut?: () => void,
onExpire?: () => void,
onExtendedElsewhere?: (expiresAt: number | null) => void,
onEndedElsewhere?: () => void,
signInAction?: { label?: string; href: string } | { label?: string; onClick: () => void },
channel?: string | null (BroadcastChannel name, default "session-expiry"),
announceAt?: number[] (default [60, 30, 10] seconds),
labels?: Partial<Labels> (every string and every announcement).
- signInAction is an href XOR onClick union, so a re-login button that does
nothing cannot compile. onExtend and onSignOut are optional and each one drives
whether its button exists at all — a session that cannot be extended renders no
extend button rather than an inert one.
- The root element is ALWAYS mounted because it carries the live regions; only
the panel mounts and unmounts. Document that className should carry layout
(width, margin, position), not a card skin.
Behavior
- Every displayed number is `target - Date.now()`, recomputed on a 250ms
interval — never "previous value minus one". A throttled background tab drops
almost every tick, so a self-decrementing clock comes back wrong; this one
comes back exact. Also recompute on visibilitychange instead of waiting for the
next tick.
- Render never reads the clock. Without `now` the first state is null and the
panel renders nothing until the first client tick, which is what makes SSR and
hydration agree; with `now` the server and the first client frame compute the
same digits from the same two injected instants.
- Four views derived from one number: idle (nothing), warning (remaining <=
warnBefore), renewed (a receipt), expired (remaining = 0, or an explicit end).
onExpire fires exactly once per deadline and is a state notification, not a
sign-out — a component that tears down credentials by itself will do it during
a laptop suspend.
- Extend is one-shot: a ref is read AND written synchronously inside the click
handler, so a double click sends one request. Resolve with an instant and the
panel adopts it immediately; resolve with nothing and the panel marks THIS
deadline as handled (it stops nagging but still tells the truth at zero);
reject and an inline destructive line appears, the countdown keeps running and
the button re-arms as "Try again".
- Cross-tab: post {kind:"extended", expiresAt:number|null} after a successful
refresh and {kind:"ended"} after a local sign-out. A received "extended" is
applied through the same code path as a local one, so both tabs end in the same
state; a received "ended" moves this tab to the closed panel. Treat the payload
as unknown — it comes from another tab that may run another version — and
ignore what you do not recognise. An adopted remote instant is stored together
with the `expiresAt` it was based on and voids itself the moment the consumer's
own prop changes, otherwise a long deadline broadcast during session A would
silently override the shorter session B.
- The closed panel says WHY it closed: the timer ran out / you ended it here /
it was ended in another tab. Three different sentences, one panel.
- Keyboard: the panel is not a dialog and never steals focus when it appears —
it is announced instead. Inside, tab order is Stay signed in → Sign out now,
and the closed panel offers the re-login control. Enter and Space are the
native button activations; nothing is trapped and Escape is deliberately
unbound, because a security warning you can dismiss with a stray keypress is a
bug, not a convenience.
- ARIA: each panel is role="group" labelled by its own title; the clock is
role="timer" with aria-live="off" so it is readable but never spoken on every
tick. Two permanently mounted visually hidden regions do the talking — a
polite role="status" for arming, milestones, extension and failure, and an
assertive role="alert" reserved for irreversible facts (expired, signed out
elsewhere). A live region that mounts together with its text is often not
announced, which is why both are always in the DOM.
- Announcements are milestones, not ticks: one line when the window opens, one at
each entry in announceAt, one at zero. Crossing several thresholds at once
(a tab waking up) announces once and consumes all of them. Spoken durations are
words ("1 minute 30 seconds"), never "1:30".
- Never the native disabled attribute: the extend button in flight uses
aria-disabled plus an early return in the handler, because disabling the
control the user is standing on blurs them to <body>.
- Focus is never dropped by a branch swap. After every commit record whether the
root contained document.activeElement; when the view changes, if it was inside
and is now on <body>, move focus to the successor this view rendered — the
re-login control (or the closed panel itself, tabIndex={-1}) for expired, the
receipt strip for renewed, the warning panel itself (also tabIndex={-1}) when a
re-arm swaps back to it. That covers all three ways a button can vanish under
a user: the countdown hitting zero, a message from another tab, and the
consumer moving the deadline — including the re-login control that re-arms the
very session whose closed panel it lives on.
- The receipt strip is dismissed on a ~6s timer, except while it still owns focus
and except while the deadline has not actually moved — in that case it is the
only signal the user has and it stays.
- Cleanup: interval, rAF, the visibilitychange listener, the notice timeout, the
BroadcastChannel listener and channel.close() all happen on unmount and on
every dependency change. Async settlement checks an alive ref before touching
state.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground with border and shadow-sm
for the panel, bg-primary/10 + text-primary for the calm icon chip,
bg-destructive/10 + text-destructive for the urgent one, bg-muted for the meter
track, bg-primary or bg-destructive for its fill, text-muted-foreground for
descriptions, bg-primary / text-primary-foreground for the main action, plain
border for the secondary one, ring-ring for every focus ring.
- Urgency is not carried by colour alone: under the last quarter of the window
(capped at 30s) the icon changes shape, the clock turns destructive and the
meter follows.
- cn() merges every className. The meter has NO transition — a suspended tab
coming back is a genuine jump, and smoothing it over would be a lie.
- Motion is decoration: a 200ms fade + slide on panel entry behind
motion-reduce:animate-none, and the refresh spinner stops spinning under
reduced motion. With motion off everything still functions.
- tabular-nums on the clock so the digits do not dance.
Customization levers
- Threshold and rhythm: warnBeforeSeconds decides both when the panel arms and
the clock format (>= 60 gives m:ss, >= 3600 gives h:mm:ss); announceAt decides
how chatty it is for a screen reader — [30] for a quiet page, [300, 60, 10] for
a long window.
- Sub-blocks are independent: drop the meter for a text-only strip, drop
onSignOut for a warning that only offers renewal, drop onExtend for a hard
session cap, drop signInAction and the closed panel becomes an epitaph.
- Words: every string, including the announcements and the spoken duration
formatter, comes from `labels` — that is also the i18n seam, and the closed
panel's three reasons are three separate keys on purpose.
- Placement: the panel is inline and non-modal by design. To dock it, position
the root (fixed bottom-4 right-4, max-w-sm) — nothing in the logic assumes it
is in flow. If your product really needs a modal, wrap it in your Dialog and
keep the same props; do not add a focus trap here, since stealing focus mid
keystroke is how people lose form data.
- Tokens: recolour the urgent state to var(--chart-4) if destructive is already
spoken for, or swap the icon chip for a plain dot.
- Sync: set channel to a per-account name ("session:" + userId) when one browser
may hold two accounts, or to null to disable cross-tab sync entirely.Concepts
- Deadline, not a decrement — everything is recomputed from one absolute instant minus the current clock, so a tab that was throttled, suspended or asleep for ten minutes wakes up showing the truth instead of the ten minutes of ticks it never received.
- Injected instant — render never calls
Date.now(): the deadline arrives as a prop and the optionalnowsupplies the first-paint reference, which is what lets the server and the first client frame print the same digits instead of arguing about them during hydration. - One-shot refresh — the guard is a ref read and written synchronously inside the click handler, so a burst of presses is still one network call; the handler may hand back the new instant, and if it does not, the panel marks that deadline as handled rather than pretending it knows more than it does.
- Cross-tab dismissal — extending in one tab broadcasts on a BroadcastChannel and the others apply it through exactly the same path as a local extension, so five open tabs cannot end up in five different opinions about when you get signed out.
- Refusal keeps the clock running — a rejected refresh becomes an inline destructive line and a re-armed "Try again" button; it never closes the panel, never resets the countdown, and never silently swallows the failure.
- Focus handoff on a branch swap — the countdown reaching zero, a message from another tab and the consumer moving the deadline all rip a control out from under whoever is standing on it, so every swap checks where focus was and hands it to the control this view rendered instead of letting it fall to
<body>.
Offline Indicator
An offline bar for apps that queue work — it names the changes waiting, proves reconnection with an injected probe instead of navigator.onLine, and replays the queue with a per-action verdict instead of declaring success.
Dock
A macOS-style dock — icon tiles magnify as the cursor approaches, with spring physics and pure-CSS label bubbles.