Feedback

Session timeout

The warning before an inactive session ends, with a live countdown and a way to stay signed in.

Built on
<dialog>role="alertdialog"<time>role="status"
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
2.2 kB
Built from
Alert dialogButton

Are you still there?

You will be signed out of Larder in . Any unsaved work may be lost.

Code
"use client";

import { useState } from "react";
import { Button } from "@/components/equip/button";
import { SessionTimeout } from "@/components/equip/session-timeout";

export default function Demo() {
  const [expiresAt, setExpiresAt] = useState<number | null>(null);
  return (
    <>
      <Button variant="outline" onClick={() => setExpiresAt(Date.now() + 90_000)}>
        Preview warning
      </Button>
      <SessionTimeout
        expiresAt={expiresAt}
        warnBefore={90}
        label="Larder"
        onExtend={() => setExpiresAt(null)}
        onSignOut={() => setExpiresAt(null)}
        onExpire={() => setExpiresAt(null)}
      />
    </>
  );
}

Examples

Inactivity timeout

Activity pushes expiry back until the warning opens 30 seconds before it; from then on it has to be answered. In a real product the server owns the session.

Then leave the page alone for 15 seconds.

Are you still there?

You will be signed out of Ledger in . Any unsaved work may be lost.

Code
"use client";

import { useEffect, useState } from "react";
import { Button } from "@/components/equip/button";
import { SessionTimeout } from "@/components/equip/session-timeout";

const IDLE_LIMIT = 45_000;

export default function Example() {
  const [expiresAt, setExpiresAt] = useState<number | null>(null);

  useEffect(() => {
    // Activity counts only until the warning shows; after that it has to be answered.
    const touch = () =>
      setExpiresAt((current) =>
        current == null || current - Date.now() <= 30_000 ? current : Date.now() + IDLE_LIMIT,
      );
    window.addEventListener("pointerdown", touch);
    window.addEventListener("keydown", touch);
    return () => {
      window.removeEventListener("pointerdown", touch);
      window.removeEventListener("keydown", touch);
    };
  }, []);

  return (
    <>
      <div style={{ display: "grid", gap: 8, justifyItems: "start" }}>
        <Button variant="outline" onClick={() => setExpiresAt(Date.now() + IDLE_LIMIT)}>
          Sign in for 45 seconds
        </Button>
        <span style={{ color: "var(--eq-ink-2)", font: "var(--eq-text-s) / 1.4 var(--eq-font)" }}>
          Then leave the page alone for 15 seconds.
        </span>
      </div>
      <SessionTimeout
        expiresAt={expiresAt}
        warnBefore={30}
        label="Ledger"
        onExtend={() => setExpiresAt(Date.now() + IDLE_LIMIT)}
        onSignOut={() => setExpiresAt(null)}
        onExpire={() => setExpiresAt(null)}
      />
    </>
  );
}

One session across tabs

Every tab reads expiresAt from shared storage, so staying signed in from one tab closes the warning in all of them.

Are you still there?

You will be signed out of Ledger in . Any unsaved work may be lost.

Code
"use client";

import { useEffect, useState } from "react";
import { Button } from "@/components/equip/button";
import { SessionTimeout } from "@/components/equip/session-timeout";

const KEY = "ledger-session-expires";

export default function Example() {
  const [expiresAt, setExpiresAt] = useState<number | null>(null);

  useEffect(() => {
    const read = () => {
      const stored = Number(localStorage.getItem(KEY));
      setExpiresAt(stored > 0 ? stored : null);
    };
    read();
    window.addEventListener("storage", read);
    return () => window.removeEventListener("storage", read);
  }, []);

  function share(next: number | null) {
    if (next == null) localStorage.removeItem(KEY);
    else localStorage.setItem(KEY, String(next));
    setExpiresAt(next);
  }

  return (
    <>
      <Button variant="outline" onClick={() => share(Date.now() + 40_000)}>
        Start a 40 second session
      </Button>
      <SessionTimeout
        expiresAt={expiresAt}
        warnBefore={30}
        label="Ledger"
        onExtend={() => share(Date.now() + 40_000)}
        onSignOut={() => share(null)}
        onExpire={() => share(null)}
      />
    </>
  );
}

Props

PropTypeDefaultWhat it does
expiresAtnumber | nullnoneWhen the session ends, in epoch milliseconds, or null while signed out. The caller owns the real session.
warnBeforenumber120Seconds before expiry that the warning opens.
onExtend() => voidnoneStay signed in, or Escape, was chosen. Refresh the session and pass the new expiresAt; the dialog closes when it moves.
onExpire() => voidnoneCalled once when the time reaches zero.
onSignOut() => voidnoneSign out was chosen. End the session and set expiresAt to null.
titlestring"Are you still there?"The warning's heading.
labelstringnoneWhat the person is signed in to, used in the message: "You will be signed out of Larder in".
idstringnoneThe dialog id. Generated when left out.
...restComponentProps<"dialog">noneNative dialog attributes, passed through Alert dialog. onClose still runs.

Anatomy

root.eq-session-timeoutThe Alert dialog, with data-phase warning or expired.
time.eq-session-timeout-timeThe remaining time in words, updated every second.
live.eq-session-timeout-liveVisually hidden polite status for the 60, 30 and 10 second marks.

Keyboard

TabShift TabMoves between Stay signed in and Sign out; focus stays in the dialog.
EnterSpaceChooses the focused action.
EscapeCloses the warning and counts as Stay signed in.

Notes

  • Meets WCAG 2.2.1 Timing adjustable: the warning gives people time to extend with one action, and warnBefore should be at least 20 seconds.
  • Stay signed in is focused when the warning opens, and the dialog's description, with the time left, is read at that moment.
  • The time updates visually every second but is announced only at the 60, 30 and 10 second marks, so screen readers are not flooded.
  • Timers are scheduled from expiresAt in an effect and recomputed from the clock on every tick and when the tab becomes visible again.
  • To keep several tabs in step, share expiresAt between them, for example through localStorage or a BroadcastChannel; extending in one tab closes the warning in all.
  • After onExpire it shows a short You were signed out state until it is closed, even if you set expiresAt to null.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/session-timeout.tsx191 lines
"use client";

import { useEffect, useId, useRef, useState } from "react";
import {
  AlertDialog,
  AlertDialogActions,
  AlertDialogDescription,
  AlertDialogTitle,
  type AlertDialogProps,
} from "./alert-dialog";
import { Button } from "./button";
import "./session-timeout.css";

export type SessionTimeoutProps = Omit<AlertDialogProps, "id" | "title" | "children" | "tone" | "ref"> & {
  /** Defaults to a generated id. */
  id?: string;
  /** When the session ends, in epoch milliseconds. null while signed out. The caller owns the real session. */
  expiresAt: number | null;
  /** Seconds before expiry that the warning opens. */
  warnBefore?: number;
  /** "Stay signed in" or Escape. Refresh the session and pass the new expiresAt. */
  onExtend: () => void;
  /** Called once when the time runs out. */
  onExpire: () => void;
  /** "Sign out" was chosen. */
  onSignOut: () => void;
  title?: string;
  /** What the person is signed in to, used in the message: "signed out of Larder in". */
  label?: string;
};

const MARKS = [60, 30, 10];
const MAX_DELAY = 2 ** 31 - 1;

function spell(seconds: number) {
  const m = Math.floor(seconds / 60);
  const s = seconds % 60;
  const parts: string[] = [];
  if (m) parts.push(`${m} minute${m === 1 ? "" : "s"}`);
  if (s || !m) parts.push(`${s} second${s === 1 ? "" : "s"}`);
  return parts.join(" ");
}

export function SessionTimeout({
  id,
  expiresAt,
  warnBefore = 120,
  onExtend,
  onExpire,
  onSignOut,
  title = "Are you still there?",
  label,
  className,
  onClose,
  ...rest
}: SessionTimeoutProps) {
  const generated = useId();
  const dialogRef = useRef<HTMLDialogElement>(null);
  const closeRef = useRef<HTMLButtonElement>(null);
  const onExpireRef = useRef(onExpire);
  const expiredFor = useRef<number | null>(null);
  const marks = useRef<number[] | null>(null);
  const [now, setNow] = useState<number | null>(null);
  const [signedOut, setSignedOut] = useState(false);
  const [announcement, setAnnouncement] = useState("");

  useEffect(() => {
    onExpireRef.current = onExpire;
  });

  useEffect(() => {
    if (expiresAt == null) {
      setNow(null);
      return;
    }
    const end = expiresAt;
    let timer = 0;
    function tick() {
      window.clearTimeout(timer);
      const t = Date.now();
      setNow(t);
      const ms = end - t;
      if (ms <= 0) {
        if (expiredFor.current !== end) {
          expiredFor.current = end;
          setSignedOut(true);
          onExpireRef.current();
        }
        return;
      }
      const untilWarning = ms - warnBefore * 1000;
      const delay = untilWarning > 0 ? untilWarning : ms % 1000 || 1000;
      timer = window.setTimeout(tick, Math.min(delay, MAX_DELAY));
    }
    function onVisible() {
      if (document.visibilityState === "visible") tick();
    }
    if (end > Date.now()) setSignedOut(false);
    tick();
    document.addEventListener("visibilitychange", onVisible);
    return () => {
      window.clearTimeout(timer);
      document.removeEventListener("visibilitychange", onVisible);
    };
  }, [expiresAt, warnBefore]);

  const left = expiresAt != null && now != null ? Math.max(0, Math.ceil((expiresAt - now) / 1000)) : null;
  const phase = signedOut ? "expired" : left != null && left > 0 && left <= warnBefore ? "warning" : "idle";

  useEffect(() => {
    if (phase !== "warning" || left == null) {
      marks.current = null;
      setAnnouncement("");
      return;
    }
    // The dialog's own description is read when it opens; the live region speaks only at later marks.
    if (marks.current == null) {
      marks.current = MARKS.filter((m) => m < left);
      return;
    }
    if (marks.current.some((m) => left <= m)) {
      marks.current = marks.current.filter((m) => left > m);
      setAnnouncement(`${spell(left)} left before you are signed out.`);
    }
  }, [phase, left]);

  useEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;
    if (phase !== "idle" && !dialog.open) dialog.showModal();
    if (phase === "idle" && dialog.open) dialog.close();
    if (phase === "expired") closeRef.current?.focus();
  }, [phase]);

  const seconds = left ?? warnBefore;
  const minutesPart = Math.floor(seconds / 60);

  return (
    <AlertDialog
      {...rest}
      ref={dialogRef}
      id={id ?? generated}
      className={["eq-session-timeout", className].filter(Boolean).join(" ")}
      data-phase={phase}
      onClose={(event) => {
        onClose?.(event);
        // Only Escape closes the dialog by itself: treat it as "Stay signed in".
        if (phase === "warning") onExtend();
        if (phase === "expired") setSignedOut(false);
      }}
    >
      {phase === "expired" ? (
        <>
          <AlertDialogTitle>You were signed out</AlertDialogTitle>
          <AlertDialogDescription>
            <p>For your security, you were signed out{label ? ` of ${label}` : ""} after a period of inactivity.</p>
          </AlertDialogDescription>
          <AlertDialogActions>
            <Button ref={closeRef} type="button" variant="outline" onClick={() => setSignedOut(false)}>
              Close
            </Button>
          </AlertDialogActions>
        </>
      ) : (
        <>
          <AlertDialogTitle>{title}</AlertDialogTitle>
          <AlertDialogDescription>
            <p>
              You will be signed out{label ? ` of ${label}` : ""} in{" "}
              <time className="eq-session-timeout-time" dateTime={`PT${minutesPart}M${seconds % 60}S`}>
                {spell(seconds)}
              </time>
              . Any unsaved work may be lost.
            </p>
          </AlertDialogDescription>
          <p role="status" className="eq-session-timeout-live">
            {announcement}
          </p>
          <AlertDialogActions>
            <Button type="button" variant="accent" onClick={onExtend}>
              Stay signed in
            </Button>
            <Button type="button" variant="outline" onClick={onSignOut}>
              Sign out
            </Button>
          </AlertDialogActions>
        </>
      )}
    </AlertDialog>
  );
}
components/equip/session-timeout.css18 lines
@layer equip {
  .eq-session-timeout-time {
    font-weight: var(--eq-weight-strong);
    font-variant-numeric: tabular-nums;
    color: var(--eq-ink);
    white-space: nowrap;
  }

  .eq-session-timeout-live {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index