Copied!
Gallery

Blur title slider

A big title and a column of tilted, blurred photos that slide one place along a curve on each change, while the title letters roll over and the background colour blends.

← Library

Element · Vanilla JS + CSSUpdated November 6, 2026

TextElementVanilla JS + CSS

Live preview needs JavaScript. The code below works without any dependencies.

Prompt for your AI assistant

Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.

Build "Blur title slider" for my site using the Enrichr Library element at https://enrichr.dev/elements/blur-title-slider.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/blur-title-slider.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.

Settings:
- titles: Bloom, Ember, Trail, Rest
- delay: 4
- duration: 1.2
- label: Enrichr Library — Nr. 02
- colour1: #126e45
- colour2: #e54c1e
- colour3: #085e3d
- colour4: #005236
- ink: #fbf8f1

Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.

The same spec as JSON, for tools that read it directly: /elements/blur-title-slider.json.

Code

Mount it on an element that contains the markup. It returns an object with update(options) and destroy().

HTML

<div class="ef-bts" tabindex="0" role="region" aria-roledescription="carousel" aria-label="Photo slider. Use the arrow keys, scroll or click to change the slide">
  <div class="ef-bts__header">
    <span class="ef-bts__mark" aria-hidden="true"><svg viewBox="0 0 50 50" aria-hidden="true" focusable="false"><path fill="currentColor" d="m34.213 25.361 c0 2.655 1.381 4.808 3.086 4.808 0.781 0 1.494 -0.452 2.038 -1.198 0.267 -0.366 0.688 -0.589 1.142 -0.589 h0.054 c0.774 0 1.401 0.627 1.401 1.401 v10.65 h-10.651 c-0.774 0 -1.401 0.627 -1.401 1.401 v0.054 c0 0.453 0.222 0.874 0.589 1.141 0.746 0.544 1.198 1.257 1.198 2.038 0 1.705 -2.152 3.086 -4.808 3.086 -2.655 0 -4.808 -1.381 -4.808 -3.086 0 -0.781 0.452 -1.494 1.198 -2.038 0.366 -0.267 0.589 -0.688 0.589 -1.142 v-0.054 c0 -0.774 -0.627 -1.401 -1.401 -1.401 h-10.65 v-10.651 c0 -0.774 -0.627 -1.401 -1.401 -1.401 h-0.054 c-0.453 0 -0.875 0.222 -1.142 0.589 -0.543 0.746 -1.257 1.198 -2.038 1.198 -1.705 0 -3.086 -2.152 -3.086 -4.808 0 -2.655 1.381 -4.808 3.086 -4.808 0.781 0 1.495 0.452 2.038 1.198 0.267 0.366 0.688 0.589 1.141 0.589 h0.054 c0.774 0 1.401 -0.627 1.401 -1.401 v-10.65 h10.65 c0.774 0 1.401 -0.627 1.401 -1.401 v-0.054 c0 -0.453 -0.222 -0.875 -0.589 -1.142 -0.746 -0.543 -1.198 -1.257 -1.198 -2.038 0 -1.705 2.152 -3.086 4.808 -3.086 2.655 0 4.808 1.381 4.808 3.086 0 0.781 -0.452 1.495 -1.198 2.038 -0.366 0.267 -0.589 0.688 -0.589 1.141 v0.054 c0 0.774 0.627 1.401 1.401 1.401 h10.65 v10.65 c0 0.774 -0.627 1.401 -1.401 1.401 h-0.053 c-0.453 0 -0.874 -0.222 -1.141 -0.589 -0.544 -0.746 -1.257 -1.198 -2.038 -1.198 -1.705 0 -3.086 2.152 -3.086 4.808 z"/></svg></span>
    <span class="ef-bts__label"></span>
  </div>
  <div class="ef-bts__body">
    <div class="ef-bts__left">
      <h2 class="ef-bts__title"></h2>
      <div class="ef-bts__footer">
        <p class="ef-bts__desc">A Library element<br>by Enrichr — motion<br>for the web</p>
        <p class="ef-bts__loc">Built in plain JavaScript<br>and curiosity.</p>
      </div>
    </div>
    <div class="ef-bts__right"><div class="ef-bts__images"></div></div>
  </div>
  <p class="ef-bts__live" aria-live="polite"></p>
  <div class="ef-bts__cursor" aria-hidden="true">+</div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-bts { --ef-bts-ink: #fbf8f1; position: relative; flex: 1 0 auto; width: 100%; height: 100%; min-height: 30rem; box-sizing: border-box; display: flex; flex-direction: column; overflow: hidden; border-radius: 1rem; background: #005236; color: var(--ef-bts-ink); font-family: "Instrument Sans", system-ui, sans-serif; -webkit-font-smoothing: antialiased; container-type: inline-size; outline: none; touch-action: pan-y; }
.ef-bts:focus-visible { box-shadow: inset 0 0 0 3px #e6ff55; }
@media (pointer: fine) { .ef-bts { cursor: none; } }
.ef-bts *, .ef-bts *::before, .ef-bts *::after { box-sizing: border-box; }
.ef-bts__header { display: flex; justify-content: space-between; align-items: center; padding: 24px 32px; flex-shrink: 0; }
.ef-bts__mark { display: flex; align-items: center; justify-content: center; width: 48px; height: 48px; border-radius: 50%; border: 1px solid color-mix(in srgb, var(--ef-bts-ink) 40%, transparent); color: color-mix(in srgb, var(--ef-bts-ink) 80%, transparent); }
.ef-bts__mark svg { width: 22px; height: 22px; display: block; }
.ef-bts__label { display: none; font-size: 11px; letter-spacing: 1.5px; color: color-mix(in srgb, var(--ef-bts-ink) 70%, transparent); text-transform: uppercase; }
.ef-bts__body { flex: 1; display: flex; flex-direction: column; padding: 0 32px 40px; min-height: 0; position: relative; }
.ef-bts__left { display: flex; flex-direction: column; flex: 1; min-height: 0; position: relative; z-index: 4; }
.ef-bts .ef-bts__title { margin: auto 0; padding: 0; font-family: "Parkinsans", "Space Grotesk", sans-serif; font-size: clamp(4rem, 15cqi, 12.5rem); font-weight: 700; color: var(--ef-bts-ink); opacity: 1; line-height: 1.15; letter-spacing: -0.02em; overflow: hidden; position: relative; white-space: nowrap; text-transform: none; }
.ef-bts__line { white-space: nowrap; }
.ef-bts__ch { display: inline-block; will-change: transform; }
.ef-bts__footer { flex-shrink: 0; margin-top: 32px; }
.ef-bts .ef-bts__desc, .ef-bts .ef-bts__loc { margin: 0; font-size: 10px; letter-spacing: 1.2px; color: var(--ef-bts-ink); opacity: 0.6; line-height: 1.6; text-transform: uppercase; max-width: none; }
.ef-bts .ef-bts__loc { margin-top: 12px; }
.ef-bts__live { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
.ef-bts__cursor { position: absolute; top: 0; left: 0; width: 80px; height: 80px; border-radius: 50%; border: 1px solid color-mix(in srgb, var(--ef-bts-ink) 30%, transparent); display: flex; align-items: center; justify-content: center; pointer-events: none; will-change: transform; z-index: 10; color: color-mix(in srgb, var(--ef-bts-ink) 60%, transparent); font-size: 24px; font-weight: 400; line-height: 1; opacity: 0; }
.ef-bts__right { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; pointer-events: none; }
.ef-bts__images { position: absolute; inset: 0; }
.ef-bts__slide { position: absolute; top: 50%; left: 50%; width: 55%; aspect-ratio: 1.4; overflow: hidden; will-change: transform, filter, opacity; contain: layout style; }
.ef-bts__slide img { display: block; width: 100%; height: 100%; object-fit: cover; filter: brightness(0.9); }
@container (min-width: 768px) {
  .ef-bts__header { padding: 40px 56px; }
  .ef-bts__label { display: flex; }
  .ef-bts__body { padding: 0 56px 56px; }
  .ef-bts__footer { margin-top: 48px; }
  .ef-bts__slide { width: 60%; }
  .ef-bts__cursor { width: 88px; height: 88px; }
}
@container (min-width: 1024px) {
  .ef-bts__body { flex-direction: row; }
  .ef-bts__left { width: 55%; flex-shrink: 0; z-index: auto; }
  .ef-bts__right { position: relative; top: auto; left: auto; width: auto; height: auto; flex: 1; min-height: 0; pointer-events: auto; }
  .ef-bts__slide { width: 68%; }
}
@media (prefers-reduced-motion: reduce) { .ef-bts__slide, .ef-bts__ch { will-change: auto; } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Blur title slider: a big title and a column of three photos. The photo in front is sharp; the ones above and below are
// tilted, scaled and blurred. On each change the photos slide one place along five fixed spots (x, y, turn, scale, blur and
// opacity per spot) over 1.2 s on a power3 in-out curve, a new photo enters from the hidden spot, the background colour
// blends over 1.2 s, and the title letters roll out and the new ones roll in, 0.04 s apart, on an expo in-out curve.
// It advances every 4 s on its own, and on the mouse wheel, a swipe, a click or the arrow keys. A round "+" ring follows
// the pointer with a 0.5 s power3 lag. Reduced motion swaps slides instantly and turns autoplay off. Autoplay stops off
// screen, in hidden tabs and while the slider has keyboard focus.
// mount(root, { titles (comma separated, one per photo), delay (autoplay seconds, 0 = off), duration (seconds), label,
//               colour1, colour2, colour3, colour4 (background per slide, repeating), ink (text colour) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var el = root.querySelector(".ef-bts");
  var titleEl = root.querySelector(".ef-bts__title");
  var imagesEl = root.querySelector(".ef-bts__images");
  var labelEl = root.querySelector(".ef-bts__label");
  var liveEl = root.querySelector(".ef-bts__live");
  var cursorEl = root.querySelector(".ef-bts__cursor");
  var PHOTOS = ["/img/library/corgi-2.jpg", "/img/library/corgi-1.jpg", "/img/library/corgi-3.jpg", "/img/library/corgi-4.jpg", "/img/library/corgi-6.jpg", "/img/library/corgi-5.jpg"];
  // The five spots a photo can sit in, from the hidden one above to the hidden one below (from the original pen).
  var SPOTS = [
    { x: -0.35, y: -0.95, rot: -30, s: 1.35, b: 16, o: 0 },
    { x: -0.18, y: -0.5, rot: -15, s: 1.15, b: 8, o: 0.55 },
    { x: 0, y: 0, rot: 0, s: 1, b: 0, o: 1 },
    { x: -0.06, y: 0.5, rot: 15, s: 0.75, b: 6, o: 0.55 },
    { x: -0.12, y: 0.95, rot: 30, s: 0.55, b: 14, o: 0 }
  ];
  var opts = {}, slides = [], current = 0, animating = false, slideEls = [], currentLine = null, bg = [0, 82, 54];
  var tweens = [], raf = 0, autoId = 0, visible = true, focused = false, destroyed = false, io = null, ro = null, offs = [];
  var lastWheel = -1e9, lastTouch = -1e9, touchX = 0, touchY = 0;
  var cur = { x: 0, y: 0, fx: 0, fy: 0, tx: 0, ty: 0, t0: 0, on: false, op: 0, opFrom: 0, opTo: 0, opT0: 0, placed: false };

  // GSAP eases, written out
  function power(n, p, kind) { return kind === "in" ? Math.pow(p, n) : kind === "out" ? 1 - Math.pow(1 - p, n) : p < 0.5 ? Math.pow(2 * p, n) / 2 : 1 - Math.pow(2 * (1 - p), n) / 2; }
  function p3io(p) { return power(4, p, "inout"); } // power3.inOut
  function p2io(p) { return power(3, p, "inout"); } // power2.inOut
  function p3o(p) { return power(4, p, "out"); }    // power3.out
  function p1o(p) { return power(2, p, "out"); }    // power1.out, GSAP's default
  function expoIn(p) { return p ? Math.pow(2, 10 * (p - 1)) : 0; }
  function expoIO(p) { return p < 0.5 ? expoIn(p * 2) / 2 : 1 - expoIn((1 - p) * 2) / 2; }
  function hex(h, d) { var m = /^#?([0-9a-f]{6})$/i.exec(h || ""); var v = parseInt(m ? m[1] : d, 16); return [v >> 16, (v >> 8) & 255, v & 255]; }
  function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }
  function mod(n) { var t = slides.length; return (n % t + t) % t; }
  function now() { return performance.now(); }

  // a tiny tween runner: each tween calls f(eased progress) from its start time until it ends
  function tween(dur, delay, ease, f, done) {
    var t = { s: now() + delay * 1000, d: Math.max(1, dur * 1000), e: ease, f: f, done: done };
    tweens.push(t); kick(); return t;
  }
  function kick() { if (!raf && !destroyed) raf = requestAnimationFrame(tick); }
  function tick() {
    raf = 0;
    var n = now(), list = tweens.slice();
    for (var i = 0; i < list.length; i++) {
      var t = list[i];
      if (n < t.s) continue;
      var p = Math.min(1, (n - t.s) / t.d);
      t.f(t.e(p));
      if (p >= 1) { tweens.splice(tweens.indexOf(t), 1); if (t.done) t.done(); }
    }
    var moving = cursorStep(n);
    if (tweens.length || moving) kick();
  }

  // pointer ring: every move starts a fresh 0.5 s power3.out glide from where the ring is (gsap.quickTo)
  function cursorStep(n) {
    if (!cur.placed) return false;
    var p = Math.min(1, (n - cur.t0) / 500), e = p3o(p);
    cur.x = cur.fx + (cur.tx - cur.fx) * e; cur.y = cur.fy + (cur.ty - cur.fy) * e;
    var q = Math.min(1, (n - cur.opT0) / 300);
    cur.op = cur.opFrom + (cur.opTo - cur.opFrom) * p1o(q);
    cursorEl.style.transform = "translate(" + cur.x.toFixed(1) + "px," + cur.y.toFixed(1) + "px) translate(-50%,-50%)";
    cursorEl.style.opacity = cur.op.toFixed(3);
    return p < 1 || q < 1;
  }
  function fadeCursor(to) { cur.opFrom = cur.op; cur.opTo = to; cur.opT0 = now(); }
  function onMove(e) {
    if (e.pointerType && e.pointerType !== "mouse") return;
    var r = el.getBoundingClientRect(), x = e.clientX - r.left, y = e.clientY - r.top;
    if (!cur.placed) { cur.x = x; cur.y = y; cur.placed = true; }
    if (!cur.on) { cur.on = true; fadeCursor(1); }
    cur.fx = cur.x; cur.fy = cur.y; cur.tx = x; cur.ty = y; cur.t0 = now();
    if (reduce) { cur.fx = cur.x = x; cur.fy = cur.y = y; }
    kick();
  }
  function onLeave() { if (cur.on) { cur.on = false; fadeCursor(0); kick(); } }

  function chars(text) {
    var line = document.createElement("div"); line.className = "ef-bts__line"; line.setAttribute("aria-hidden", "true");
    Array.prototype.forEach.call(text, function (ch) {
      var s = document.createElement("span"); s.className = "ef-bts__ch"; s.textContent = ch === " " ? " " : ch; line.appendChild(s);
    });
    return line;
  }
  function setTitle(text) {
    titleEl.textContent = ""; titleEl.style.height = "";
    currentLine = chars(text); titleEl.appendChild(currentLine);
    titleEl.setAttribute("aria-label", text);
  }
  function announce(i) { liveEl.textContent = "Slide " + (i + 1) + " of " + slides.length + ": " + slides[i].name; }

  function animateTitle(text, dir, dur, stagger) {
    var h = titleEl.offsetHeight, oldLine = currentLine, oldChars = [].slice.call(oldLine.children);
    titleEl.style.height = h + "px";
    oldLine.style.cssText = "position:absolute;top:0;left:0;width:100%";
    var newLine = chars(text); newLine.style.cssText = "position:absolute;top:0;left:0;width:100%";
    titleEl.appendChild(newLine); titleEl.setAttribute("aria-label", text);
    var newChars = [].slice.call(newLine.children);
    newChars.forEach(function (c) { c.style.transform = "translateY(" + h * dir + "px)"; });
    var count = Math.max(oldChars.length, newChars.length), left = oldChars.length + newChars.length;
    function fin() {
      if (--left > 0) return;
      oldLine.remove(); newLine.style.cssText = "";
      newChars.forEach(function (c) { c.style.transform = ""; });
      titleEl.style.height = ""; currentLine = newLine;
    }
    oldChars.forEach(function (c, i) { tween(dur, i * stagger, expoIO, function (e) { c.style.transform = "translateY(" + (-h * dir * e).toFixed(2) + "px)"; }, fin); });
    newChars.forEach(function (c, i) { tween(dur, i * stagger, expoIO, function (e) { c.style.transform = "translateY(" + (h * dir * (1 - e)).toFixed(2) + "px)"; }, fin); });
    if (!left) fin();
    return dur + Math.max(0, count - 1) * stagger;
  }

  function props(step) {
    var h = imagesEl.offsetHeight, a = Math.abs(step), p = SPOTS[Math.max(0, Math.min(4, step + 2))];
    return { x: p.x * h, y: p.y * h, rot: p.rot, s: p.s, b: p.b, o: p.o, z: a === 0 ? 3 : a === 1 ? 2 : 1 };
  }
  function paint(s) {
    var q = s.p;
    s.el.style.transform = "translate(-50%,-50%) translate(" + q.x.toFixed(2) + "px," + q.y.toFixed(2) + "px) rotate(" + q.rot.toFixed(3) + "deg) scale(" + q.s.toFixed(4) + ")";
    s.el.style.filter = "blur(" + q.b.toFixed(2) + "px)";
    s.el.style.opacity = q.o.toFixed(3);
  }
  function makeSlide(idx, step) {
    var div = document.createElement("div"); div.className = "ef-bts__slide";
    var img = document.createElement("img"); img.src = slides[idx].image; img.alt = ""; img.width = 600; img.height = 420; img.decoding = "async";
    div.appendChild(img); imagesEl.appendChild(div);
    var s = { el: div, step: step, p: props(step) };
    div.style.zIndex = s.p.z;
    div.setAttribute("aria-hidden", "true");
    paint(s); return s;
  }
  function buildCarousel() {
    imagesEl.textContent = ""; slideEls = [];
    if (!imagesEl.offsetHeight) return;
    for (var step = -1; step <= 1; step++) slideEls.push(makeSlide(mod(current + step), step));
  }
  function animateCarousel(dir, dur) {
    if (!imagesEl.offsetHeight) return 0;
    var shift = dir === 1 ? -1 : 1, enter = dir === 1 ? 2 : -2;
    slideEls.push(makeSlide(mod(current + 2 * dir), enter));
    var left = slideEls.length;
    slideEls.forEach(function (s) {
      s.step += shift;
      var from = s.p, to = props(s.step);
      s.el.style.zIndex = to.z;
      tween(dur, 0, p3io, function (e) {
        s.p = { x: from.x + (to.x - from.x) * e, y: from.y + (to.y - from.y) * e, rot: from.rot + (to.rot - from.rot) * e, s: from.s + (to.s - from.s) * e, b: from.b + (to.b - from.b) * e, o: from.o + (to.o - from.o) * e };
        paint(s);
      }, function () {
        if (--left > 0) return;
        slideEls = slideEls.filter(function (q) { if (Math.abs(q.step) >= 2) { q.el.remove(); return false; } return true; });
      });
    });
    return dur;
  }
  function setBg(c) { bg = c; el.style.backgroundColor = "rgb(" + c.map(Math.round).join(",") + ")"; }

  function go(dir) {
    if (animating || slides.length < 2) return;
    animating = true;
    startAuto();
    var next = mod(current + dir), dur = reduce ? 0.01 : opts.duration, stagger = reduce ? 0 : 0.04;
    var from = bg.slice(), to = hex(slides[next].color, "005236");
    tween(dur, 0, p2io, function (e) { setBg([from[0] + (to[0] - from[0]) * e, from[1] + (to[1] - from[1]) * e, from[2] + (to[2] - from[2]) * e]); });
    var tTitle = animateTitle(slides[next].name, dir, reduce ? 0.01 : opts.duration / 1.2, stagger);
    var tCar = animateCarousel(dir, dur);
    var total = Math.max(dur, tTitle, tCar);
    tween(total, 0, function (p) { return p; }, function () {}, function () { current = next; animating = false; });
    announce(next);
  }
  function startAuto() {
    stopAuto();
    if (reduce || !opts.delay || !visible || focused || document.hidden) return;
    autoId = setInterval(function () { if (!animating) go(1); }, opts.delay * 1000);
  }
  function stopAuto() { if (autoId) { clearInterval(autoId); autoId = 0; } }

  function on(t, type, f, opt) { t.addEventListener(type, f, opt); offs.push(function () { t.removeEventListener(type, f, opt); }); }
  on(el, "wheel", function (e) {
    var n = now(); if (n - lastWheel < 1800) return; lastWheel = n;
    if (animating) return;
    go(e.deltaY > 0 ? 1 : -1);
  }, { passive: true });
  on(el, "touchstart", function (e) { touchX = e.touches[0].clientX; touchY = e.touches[0].clientY; }, { passive: true });
  on(el, "touchend", function (e) {
    var dx = touchX - e.changedTouches[0].clientX, dy = touchY - e.changedTouches[0].clientY, d = Math.abs(dx) > Math.abs(dy) ? dx : dy;
    if (Math.abs(d) < 40) return;
    var n = now(); if (n - lastTouch < 1800) return; lastTouch = n;
    if (animating) return;
    go(d > 0 ? 1 : -1);
  }, { passive: true });
  on(el, "keydown", function (e) {
    var k = e.key;
    if (k === "ArrowDown" || k === "ArrowRight") { e.preventDefault(); if (!animating) go(1); }
    else if (k === "ArrowUp" || k === "ArrowLeft") { e.preventDefault(); if (!animating) go(-1); }
  });
  on(el, "click", function (e) { if (e.pointerType !== "touch" && !animating) go(1); });
  on(el, "pointermove", onMove, { passive: true });
  on(el, "pointerleave", onLeave);
  on(el, "focusin", function () { focused = true; stopAuto(); });
  on(el, "focusout", function (e) { if (!el.contains(e.relatedTarget)) { focused = false; startAuto(); } });
  on(document, "visibilitychange", function () { if (document.hidden) stopAuto(); else startAuto(); });

  function build() {
    var names = String(opts.titles).split(",").map(function (s) { return s.trim(); }).filter(Boolean).slice(0, 12);
    if (names.length < 2) names = names.concat(["Bloom", "Ember"]).slice(0, Math.max(2, names.length));
    var cols = [opts.colour1, opts.colour2, opts.colour3, opts.colour4];
    slides = names.map(function (n, i) { return { name: n, color: cols[i % 4], image: PHOTOS[i % PHOTOS.length] }; });
    current = Math.min(current, slides.length - 1);
    tweens = []; animating = false;
    setTitle(slides[current].name);
    setBg(hex(slides[current].color, "005236"));
    buildCarousel();
  }
  function update(next) {
    opts = Object.assign({ titles: "Bloom, Ember, Trail, Rest", delay: 4, duration: 1.2, label: "Enrichr Library — Nr. 02", colour1: "#126e45", colour2: "#e54c1e", colour3: "#085e3d", colour4: "#005236", ink: "#fbf8f1" }, opts, next || {});
    opts.delay = Math.max(0, num(opts.delay, 4)); opts.duration = Math.max(0.2, num(opts.duration, 1.2));
    el.style.setProperty("--ef-bts-ink", opts.ink);
    labelEl.textContent = opts.label;
    var key = [opts.titles, opts.colour1, opts.colour2, opts.colour3, opts.colour4].join("|");
    if (key !== update.key) { update.key = key; build(); }
    startAuto();
  }

  update(o);
  slides.forEach(function (s) { var i = new Image(); i.src = s.image; });
  if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; if (visible) startAuto(); else stopAuto(); }); io.observe(el); }
  if ("ResizeObserver" in window) {
    ro = new ResizeObserver(function () {
      if (destroyed || animating) return;
      if (!slideEls.length) { buildCarousel(); return; }
      slideEls.forEach(function (s) { s.p = props(s.step); paint(s); });
    });
    ro.observe(imagesEl);
  }
  return {
    update: update,
    destroy: function () {
      destroyed = true; stopAuto(); cancelAnimationFrame(raf); tweens = [];
      offs.forEach(function (f) { f(); }); offs = [];
      if (io) io.disconnect(); if (ro) ro.disconnect();
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"titles":"Bloom, Ember, Trail, Rest","delay":4,"duration":1.2,"label":"Enrichr Library — Nr. 02","colour1":"#126e45","colour2":"#e54c1e","colour3":"#085e3d","colour4":"#005236","ink":"#fbf8f1"});

How it works

Three photos are on stage at once, each in one of five fixed spots measured from the height of the photo area: hidden above, tilted and blurred above, sharp in the middle, small and blurred below, hidden below. Every spot sets x, y, turn, scale, blur and opacity. On a change a new photo enters at the hidden spot on the side it comes from, every photo moves one spot along over 1.2 seconds on a power3 in-out curve, and the photo that falls into a hidden spot is removed. At the same moment the background colour blends to the next slide's colour on a power2 in-out curve, and the title letters roll: the old ones slide up and out, the new ones slide up from below, each 0.04 seconds after the one before, over 1 second on an expo in-out curve. A ring with a plus follows the pointer; each move starts a new 0.5 second power3 glide from where it is, as gsap.quickTo does. Wheel, swipe and the arrow keys change slides (wheel and swipe at most once every 1.8 seconds), a click goes forward, and it advances on its own every 4 seconds.

Accessibility and performance

The slider is a focusable region with a label that explains the arrow keys; a hidden live region announces each new slide by number and title. The title is one aria-label while its letter spans are hidden, and the photos are decorative. Autoplay stops while the slider has keyboard focus, off screen and in hidden tabs. With prefers-reduced-motion slides change instantly and autoplay is off.

Moving a photo is a transform, an opacity and a blur filter on one element; at most four photos exist at a time. The blur is the expensive part, so only three photos carry it and only during a change or at rest in their spot. Nothing runs between changes except the pointer ring.

SectionGallery
TechniquesCarousel, Text animation, Stagger, Pointer interaction
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
On CodePenOpen the live pen on our CodePen account; fork it to experiment.
Inspired byAnimated Slider by Enrichr on CodePen. Original code, written from scratch.

Find this technique on a real site

Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.

Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns

Licence and provenance

Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.

This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.

Distributed letters hoverA big headline whose letters scatter evenly across the stage on hover or button press, then spring back into the word. Flight path heroA photo hero with a gradient headline that drops in letter by letter, a plane flying a soft contrail, and a menu button that wipes a full-screen menu down while the page swings away. Hover lift typeBig uppercase type that rises into place letter by letter, then lifts and changes colour under the pointer, word by word or letter by letter.

FAQ

Does the mouse wheel stop the page from scrolling?

No. The wheel listener is passive, so the page still scrolls; a wheel turn over the slider also moves to the next or previous slide, at most once every 1.8 seconds, as in the original. Put it in a full-screen section if you want the wheel to belong to the slider alone.

How many slides can it have?

As many titles as you type, up to twelve. Photos and background colours repeat in order when there are more titles than photos or colours.

How do I use my own photos?

Change the PHOTOS list at the top of the script. Landscape photos suit the 1.4 to 1 frame best. Photos: Alvan Nee and fatty corgi on Unsplash.

Why are long titles cut off?

The title is very large, 15% of the element's width, and stays on one line so the letter roll works. Short words of four to six letters fit best.