All patterns

Session Expiry Countdown

A session-timeout dialog counts down on a draining ring with a clear stay-signed-in action.

feedbackcalmminimalautomatic · finite · intermediate · ~1.4s
Variant

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.

437 lines · react + motion only
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.

Session timeout warningIdle sign-out promptBanking or health portal sessionRe-authentication before a session lapses

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Ridgeline
    Issues
    Backlog
    Active
    Cycles
    Views
    IssuesNew
    Colourway picker drops a frameRID-412 · PriyaIn progress
    Receipt totals misalign on narrowRID-408 · MarcusTodo
    Session expires without warningRID-401 · DanaIn review
    Export queue stalls past 500 rowsRID-397 · NilsTodo
    Search ranks archived firstRID-390 · PriyaDone
    Delete this project?Its 21 files and every share link stop working. This cannot be undone.
    CancelDelete
    Modal sheet

    A session-expiry modal counts the remaining seconds while offering to renew in place.

Related patterns