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.
npx shadcn@latest add @paragon/session-timeoutAlso installs: button, dialog
"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>
);
}