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 "Rapid layers intro" for my site using the Enrichr Library element at https://enrichr.dev/elements/rapid-layers-intro.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/rapid-layers-intro.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:
- title: Make it move
- direction: right
- layers: 5
- colorA: #005236
- colorB: #e6ff55
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/rapid-layers-intro.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-rli">
<div class="ef-rli__stage">
<div class="ef-rli__copy">
<p class="ef-rli__eye">Introducing</p>
<h3 class="ef-rli__t"></h3>
<button type="button" class="ef-rli__btn">Replay</button>
</div>
<div class="ef-rli__layers" aria-hidden="true"></div>
</div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-rli { width: 100%; }
.ef-rli__stage { position: relative; isolation: isolate; overflow: hidden; width: 100%; min-height: 18rem; border-radius: 1rem; background: #0c1e18; color: #fbf8f1; display: flex; align-items: center; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ef-rli__copy { position: relative; z-index: 1; box-sizing: border-box; width: 100%; padding: 1.6rem clamp(1.2rem, 5vw, 3rem); display: flex; flex-direction: column; align-items: flex-start; gap: 0.9rem; }
.ef-rli__eye { margin: 0; font-size: 0.72rem; letter-spacing: 0.2em; text-transform: uppercase; opacity: 0.65; }
.ef-rli__t { margin: 0; font-size: clamp(2rem, 8vw, 4.6rem); font-weight: 800; line-height: 0.98; letter-spacing: -0.03em; max-width: 12ch; text-wrap: balance; }
.ef-rli__btn { appearance: none; margin-top: 0.4rem; background: transparent; color: inherit; border: 1.5px solid currentColor; border-radius: 999px; font: inherit; font-size: 0.82rem; font-weight: 700; letter-spacing: 0.04em; padding: 0.55rem 1.2rem; cursor: pointer; transition: background-color 200ms, color 200ms; }
.ef-rli__btn:hover { background: #fbf8f1; color: #0c1e18; }
.ef-rli__btn:focus-visible { outline: 2px solid #e6ff55; outline-offset: 3px; }
.ef-rli__layers { position: absolute; inset: 0; z-index: 2; pointer-events: none; }
.ef-rli__layer { position: absolute; inset: 0; will-change: clip-path; }JavaScript
// Enrichr Library · https://enrichr.dev
// Rapid layers intro: a stack of full-bleed colour layers wipes in one after another, then wipes back out in the same direction to reveal a headline.
// Built with clip-path and the Web Animations API. Plays when scrolled into view and again from the Replay button.
// Reduced motion shows the headline straight away and the button only does a soft fade.
// mount(root, { title, direction ("right", "left", "up" or "down"), layers (3-8), colorA, colorB })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var stage = root.querySelector(".ef-rli__stage");
var holder = root.querySelector(".ef-rli__layers");
var titleEl = root.querySelector(".ef-rli__t");
var copy = root.querySelector(".ef-rli__copy");
var eye = root.querySelector(".ef-rli__eye");
var btn = root.querySelector(".ef-rli__btn");
var opts = {}, layers = [], anims = [], io = null, played = false, timer = 0;
var FULL = "inset(0% 0% 0% 0%)";
var SHAPES = {
right: ["inset(0% 100% 0% 0%)", "inset(0% 0% 0% 100%)"],
left: ["inset(0% 0% 0% 100%)", "inset(0% 100% 0% 0%)"],
down: ["inset(0% 0% 100% 0%)", "inset(100% 0% 0% 0%)"],
up: ["inset(100% 0% 0% 0%)", "inset(0% 0% 100% 0%)"]
};
function shape() { return SHAPES[opts.direction] || SHAPES.right; }
function clearAnims() { anims.forEach(function (a) { try { a.cancel(); } catch (e) {} }); anims = []; clearTimeout(timer); }
function build() {
clearAnims();
holder.textContent = "";
layers = [];
var n = opts.layers;
for (var i = 0; i < n; i++) {
var d = document.createElement("div");
d.className = "ef-rli__layer";
var m = n > 1 ? Math.round((i / (n - 1)) * 100) : 100;
var m2 = Math.min(100, m + 14);
d.style.background = "linear-gradient(135deg, color-mix(in srgb, " + opts.colorB + " " + m + "%, " + opts.colorA + "), color-mix(in srgb, " + opts.colorB + " " + m2 + "%, " + opts.colorA + "))";
holder.appendChild(d);
layers.push(d);
}
rest();
}
function rest() {
if (reduce) { holder.style.display = "none"; copy.style.opacity = "1"; return; }
holder.style.display = "";
layers.forEach(function (l) { l.style.clipPath = played ? shape()[1] : shape()[0]; });
copy.style.opacity = played ? "1" : "0";
}
function play() {
clearAnims();
if (reduce) {
copy.style.opacity = "1";
if (copy.animate) anims.push(copy.animate([{ opacity: 0.2 }, { opacity: 1 }], { duration: 420, easing: "ease-out" }));
return;
}
played = true;
var s = shape(), n = layers.length;
var dIn = 520, stag = Math.max(40, Math.round(420 / n)), hold = 120, dOut = 520;
var inTotal = dIn + stag * (n - 1);
var outStart = inTotal + hold;
var ease = "cubic-bezier(.76,0,.2,1)";
layers.forEach(function (l, i) {
l.style.clipPath = s[0];
anims.push(l.animate([{ clipPath: s[0] }, { clipPath: FULL }], { duration: dIn, delay: i * stag, easing: ease, fill: "both" }));
anims.push(l.animate([{ clipPath: FULL }, { clipPath: s[1] }], { duration: dOut, delay: outStart + (n - 1 - i) * stag, easing: ease, fill: "forwards" }));
});
copy.style.opacity = "1";
var ch = [eye, titleEl, btn];
ch.forEach(function (el, i) {
anims.push(el.animate([{ opacity: 0, transform: "translateY(36px)" }, { opacity: 1, transform: "translateY(0)" }], { duration: 700, delay: outStart + stag * (n - 1) * 0.6 + i * 110, easing: "cubic-bezier(.2,.8,.2,1)", fill: "both" }));
});
}
function onBtn() { play(); }
function update(next) {
var prev = opts;
opts = Object.assign({ title: "Make it move", direction: "right", layers: 5, colorA: "#005236", colorB: "#e6ff55" }, opts, next || {});
opts.layers = Math.max(3, Math.min(8, Math.round(opts.layers)));
titleEl.textContent = opts.title;
var rebuild = prev.layers !== opts.layers || prev.colorA !== opts.colorA || prev.colorB !== opts.colorB;
if (rebuild) build();
if (prev.direction !== undefined && (rebuild || prev.direction !== opts.direction)) { played = false; play(); }
}
update(o);
btn.addEventListener("click", onBtn);
if ("IntersectionObserver" in window && !reduce) {
io = new IntersectionObserver(function (en) { if (en[0].isIntersecting && !played) { play(); io.disconnect(); io = null; } }, { threshold: 0.35 });
io.observe(stage);
} else if (!reduce) { play(); }
return { update: update, destroy: function () { clearAnims(); if (io) io.disconnect(); btn.removeEventListener("click", onBtn); } };
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"title":"Make it move","direction":"right","layers":5,"colorA":"#005236","colorB":"#e6ff55"});How it works
Each layer is a full-size block whose clip-path starts as an empty inset. The Web Animations API animates it to a full inset, with each layer delayed a little more than the one below, so the colours stack up. After a short hold the layers animate out to an empty inset on the far side, starting with the top layer, so the wipe keeps travelling in the same direction and reveals the headline underneath. Layer colours are mixed between the two colour params with color-mix. The text rises in as the last layers clear. The animation plays once when the element scrolls into view, and again from the Replay button or when you change the direction.
Accessibility and performance
The headline is real text and is present in the document from the start, only its opacity is animated. The layers are decorative and hidden from assistive technology, and they never receive pointer events. With reduced motion the headline is shown immediately with no wipe, and Replay only performs a soft fade. The Replay control is a native button with a visible focus ring.
Clip-path animations on a handful of full-size layers. For large screens keep the layer count at eight or fewer. Animations are cancelled when the element is destroyed or replayed.
| Section | Hero |
|---|---|
| Techniques | Reveal and intro, Stagger |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | Rapid Image Layers Animation 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
Can it run on page load instead of on scroll?
Yes. Call the replay on mount by removing the IntersectionObserver gate, or trigger it from your own load event.
Can the layers be images?
Yes. Replace the gradient backgrounds on the .ef-rli__layer elements with images or video frames. Keep them the same size as the stage.
How do I change the speed?
The durations are constants near the top of play(). Lower dIn and dOut for a snappier wipe or raise the hold value to pause on the full colour.