Guest Mode Limit
A guest hits the limit and the gate glides up over a soft fade, offering a way in.
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 · Guest Mode Limit
*
* The moment a guest runs out of free reading. The content stays where
* it was and stays visible — a soft fade, not a blackout — while a panel
* rises from the bottom edge with a way forward. A limit that slams
* shut reads as a punishment; this one reads as an offer.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Neutrals mix from the inherited text color; the panel above the fade
* uses the CSS system colors so it stays opaque and correctly toned in
* both a light and a dark app.
* Works with zero props; tune via `variant`, `headline`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type GuestModeLimitProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Heading on the gate. */
headline?: string;
/** Line under the heading. */
detail?: string;
/** Primary button color. */
accent?: string;
/** Fires once the gate has settled. */
onGate?: () => void;
};
type VariantConfig = {
/** Seconds of free browsing before the gate arrives. */
lead: number;
/** How long the fade over the content takes. */
veil: number;
/** Deliberately unhurried: the gate glides rather than snaps. */
panel: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: the gate is asking for something, so it never snaps and
// never bounces — every spring sits above a 0.8 damping ratio and the
// stiffness is low enough that the panel glides to a stop. Variants
// change how long the guest browses first, and how far the glide is.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Quick and quiet. For a soft paywall the user has already seen once.
subtle: {
lead: 0.35,
veil: 0.3,
panel: { type: "spring", stiffness: 260, damping: 30 },
},
// The all-purpose setting: unhurried, clearly a boundary, not a slam.
default: {
lead: 0.6,
veil: 0.42,
panel: { type: "spring", stiffness: 180, damping: 26 },
},
// The longest glide, for a first encounter with the limit.
playful: {
lead: 0.85,
veil: 0.55,
panel: { type: "spring", stiffness: 130, damping: 23 },
},
};
/** Theme-adaptive neutral: `currentColor` is the inherited text color, so
* mixing it with `transparent` yields a surface, border or fill that is
* correctly toned on a light page and on a dark one. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const DOCUMENTS = [
{ initials: "QR", title: "Q3 revenue summary", meta: "Shared by Priya · 4 min ago" },
{ initials: "BR", title: "Brand refresh brief", meta: "Shared by Luca · Yesterday" },
{ initials: "HP", title: "Hiring plan 2027", meta: "Shared by Amara · Monday" },
{ initials: "SN", title: "Support notes", meta: "Shared by Tomas · Monday" },
];
export default function GuestModeLimit({
variant = "default",
headline = "You have reached the guest limit",
detail = "Sign in to keep reading all 128 documents shared with this workspace.",
accent = "#5B5BD6",
onGate,
}: GuestModeLimitProps) {
const [gated, setGated] = useState(false);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
useEffect(() => {
const timer = setTimeout(() => setGated(true), cfg.lead * 1000);
return () => clearTimeout(timer);
}, [cfg.lead]);
return (
<div
style={{
position: "relative",
width: 320,
height: 300,
borderRadius: 16,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
overflow: "hidden",
}}
>
<div style={{ padding: "16px 16px 0" }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 10,
}}
>
<div style={{ fontSize: 13.5, fontWeight: 650 }}>Shared with Northwind</div>
<span
style={{
padding: "4px 9px",
borderRadius: 999,
background: tone(9),
border: `1px solid ${tone(10)}`,
fontSize: 10.5,
fontWeight: 650,
letterSpacing: 0.3,
opacity: 0.7,
}}
>
GUEST
</span>
</div>
<div style={{ marginTop: 12 }}>
{DOCUMENTS.map((doc) => (
<div
key={doc.title}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "9px 0",
borderBottom: `1px solid ${tone(8)}`,
}}
>
<span
aria-hidden
style={{
display: "grid",
placeItems: "center",
width: 28,
height: 28,
flexShrink: 0,
borderRadius: 8,
background: tone(10),
fontSize: 10,
fontWeight: 700,
}}
>
{doc.initials}
</span>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 12.5, fontWeight: 600 }}>{doc.title}</div>
<div style={{ fontSize: 11, opacity: 0.5, marginTop: 2 }}>{doc.meta}</div>
</div>
</div>
))}
</div>
</div>
{/* The fade is what keeps this from being a wall: the list stays
visible and simply recedes toward the page colour, so the guest
can still see what they are being offered. */}
<AnimatePresence>
{gated && (
<motion.div
key="veil"
aria-hidden
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduceMotion ? 0.15 : cfg.veil, ease: "easeOut" }}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: 210,
background: "linear-gradient(to bottom, transparent, Canvas 62%)",
pointerEvents: "none",
}}
/>
)}
</AnimatePresence>
<AnimatePresence>
{gated && (
<motion.div
key="gate"
role="dialog"
aria-label={headline}
initial={reduceMotion ? { opacity: 0 } : { y: "100%", opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={reduceMotion ? { opacity: 0 } : { y: "100%", opacity: 0 }}
transition={
reduceMotion
? { duration: 0.18, ease: "easeOut" }
: { ...cfg.panel, opacity: { duration: 0.24, ease: "easeOut" } }
}
onAnimationComplete={() => onGate?.()}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
padding: "16px 16px 18px",
// Opaque on purpose: this panel sits over content, and a
// translucent gate would leave the text behind it legible
// through the offer. `Canvas`/`CanvasText` are the CSS
// system colors for page background and page text, so it
// lands light in a light app and dark in a dark one.
background: "Canvas",
color: "CanvasText",
borderTop: `1px solid ${tone(12)}`,
borderRadius: "16px 16px 15px 15px",
boxShadow: "0 -12px 32px rgba(0,0,0,0.14)",
}}
>
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "4px 9px",
borderRadius: 999,
background: tone(9),
fontSize: 10.5,
fontWeight: 650,
letterSpacing: 0.3,
}}
>
<svg
width="12"
height="12"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M2.6 10S5.4 5.4 10 5.4 17.4 10 17.4 10 14.6 14.6 10 14.6 2.6 10 2.6 10z" />
<circle cx="10" cy="10" r="2.1" />
</svg>
GUEST PREVIEW
</span>
<div style={{ fontSize: 14.5, fontWeight: 650, marginTop: 10 }}>{headline}</div>
<div style={{ fontSize: 12, opacity: 0.6, marginTop: 5, lineHeight: 1.5 }}>
{detail}
</div>
<div style={{ display: "flex", gap: 8, marginTop: 14 }}>
<button
type="button"
style={{
flex: 1,
padding: "10px 12px",
fontSize: 12.5,
fontWeight: 650,
fontFamily: "inherit",
borderRadius: 10,
border: "none",
background: accent,
color: "#FFFFFF",
cursor: "pointer",
}}
>
Sign in
</button>
<button
type="button"
style={{
flex: 1,
padding: "10px 12px",
fontSize: 12.5,
fontWeight: 600,
fontFamily: "inherit",
borderRadius: 10,
border: `1px solid ${tone(16)}`,
background: "transparent",
color: "inherit",
cursor: "pointer",
}}
>
Create account
</button>
</div>
<div style={{ fontSize: 11, opacity: 0.45, marginTop: 10, textAlign: "center" }}>
Guests can open three documents a day
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}About this pattern
A limit that slams shut reads as a punishment; this one reads as an offer. The content stays exactly where it was and stays visible, receding toward the page colour behind a soft gradient rather than a blackout, so the guest can still see what is on the other side. The panel glides up from the bottom edge on a low-stiffness spring that is heavily damped — it is asking for something, so it never snaps and never bounces at the end. The gate is deliberately opaque where it overlaps the list, using the CSS system colors so it lands light in a light app and dark in a dark one without a hardcoded surface.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Document page
An article fading under a panel that offers a way to continue reading.
Related patterns
- Password Reset SentThe request panel gives way to a confirmation, with the envelope settling in and its flap stroking closed.
- Permission Scope ListA consent screen reveals each requested scope in turn and keeps the approve button inert until they have all landed.
- Sign-in Success HandoffThe panel recedes as the product assembles behind it and the avatar travels into the header.