Progressive Blur Fade on Scrolling List

A phone-sized music queue where the rows do not stop at a line or fade to white. They go gradually out of focus as they pass under the floating player at the bottom and the title at the top, the way system interfaces on recent phones treat scrolling content. The blur is a small reusable piece you can lay over any scroll area. The list scrolls itself gently until the visitor takes over. On phones the explanation sits above the device frame.

Code

// Adapted from Magic UI (https://magicui.design/docs/components/progressive-blur)
// Copyright (c) Magic UI. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";

const queue = [
  { title: "Paper Lanterns", artist: "Ines Calloway", length: "3:42", art: "from-rose-400 to-orange-300" },
  { title: "Low Winter Sun", artist: "The Harbour Lights", length: "4:18", art: "from-sky-400 to-indigo-500" },
  { title: "Marigold", artist: "Tobias Wren", length: "2:57", art: "from-amber-300 to-yellow-500" },
  { title: "Night Bus Home", artist: "Okafor & Lind", length: "5:06", art: "from-violet-500 to-fuchsia-400" },
  { title: "Saltwater Hymn", artist: "Mira Solveig", length: "3:29", art: "from-teal-300 to-emerald-500" },
  { title: "Fig Tree", artist: "Ines Calloway", length: "4:01", art: "from-lime-300 to-green-500" },
  { title: "Static on the Line", artist: "Cold Kettle", length: "3:14", art: "from-slate-400 to-slate-700" },
  { title: "Small Hours", artist: "The Harbour Lights", length: "6:22", art: "from-blue-500 to-cyan-300" },
  { title: "Persimmon", artist: "Tobias Wren", length: "3:48", art: "from-orange-400 to-red-500" },
  { title: "Until the Ferry", artist: "Okafor & Lind", length: "4:35", art: "from-pink-400 to-rose-600" },
  { title: "Glasshouse", artist: "Mira Solveig", length: "2:44", art: "from-emerald-300 to-cyan-500" },
  { title: "Last Orders", artist: "Cold Kettle", length: "5:11", art: "from-purple-400 to-indigo-600" },
];

/**
 * A blur that grows from nothing to heavy across its own height.
 * Each layer blurs twice as much as the one before and is masked to its own band.
 */
function ProgressiveBlur({ position = "bottom", height = "34%", levels = [0.5, 1, 2, 4, 8, 16, 32] }: { position?: "top" | "bottom"; height?: string; levels?: number[] }) {
  const step = 100 / (levels.length + 1);
  const dir = position === "bottom" ? "to bottom" : "to top";
  return (
    <div aria-hidden="true" className={`pointer-events-none absolute inset-x-0 z-10 ${position === "bottom" ? "bottom-0" : "top-0"}`} style={{ height }}>
      {levels.map((blur, i) => {
        const last = i === levels.length - 1;
        const mask = last
          ? `linear-gradient(${dir}, transparent ${i * step}%, #000 ${(i + 1) * step}%)`
          : `linear-gradient(${dir}, transparent ${i * step}%, #000 ${(i + 1) * step}%, #000 ${(i + 2) * step}%, transparent ${(i + 3) * step}%)`;
        return <div key={blur} className="absolute inset-0" style={{ zIndex: i + 1, backdropFilter: `blur(${blur}px)`, WebkitBackdropFilter: `blur(${blur}px)`, maskImage: mask, WebkitMaskImage: mask }} />;
      })}
    </div>
  );
}

export default function ProgressiveBlurFadeOnScrollingList() {
  const scroller = useRef<HTMLDivElement>(null);
  const [playing, setPlaying] = useState(true);
  const [current, setCurrent] = useState(1);

  // Demo: the list drifts down and back by itself until the visitor scrolls it.
  useEffect(() => {
    const el = scroller.current;
    if (!el || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
    let frame = 0;
    let auto = true;
    let visible = true;
    const started = performance.now();
    const loop = (now: number) => {
      if (!auto) return;
      frame = requestAnimationFrame(loop);
      if (!visible || document.hidden) return;
      const max = el.scrollHeight - el.clientHeight;
      const t = ((now - started) % 14000) / 14000;
      el.scrollTop = (0.5 - 0.5 * Math.cos(t * Math.PI * 2)) * max;
    };
    const takeOver = () => {
      auto = false;
      cancelAnimationFrame(frame);
    };
    const io = new IntersectionObserver(([entry]) => { visible = entry.isIntersecting; });
    io.observe(el);
    frame = requestAnimationFrame(loop);
    const events = ["wheel", "touchstart", "pointerdown", "keydown"] as const;
    events.forEach((name) => el.addEventListener(name, takeOver, { passive: true }));
    return () => {
      takeOver();
      io.disconnect();
      events.forEach((name) => el.removeEventListener(name, takeOver));
    };
  }, []);

  const track = queue[current];

  return (
    <section className="bg-gradient-to-b from-orange-50 to-rose-100 px-4 py-14 sm:px-6 sm:py-20">
      <div className="mx-auto grid max-w-5xl items-center gap-12 lg:grid-cols-2">
        <div>
          <p className="text-sm font-semibold uppercase tracking-widest text-rose-600">Progressive blur</p>
          <h2 className="mt-3 text-4xl font-semibold tracking-tight text-slate-900 sm:text-5xl">Lists that melt away at the edge</h2>
          <p className="mt-4 max-w-md text-lg leading-relaxed text-slate-700">Rows soften a little at first and then fully as they slide under the player, so the edge of the list never ends in a hard line or a flat white fade.</p>
          <ul className="mt-7 space-y-3 text-slate-700">
            {["Blur builds up in seven steps, from half a pixel to thirty-two", "Works over photos, colour and text alike", "Top, bottom or both edges"].map((line) => (
              <li key={line} className="flex items-start gap-3">
                <span className="mt-2 h-1.5 w-1.5 flex-none rounded-full bg-rose-500" aria-hidden="true" />
                {line}
              </li>
            ))}
          </ul>
        </div>

        <div className="relative mx-auto h-[560px] w-full max-w-sm overflow-hidden rounded-[36px] bg-white shadow-2xl shadow-rose-900/20 ring-1 ring-slate-900/10">
          <div className="absolute inset-x-0 top-0 z-20 flex items-center justify-between px-6 pb-3 pt-6">
            <h3 className="text-xl font-semibold tracking-tight text-slate-900">Up next</h3>
            <span className="rounded-full bg-slate-900/5 px-3 py-1 text-xs font-medium text-slate-700">{queue.length} tracks</span>
          </div>
          <ProgressiveBlur position="top" height="16%" levels={[1, 2, 4, 8, 16]} />

          <div ref={scroller} tabIndex={0} role="region" aria-label="Queue of tracks" className="h-full overflow-y-auto overscroll-contain px-3 pb-36 pt-20 focus:outline-none [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
            <ul>
              {queue.map((t, i) => (
                <li key={t.title}>
                  <button
                    type="button"
                    onClick={() => { setCurrent(i); setPlaying(true); }}
                    aria-current={i === current ? "true" : undefined}
                    className={`flex w-full items-center gap-3.5 rounded-2xl p-3 text-left transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-rose-500 ${i === current ? "bg-rose-50" : "hover:bg-slate-50"}`}
                  >
                    <span className={`h-12 w-12 flex-none rounded-xl bg-gradient-to-br ${t.art}`} aria-hidden="true" />
                    <span className="min-w-0 flex-1">
                      <span className={`block truncate text-[15px] font-semibold ${i === current ? "text-rose-700" : "text-slate-900"}`}>{t.title}</span>
                      <span className="block truncate text-sm text-slate-600">{t.artist}</span>
                    </span>
                    <span className="font-mono text-xs text-slate-600">{t.length}</span>
                  </button>
                </li>
              ))}
            </ul>
          </div>

          <ProgressiveBlur position="bottom" height="38%" />

          {/* Floating player: sits on top of the blurred rows. */}
          <div className="absolute inset-x-4 bottom-4 z-20 flex items-center gap-3 rounded-3xl bg-slate-900/90 p-3 pr-4 text-white shadow-xl">
            <span className={`h-12 w-12 flex-none rounded-2xl bg-gradient-to-br ${track.art}`} aria-hidden="true" />
            <span className="min-w-0 flex-1">
              <span className="block truncate text-sm font-semibold">{track.title}</span>
              <span className="block truncate text-xs text-slate-300">{track.artist}</span>
            </span>
            <button
              type="button"
              onClick={() => setPlaying((p) => !p)}
              aria-label={playing ? "Pause" : "Play"}
              aria-pressed={playing}
              className="flex h-11 w-11 flex-none items-center justify-center rounded-full bg-white text-slate-900 transition-transform active:scale-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-900"
            >
              <svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor" aria-hidden="true">
                {playing ? <path d="M5 3h3.500v14H5zM11.500 3H15v14h-3.500z" /> : <path d="M6 3.500v13l10-6.500z" />}
              </svg>
            </button>
          </div>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/progressive-blur-fade-on-scrolling-list.json

When to use it

How to customize it

Accessibility

Questions

How is a progressive blur different from a normal blur?

A single `backdrop-filter` blurs everything behind it by the same amount, which gives a hard edge where it starts. This stacks several layers, each with a stronger blur than the last, and masks each one to its own band. The bands overlap, so the blur ramps up smoothly from nothing.

Why do the blur values double each time?

Perceived blurriness grows roughly with the logarithm of the radius. Doubling from layer to layer therefore looks like an even ramp, while equal steps would look as though the blur arrives all at once near the end.

Is it expensive to render?

Backdrop filters are among the costlier CSS effects, and this uses several over the same area. Keep the blurred region small, use it once or twice per screen and reduce the number of levels on long pages.

Which browsers support it?

`backdrop-filter` and `mask-image` work in current Chrome, Edge, Safari and Firefox. The component sets the `-webkit-` versions as well for Safari. Where it is unsupported the content simply stays sharp.

Can I tint the blurred area?

Yes. Add one more absolutely positioned layer inside the wrapper with a gradient from transparent to your colour at low opacity. That is useful when text sits directly on the blur.

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

Related: Reading Progress Bar with Section Reveals, Habit Tracker Phone Screen with Tab Bar, Three Column Testimonial Wall with Vertical Scroll.

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

More in Scroll Effects

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