Session Timeout
Overlays

Session Timeout

Inactivity dialog with a depleting ring around a rolling mm:ss clock — wall-clock accurate in background tabs, refills with an ease-out sweep on 'Stay signed in', and turns destructive near zero.

Install

npx shadcn@latest add @paragon/session-timeout

Also installs: button, dialog

session-timeout.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/paragon/ui/button";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "@/registry/paragon/ui/dialog";

const RADIUS = 24;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;

const timeoutStyles = `
@keyframes pg-session-refill-pop {
  0% { scale: 1; }
  45% { scale: 1.06; }
  100% { scale: 1; }
}
@media (prefers-reduced-motion: reduce) {
  @keyframes pg-session-refill-pop { 0%, 100% { scale: 1; } }
}
`;

export interface SessionTimeoutDialogProps {
  open: boolean;
  onOpenChange?: (open: boolean) => void;
  /** Seconds until the session expires, counted from when the dialog opens. */
  duration?: number;
  /** Remaining seconds at which the ring and clock turn destructive. */
  warnBelow?: number;
  title?: React.ReactNode;
  description?: React.ReactNode;
  continueLabel?: string;
  signOutLabel?: string;
  /** Runs after "Stay signed in" — the ring refills first, then this fires. */
  onContinue?: () => void;
  onSignOut?: () => void;
  /** Runs once when the countdown reaches zero. */
  onExpire?: () => void;
}

function formatClock(totalSeconds: number): string {
  const minutes = Math.floor(totalSeconds / 60);
  const seconds = totalSeconds % 60;
  return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
}

/**
 * Inactivity warning dialog with a depleting ring around a rolling mm:ss
 * clock. The countdown tracks wall-clock time from the moment the dialog
 * opens (background-tab throttling can't stretch a real session), the ring
 * depletes in smooth linear 1s segments, and "Stay signed in" refills it
 * with an ease-out sweep and a small pop before handing control back. Under
 * warnBelow seconds everything shifts to the destructive token. The clock is
 * `role="timer"` (deliberately not live — per-second announcements are
 * noise); open/expire moments are what callers announce.
 */
export function SessionTimeoutDialog({
  open,
  onOpenChange,
  duration = 120,
  warnBelow = 15,
  title = "Are you still there?",
  description,
  continueLabel = "Stay signed in",
  signOutLabel = "Sign out",
  onContinue,
  onSignOut,
  onExpire,
}: SessionTimeoutDialogProps) {
  const reducedMotion = useReducedMotion();
  const [remaining, setRemaining] = React.useState(duration);
  const [refillPop, setRefillPop] = React.useState(false);
  const refilling = React.useRef(false);
  const anchor = React.useRef(0);
  const expired = React.useRef(false);
  const closeTimer = React.useRef<ReturnType<typeof setTimeout>>(null);
  const continueRef = React.useRef<HTMLButtonElement>(null);

  // Wall-clock countdown: remaining derives from an anchor timestamp, so a
  // throttled background tab still expires on time and resyncs on return.
  React.useEffect(() => {
    if (!open) return;
    anchor.current = Date.now();
    expired.current = false;
    refilling.current = false;
    setRemaining(duration);
    setRefillPop(false);

    const tick = () => {
      const elapsed = Math.floor((Date.now() - anchor.current) / 1000);
      setRemaining(Math.max(0, duration - elapsed));
    };
    const interval = setInterval(tick, 1000);
    document.addEventListener("visibilitychange", tick);
    return () => {
      clearInterval(interval);
      document.removeEventListener("visibilitychange", tick);
    };
  }, [open, duration]);

  React.useEffect(() => {
    if (open && remaining === 0 && !expired.current) {
      expired.current = true;
      onOpenChange?.(false);
      onExpire?.();
    }
  }, [open, remaining, onOpenChange, onExpire]);

  React.useEffect(() => {
    return () => {
      if (closeTimer.current) clearTimeout(closeTimer.current);
    };
  }, []);

  const handleContinue = () => {
    if (refilling.current) return;
    refilling.current = true;
    anchor.current = Date.now();
    setRemaining(duration);
    setRefillPop(true);
    // Let the refill land before closing — the reassurance is the point.
    closeTimer.current = setTimeout(
      () => {
        onOpenChange?.(false);
        onContinue?.();
      },
      reducedMotion ? 150 : 500,
    );
  };

  const handleSignOut = () => {
    onOpenChange?.(false);
    onSignOut?.();
  };

  const clock = formatClock(remaining);
  const danger = remaining <= warnBelow && !refilling.current;
  const progress = duration === 0 ? 0 : remaining / duration;

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <style href="paragon-session-timeout" precedence="paragon">
        {timeoutStyles}
      </style>
      <DialogContent
        className="sm:max-w-sm"
        onOpenAutoFocus={(event) => {
          event.preventDefault();
          continueRef.current?.focus();
        }}
      >
        <DialogHeader>
          <DialogTitle>{title}</DialogTitle>
          <DialogDescription>
            {description ??
              "You've been inactive for a while. For your security, you'll be signed out when the timer runs out."}
          </DialogDescription>
        </DialogHeader>

        <div className="flex items-center justify-center py-2">
          <div
            className="relative"
            style={
              refillPop
                ? {
                    animation:
                      "pg-session-refill-pop 300ms var(--ease-bounce) both",
                  }
                : undefined
            }
            onAnimationEnd={(event) => {
              if (event.animationName === "pg-session-refill-pop")
                setRefillPop(false);
            }}
          >
            <svg
              viewBox="0 0 56 56"
              className={cn(
                "size-24 -rotate-90 transition-colors duration-300",
                danger ? "text-destructive" : "text-primary",
              )}
              aria-hidden
            >
              <circle
                cx="28"
                cy="28"
                r={RADIUS}
                fill="none"
                stroke="currentColor"
                strokeOpacity={0.15}
                strokeWidth={3.5}
              />
              <circle
                cx="28"
                cy="28"
                r={RADIUS}
                fill="none"
                stroke="currentColor"
                strokeWidth={3.5}
                strokeLinecap="round"
                strokeDasharray={CIRCUMFERENCE}
                strokeDashoffset={CIRCUMFERENCE * (1 - progress)}
                style={{
                  transitionProperty: "stroke-dashoffset",
                  // Linear 1s segments while depleting; a fast ease-out sweep
                  // on refill. Reduced motion steps discretely — the state
                  // still changes, the movement goes.
                  transitionDuration: reducedMotion
                    ? "0ms"
                    : refilling.current
                      ? "300ms"
                      : "1000ms",
                  transitionTimingFunction: refilling.current
                    ? "var(--ease-out)"
                    : "linear",
                }}
              />
            </svg>
            <span
              role="timer"
              aria-live="off"
              aria-label={`Session expires in ${clock}`}
              className={cn(
                "absolute inset-0 flex items-center justify-center font-mono text-sm font-medium tabular-nums transition-colors duration-300",
                danger ? "text-destructive" : "text-foreground",
              )}
            >
              {clock.split("").map((char, index) => (
                <span
                  key={index}
                  className={cn(
                    "inline-flex justify-center",
                    char === ":" ? "w-[0.5ch]" : "w-[1ch]",
                  )}
                >
                  <AnimatePresence mode="popLayout" initial={false}>
                    <motion.span
                      key={char}
                      initial={
                        reducedMotion
                          ? { opacity: 0 }
                          : { opacity: 0, y: -8, filter: "blur(3px)" }
                      }
                      animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
                      exit={
                        reducedMotion
                          ? { opacity: 0 }
                          : { opacity: 0, y: 8, filter: "blur(3px)" }
                      }
                      transition={{ type: "spring", duration: 0.3, bounce: 0 }}
                      className="inline-block"
                    >
                      {char}
                    </motion.span>
                  </AnimatePresence>
                </span>
              ))}
            </span>
          </div>
        </div>

        <DialogFooter>
          <Button variant="ghost" onClick={handleSignOut}>
            {signOutLabel}
          </Button>
          <Button ref={continueRef} onClick={handleContinue}>
            {continueLabel}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}