All components

Kiln Seven — Gradual Blur Edges

GSAP animation component · Published 2026-08-04 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A progressive blur band that softens the edge of a scrolling page instead of cutting it. Rather than one blurred layer behind a gradient mask — which still shows the line where blurring starts — the band is a stack of N backdrop-filter layers, each with its own blur radius and its own linear-gradient mask window, and the windows deliberately overlap so each layer fades out where the next has already faded in. A falloff curve distributes the radii (linear, smoothstep or exponential), the same builder serves top, bottom, left and right edges, and one scrubbed ScrollTrigger breathes the fixed bottom band from 3rem to 11rem as the index runs.

How it's built

Categoryscroll
Techgsap, lenis
GSAP pluginsScrollTrigger
Complexitypage
Performance costlight
Mobile-safeyes
Scrollhijacks scrolling

backdrop-filter progressive-blur css-mask layered-blur sticky-edge scrolltrigger scrub lenis editorial ceramics

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

Gradual Blur Edges (Stacked Masked backdrop-filter Bands)

Goal

Build a page whose edges dissolve instead of being cut. A progressive blur band is pinned to the bottom of the window and a long index scrolls under it, so the list never "ends" — it fades out of focus. A second band sits under a fixed header that rests on a full-bleed photograph, keeping the navigation legible without drawing a bar. A third sits on the right end of a horizontal rail, so the crop reads as *there is more* rather than as a bug. Each band is a stack of N backdrop-filter layers whose linear-gradient mask windows deliberately overlap, with a falloff curve distributing the radii. The only scroll-driven motion is the bottom band's height, animated through a single CSS custom property by two scrubbed ScrollTriggers.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) with the ScrollTrigger plugin, plus lenis for smooth scroll:

import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";

Everything runs inside a DOMContentLoaded listener. Register the plugin, then wire Lenis the standard way:

const lenis = new Lenis({ smoothWheel: !reduced });
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((t) => lenis.raf(t * 1000));
gsap.ticker.lagSmoothing(0);

The blur mechanic itself uses no library at all — it is DOM elements with inline mask-image and backdrop-filter. GSAP is only there for the band's height.

The mechanic, and the mistake almost everyone makes first

The obvious build is one layer with backdrop-filter: blur(12px) and mask-image: linear-gradient(to bottom, transparent, black). It does not work. The mask fades that layer's *opacity*, but every pixel inside it still carries the same 12px radius — so the top of the band is a 12px blur at 5% opacity, and the eye finds that boundary instantly. You get a visible line where blurring starts, sitting inside what was meant to be a gradient.

A gradual blur is N layers. Layer i gets its own radius (small at the content end, large at the edge) *and* its own narrow mask window that slides along the band as i grows. The windows overlap, and that is the whole trick: layer i is transparent until step i-1, opaque from step i to i+1, and gone by step i+2 — exactly where layer i+1 has already reached full opacity. Every layer hands off inside the next one's opaque region, so no layer's own edge is ever the outermost thing on screen.

layer 1  ▁▇▇▁___________     blur 1.1px
layer 2  ___▁▇▇▁________     blur 1.8px
layer 3  ______▁▇▇▁_____     blur 2.9px
layer 4  _________▁▇▇▁__     blur 4.2px
layer 5  ____________▁▇▇     blur 5.6px

Drop the two trailing stops from each mask and you get banding — N visible stripes. That is the single most common way this effect ships broken.

Four to eight layers. Below four the handoff is coarse enough to see; above eight you pay for compositor work nobody can perceive. Six suits a tall band.

Layout / HTML

<body>
  <header class="chrome">
    <a class="mark" href="#">Kiln&nbsp;Seven</a>
    <nav class="chrome-nav">…three links…</nav>
  </header>

  <!-- Page level, and BELOW the header: a band placed inside the header paints over its own
       navigation and erases it. See "Stacking is a parameter" below. -->
  <div class="edge edge--top" data-edge="top" data-layers="5" data-strength="2.4"
       data-curve="ease-out" data-target="page" data-z="45" data-height="7rem"></div>

  <section class="hero">
    <img class="hero-img" src="(raked grit, full bleed)" alt="…" />
    <div class="hero-copy">
      <p class="eyebrow">Stoneware · reduction fired · Cone 10</p>
      <h1>Every glaze we<br />have ever pulled<br />out of the kiln.</h1>
      <p class="lede">…two lines…</p>
    </div>
  </section>

  <section class="book" id="book">
    <div class="book-head">
      <h2>Glaze book</h2>
      <div class="dial" role="group" aria-label="Falloff curve">
        <span class="dial-label">Falloff</span>
        <button class="dial-btn" data-curve="linear">Linear</button>
        <button class="dial-btn is-on" data-curve="bezier">Bezier</button>
        <button class="dial-btn" data-curve="exponential">Exponential</button>
      </div>
    </div>
    <ol class="index">
      <li class="entry">
        <img src="…" alt="" />
        <span class="code">KS-014</span>
        <span class="name">Bone ash, thin</span>
        <span class="cone">Cone 10 R</span>
        <span class="date">March 2009</span>
      </li>
      …twelve rows…
    </ol>
  </section>

  <section class="shelf" id="shelf">
    <h2>On the shelf this month</h2>
    <div class="rail-wrap">
      <div class="rail">…five figures with figcaption…</div>
      <div class="edge edge--right" data-edge="right" data-layers="6"
           data-strength="3" data-curve="bezier" data-z="30"></div>
    </div>
  </section>

  <section class="visit" id="visit">…heading, one paragraph, one CTA…</section>

  <!-- data-height is a custom property ON PURPOSE. The script writes the thickness inline, and an
       inline height beats any stylesheet rule — so if the markup gave a plain "8rem" here, the
       scrubbed tween on --edge-height would still be running and simply never be seen. Handing the
       band `var(--edge-height)` keeps the inline style resolving through the property the tween
       drives. Both page-level bands must also declare data-target="page" or the documented default
       (`parent`) makes them absolute and they detach from the window. -->
  <div class="edge edge--bottom" id="bottomEdge" data-edge="bottom" data-layers="6"
       data-strength="3.2" data-curve="bezier" data-target="page" data-z="40"
       data-height="var(--edge-height)"></div>
</body>

The bands are empty containers; the script fills them. Read every knob off data- attributes so the markup stays the single source of truth and the values can be retuned without touching JS.

Styling

.edge { position: absolute; pointer-events: none; isolation: isolate; z-index: 30; }
.edge > i { position: absolute; inset: 0; display: block; }

.edge--top    { position: fixed; top: 0; left: 0; right: 0; height: 7rem; }
.edge--bottom { position: fixed; bottom: 0; left: 0; right: 0;
                height: var(--edge-height); }
.edge--right  { top: 0; bottom: 0; right: 0; width: 9rem; }

/* The chrome outranks the top band, so its own type stays sharp on the blurred photograph. */
.chrome { z-index: 50; }
.mark,
.chrome-nav { position: relative; z-index: 2; }

@supports not (backdrop-filter: blur(1px)) {
  .edge > i { background: var(--ground); opacity: 0.42; }   /* the PAGE ground, never black */
}

--edge-height: 8rem lives on :root so one scrubbed tween can drive it. The per-band z-index comes from data-z, applied by the builder — see below for why that has to be a knob.

Palette. Fired-clay near-black #14100d, poured-slip off-white #f0ebe2, one celadon signal #8fb3a3 for codes, the eyebrow and the CTA. Nothing else carries colour.

Type. Anybody for display at font-stretch: 88% (76% for the wordmark) — a variable width axis reads as workshop stencilling, not as a brand face. Sometype Mono everywhere else, because a glaze book is a technical document.

Placing the band (do this in JS, not in CSS)

function placeBand(el, edge, { height, width, target, z } = {}) {
  const mode = target === "page" ? "page" : "parent";   // `parent` is the default (see the knobs table)
  const vertical = edge === "top" || edge === "bottom";
  el.style.position = mode === "parent" ? "absolute" : "fixed";
  if (mode === "parent") el.style.overflow = "hidden";
  // Number.isFinite, never `||`: z-index 0 is legal and useful — it is how a band goes BEHIND the
  // content it softens — and `z || 1000` throws it away.
  const layer = Number.isFinite(Number(z)) && z !== "" && z != null ? Number(z) : 1000;
  el.style.zIndex = String(layer + (mode === "page" ? 100 : 0));

  el.style.top = el.style.right = el.style.bottom = el.style.left = "";
  if (vertical) {
    el.style.left = "0"; el.style.right = "0"; el.style[edge] = "0";
    if (height) el.style.height = height;
    if (width) el.style.width = width;
  } else {
    el.style.top = "0"; el.style.bottom = "0"; el.style[edge] = "0";
    el.style.height = "100%";
    const thickness = width || height;      // for a horizontal band, `height` IS the thickness
    if (thickness) el.style.width = thickness;
  }
}

Once JS owns the box, responsive stops being optional. A stylesheet media query can no longer win against an inline width, so a @media rule on the band is dead code that *looks* alive. Either answer the breakpoint in JS — responsive with the original's three thresholds and a 100 ms debounce on resize, because a drag fires it dozens of times and each pass rebuilds every layer — or do not write the box inline at all. Leaving both is the worst of the three: measured on the demo's rail band at 390px, the CSS asked for 5rem and the inline gave 9rem, 37% of the viewport.

Every knob has a default and the defaults have to be usable. A band declared with nothing but edge must come out 6rem thick, anchored to its parent, at z-index 1000. And guard the numbers with Number.isFinite, not ||layers: 0 (draw nothing) and strength: 0 (present but not blurring) are both legal values that || silently replaces with the default.

Do not split this between CSS and JS. If the stylesheet owns the box and the script owns the layers, a direction can exist in the code and be invisible on screen — left in the direction map with no .edge--left rule builds five perfectly correct layers inside a 0×0 container. Deriving the box from edge in one place means the four directions cannot drift apart. Note the axis swap: for left/right, height means *thickness*, and width overrides it.

target: "parent" also clips the band to itself with overflow: hidden, so a band anchored inside a section cannot spill past it.

The blur builder (the important part — be exhaustive)

const CURVES = {
  linear:        (p) => p,
  bezier:        (p) => p * p * (3 - 2 * p),      // smoothstep
  "ease-in":     (p) => p * p,
  "ease-out":    (p) => 1 - (1 - p) ** 2,
  "ease-in-out": (p) => (p < 0.5 ? 2 * p * p : 1 - (-2 * p + 2) ** 2 / 2),
};
const DIRECTION = { top: "to top", bottom: "to bottom", left: "to left", right: "to right" };

function buildGradualBlur(el, { layers = 5, strength = 2, curve = "linear",
                                edge = "bottom", exponential = false, opacity = 1 } = {}) {
  const f = CURVES[curve] ?? CURVES.linear;
  const step = 100 / layers;
  // Reuse the existing <i> when the count has not changed. A blanket replaceChildren() is the
  // reason a radius "transition" never fires: a brand-new element has no previous value to
  // interpolate from, so every rebuild is a jump. Only rebuild when the layer count changes.
  const reuse = animated && el.children.length === layers;
  if (!reuse) el.replaceChildren();

  for (let i = 1; i <= layers; i++) {
    // The curve ALWAYS runs first. It decides where on the ramp this layer sits; the ramp decides
    // how steeply the radius grows. Two independent knobs — five curves x two ramps = ten
    // combinations. Folding them into one ("exponential" as a sixth curve) silently throws the
    // curve away on the exponential branch and costs you four of the ten.
    const p = f(i / layers);

    // 0.0625rem = 1px at the default root size, so the band survives a larger base font.
    const radius = exponential
      ? 2 ** (p * 4) * 0.0625 * strength
      : 0.0625 * (p * layers + 1) * strength;

    const a = +(step * (i - 1)).toFixed(1);
    const b = +(step * i).toFixed(1);
    const c = +(step * (i + 1)).toFixed(1);
    const d = +(step * (i + 2)).toFixed(1);

    let stops = `transparent ${a}%, #000 ${b}%`;
    if (c <= 100) stops += `, #000 ${c}%`;   // the overlap — omit and you get stripes
    if (d <= 100) stops += `, transparent ${d}%`;

    const mask = `linear-gradient(${DIRECTION[edge]}, ${stops})`;
    const layer = document.createElement("i");
    layer.style.cssText =
      `-webkit-mask-image:${mask};mask-image:${mask};` +
      `-webkit-backdrop-filter:blur(${radius.toFixed(3)}rem);` +
      `backdrop-filter:blur(${radius.toFixed(3)}rem);`;
    el.append(layer);
  }
}

The falloff curve distributes the radii across the band; it is not the same knob as strength.

| curve | reads as | |---|---| | linear | mechanical — the very first step is already visibly soft | | bezier | natural: first steps stay nearly sharp, the change happens in the middle | | ease-in | holds sharp a long time, then goes fast | | ease-out | softens immediately — right under a header where the top row must be legible now | | exponential | for tall bands that have to swallow a lot of detail |

Direction. The mask always runs *towards the edge the band is pinned to*, so it is opaque at the page side and transparent at the window side. One builder serves all four; the band's size and position come from CSS, and the builder only fills it.

Stacking is a parameter, not a detail. Expose the band's z-index (data-z) and read it in the builder. The documented default is 1000 (+100 when target: "page") so that a band dropped into an unknown page lands on top of things; the demo deliberately works on a much lower scale — 45, 40, 30 against a chrome at 50 — because here the page IS known and the bands have to sit *under* the navigation. Both are correct; what matters is that the number is chosen against the host page's own scale, not inherited.

And mind the +100. A band with target: "page" is placed at z + 100, so data-z="45" lands at 145, not 45. Anything that must stay sharp above it has to clear the resolved number, not the one written in the markup — the demo's header sits at 150 for exactly this reason. Getting this wrong is subtle and expensive: the band is correctly lifted out of the header, and then quietly climbs back over it and blurs the navigation into a smear. It is also invisible to automated layout checks, because the band carries pointer-events: none and document.elementFromPoint therefore reports the *header* as the topmost element at that point. The only way to catch it is to look at the page. A band dropped *inside* the element whose content it is meant to protect will paint over that content: the band is positioned and its siblings usually are not, so it wins the paint order and blurs the very type it exists to keep legible. Either give it a lower z-index than the protected content, or — better for a header — lift it out to page level as a position: fixed band sitting *below* the chrome's stacking context, and give the chrome's own children position: relative; z-index: 2.

GSAP effect

Two scrubbed ScrollTriggers on .book, both driving the same custom property, both skipped entirely under prefers-reduced-motion (the band stays at its CSS default):

gsap.to(document.documentElement, {
  "--edge-height": "11rem", ease: "none",
  scrollTrigger: { trigger: book, start: "top 80%", end: "top 20%", scrub: 0.4 },
});
gsap.to(document.documentElement, {
  "--edge-height": "3rem", ease: "none",
  scrollTrigger: { trigger: book, start: "bottom 90%", end: "bottom 40%", scrub: 0.4 },
});

ease: "none" because the scrub owns the timing — an ease here fights it. Scrub, not toggle: a band that snaps between two heights is more distracting than no band at all.

The dial buttons rebuild the bottom stack. Note what they actually switch: Linear and Bezier set the *curve* and put the ramp back to linear; Exponential switches the *ramp* and leaves the curve alone — because those are two knobs, not three values of one. Rebuilding is cheap (the layers are six empty <i> elements) and it is also the honest demo: the curve is a build-time decision, not a transition.

Behavior notes

  • isolation: isolate on the container. backdrop-filter samples the backdrop of its stacking context. Without explicit isolation, an unrelated element elsewhere on the page in a blend mode can land inside that sample and tint the band on one section only.
  • pointer-events: none. A fixed band across the bottom of the window otherwise eats every click in that strip, and the bug reads as "the last row of my list is broken".
  • It is invisible on flat colour. The band only reads over texture or type. Put it where something detailed passes under it.
  • Ship the @supports fallback, and put it on the LAYERS. @supports not (backdrop-filter: blur(1px)) { .edge > i { background: var(--ground); opacity: 0.42 } }. On the container it collapses to one flat wash; on the layers each one keeps its mask window, so the same overlapping ladder still builds a gradient. Use the page's ground colour, not black — a black tint on a dark page is invisible, and legibility was the point.
  • Fade the band in with el.animate(), not with a CSS transition. A transition needs the browser to compute a painted frame at opacity: 0 before it sees the 1. Mounting happens inside DOMContentLoaded, before first paint, so both writes land in the same style change — measured 1 ms apart, with no transitionrun ever firing and the band simply appearing. void el.offsetHeight does not save it. The Animations API has no such dependency, and its finished promise is a cleaner onAnimationComplete than a transitionend that may never come. The *layers'* radius transition is a normal CSS transition and does work, because by then the element has been painted — but it only interpolates if the rebuild reuses the existing nodes: new nodes have nothing to transition from.
  • Animate the band's height, never the radii. Height is a cheap layout change on a fixed element. Re-rasterising N backdrop filters every frame is not, and it shows up on mid-range phones first.
  • Set document.documentElement.dataset.movimientoListo = "1" once the layers are built and the triggers are wired.

Assets / images

Fourteen photographs, all quiet still life of clay, cloth, plaster and dried grasses. They are the substrate the blur acts on, so they need texture and mid-tones — a page of high-contrast graphics would fight the band instead of feeding it. The hero is raked grit filling the frame, chosen precisely because its high-frequency detail is what makes the top band legible.

Images

| file | what it is | where | |---|---|---| | kiln-floor.jpg | Raked ridges of warm sand-coloured grit, low raking light | Full-bleed hero | | throwing.jpg | Two hands centring a wet clay bowl on a wheel | Last card of the shelf rail | | glaze-01…08.jpg | Vessels, plates, a clay block, a dried fern, hands with a raw bowl | 56×44 thumbnails in the index | | shelf-01…04.jpg | Concrete blocks, folded cloth, a plant on a bare wall, backlit grasses | Cards in the shelf rail |

Using this outside its demo page

buildGradualBlur has no dependency on the rest of the page. The full knob set, all readable from data- attributes:

| knob | what it does | |---|---| | layers | how many stacked layers (4–8) | | strength | multiplier on every radius | | curve | where each layer sits on the ramp — the five functions above | | exponential | which ramp the curve feeds. Orthogonal to curve | | edge | top · bottom · left · right | | height / width | band thickness, default 6rem; for horizontal bands height is the thickness and width overrides | | target | parent (default: absolute, clipped to itself) or page (fixed) | | z | stacking order, default 1000, +100 when target is page — see above for why this must be a knob | | opacity | per-layer opacity | | animated + duration + easing | the band fades in and its layers transition their radius instead of snapping. Two things, not one | | hoverIntensity | band becomes interactive (pointer-events: auto) and rebuilds at strength × intensity under the pointer. It must propagate the band's own animated, not force it — otherwise a band that never asked for animation gets a transition it does not declare | | responsive + mobileHeight / tabletHeight / desktopHeight | thickness per breakpoint (≤480 / ≤768 / ≤1024), recomputed on resize with a 100 ms debounce | | preset | a named bundle. A preset only fills knobs the markup did not set |

The nine bundles, in full — naming them without their values leaves the most reusable part of the component to be guessed:

const PRESETS = {
  top:    { edge: "top",    height: "6rem" },
  bottom: { edge: "bottom", height: "6rem" },
  left:   { edge: "left",   height: "6rem" },
  right:  { edge: "right",  height: "6rem" },
  subtle:  { height: "4rem",  strength: 1, opacity: 0.8, layers: 3 },
  intense: { height: "10rem", strength: 4, layers: 8, exponential: true },
  smooth:  { height: "8rem",  curve: "bezier", layers: 10 },
  sharp:   { height: "5rem",  curve: "linear", layers: 4 },
  header:  { edge: "top",    height: "8rem", curve: "ease-out" },
  footer:  { edge: "bottom", height: "8rem", curve: "ease-out" },
  sidebar: { edge: "left",   height: "6rem", strength: 2.5 },
  "page-header": { edge: "top",    height: "10rem", target: "page", strength: 3 },
  "page-footer": { edge: "bottom", height: "10rem", target: "page", strength: 3 },
};

animated has a third value, "scroll": the band mounts at opacity 0 and fades in when an IntersectionObserver (threshold 0.1) says it has entered the viewport. In that mode the *layers* do not transition their radius — the fade is the whole animation. For a header band use curve: "ease-out" and 4–5 layers; for a page-bottom band use curve: "bezier" and 6; for a tall hero band use exponential and 8. If you drop the GSAP height animation the component still works — the mechanic is entirely CSS, and the scroll wiring is a refinement, not a requirement.