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 "Filter reveal slideshow" for my site using the Enrichr Library element at https://enrichr.dev/elements/filter-reveal-slideshow.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/filter-reveal-slideshow.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:
- accent: #e6ff55
- duration: 1100
- distortion: 120
- blur: 8
- autoplay: 0
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/filter-reveal-slideshow.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-frs">
<div class="ef-frs__wrap">
<svg class="ef-frs__defs" width="0" height="0" aria-hidden="true" focusable="false"></svg>
<div class="ef-frs__stage" tabindex="0" role="group" aria-roledescription="carousel" aria-label="Slideshow. Use the left and right arrow keys to change slide.">
<div class="ef-frs__slides"></div>
</div>
<div class="ef-frs__bar">
<button type="button" class="ef-frs__btn ef-frs__prev" aria-label="Previous slide">←</button>
<div class="ef-frs__dots" role="group" aria-label="Choose slide"></div>
<button type="button" class="ef-frs__btn ef-frs__next" aria-label="Next slide">→</button>
</div>
<p class="ef-frs__live" aria-live="polite"></p>
</div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-frs { width: 100%; }
.ef-frs__wrap { position: relative; box-sizing: border-box; width: 100%; padding: 1rem; border-radius: 1rem; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: #fbf8f1; }
.ef-frs__defs { position: absolute; width: 0; height: 0; pointer-events: none; }
.ef-frs__stage { position: relative; width: 100%; aspect-ratio: 16 / 9; min-height: 14rem; border-radius: 0.8rem; overflow: hidden; outline: none; background: #005236; }
.ef-frs__stage:focus-visible { box-shadow: 0 0 0 2px var(--ef-acc, #e6ff55); }
.ef-frs__slides { position: absolute; inset: 0; }
.ef-frs__slide { isolation: isolate; overflow: hidden; position: absolute; inset: 0; opacity: 0; visibility: hidden; display: flex; flex-direction: column; justify-content: flex-end; padding: 1.4rem 1.6rem; box-sizing: border-box; }
.ef-frs__slide.is-cur { opacity: 1; visibility: visible; }
.ef-frs__slide.is-in, .ef-frs__slide.is-out { visibility: visible; }
.ef-frs__slide::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, transparent 35%, rgba(0, 0, 0, 0.55)); pointer-events: none; }
.ef-frs__n { position: absolute; top: 1rem; left: 1.2rem; z-index: 1; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.16em; opacity: 0.85; }
.ef-frs__t { position: relative; z-index: 1; font-size: clamp(1.4rem, 5vw, 2.4rem); font-weight: 800; letter-spacing: -0.02em; line-height: 1.05; text-shadow: 0 2px 12px rgba(0, 0, 0, 0.35); }
.ef-frs__s { position: relative; z-index: 1; margin-top: 0.35rem; font-size: 0.8rem; letter-spacing: 0.05em; opacity: 0.85; }
.ef-frs__bar { display: flex; align-items: center; justify-content: space-between; gap: 0.8rem; margin-top: 0.8rem; }
.ef-frs__btn { width: 2.4rem; height: 2.4rem; border-radius: 50%; border: 1px solid rgba(251, 248, 241, 0.35); background: transparent; color: inherit; font-size: 1.05rem; cursor: pointer; transition: background 200ms, color 200ms, border-color 200ms; }
.ef-frs__btn:hover { background: var(--ef-acc, #e6ff55); color: #0c1e18; border-color: transparent; }
.ef-frs__btn:focus-visible, .ef-frs__dot:focus-visible { outline: 2px solid var(--ef-acc, #e6ff55); outline-offset: 3px; }
.ef-frs__dots { display: flex; gap: 0.5rem; }
.ef-frs__dot { width: 0.7rem; height: 0.7rem; padding: 0; border-radius: 50%; border: 1px solid rgba(251, 248, 241, 0.6); background: transparent; cursor: pointer; transition: background 200ms, transform 200ms; }
.ef-frs__dot[aria-current="true"] { background: var(--ef-acc, #e6ff55); border-color: var(--ef-acc, #e6ff55); transform: scale(1.25); }
.ef-frs__live { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) { .ef-frs__slide { transition: opacity 250ms; } .ef-frs__btn, .ef-frs__dot { transition: none; } }JavaScript
// Enrichr Library · https://enrichr.dev
// Filter reveal slideshow: changing slide crossfades while an SVG feTurbulence + feDisplacementMap + blur filter on the slide stack
// ramps up and back down, so the next slide melts in through a warp. Reduced motion gets a plain crossfade and no autoplay.
// mount(root, { background, accent, duration (ms), distortion (0-240), blur (0-20), autoplay (s, 0 = off) })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var NS = "http://www.w3.org/2000/svg";
var wrap = root.querySelector(".ef-frs__wrap");
var defs = root.querySelector(".ef-frs__defs");
var layer = root.querySelector(".ef-frs__slides");
var dotsEl = root.querySelector(".ef-frs__dots");
var prevB = root.querySelector(".ef-frs__prev");
var nextB = root.querySelector(".ef-frs__next");
var live = root.querySelector(".ef-frs__live");
var id = "ef-frs-" + Math.random().toString(36).slice(2, 8);
var DATA = [["Tidal", "Field notes"], ["Ember", "Studio series"], ["Moss", "Quiet objects"], ["Dusk", "Night shift"]];
var opts = {}, slides = [], dots = [], offs = [], cur = 0, raf = 0, auto = 0, hold = false, anim = false, builtAccent = null;
var disp, blurN;
// Enrichr puzzle-piece artwork, inlined so the code stays portable. piece(i, accent) returns a decorative <svg> string (viewBox + one path,
// currentColor fill), a different piece, size, rotation and crop per index. It sits behind the card text and never takes pointer events.
var PIECES = [["1513.7 1453.9 322.3 322.3","M1821 1761.2h-292.3v-111.6c0-8 6.5-14.5 14.5-14.5 4.2 0 8.2 1.9 11 5.1 7.8 8.6 19.7 13.6 32.7 11.9 16.8-2.3 30.1-16 32-32.7 2.5-22.6-15.1-41.6-37.1-41.6-11 0-20.9 4.7-27.7 12.3-2.8 3.1-6.7 4.9-10.9 4.9-8 0-14.5-6.4-14.5-14.4v-111.7h292.3v111.7c0 8-6.4 14.4-14.5 14.4-4.2 0-8.1-1.8-10.9-5-7.9-8.6-19.7-13.6-32.7-11.9-16.8 2.2-30.1 15.9-32 32.7-2.5 22.6 15.1 41.6 37.1 41.6 11 0 20.8-4.8 27.7-12.3 2.8-3.1 6.7-5 10.8-5 8.1 0 14.5 6.5 14.5 14.5z"],["1423.36 860.8 503.07 322.3","M1821 875.8v111.6c0 8 6.5 14.5 14.5 14.5 4.2 0 8.1-1.9 10.9-5 6.8-7.5 16.7-12.3 27.7-12.3 22 0 39.6 19 37.1 41.6-1.9 16.7-15.2 30.5-32 32.7-13 1.7-24.8-3.3-32.7-11.9-2.8-3.2-6.8-5-11-5-8 0-14.5 6.4-14.5 14.4v111.7h-111.6c-8 0-14.5-6.5-14.5-14.5 0-4.2 1.9-8.2 5-11 8.7-7.9 13.7-19.7 12-32.7-2.3-16.8-16-30.1-32.8-32-22.5-2.5-41.5 15.1-41.5 37.1 0 11 4.7 20.9 12.3 27.7 3.1 2.8 4.9 6.7 4.9 10.8v0.1c0 8-6.4 14.5-14.5 14.5h-111.6v-111.7c0-8-6.4-14.4-14.5-14.4-4.1 0-8 1.8-10.8 4.9-6.9 7.6-16.7 12.3-27.7 12.3-22 0-39.6-19-37.1-41.6 1.9-16.7 15.2-30.4 32-32.7 13-1.7 24.8 3.3 32.7 11.9 2.8 3.2 6.7 5.1 10.9 5.1 8.1 0 14.5-6.5 14.5-14.5v-111.6z"],["1513.7 166.86 322.3 503.06","M1528.7 564.5v-111.6c0-8 6.5-14.5 14.5-14.5 4.2 0 8.2 1.9 11 5 7.8 8.7 19.7 13.7 32.7 11.9 16.8-2.2 30.1-15.9 32-32.7 2.5-22.5-15.1-41.5-37.1-41.5-11 0-20.9 4.7-27.7 12.3-2.8 3.1-6.7 4.9-10.9 4.9-8 0-14.5-6.5-14.5-14.5v-111.6h111.6c8.1 0 14.5-6.5 14.5-14.5 0-4.1-1.8-8.1-4.9-10.9-7.6-6.8-12.3-16.6-12.3-27.6 0-22 19-39.6 41.5-37.1 16.8 1.8 30.5 15.2 32.8 32 1.7 12.9-3.3 24.8-12 32.7-3.1 2.8-5 6.7-5 10.9 0 8 6.5 14.5 14.5 14.5h111.6v111.6c0 8-6.4 14.5-14.5 14.5-4.2 0-8.1-1.9-10.9-5-7.9-8.7-19.7-13.6-32.7-11.9-16.8 2.2-30.1 15.9-32 32.7-2.5 22.5 15.1 41.6 37.1 41.6 11 0 20.8-4.8 27.7-12.3 2.8-3.1 6.7-5 10.8-5 8.1 0 14.5 6.5 14.5 14.5v111.6h-111.6c-8 0-14.5 6.5-14.5 14.5 0 4.2 1.9 8.1 5 10.9 7.5 6.8 12.3 16.7 12.3 27.7 0 22-19 39.5-41.6 37.1-16.8-1.9-30.5-15.3-32.7-32-1.7-13 3.3-24.9 11.9-32.7 3.1-2.9 5-6.8 5-11 0-8-6.4-14.5-14.5-14.5z"]];
var PLAY = [[38, -12, 95, 16, 0, 0.92], [-30, 28, 150, -22, "#2f7d5b", 0.95], [-8, -30, 80, 32, "#c6f26b", 0.8], [30, 34, 110, -8, "#0a6b46", 0.95], [-24, -10, 135, 12, "#fbf8f1", 0.2], [46, 20, 90, -28, 0, 0.85]];
function piece(i, a) {
var p = PIECES[(i + Math.floor(i / 3)) % 3], s = PLAY[i % 6];
return '<svg viewBox="' + p[0] + '" aria-hidden="true" focusable="false" style="position:absolute;z-index:-1;pointer-events:none;height:auto;left:' + s[0] + '%;top:' + s[1] + '%;width:' + s[2] + '%;transform:rotate(' + s[3] + 'deg);color:' + (s[4] || a || "#e6ff55") + ';opacity:' + s[5] + '"><path fill="currentColor" d="' + p[1] + '"/></svg>';
}
function art(i, a) {
var c = "#fbf8f1", d = "#0c1e18", p = i % 4;
if (p === 0) return "radial-gradient(circle at 72% 32%, " + a + " 0 14%, transparent 15%), linear-gradient(150deg, #005236, " + d + ")";
if (p === 1) return "conic-gradient(from 200deg at 30% 70%, #005236, " + a + ", " + c + ", #005236)";
if (p === 2) return "repeating-linear-gradient(120deg, #005236 0 18px, " + d + " 18px 36px)";
return "radial-gradient(circle at 28% 40%, " + a + " 0 16%, transparent 17%), radial-gradient(circle at 66% 62%, " + c + " 0 14%, transparent 15%), linear-gradient(200deg, #0a6b46, " + d + ")";
}
function buildFilter() {
defs.textContent = "";
var f = document.createElementNS(NS, "filter");
f.setAttribute("id", id); f.setAttribute("x", "-15%"); f.setAttribute("y", "-15%"); f.setAttribute("width", "130%"); f.setAttribute("height", "130%");
f.setAttribute("color-interpolation-filters", "sRGB");
var t = document.createElementNS(NS, "feTurbulence");
t.setAttribute("type", "fractalNoise"); t.setAttribute("baseFrequency", "0.008 0.022"); t.setAttribute("numOctaves", "2"); t.setAttribute("seed", "7"); t.setAttribute("result", "n");
disp = document.createElementNS(NS, "feDisplacementMap");
disp.setAttribute("in", "SourceGraphic"); disp.setAttribute("in2", "n"); disp.setAttribute("scale", "0"); disp.setAttribute("xChannelSelector", "R"); disp.setAttribute("yChannelSelector", "G"); disp.setAttribute("result", "d");
blurN = document.createElementNS(NS, "feGaussianBlur");
blurN.setAttribute("in", "d"); blurN.setAttribute("stdDeviation", "0");
f.appendChild(t); f.appendChild(disp); f.appendChild(blurN);
defs.appendChild(f);
}
function clear() {
offs.forEach(function (f) { f(); }); offs = [];
if (raf) cancelAnimationFrame(raf);
raf = 0; anim = false;
clearTimeout(auto); auto = 0;
}
function setFx(k) {
disp.setAttribute("scale", (opts.distortion * k).toFixed(1));
blurN.setAttribute("stdDeviation", (opts.blur * k).toFixed(2));
}
function mark() {
slides.forEach(function (s, i) { s.classList.toggle("is-cur", i === cur); });
dots.forEach(function (d, i) { if (i === cur) d.setAttribute("aria-current", "true"); else d.removeAttribute("aria-current"); });
live.textContent = "Slide " + (cur + 1) + " of " + slides.length + ": " + DATA[cur % DATA.length][0];
}
function finish() {
if (raf) cancelAnimationFrame(raf);
raf = 0; anim = false;
layer.style.filter = "";
slides.forEach(function (s) { s.classList.remove("is-in", "is-out"); s.style.opacity = ""; });
if (disp) setFx(0);
mark();
}
function schedule() {
clearTimeout(auto); auto = 0;
if (reduce || !opts.autoplay || hold) return;
auto = setTimeout(function () { go(cur + 1); }, opts.autoplay * 1000);
}
function go(n) {
var len = slides.length;
n = ((n % len) + len) % len;
if (anim) finish();
if (n === cur) { schedule(); return; }
var from = cur;
cur = n;
if (reduce) { mark(); schedule(); return; }
var a = slides[from], b = slides[n];
a.classList.add("is-out"); b.classList.add("is-in");
a.style.opacity = "1"; b.style.opacity = "0";
layer.style.filter = "url(#" + id + ")";
dots.forEach(function (d, i) { if (i === cur) d.setAttribute("aria-current", "true"); else d.removeAttribute("aria-current"); });
live.textContent = "Slide " + (cur + 1) + " of " + len + ": " + DATA[cur % DATA.length][0];
var t0 = performance.now(), dur = opts.duration;
anim = true;
function frame(now) {
var t = Math.max(0, Math.min(1, (now - t0) / dur));
var e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
b.style.opacity = e.toFixed(3);
a.style.opacity = (1 - e).toFixed(3);
setFx(Math.sin(Math.PI * t));
if (t < 1) raf = requestAnimationFrame(frame);
else { raf = 0; finish(); schedule(); }
}
raf = requestAnimationFrame(frame);
}
function build() {
clear();
layer.textContent = ""; dotsEl.textContent = "";
slides = []; dots = [];
buildFilter();
DATA.forEach(function (d, i) {
var s = document.createElement("div");
s.className = "ef-frs__slide"; s.style.background = art(i, opts.accent); s.insertAdjacentHTML("afterbegin", piece(i, opts.accent));
s.setAttribute("role", "group"); s.setAttribute("aria-roledescription", "slide"); s.setAttribute("aria-label", (i + 1) + " of " + DATA.length);
var n = document.createElement("span"); n.className = "ef-frs__n"; n.textContent = String(i + 1).padStart(2, "0");
var t = document.createElement("span"); t.className = "ef-frs__t"; t.textContent = d[0];
var sub = document.createElement("span"); sub.className = "ef-frs__s"; sub.textContent = d[1];
s.appendChild(n); s.appendChild(t); s.appendChild(sub);
layer.appendChild(s); slides.push(s);
var b = document.createElement("button");
b.type = "button"; b.className = "ef-frs__dot"; b.setAttribute("aria-label", "Go to slide " + (i + 1));
var h = function () { go(i); };
b.addEventListener("click", h);
offs.push(function () { b.removeEventListener("click", h); });
dotsEl.appendChild(b); dots.push(b);
});
if (cur >= slides.length) cur = 0;
mark();
var pv = function () { go(cur - 1); }, nx = function () { go(cur + 1); };
prevB.addEventListener("click", pv); nextB.addEventListener("click", nx);
var kd = function (e) {
if (e.key === "ArrowRight") { e.preventDefault(); go(cur + 1); }
else if (e.key === "ArrowLeft") { e.preventDefault(); go(cur - 1); }
else if (e.key === "Home") { e.preventDefault(); go(0); }
else if (e.key === "End") { e.preventDefault(); go(slides.length - 1); }
};
root.addEventListener("keydown", kd);
var pause = function () { hold = true; clearTimeout(auto); auto = 0; };
var resume = function () { hold = false; if (!anim) schedule(); };
wrap.addEventListener("pointerenter", pause); wrap.addEventListener("pointerleave", resume);
wrap.addEventListener("focusin", pause); wrap.addEventListener("focusout", resume);
offs.push(function () {
prevB.removeEventListener("click", pv); nextB.removeEventListener("click", nx); root.removeEventListener("keydown", kd);
wrap.removeEventListener("pointerenter", pause); wrap.removeEventListener("pointerleave", resume);
wrap.removeEventListener("focusin", pause); wrap.removeEventListener("focusout", resume);
});
builtAccent = opts.accent;
schedule();
}
function update(next) {
opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", duration: 1100, distortion: 120, blur: 8, autoplay: 0 }, opts, next || {});
opts.duration = Math.max(200, Math.min(4000, +opts.duration));
opts.distortion = Math.max(0, Math.min(240, +opts.distortion));
opts.blur = Math.max(0, Math.min(20, +opts.blur));
opts.autoplay = Math.max(0, Math.min(15, +opts.autoplay));
wrap.style.background = opts.background;
wrap.style.setProperty("--ef-acc", opts.accent);
if (builtAccent !== opts.accent) build(); else schedule();
}
update(o);
return { update: update, destroy: function () { clear(); layer.style.filter = ""; } };
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","duration":1100,"distortion":120,"blur":8,"autoplay":0});How it works
The slides are stacked absolutely inside one layer. When the slide changes, the outgoing slide is kept visible, the incoming one starts at zero opacity, and an SVG filter is applied to the whole layer: feTurbulence makes a noise map, feDisplacementMap uses it to push pixels sideways, and feGaussianBlur softens them. A requestAnimationFrame loop eases the crossfade and drives the displacement scale and blur with a sine curve, so both start and end at zero and peak in the middle of the move. When the animation ends the filter is removed so resting slides are crisp.
Accessibility and performance
Prev and next are real buttons, the dots are buttons with aria-current, and the stage takes focus so the left, right, Home and End keys work. A visually hidden live region announces the current slide. Reduced motion swaps the warp for a short crossfade and turns autoplay off. Autoplay also pauses on hover and focus.
SVG filters are repainted on the CPU each frame, so they only run during the transition and are removed afterwards. Keep the stage a sensible size and avoid very high distortion or blur values on low-powered devices.
| Section | Gallery |
|---|---|
| Techniques | Carousel, Reveal and intro |
| Stack | Vanilla JS, CSS, SVG filter |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | Slideshow with Filter Reveal Effect by Manoela Ilic on Codrops. 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.
More elements
FAQ
Why apply the filter to the layer instead of each slide?
One filter on the layer warps the outgoing and incoming slides together, which looks like a single liquid move and costs one filter pass instead of two.
Can I use photos instead of gradients?
Yes. Set an image as the background of each slide. The filter works on whatever is rendered, so no other change is needed.
Why does the filter id contain random characters?
Each mount makes its own filter id so several slideshows on one page do not share or overwrite each other's filter.