All patterns

Guest Mode Limit

A guest hits the limit and the gate glides up over a soft fade, offering a way in.

authenticationcalmfriendlyautomatic · finite · starter · ~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.

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

Guest browsing limitSoft paywall on shared contentRead-only preview gateSign-in prompt after free usage

Where it shows up

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

  • Ridgeline
    Docs
    Recent
    Shared
    Templates
    Trash
    DocsNew
    Q3 planning notesEdited 14 minutes agoScope
    Document page

    An article fading under a panel that offers a way to continue reading.

Related patterns