# 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:
```js
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:
```js
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
```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
```css
.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)
```js
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)
```js
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):
```js
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:

```js
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.
