All patterns

Paywall Reveal

Article text dissolves into the page under a gradient while the upgrade offer rises beneath it.

commercepremiumelegantautomatic · finite · starter · ~0.9s
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.

327 lines · react + motion only
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Paywall Reveal
 *
 * The article keeps reading right up to the edge: the text dissolves
 * into the page under a gradient while the upgrade card rises beneath
 * it. Nothing is stamped over the value, and nothing is hidden behind a
 * hard cut — the reader can see the sentence they are being asked to
 * pay for, which is the difference between an offer and a hostage note.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * The reading surface uses the CSS system colors, so the fade resolves
 * to the host page's own background in light and dark alike.
 * Works with zero props; tune via `variant`, `planName`, `priceLabel`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type PaywallRevealProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Name of the plan being offered. */
  planName?: string;
  /** Price line shown under the offer. */
  priceLabel?: string;
  /** Fires when the reader takes the offer. */
  onSubscribe?: () => void;
  /** Fires when an existing subscriber wants to sign in instead. */
  onSignIn?: () => void;
};

type VariantConfig = {
  /** Seconds for the gradient to establish itself. */
  fade: number;
  /** How far the offer travels up, in pixels. */
  rise: number;
  spring: { type: "spring"; stiffness: number; damping: number };
  /** Seconds between benefit lines. */
  stagger: number;
};

// Quality rule: this is a moment where restraint is the product decision,
// not just a taste one. Every spring here is at or above a 0.8 damping
// ratio, so the offer arrives and stops — a card that bounces at someone
// reads as a pitch. Nothing pulses, nothing counts down, nothing loops.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Barely a movement — the fade does the talking. For metered walls the
  // reader meets often.
  subtle: {
    fade: 0.34,
    rise: 10,
    spring: { type: "spring", stiffness: 520, damping: 42 },
    stagger: 0.04,
  },
  // One soft settle as the offer lands. All-purpose.
  default: {
    fade: 0.46,
    rise: 22,
    spring: { type: "spring", stiffness: 380, damping: 34 },
    stagger: 0.055,
  },
  // A longer travel for a wall the reader hits once, at the end of a
  // long piece. Energy comes from the distance, never from a rebound.
  playful: {
    fade: 0.56,
    rise: 34,
    spring: { type: "spring", stiffness: 330, damping: 31 },
    stagger: 0.07,
  },
};

const ACCENT = "#7C7CF0";

/** Theme-adaptive neutral: `currentColor` is the text color this component
 *  inherits — near-black on a light page, near-white on a dark one — so
 *  mixing it with `transparent` yields a surface, border or fill that is
 *  correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

// The fade resolves to `Canvas`, the CSS system color for page
// background, so the last visible line dissolves into the page instead
// of hitting a band of the wrong grey. Explicit stops rather than
// `transparent → Canvas`, which some engines route through black.
const FADE = [
  "linear-gradient(180deg",
  "color-mix(in srgb, Canvas 0%, transparent) 0%",
  "color-mix(in srgb, Canvas 45%, transparent) 38%",
  "color-mix(in srgb, Canvas 86%, transparent) 72%",
  "Canvas 100%)",
].join(", ");

const BODY = [
  "Fragile orders fail in the last hundred metres, not in the warehouse. A parcel survives the sorting belt, the van and the depot, and then a courier leaves it upright on a doorstep in the rain.",
  "So the question worth asking a carrier is not what their damage rate is. It is what happens to a package after the final scan — who is holding it, what they are told to do with it, and whether anyone measures the answer.",
  "We put the same six-item order through four national carriers, twice a week, for three months. The results are less about speed than about",
];

const BENEFITS = [
  "The full carrier comparison, updated quarterly",
  "Packaging teardowns and cost-per-parcel data",
];

export default function PaywallReveal({
  variant = "default",
  planName = "Logistics Weekly",
  priceLabel = "$6 / month · cancel anytime",
  onSubscribe,
  onSignIn,
}: PaywallRevealProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  // Reduced motion: the offer still arrives after the fade, it just
  // stops travelling to get there. The reading order is unchanged.
  const rise = reduceMotion ? 0 : cfg.rise;
  const transition = reduceMotion
    ? { duration: 0.2, ease: "easeOut" as const }
    : cfg.spring;

  return (
    <div
      style={{
        width: 342,
        borderRadius: 18,
        // The reading surface is the page, not a tinted panel: the
        // gradient below has to land on exactly this color to look like
        // a fade rather than a lid.
        background: "Canvas",
        color: "CanvasText",
        border: `1px solid ${tone(12)}`,
        overflow: "hidden",
        fontFamily: "inherit",
      }}
    >
      <div style={{ position: "relative" }}>
        <div style={{ padding: "16px 18px 0" }}>
          <div
            style={{
              fontSize: 10.5,
              fontWeight: 650,
              letterSpacing: "0.07em",
              textTransform: "uppercase",
              color: ACCENT,
            }}
          >
            Field report
          </div>
          <h3
            style={{
              margin: "7px 0 9px",
              fontSize: 17,
              fontWeight: 660,
              lineHeight: 1.28,
              letterSpacing: "-0.01em",
            }}
          >
            What four carriers actually do with a fragile parcel
          </h3>
          {/* The body is laid out at full length and clipped optically by
              the gradient — never truncated in the markup. The reader can
              see the shape of what continues, and a screen reader that
              ignores the visual fade still reads a complete sentence. */}
          <div style={{ height: 150, overflow: "hidden" }}>
            {BODY.map((paragraph) => (
              <p
                key={paragraph.slice(0, 24)}
                style={{
                  margin: "0 0 9px",
                  fontSize: 12.5,
                  lineHeight: 1.62,
                  opacity: 0.78,
                }}
              >
                {paragraph}
              </p>
            ))}
          </div>
        </div>

        <motion.div
          aria-hidden
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          transition={{ duration: cfg.fade, ease: "easeOut" }}
          style={{
            position: "absolute",
            left: 0,
            right: 0,
            bottom: 0,
            height: 104,
            background: FADE,
            pointerEvents: "none",
          }}
        />
      </div>

      <motion.div
        initial={{ opacity: 0, y: rise }}
        animate={{ opacity: 1, y: 0 }}
        transition={{
          ...transition,
          delay: reduceMotion ? 0.1 : cfg.fade * 0.42,
          opacity: {
            duration: reduceMotion ? 0.2 : 0.26,
            ease: "easeOut",
            delay: reduceMotion ? 0.1 : cfg.fade * 0.42,
          },
        }}
        style={{
          position: "relative",
          padding: "2px 18px 18px",
        }}
      >
        {/* A rule, not a border: it draws from the left as the card
            settles, which reads as the offer arriving with the text
            rather than being pasted over it. A rule can scale — it has
            no glyphs to distort. */}
        <motion.div
          aria-hidden
          initial={{ scaleX: reduceMotion ? 1 : 0 }}
          animate={{ scaleX: 1 }}
          transition={{
            duration: reduceMotion ? 0 : 0.42,
            ease: "easeOut",
            delay: reduceMotion ? 0 : cfg.fade * 0.6,
          }}
          style={{
            height: 2,
            borderRadius: 2,
            background: ACCENT,
            transformOrigin: "left center",
            marginBottom: 13,
          }}
        />

        <div style={{ fontSize: 14.5, fontWeight: 660 }}>
          Keep reading with {planName}
        </div>

        <div style={{ marginTop: 10, display: "grid", gap: 7 }}>
          {BENEFITS.map((benefit, index) => (
            <motion.div
              key={benefit}
              initial={{ opacity: 0, x: reduceMotion ? 0 : -6 }}
              animate={{ opacity: 1, x: 0 }}
              transition={{
                duration: 0.28,
                ease: "easeOut",
                delay: reduceMotion
                  ? 0.12
                  : cfg.fade * 0.7 + index * cfg.stagger,
              }}
              style={{ display: "flex", alignItems: "center", gap: 8 }}
            >
              <svg
                width="13"
                height="13"
                viewBox="0 0 20 20"
                fill="none"
                stroke={ACCENT}
                strokeWidth="2.2"
                strokeLinecap="round"
                strokeLinejoin="round"
                aria-hidden
                style={{ flexShrink: 0 }}
              >
                <path d="M4 10.5 8.2 14.5 16 5.5" />
              </svg>
              <span style={{ fontSize: 12, opacity: 0.72 }}>{benefit}</span>
            </motion.div>
          ))}
        </div>

        <button
          type="button"
          onClick={onSubscribe}
          style={{
            marginTop: 14,
            width: "100%",
            padding: "10px 14px",
            fontSize: 13,
            fontWeight: 650,
            fontFamily: "inherit",
            borderRadius: 10,
            border: "none",
            background: ACCENT,
            color: "#FFFFFF",
            cursor: "pointer",
          }}
        >
          Subscribe
        </button>

        <div
          style={{
            marginTop: 9,
            display: "flex",
            alignItems: "center",
            justifyContent: "space-between",
            gap: 10,
          }}
        >
          <span style={{ fontSize: 11.5, opacity: 0.55 }}>{priceLabel}</span>
          <button
            type="button"
            onClick={onSignIn}
            style={{
              padding: 0,
              fontSize: 11.5,
              fontWeight: 600,
              fontFamily: "inherit",
              border: "none",
              background: "transparent",
              color: "inherit",
              textDecoration: "underline",
              textUnderlineOffset: 2,
              cursor: "pointer",
            }}
          >
            I already subscribe
          </button>
        </div>
      </motion.div>
    </div>
  );
}

About this pattern

The moment a metered article runs out. The body is laid out in full and clipped optically by a gradient that resolves to the page background, so the last sentence fades instead of hitting a lid — the reader can see the shape of what continues, which is what makes the offer legible rather than coercive. The offer then rises from below on a single settle, its rule drawing in from the left. Nothing pulses, nothing counts down and nothing covers the value above the ask; the restraint is the design decision, not a stylistic one.

Metered article limitPremium report gateSubscription upgrade offerMembers-only section

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

    Member-only stories fade the body into the page with the membership offer directly beneath.

Related patterns