Media

Paywall Fade

A metered-article paywall that clips the preview and fades the last lines into the background beneath a subscribe CTA card.

Install

npx shadcn@latest add @paragon/paywall-fade

paywall-fade.tsx

"use client";

import * as React from "react";
import { Lock } from "lucide-react";
import { cn } from "@/lib/utils";

export interface PaywallFadeProps extends React.ComponentProps<"div"> {
  /** Height of visible article before the fade begins, in px. */
  previewHeight?: number;
  /** CTA heading. */
  title?: string;
  /** CTA supporting line. */
  description?: string;
  /** Subscribe button label. */
  cta?: string;
  /** Called when the CTA is pressed. */
  onSubscribe?: () => void;
  /** The gated article content. */
  children: React.ReactNode;
}

/**
 * A metered-article paywall. The article is clipped to a preview height and a
 * bottom-anchored gradient mask fades the last lines into the background,
 * beneath a subscribe CTA card. The fade is a static gradient (no motion), the
 * body is aria-hidden from the fade down so screen readers aren't fed
 * half-sentences, and the CTA is a real button. Beautiful in both themes since
 * the mask keys off the background token.
 */
export function PaywallFade({
  previewHeight = 220,
  title = "Continue reading",
  description = "Subscribe to unlock the full article and every story in the archive.",
  cta = "Subscribe",
  onSubscribe,
  className,
  children,
  ...props
}: PaywallFadeProps) {
  return (
    <div
      data-slot="paywall-fade"
      className={cn("relative w-full max-w-md", className)}
      {...props}
    >
      <div
        className="relative overflow-hidden"
        style={{ maxHeight: previewHeight }}
      >
        <div className="prose-sm space-y-3 text-sm leading-relaxed text-foreground/90">
          {children}
        </div>
        {/* Fade to background */}
        <div
          aria-hidden
          className="pointer-events-none absolute inset-x-0 bottom-0 h-32"
          style={{
            background:
              "linear-gradient(to bottom, transparent, var(--background))",
          }}
        />
      </div>

      {/* CTA card, overlapping the fade */}
      <div className="-mt-10 relative">
        <div className="flex flex-col items-center gap-3 rounded-xl bg-card p-6 text-center shadow-overlay">
          <span className="flex size-10 items-center justify-center rounded-full bg-accent text-foreground">
            <Lock className="size-4" />
          </span>
          <div>
            <h3 className="text-base font-semibold">{title}</h3>
            <p className="mt-1 text-sm text-muted-foreground">{description}</p>
          </div>
          <button
            type="button"
            onClick={onSubscribe}
            className="pressable inline-flex h-9 items-center justify-center rounded-lg bg-primary px-5 text-sm font-medium text-primary-foreground outline-none transition-[scale,background-color] duration-150 ease-out hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
          >
            {cta}
          </button>
          <p className="text-xs text-muted-foreground">
            Already a member?{" "}
            <span className="font-medium text-foreground underline-offset-4 hover:underline">
              Sign in
            </span>
          </p>
        </div>
      </div>
    </div>
  );
}