Feedback

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { LogIn, LogOut, RefreshCw, ShieldAlert, ShieldCheck, TriangleAlert } from "lucide-react"
import { cn } from "@/lib/utils"

/** ISO string, epoch ms or Date — everything the component turns into one absolute instant. */
export type SessionInstant = string | number | Date

/**
 * 跨标签页同步的载荷。只能是纯数据——它要过一次结构化克隆。
 *
 * `expiresAt: null` 的语义是「续上了,但这条消息不知道新的到期时刻」:收到的
 * 标签页只把**当前这个**截止时刻标记为已处理(面板立刻安静),然后等自己的

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/session-expiry.json

Prompt

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 optional now supplies 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>.

On This Page