Cover Cards with Progressive Blur Caption Reveal

Three cover cards for a weekly music newsletter. Each shows its artwork with the title at the bottom edge. Pointing at a card, or tabbing into it, fades in a blur that is clear at the top and gets stronger toward the bottom, and the title lifts to make room for the mood line, track count and a play button. The blur is a ramp made of eight masked layers, so there is no hard line where it starts. The reveal steps along the row on its own until a visitor takes over. On phones the cards stack and become wider than they are tall.

Code

// Adapted from Motion Primitives (https://motion-primitives.com/docs/progressive-blur)
// Copyright (c) ibelick. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import { motion, useReducedMotion } from "framer-motion";

const mixes = [
  {
    id: "tide",
    title: "Low Tide Radio",
    by: "Mixed by Ines Okafor",
    length: "58 min",
    tracks: 14,
    mood: "Slow house for late drives",
    colours: ["#fb7185", "#f97316", "#fde047", "#881337"],
  },
  {
    id: "glass",
    title: "Glasshouse",
    by: "Mixed by Tomas Reyes",
    length: "1 h 12",
    tracks: 19,
    mood: "Ambient and broken beat",
    colours: ["#22d3ee", "#6366f1", "#e0f2fe", "#1e1b4b"],
  },
  {
    id: "moss",
    title: "Moss and Static",
    by: "Mixed by Aiko Brandt",
    length: "44 min",
    tracks: 11,
    mood: "Field recordings, tape hiss",
    colours: ["#a3e635", "#10b981", "#fef9c3", "#064e3b"],
  },
];
type Mix = (typeof mixes)[number];

/**
 * A blur that gets stronger toward one edge. Several layers each blur what is behind
 * them a little more, and each is masked to its own band so the bands blend into a ramp.
 */
function ProgressiveBlur({ layers = 8, intensity = 1.1, className = "" }: { layers?: number; intensity?: number; className?: string }) {
  const count = Math.max(layers, 2);
  const segment = 1 / (count + 1);
  return (
    <div aria-hidden="true" className={`pointer-events-none ${className}`}>
      {Array.from({ length: count }).map((_, index) => {
        const stops = [index, index + 1, index + 2, index + 3]
          .map((step, at) => `rgba(255,255,255,${at === 1 || at === 2 ? 1 : 0}) ${step * segment * 100}%`)
          .join(", ");
        const mask = `linear-gradient(180deg, ${stops})`;
        return (
          <div
            key={index}
            className="absolute inset-0"
            style={{
              maskImage: mask,
              WebkitMaskImage: mask,
              backdropFilter: `blur(${index * intensity}px)`,
              WebkitBackdropFilter: `blur(${index * intensity}px)`,
            }}
          />
        );
      })}
    </div>
  );
}

function Cover({ mix }: { mix: Mix }) {
  const [a, b, c, d] = mix.colours;
  return (
    <svg viewBox="0 0 300 380" preserveAspectRatio="xMidYMid slice" aria-hidden="true" className="absolute inset-0 h-full w-full">
      <rect width="300" height="380" fill={d} />
      <circle cx="150" cy="150" r="170" fill={b} />
      <circle cx="150" cy="150" r="128" fill={a} />
      <circle cx="150" cy="150" r="88" fill={c} />
      <circle cx="150" cy="150" r="48" fill={d} />
      <circle cx="150" cy="150" r="12" fill={c} />
      {Array.from({ length: 13 }).map((_, index) => (
        <rect key={index} x={index * 24 - 4} y={236 + (index % 3) * 10} width="12" height="160" fill={index % 2 ? a : c} opacity="0.9" />
      ))}
      {Array.from({ length: 9 }).map((_, index) => (
        <circle key={index} cx={20 + index * 33} cy={30 + ((index * 37) % 50)} r="5" fill={c} />
      ))}
    </svg>
  );
}

export default function CoverCardsWithProgressiveBlurCaptionReveal() {
  const reduceMotion = useReducedMotion();
  const [active, setActive] = useState(0);
  const [auto, setAuto] = useState(true);
  const [playing, setPlaying] = useState<string | null>(null);
  const resume = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);

  // While nobody is pointing at a card, the reveal walks along the row on its own.
  useEffect(() => {
    if (!auto || reduceMotion) return;
    const timer = setInterval(() => setActive((index) => (index + 1) % mixes.length), 2400);
    return () => clearInterval(timer);
  }, [auto, reduceMotion]);
  useEffect(() => () => clearTimeout(resume.current), []);

  const focusCard = (index: number) => {
    clearTimeout(resume.current);
    setAuto(false);
    setActive(index);
  };
  const leave = () => {
    clearTimeout(resume.current);
    resume.current = setTimeout(() => setAuto(true), 3000);
  };

  return (
    <section className="bg-white px-5 py-14 text-slate-900 sm:px-8 sm:py-16">
      <div className="mx-auto max-w-5xl">
        <div className="flex flex-wrap items-end justify-between gap-4">
          <div>
            <p className="text-xs font-semibold uppercase tracking-widest text-slate-600">Sunday Pressings, issue 27</p>
            <h2 className="mt-3 text-3xl font-bold tracking-tight sm:text-5xl">Three mixes for the week.</h2>
          </div>
          <p className="max-w-xs text-sm leading-6 text-slate-600">Point at a cover. The artwork melts into a soft blur from the bottom up and the details rise through it.</p>
        </div>

        <ul className="mt-9 grid gap-5 sm:grid-cols-3">
          {mixes.map((mix, index) => {
            const on = active === index;
            const isPlaying = playing === mix.id;
            return (
              <li
                key={mix.id}
                onPointerEnter={() => focusCard(index)}
                onPointerLeave={leave}
                onFocus={() => focusCard(index)}
                onBlur={leave}
                className="relative aspect-[5/4] overflow-hidden rounded-3xl bg-slate-900 shadow-lg shadow-slate-900/10 sm:aspect-[4/5]"
              >
                <motion.div className="absolute inset-0" animate={{ scale: on && !reduceMotion ? 1.06 : 1 }} transition={{ type: "spring", stiffness: 120, damping: 20 }}>
                  <Cover mix={mix} />
                </motion.div>

                {/* The blur ramp: clear at the top of this box, strongest at the bottom edge of the card. */}
                <motion.div className="absolute inset-x-0 bottom-0 h-3/4" initial={false} animate={{ opacity: on ? 1 : 0 }} transition={{ duration: 0.35, ease: "easeOut" }}>
                  <ProgressiveBlur className="absolute inset-0" />
                </motion.div>
                <div aria-hidden="true" className="pointer-events-none absolute inset-x-0 bottom-0 h-2/3 bg-gradient-to-t from-slate-950/80 via-slate-950/30 to-transparent" />

                <div className="absolute inset-x-0 bottom-0 p-5 text-white">
                  <motion.div initial={false} animate={{ y: on ? 0 : 52 }} transition={{ type: "spring", stiffness: 210, damping: 24 }}>
                    <h3 className="text-xl font-bold tracking-tight">{mix.title}</h3>
                    <p className="mt-0.5 text-sm text-slate-100">{mix.by}</p>
                    <motion.div
                      initial={false}
                      animate={{ opacity: on ? 1 : 0 }}
                      transition={{ duration: 0.25, delay: on ? 0.08 : 0 }}
                      className="mt-3 flex items-center justify-between gap-3"
                    >
                      <p className="text-xs leading-5 text-slate-100">
                        {mix.mood}
                        <span className="block font-mono text-slate-200">
                          {mix.tracks} tracks, {mix.length}
                        </span>
                      </p>
                      <button
                        type="button"
                        aria-pressed={isPlaying}
                        aria-label={`${isPlaying ? "Pause" : "Play"} ${mix.title}`}
                        onClick={() => setPlaying(isPlaying ? null : mix.id)}
                        className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-white text-slate-950 transition-transform duration-200 hover:scale-105 focus:outline-none focus-visible:ring-4 focus-visible:ring-white/60 motion-reduce:transition-none"
                      >
                        <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" className="h-4 w-4">
                          {isPlaying ? <path d="M5 4h3.5v12H5zm6.5 0H15v12h-3.5z" /> : <path d="M6 3.8v12.4a.6.6 0 0 0 .9.5l10-6.2a.6.6 0 0 0 0-1L6.9 3.3a.6.6 0 0 0-.9.5Z" />}
                        </svg>
                      </button>
                    </motion.div>
                  </motion.div>
                </div>
              </li>
            );
          })}
        </ul>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/cover-cards-with-progressive-blur-caption-reveal.json

When to use it

How to customize it

Accessibility

Questions

What is a progressive blur?

It is a backdrop blur that increases gradually across an area instead of applying one strength to a whole box. It reads as depth of field rather than a frosted panel with an edge.

How is the gradual blur made in CSS?

CSS has no blur gradient, so the component stacks several layers. Each has a slightly stronger `backdrop-filter: blur()` and a `mask-image` that shows only its own band, overlapping its neighbours. Together they blend into a ramp.

Is backdrop-filter slow?

It costs more than a plain gradient because the browser blurs what is behind each layer. Keep the blurred area small, as it is here, and avoid putting dozens on one page. On these three cards it runs smoothly.

Does it work in all browsers?

Current versions of Chrome, Edge, Safari and Firefox support `backdrop-filter` and `mask-image`. The dark gradient under the caption stays in place so the text is readable even where blur is unavailable.

Can I use the blur ramp on its own?

Yes. Copy the `ProgressiveBlur` function and place it in any relatively positioned box, for example along the bottom of a scroll area or under a sticky header.

Adapted from Progressive Blur in Motion Primitives, used under its open-source licence. Licences and credits

Related: Spotlight Cards with Pointer Light, 3D Tilt Card with Parallax Layers and Glare, Flip Cards with Details on the Back.

More animated cards or all free UI components. Start for free to have a full site built around it.

More in Animated Cards

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap