All patterns

Logout Confirm

The workspace dims and settles back while the sign-out question rises over it and takes focus.

authenticationcalmpremiuminteraction · finite · intermediate · ~0.5s
Interactive · click to play
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.

389 lines · react + motion only
import { useEffect, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Logout Confirm
 *
 * The workspace dims and settles back while the sign-out question rises
 * over it; confirming hands the frame over to a signed-out state.
 *
 * Self-contained: depends only on `react` and `motion`. The card 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`, `title`, `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type LogoutConfirmProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Question on the card. */
  title?: string;
  /** Line under the question. */
  subtitle?: string;
  /** Destructive button color. */
  danger?: string;
  /** Workspace accent. */
  accent?: string;
  /** Fires when the account is actually signed out. */
  onSignOut?: () => void;
};

type VariantConfig = {
  /** How far the workspace settles back, in px. */
  recede: number;
  /** Opacity the workspace holds while the question is up. */
  dim: number;
  /** How far the card travels before it lands, in px. */
  rise: number;
  /** Seconds the scrim takes to arrive. */
  scrim: number;
  spring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: nothing here scales. The workspace behind is full of
// text, and shrinking a page of type to signal "this is behind now"
// blurs every glyph on it — a few pixels of settle plus a dim says the
// same thing cleanly. Springs sit above a 0.8 damping ratio; a question
// about ending a session should not arrive bouncing.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // The page barely moves. For apps where signing out is a menu item,
  // not a decision.
  subtle: {
    recede: 2,
    dim: 0.62,
    rise: 8,
    scrim: 0.16,
    spring: { type: "spring", stiffness: 560, damping: 46 },
  },
  // The page steps back and the card takes the room. The all-purpose
  // setting.
  default: {
    recede: 4,
    dim: 0.5,
    rise: 14,
    scrim: 0.2,
    spring: { type: "spring", stiffness: 460, damping: 40 },
  },
  // A deeper recede and a longer travel, for a workspace you are
  // properly leaving rather than switching away from.
  playful: {
    recede: 7,
    dim: 0.4,
    rise: 20,
    scrim: 0.24,
    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 LogoutConfirm({
  variant = "default",
  title = "Sign out of Meridian?",
  subtitle = "Anything unsaved stays on this device. You will need your password or a one-time link to come back.",
  danger = "#E05260",
  accent = "#5B5BD6",
  onSignOut,
}: LogoutConfirmProps) {
  const [asking, setAsking] = useState(false);
  const [signedOut, setSignedOut] = useState(false);
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  useEffect(() => {
    if (!asking) return;
    const onKey = (event: KeyboardEvent) => {
      if (event.key === "Escape") setAsking(false);
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [asking]);

  return (
    <div
      style={{
        position: "relative",
        width: 336,
        height: 288,
        borderRadius: 16,
        border: `1px solid ${tone(12)}`,
        background: tone(4),
        color: "inherit",
        overflow: "hidden",
      }}
    >
      <motion.div
        aria-hidden={asking}
        animate={{
          opacity: asking ? cfg.dim : 1,
          y: asking && !reduceMotion ? cfg.recede : 0,
        }}
        transition={
          reduceMotion
            ? { duration: 0.18, ease: "easeOut" }
            : { ...cfg.spring, opacity: { duration: cfg.scrim } }
        }
        style={{ padding: 16, height: "100%", boxSizing: "border-box" }}
      >
        <div
          style={{
            display: "flex",
            alignItems: "center",
            gap: 9,
            paddingBottom: 12,
            borderBottom: `1px solid ${tone(10)}`,
          }}
        >
          <span
            aria-hidden
            style={{
              display: "grid",
              placeItems: "center",
              width: 26,
              height: 26,
              borderRadius: "50%",
              background: accent,
              color: "#ffffff",
              fontSize: 11,
              fontWeight: 700,
            }}
          >
            PR
          </span>
          <span>
            <span style={{ display: "block", fontSize: 12.5, fontWeight: 650 }}>
              Priya Raman
            </span>
            <span style={{ display: "block", fontSize: 11, opacity: 0.5 }}>
              Meridian · Design team
            </span>
          </span>
        </div>

        <AnimatePresence mode="wait" initial={false}>
          {signedOut ? (
            <motion.div
              key="out"
              initial={{ opacity: 0, y: reduceMotion ? 0 : 8 }}
              animate={{ opacity: 1, y: 0 }}
              transition={{ duration: 0.24, ease: "easeOut" }}
              style={{
                display: "flex",
                flexDirection: "column",
                alignItems: "center",
                justifyContent: "center",
                gap: 10,
                height: 190,
                textAlign: "center",
              }}
            >
              <div style={{ fontSize: 14, fontWeight: 650 }}>Signed out</div>
              <div style={{ fontSize: 12, opacity: 0.55, maxWidth: 210 }}>
                This device has been cleared. See you next time.
              </div>
              <button
                type="button"
                onClick={() => setSignedOut(false)}
                style={{
                  marginTop: 2,
                  padding: "8px 14px",
                  fontSize: 12.5,
                  fontWeight: 600,
                  fontFamily: "inherit",
                  color: "inherit",
                  background: tone(8),
                  border: `1px solid ${tone(14)}`,
                  borderRadius: 9,
                  cursor: "pointer",
                }}
              >
                Sign back in
              </button>
            </motion.div>
          ) : (
            <motion.div
              key="in"
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              transition={{ duration: 0.2, ease: "easeOut" }}
            >
              {[
                ["Notifications", "On · mentions only"],
                ["Two-step verification", "Authenticator app"],
                ["Active devices", "3 signed in"],
              ].map(([label, value]) => (
                <div
                  key={label}
                  style={{
                    display: "flex",
                    justifyContent: "space-between",
                    gap: 12,
                    padding: "10px 0",
                    borderBottom: `1px solid ${tone(8)}`,
                    fontSize: 12,
                  }}
                >
                  <span style={{ opacity: 0.55 }}>{label}</span>
                  <span style={{ fontWeight: 600 }}>{value}</span>
                </div>
              ))}

              <button
                type="button"
                onClick={() => setAsking(true)}
                style={{
                  width: "100%",
                  marginTop: 14,
                  padding: "9px 14px",
                  fontSize: 12.5,
                  fontWeight: 600,
                  fontFamily: "inherit",
                  color: danger,
                  background: "transparent",
                  border: `1px solid color-mix(in srgb, ${danger} 34%, transparent)`,
                  borderRadius: 9,
                  cursor: "pointer",
                }}
              >
                Sign out
              </button>
            </motion.div>
          )}
        </AnimatePresence>
      </motion.div>

      <AnimatePresence>
        {asking && (
          <motion.div
            key="scrim"
            aria-hidden
            onClick={() => setAsking(false)}
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0, transition: { duration: 0.14 } }}
            transition={{ duration: cfg.scrim, ease: "easeOut" }}
            style={{
              position: "absolute",
              inset: 0,
              // A scrim darkens in both themes — light or dark, the page
              // behind a question recedes — so this one stays literal.
              background: "rgba(0,0,0,0.42)",
              cursor: "pointer",
            }}
          />
        )}
      </AnimatePresence>

      <div
        style={{
          position: "absolute",
          inset: 0,
          display: "grid",
          placeItems: "center",
          padding: "20px 20px 34px",
          pointerEvents: "none",
        }}
      >
        <AnimatePresence>
          {asking && (
            <motion.div
              key="ask"
              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.45,
                transition: { duration: 0.14, ease: "easeIn" },
              }}
              transition={
                reduceMotion
                  ? { duration: 0.16, ease: "easeOut" }
                  : { ...cfg.spring, opacity: { duration: 0.18 } }
              }
              style={{
                width: "100%",
                pointerEvents: "auto",
                padding: "16px 16px 14px",
                borderRadius: 14,
                // The one surface here that cannot be translucent: it
                // sits on top of the scrim, and a see-through card would
                // read as more scrim. `Canvas`/`CanvasText` are the CSS
                // system colors for page background and page text, so
                // the card 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.32)",
              }}
            >
              <div style={{ fontSize: 14.5, fontWeight: 650 }}>{title}</div>
              <p
                style={{
                  margin: "6px 0 0",
                  fontSize: 12,
                  lineHeight: 1.5,
                  opacity: 0.65,
                }}
              >
                {subtitle}
              </p>

              <div style={{ display: "flex", gap: 8, marginTop: 14 }}>
                <button
                  type="button"
                  onClick={() => setAsking(false)}
                  style={{
                    flex: 1,
                    padding: "9px 12px",
                    fontSize: 12.5,
                    fontWeight: 600,
                    fontFamily: "inherit",
                    color: "inherit",
                    background: "transparent",
                    border: `1px solid ${tone(16)}`,
                    borderRadius: 10,
                    cursor: "pointer",
                  }}
                >
                  Stay signed in
                </button>
                <button
                  type="button"
                  onClick={() => {
                    setAsking(false);
                    setSignedOut(true);
                    onSignOut?.();
                  }}
                  style={{
                    flex: 1,
                    padding: "9px 12px",
                    fontSize: 12.5,
                    fontWeight: 600,
                    fontFamily: "inherit",
                    color: "#ffffff",
                    background: danger,
                    border: "none",
                    borderRadius: 10,
                    cursor: "pointer",
                  }}
                >
                  Sign out
                </button>
              </div>
            </motion.div>
          )}
        </AnimatePresence>
      </div>
    </div>
  );
}

About this pattern

Ending a session is cheap to do and annoying to undo, so the question deserves a moment of stage management. The workspace behind settles a few pixels and dims rather than shrinking: it is a page full of type, and scaling type to say 'this is behind now' softens every glyph on it, while a small settle plus a dim says the same thing without touching legibility. The card then rises into the space that opened, opaque against the scrim, and leaves on a quick ease-in rather than by replaying its entrance backwards. Confirming hands the same frame over to a signed-out state instead of blanking it, so nothing about the layout jumps at the end.

Sign-out confirmationEnd a session on a shared deviceLeave a workspace promptDisconnect an account

Where it shows up

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

  • Sign inUse the address your team invited
    Email
    nils@ridgeline.co
    Password
    ••••••••••
    Continue
    Sign-in screen

    The page behind steps back a fraction while the question owns the screen.

Related patterns