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 "Zigzag stripe flow" for my site using the Enrichr Library element at https://enrichr.dev/elements/zigzag-stripe-flow.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/zigzag-stripe-flow.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:
- size: 200
- thickness: 50
- speed: 1
- stripes: #0c1e18
- background: #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/zigzag-stripe-flow.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-zzs"><svg class="ef-zzs__svg" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Bold zigzag stripes drifting slowly downward in a seamless loop"></svg></div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-zzs { position: relative; width: 100%; min-height: 24rem; overflow: hidden; border-radius: 1rem; background: #fbf8f1; }
.ef-zzs .ef-zzs__svg { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }JavaScript
// Enrichr Library · https://enrichr.dev
// Zigzag stripe flow: an SVG pattern of bold chevron bands that drifts straight down forever. One square tile holds a
// background and three zigzag bands (each band rises and falls 100 units across a 200 unit tile), so the tiles meet with no
// seam. The pattern's own transform moves it down by exactly one tile height per loop at a constant rate (linear, like an
// SVG animateTransform), so the end of a loop looks the same as the start. Default timing: one tile every 8 seconds.
// Reduced motion shows the stripes still. The loop pauses off screen and in hidden tabs.
// mount(root, { size (tile size, px), thickness (band thickness, out of 200), speed (1 = one tile per 8 s),
// stripes (band colour), background })
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-zzs");
var svg = root.querySelector(".ef-zzs__svg");
var uid = "ef-zzs-" + Math.random().toString(36).slice(2, 10);
var opts = {}, raf = 0, visible = true, io = null, ro = null, destroyed = false, last = 0, phase = 0;
function el(name, attrs) {
var n = document.createElementNS(NS, name);
for (var k in attrs) n.setAttribute(k, attrs[k]);
return n;
}
function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }
// one 200 x 200 tile: the background, then three bands. Band 0 sits across the top edge and band 2 across the bottom
// edge, so where one tile ends the next one carries on the same band.
svg.textContent = "";
var defs = el("defs", {});
var pat = el("pattern", { id: uid, width: 200, height: 200, patternUnits: "userSpaceOnUse" });
var bg = el("rect", { width: 200, height: 200 });
var bands = [];
pat.appendChild(bg);
for (var i = 0; i < 3; i++) { bands.push(el("polygon", {})); pat.appendChild(bands[i]); }
defs.appendChild(pat);
svg.appendChild(defs);
svg.appendChild(el("rect", { width: "100%", height: "100%", fill: "url(#" + uid + ")" }));
function shape() {
var th = opts.thickness;
for (var k = 0; k < 3; k++) {
var y = -75 + 100 * k;
bands[k].setAttribute("points", "0," + y + " 100," + (y + 100) + " 200," + y + " 200," + (y + th) + " 100," + (y + 100 + th) + " 0," + (y + th));
}
}
// the tile is drawn in 200 unit space and scaled to the chosen size; the translate moves it down by one tile per loop
function frame() {
var k = opts.size / 200;
pat.setAttribute("patternTransform", "translate(0 " + (phase * opts.size).toFixed(3) + ") scale(" + k + ")");
}
function loop(now) {
raf = requestAnimationFrame(loop);
var dt = Math.min(0.05, (now - last) / 1000 || 0); last = now;
if (!visible || document.hidden) return;
phase = (phase + dt * opts.speed / 8) % 1; // 8 s per tile at speed 1, as in the original
frame();
}
function update(partial) {
opts = Object.assign({ size: 200, thickness: 50, speed: 1, stripes: "#0c1e18", background: "#fbf8f1" }, opts, partial || {});
opts.size = Math.max(20, num(opts.size, 200));
opts.thickness = Math.min(99, Math.max(1, num(opts.thickness, 50)));
opts.speed = Math.max(0, num(opts.speed, 1));
bg.setAttribute("fill", opts.background);
for (var k = 0; k < 3; k++) bands[k].setAttribute("fill", opts.stripes);
wrap.style.background = opts.background;
if (destroyed) return;
shape();
frame();
}
update(o);
if (!reduce) {
if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(wrap); }
last = performance.now(); raf = requestAnimationFrame(loop);
}
// the pattern is in user space, so it needs no maths on resize; redraw once in case the box was hidden at mount
if ("ResizeObserver" in window) { ro = new ResizeObserver(function () { if (!destroyed) frame(); }); ro.observe(wrap); }
return {
update: update,
destroy: function () {
destroyed = true; cancelAnimationFrame(raf);
if (io) io.disconnect();
if (ro) ro.disconnect();
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"size":200,"thickness":50,"speed":1,"stripes":"#0c1e18","background":"#fbf8f1"});How it works
One SVG pattern tile, 200 units square, holds the background and three zigzag bands. Each band rises and falls 100 units across the tile and is 50 units thick. One band sits across the top edge and one across the bottom edge, so where one tile ends the next carries the same band on and no seam shows. The pattern fills the whole box. Each frame sets the pattern's own transform to a downward move of progress times the tile height, plus a scale for the tile size, with no easing. That is what the original SVG animateTransform did: 200 units in 8 seconds, on repeat. Because each loop moves exactly one tile, the end of a loop looks the same as the start.
Accessibility and performance
The SVG is decorative and has a text label. With prefers-reduced-motion the stripes are shown still. The motion is slow and steady, 25 pixels a second at the default size. High-contrast stripes in motion can bother some people, so keep the speed low behind text or soften the colours. Nothing needs the keyboard.
Each frame changes one attribute, the pattern transform, and the browser repaints the pattern from one small tile. There are no filters and no element per stripe. The loop pauses off screen or in a hidden tab.
| Section | Animated SVG backgrounds |
|---|---|
| Stack | Vanilla JS, SVG |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| On CodePen | Open the live pen on our CodePen account; fork it to experiment. |
| Inspired by | Zig Zag Stripe Animation 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.
More elements
FAQ
Can I put content on top of it?
Yes. Give .ef-zzs a height and place your content over it with position: absolute, or stack both in one grid cell. The stripes are loud, so put text on a solid panel.
Why not use SVG animateTransform like the original?
It works, but a script-driven transform can pause off screen, respect reduced motion and change speed without a jump. The motion is the same: a straight, even slide of one tile per loop.
How do I make the stripes move up instead?
In frame(), use a negative translate (minus progress times the tile size). The tile repeats both ways, so nothing else needs to change.
Can I make the zigzag steeper or flatter?
Each band climbs 100 units over 100 units across, a 45 degree chevron. Change the 100 in the band points to change the slope, and keep the tile 200 units high so it still repeats.