Session Expiry Countdown
A session-timeout dialog counts down on a draining ring with a clear stay-signed-in action.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Session Expiry Countdown
*
* The workspace dims, a timeout dialog rises over it, and a ring drains
* beside a figure that steps down a second at a time. Staying signed in
* is the obvious move: the ring runs back to full and the dialog leaves.
*
* Self-contained: depends only on `react` and `motion`. The dialog uses
* the CSS system colors, so it lands light in a light app and dark in a
* dark one.
* Works with zero props; tune via `variant`, `seconds`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type SessionExpiryCountdownProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Seconds left when the dialog appears. */
seconds?: number;
/** Question on the dialog. */
title?: string;
/** Length of a renewed session, shown once the ring refills. */
renewedLabel?: string;
/** Ring and primary button color. */
accent?: string;
/** Fires when the session is extended. */
onStay?: () => void;
};
type VariantConfig = {
/** How far the dialog travels before it lands, in px. */
rise: number;
/** Opacity the workspace holds while the dialog is up. */
dim: number;
/** Seconds the scrim takes to arrive. */
scrim: number;
/** When the dialog arrives, in seconds. */
delay: number;
spring: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: nothing here scales — least of all the figure, which is
// the one thing on screen a person is reading. It steps between values
// in a fixed tabular slot instead. Springs sit well above a 0.8 damping
// ratio (0.97 / 0.93 / 0.90): a dialog about time running out should not
// arrive bouncing, and the ring drains on a linear curve because that is
// what a clock does.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Barely a transition. For an app that asks this often.
subtle: {
rise: 8,
dim: 0.62,
scrim: 0.16,
delay: 0.35,
spring: { type: "spring", stiffness: 560, damping: 46 },
},
// The all-purpose setting.
default: {
rise: 14,
dim: 0.5,
scrim: 0.2,
delay: 0.5,
spring: { type: "spring", stiffness: 460, damping: 40 },
},
// A longer travel, for a session worth properly interrupting.
playful: {
rise: 20,
dim: 0.42,
scrim: 0.24,
delay: 0.6,
spring: { type: "spring", stiffness: 400, damping: 36 },
},
};
/** Theme-adaptive neutral: `currentColor` is the inherited text color —
* near-black on a light page, near-white on a dark one — so mixing it
* with `transparent` yields a surface or border correctly toned in
* either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function SessionExpiryCountdown({
variant = "default",
seconds = 20,
title = "Still there?",
renewedLabel = "30m",
accent = "#4F7CE8",
onStay,
}: SessionExpiryCountdownProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [remaining, setRemaining] = useState(seconds);
const [stayed, setStayed] = useState(false);
const [open, setOpen] = useState(true);
const expired = remaining <= 0;
useEffect(() => {
if (!open || stayed || expired) return;
// One tick at a time. setState runs from the timeout callback, never
// synchronously in the effect body.
const timer = setTimeout(() => setRemaining((left) => left - 1), 1000);
return () => clearTimeout(timer);
}, [remaining, open, stayed, expired]);
useEffect(() => {
if (!stayed) return;
// Let the ring finish running back to full before the dialog leaves.
const timer = setTimeout(() => setOpen(false), 1150);
return () => clearTimeout(timer);
}, [stayed]);
const fraction = stayed ? 1 : Math.max(0, remaining) / seconds;
return (
<div
style={{
position: "relative",
width: 340,
height: 272,
borderRadius: 16,
border: `1px solid ${tone(12)}`,
background: tone(4),
color: "inherit",
overflow: "hidden",
}}
>
{/* The work that is not going anywhere. It dims rather than being
replaced, because the whole message is that it is still here. */}
<motion.div
aria-hidden={open}
animate={{ opacity: open ? cfg.dim : 1 }}
transition={{ duration: cfg.scrim, ease: "easeOut" }}
style={{ padding: 16, height: "100%", boxSizing: "border-box" }}
>
<div style={{ fontSize: 13, fontWeight: 650 }}>Quarterly summary</div>
<div style={{ fontSize: 11, opacity: 0.5, marginTop: 3 }}>
Draft · saved a moment ago
</div>
<div style={{ marginTop: 14 }}>
{[100, 88, 94, 72, 96, 60].map((width, index) => (
<div
key={index}
style={{
width: `${width}%`,
height: 7,
marginBottom: 9,
borderRadius: 4,
background: tone(11),
}}
/>
))}
</div>
</motion.div>
<AnimatePresence>
{open && (
<motion.div
key="scrim"
aria-hidden
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.2 } }}
transition={{ duration: cfg.scrim, delay: cfg.delay, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
// A scrim darkens in both themes, so it stays literal.
background: "rgba(0,0,0,0.38)",
}}
/>
)}
</AnimatePresence>
<div
style={{
position: "absolute",
inset: 0,
display: "grid",
placeItems: "center",
padding: 16,
pointerEvents: "none",
}}
>
<AnimatePresence>
{open && (
<motion.div
key="dialog"
role="dialog"
aria-modal="true"
aria-label={title}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: cfg.rise }}
animate={{ opacity: 1, y: 0 }}
exit={{
opacity: 0,
y: reduceMotion ? 0 : cfg.rise * 0.4,
transition: { duration: 0.2, ease: "easeIn" },
}}
transition={
reduceMotion
? { duration: 0.2, delay: cfg.delay, ease: "easeOut" }
: {
...cfg.spring,
delay: cfg.delay,
opacity: { duration: 0.2, delay: cfg.delay },
}
}
style={{
width: "100%",
pointerEvents: "auto",
padding: 15,
borderRadius: 14,
boxSizing: "border-box",
// Sits on top of the scrim, so it cannot be translucent —
// a see-through panel there just reads as more scrim.
// `Canvas`/`CanvasText` are the CSS system colors for page
// background and page text, so the dialog lands light in a
// light app and dark in a dark one. Everything inside then
// mixes from `currentColor`.
background: "Canvas",
color: "CanvasText",
border: `1px solid ${tone(14)}`,
boxShadow: "0 18px 44px rgba(0,0,0,0.3)",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 13 }}>
<Ring
fraction={fraction}
accent={accent}
stayed={stayed}
expired={expired}
reduceMotion={Boolean(reduceMotion)}
remaining={Math.max(0, remaining)}
renewedLabel={renewedLabel}
/>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 650 }}>
{expired
? "Signed out"
: stayed
? "You're still signed in"
: title}
</div>
<p
style={{
margin: "5px 0 0",
fontSize: 11.5,
lineHeight: 1.5,
opacity: 0.62,
}}
>
{/* Deliberately not a live region and deliberately
without the figure in it: a count that re-announces
every second is the opposite of calm. */}
{expired
? "Your draft is saved exactly where you left it."
: stayed
? "Session renewed. Nothing was interrupted."
: "We'll sign you out shortly to keep this device secure. Your draft is already saved."}
</p>
</div>
</div>
{!stayed && !expired && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginTop: 14,
}}
>
<button
type="button"
onClick={() => {
setStayed(true);
onStay?.();
}}
style={{
flex: 1,
padding: "9px 12px",
fontSize: 12.5,
fontWeight: 650,
fontFamily: "inherit",
color: "#ffffff",
background: accent,
border: "none",
borderRadius: 10,
cursor: "pointer",
}}
>
Stay signed in
</button>
<button
type="button"
onClick={() => setRemaining(0)}
style={{
flex: "none",
padding: "9px 10px",
fontSize: 12,
fontWeight: 500,
fontFamily: "inherit",
color: "inherit",
opacity: 0.55,
background: "transparent",
border: "none",
borderRadius: 10,
cursor: "pointer",
}}
>
Sign out now
</button>
</div>
)}
{expired && (
<button
type="button"
onClick={() => setRemaining(seconds)}
style={{
width: "100%",
marginTop: 14,
padding: "9px 12px",
fontSize: 12.5,
fontWeight: 650,
fontFamily: "inherit",
color: "#ffffff",
background: accent,
border: "none",
borderRadius: 10,
cursor: "pointer",
}}
>
Sign back in
</button>
)}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
}
/** The ring is the clock and the figure is the same fact in words, so
* the pair is hidden from assistive tech and the prose beside it carries
* the meaning. The stroke drains linearly one second at a time, which
* keeps it locked to the figure instead of running its own animation. */
function Ring({
fraction,
accent,
stayed,
expired,
reduceMotion,
remaining,
renewedLabel,
}: {
fraction: number;
accent: string;
stayed: boolean;
expired: boolean;
reduceMotion: boolean;
remaining: number;
renewedLabel: string;
}) {
const label = stayed ? renewedLabel : String(remaining);
return (
<div
aria-hidden
style={{
position: "relative",
flex: "none",
width: 52,
height: 52,
display: "grid",
placeItems: "center",
}}
>
<svg width="52" height="52" viewBox="0 0 52 52" fill="none">
<circle cx="26" cy="26" r="22" stroke={tone(12)} strokeWidth="3" />
<motion.circle
cx="26"
cy="26"
r="22"
stroke={accent}
strokeWidth="3"
strokeLinecap="round"
initial={{ pathLength: 1 }}
animate={{ pathLength: fraction }}
transition={
stayed
? { duration: 0.55, ease: "easeOut" }
: { duration: 1, ease: "linear" }
}
style={{ transform: "rotate(-90deg)", transformOrigin: "50% 50%" }}
/>
</svg>
{/* A fixed tabular slot. The figure never scales and never bounces:
it translates the height of one line and crossfades, so the eye
reads a value stepping down rather than an object moving. */}
<span
style={{
position: "absolute",
display: "block",
width: 34,
height: 19,
overflow: "hidden",
fontSize: 14.5,
fontWeight: 650,
lineHeight: "19px",
textAlign: "center",
fontVariantNumeric: "tabular-nums",
opacity: expired ? 0.4 : 1,
}}
>
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={label}
initial={{ y: reduceMotion ? 0 : -19, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: reduceMotion ? 0 : 19, opacity: 0 }}
transition={{ duration: 0.26, ease: "easeOut" }}
style={{ display: "block", width: "100%" }}
>
{label}
</motion.span>
</AnimatePresence>
</span>
</div>
);
}About this pattern
A timeout warning that reassures instead of alarming. The workspace dims but stays visible — the point of the message is that the draft is still there — and the dialog rises over it with the renew action already the obvious one: filled, first in reading order, and paired with a plain-text sign-out rather than a matching button. The ring drains a second at a time on a linear curve, locked to the figure beside it rather than running its own animation, and the figure steps between values in a fixed tabular slot: it translates one line height and crossfades, never scaling, because the one element a person is actually reading is the last thing that should wobble. Nothing here is red and nothing pulses. Staying signed in runs the ring back to full, states the renewed window, and hands the page back.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Modal sheet
A session-expiry modal counts the remaining seconds while offering to renew in place.
Related patterns
- Offline Banner DropA connection bar opens down out of the top edge, holds while the app retries, then retracts once it is back.
- Password Strength BarA segmented meter fills bar by bar and shifts hue as a password improves.
- Validation Summary ListA rejected submit answers with the whole list of what stopped it, each entry arriving a beat apart.