Logout Confirm
The workspace dims and settles back while the sign-out question rises over it and takes focus.
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 · 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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Sign-in screen
The page behind steps back a fraction while the question owns the screen.
Related patterns
- Session Lock BlurAn idle workspace softens behind a scrim while a lock card rises over it, keeping shape but not content.
- Account SwitchThe chosen avatar travels up into the header slot while the one it replaces goes back down into the list.
- Login Error RecoverA refused sign-in expands its reason under the field, wipes only the password, and hands the caret back.