Copied!
Gallery

Lookbook hover wipes (4 effects)

A lookbook grid where hovering a look wipes in a colour card, with buttons to choose four effects: Sweep, Reach, Peel and Glass.

← Library

Element · Vanilla JS + CSS + SVGUpdated November 6, 2026

CursorElementVanilla JS + CSS + SVG

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 "Lookbook hover wipes (4 effects)" for my site using the Enrichr Library element at https://enrichr.dev/elements/lookbook-hover-wipes.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/lookbook-hover-wipes.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
- effect: sweep
- speed: 1
- edge: 0.6
- looks: 6

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/lookbook-hover-wipes.json.

Code

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

HTML

<div class="ef-lhw">
  <div class="ef-lhw__wrap">
    <div class="ef-lhw__bar" role="group" aria-label="Hover effect">
      <button type="button" class="ef-lhw__btn" data-fx="sweep" aria-pressed="true">Sweep</button>
      <button type="button" class="ef-lhw__btn" data-fx="reach" aria-pressed="false">Reach</button>
      <button type="button" class="ef-lhw__btn" data-fx="peel" aria-pressed="false">Peel</button>
      <button type="button" class="ef-lhw__btn" data-fx="glass" aria-pressed="false">Glass</button>
    </div>
    <ul class="ef-lhw__looks"></ul>
  </div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
/* Adapted from https://github.com/codrops/LookbookHoverEffects (MIT) — Copyright (c) 2009 - 2026 [Codrops](https://codrops.com) */
.ef-lhw { width: 100%; }
.ef-lhw__wrap { --ef-acc: #e6ff55; --ef-card: #e6ff55; --ef-card-text: #0c1e18; 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-lhw__bar { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.9rem; }
.ef-lhw__btn { appearance: none; font: inherit; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #fbf8f1; background: transparent; border: 1px solid rgba(251, 248, 241, 0.35); border-radius: 999px; padding: 0.4rem 0.85rem; cursor: pointer; transition: background 200ms, color 200ms, border-color 200ms; }
.ef-lhw__btn:hover { border-color: var(--ef-acc); }
.ef-lhw__btn[aria-pressed="true"] { background: var(--ef-acc); border-color: var(--ef-acc); color: #0c1e18; }
.ef-lhw__btn:focus-visible { outline: 2px solid var(--ef-acc); outline-offset: 2px; }
.ef-lhw__looks { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.7rem; }
.ef-lhw__look { position: relative; margin: 0; overflow: hidden; aspect-ratio: 4 / 5; border-radius: 0.55rem; container-type: inline-size; cursor: default; outline: none; isolation: isolate; background: #0c1e18; }
.ef-lhw__look:focus-visible { box-shadow: 0 0 0 2px var(--ef-acc); }
.ef-lhw__art, .ef-lhw__flap { position: absolute; inset: 0; z-index: 1; overflow: hidden; }
.ef-lhw__art svg, .ef-lhw__flap svg { position: absolute; display: block; }
.ef-lhw__flap { display: none; z-index: 3; transform-origin: 0 0; clip-path: polygon(0 0, 0 0, 0 0); filter: brightness(1.15) saturate(0.8); }
.ef-lhw__flap::after { content: ""; position: absolute; inset: 0; background: rgba(251, 248, 241, 0.5); }
.ef-lhw__card { position: absolute; inset: 0; z-index: 2; display: flex; flex-direction: column; box-sizing: border-box; padding: 9cqw; margin: 0; background: var(--ef-card); color: var(--ef-card-text); clip-path: polygon(0 0, 0 0, 0 0); }
.ef-lhw__inner { display: flex; flex-direction: column; height: 100%; transform-origin: 50% 50%; transform: scale(var(--ef-s, 1)); }
.ef-lhw__label { font-size: 0.6rem; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; opacity: 0.75; }
.ef-lhw__num { font-family: Georgia, "Times New Roman", serif; font-weight: 400; font-size: 36cqw; line-height: 0.85; margin-top: 3cqw; }
.ef-lhw__pieces { margin-top: auto; font-size: max(0.62rem, 7.4cqw); font-weight: 600; line-height: 1.3; }
.ef-lhw__rim { position: absolute; z-index: 4; pointer-events: none; border: 1.5px solid rgba(251, 248, 241, 0.75); box-shadow: inset 0 0 14px rgba(251, 248, 241, 0.4); opacity: 0; box-sizing: border-box; }
.ef-lhw__wrap[data-fx="peel"] .ef-lhw__flap { display: block; }
.ef-lhw__wrap[data-fx="peel"] .ef-lhw__art { z-index: 2; }
.ef-lhw__wrap[data-fx="peel"] .ef-lhw__card { z-index: 1; clip-path: none; }
.ef-lhw__wrap[data-fx="glass"] .ef-lhw__card { background: color-mix(in srgb, var(--ef-card) 84%, transparent); -webkit-backdrop-filter: blur(7px); backdrop-filter: blur(7px); }
.ef-lhw__look.is-on .ef-lhw__card { z-index: 5; clip-path: none; }
.ef-lhw__look.is-on .ef-lhw__rim, .ef-lhw__look.is-on .ef-lhw__flap { display: none; }
@media (prefers-reduced-motion: reduce) { .ef-lhw__btn { transition: none; } .ef-lhw__wrap .ef-lhw__inner { transform: none; } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Adapted from https://github.com/codrops/LookbookHoverEffects (MIT) — Copyright (c) 2009 - 2026 [Codrops](https://codrops.com)
// Lookbook hover wipes: a grid of "looks" (puzzle-piece art on gradients). Hover or focus a look and a colour card takes it over; buttons pick
// one of four effects. Sweep: the card wipes in from the side the pointer entered by with a frizzy edge, and leaves off the far side or back
// the way it came. Reach: the card's edge is a row of points that chase the pointer at different speeds. Peel: the art peels back from the
// nearest corner like a print, its mirrored flap showing paper. Glass: a frosted pane swells from the middle and squares off. Vanilla port,
// no GSAP, WebGL or images: all motion is a requestAnimationFrame lerp writing clip-path and transform. Reduced motion shows the card at once.
// mount(root, { background, accent (card colour), effect (sweep|reach|peel|glass), speed (0.5-2), looks (2-6), edge (0-1, jaggedness) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var wrap = root.querySelector(".ef-lhw__wrap");
  var list = root.querySelector(".ef-lhw__looks");
  var btns = [].slice.call(root.querySelectorAll(".ef-lhw__btn"));
  var opts = {}, looks = [], offs = [], raf = 0, last = 0;
  var N = 14, NS = 8;
  var PIECES = {
    p1: { vb: "1513.7 1453.9 322.3 322.3", d: "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" },
    p4: { vb: "1423.36 860.8 503.07 322.3", d: "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" },
    p8: { vb: "1513.7 166.86 322.3 503.06", d: "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" }
  };
  // bg gradient, piece key, piece colour, piece css box
  var ART = [
    ["radial-gradient(circle at 72% 22%, #c6f26b 0 13%, transparent 14%), linear-gradient(170deg, #0a6b46, #0c1e18)", "p8", "#fbf8f1", "left:6%;bottom:-8%;width:62%;transform:rotate(-8deg)"],
    ["linear-gradient(200deg, #e6ff55, #c6f26b 45%, #2f7d5b)", "p4", "#0c1e18", "left:-12%;top:30%;width:120%;transform:rotate(6deg)"],
    ["repeating-linear-gradient(115deg, #005236 0 14px, #0c1e18 14px 28px)", "p1", "#e6ff55", "left:14%;top:22%;width:72%;transform:rotate(14deg)"],
    ["conic-gradient(from 200deg at 30% 70%, #005236, #2f7d5b, #fbf8f1, #005236)", "p8", "#0c1e18", "right:-6%;top:-6%;width:55%;transform:rotate(18deg)"],
    ["linear-gradient(160deg, #fbf8f1, #c6f26b)", "p1", "#005236", "left:8%;bottom:6%;width:78%;transform:rotate(-12deg)"],
    ["radial-gradient(ellipse at 30% 30%, #2f7d5b, #0c1e18 70%)", "p4", "#e6ff55", "left:-4%;top:40%;width:108%;transform:rotate(-5deg)"]
  ];
  var TITLES = [["Piece 08", "Field edge"], ["Piece 04", "Wide cut"], ["Piece 01", "Corner set"], ["Piece 08", "Night tile"], ["Piece 01", "Soft lime"], ["Piece 04", "Deep moss"]];

  function clamp(v, a, b) { return v < a ? a : v > b ? b : v; }
  function ease(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }
  function lum(hex) {
    var m = /^#?([0-9a-f]{6})$/i.exec(hex || "");
    if (!m) return 0.2;
    var n = parseInt(m[1], 16);
    return (0.299 * (n >> 16) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)) / 255;
  }
  function corner(side, d, a) {
    d = clamp(d, 0, 1) * 100; a = clamp(a, 0, 1) * 100;
    if (side === "left") return d + "% " + a + "%";
    if (side === "right") return (100 - d) + "% " + a + "%";
    if (side === "top") return a + "% " + d + "%";
    return a + "% " + (100 - d) + "%";
  }
  function locate(L, e) {
    var r = L.el.getBoundingClientRect();
    var x = clamp((e.clientX - r.left) / Math.max(1, r.width), 0, 1);
    var y = clamp((e.clientY - r.top) / Math.max(1, r.height), 0, 1);
    var gaps = { left: x, right: 1 - x, top: y, bottom: 1 - y }, side = "left";
    for (var k in gaps) if (gaps[k] < gaps[side]) side = k;
    return { x: x, y: y, side: side };
  }
  function inward(p, side) { return side === "left" ? p.x : side === "right" ? 1 - p.x : side === "top" ? p.y : 1 - p.y; }
  function makeArt(i, cls) {
    var a = ART[i % ART.length], d = document.createElement("div");
    d.className = cls;
    d.setAttribute("aria-hidden", "true");
    d.style.background = a[0];
    var pc = PIECES[a[1]];
    d.innerHTML = '<svg viewBox="' + pc.vb + '" focusable="false" style="color:' + a[2] + ";" + a[3] + '"><path fill="currentColor" d="' + pc.d + '"/></svg>';
    return d;
  }

  function hidePoly(L) { L.card.style.clipPath = ""; }
  function drawPoly(L, lead, trail) {
    var pts = [], i;
    for (i = 0; i < lead.length; i++) pts.push(corner(L.from, lead[i], i / (lead.length - 1)));
    for (i = trail.length - 1; i >= 0; i--) pts.push(corner(L.from, trail[i], i / (trail.length - 1)));
    L.card.style.clipPath = "polygon(" + pts.join(",") + ")";
  }
  function edgeArr(L, v) {
    var out = [], s = Math.sin(Math.PI * v) * opts.edge * 0.5;
    for (var i = 0; i < N; i++) out.push(v + (L.noise[i] - 0.5) * s);
    return out;
  }
  function resetL(L) {
    L.from = null; L.lead = L.trail = L.tl = L.tt = 0; L.p = L.tp = 0; L.full = false; L.rt = 0;
    for (var i = 0; i < NS; i++) { L.pl[i] = 0; L.pt[i] = 0; }
    L.card.style.clipPath = ""; L.card.style.removeProperty("--ef-s");
    L.art.style.clipPath = ""; L.flap.style.clipPath = ""; L.flap.style.transform = "";
    L.rim.style.opacity = "0";
    L.el.classList.remove("is-on");
  }

  // ---- effects: each step() returns true while the look still needs frames ----
  function stepSweep(L, dt) {
    var k = 1 - Math.exp(-dt * 7 * opts.speed);
    L.lead += (L.tl - L.lead) * k; L.trail += (L.tt - L.trail) * k;
    var busy = Math.abs(L.tl - L.lead) > 0.002 || Math.abs(L.tt - L.trail) > 0.002;
    if (!busy) { L.lead = L.tl; L.trail = L.tt; }
    if (!L.from) return false;
    if (L.trail > 0.995 || (L.lead < 0.004 && L.tl === 0)) { resetL(L); return false; }
    if (L.lead > 0.998 && L.trail < 0.002) L.card.style.clipPath = "none";
    else drawPoly(L, edgeArr(L, L.lead), edgeArr(L, L.trail));
    return busy;
  }
  function stepReach(L, dt) {
    var busy = false, i, allT = true, allL = true;
    for (i = 0; i < NS; i++) {
      var kk = 1 - Math.exp(-dt * L.rate[i] * opts.speed);
      L.pl[i] += (L.rt - L.pl[i]) * kk; L.pt[i] += (L.tt - L.pt[i]) * kk;
      if (Math.abs(L.rt - L.pl[i]) > 0.003 || Math.abs(L.tt - L.pt[i]) > 0.003) busy = true;
      if (L.pt[i] < 0.995) allT = false;
      if (L.pl[i] > 0.004) allL = false;
    }
    if (!L.from) return false;
    if (allT || (allL && L.rt === 0 && !L.hover)) { resetL(L); return false; }
    var lead = [], trail = [];
    for (i = 0; i < NS; i++) { lead.push(L.pl[i]); trail.push(L.pt[i]); }
    drawPoly(L, lead, trail);
    return busy;
  }
  function stepToggle(L, dt, rate) {
    var k = 1 - Math.exp(-dt * rate * opts.speed);
    L.p += (L.tp - L.p) * k;
    var busy = Math.abs(L.tp - L.p) > 0.002;
    if (!busy) L.p = L.tp;
    return busy;
  }
  function clipHalf(poly, cx, cy, nx, ny, s, sign) {
    var out = [], i, n = poly.length;
    for (i = 0; i < n; i++) {
      var a = poly[i], b = poly[(i + 1) % n];
      var fa = sign * ((a[0] - cx) * nx + (a[1] - cy) * ny - s), fb = sign * ((b[0] - cx) * nx + (b[1] - cy) * ny - s);
      if (fa >= 0) out.push(a);
      if ((fa >= 0) !== (fb >= 0)) { var t = fa / (fa - fb); out.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]); }
    }
    return out;
  }
  function polyStr(p) {
    if (p.length < 3) return "polygon(0 0,0 0,0 0)";
    return "polygon(" + p.map(function (q) { return q[0].toFixed(1) + "px " + q[1].toFixed(1) + "px"; }).join(",") + ")";
  }
  function stepPeel(L, dt) {
    var busy = stepToggle(L, dt, 5);
    if (L.p < 0.002 && L.tp === 0) { resetL(L); return false; }
    var W = L.el.clientWidth, H = L.el.clientHeight;
    var cx = L.cx * W, cy = L.cy * H, dx = W - 2 * cx, dy = H - 2 * cy, len = Math.hypot(dx, dy) || 1;
    var nx = dx / len, ny = dy / len, s = ease(L.p) * len;
    var rect = [[0, 0], [W, 0], [W, H], [0, H]];
    L.art.style.clipPath = polyStr(clipHalf(rect, cx, cy, nx, ny, s, 1));
    L.flap.style.clipPath = polyStr(clipHalf(rect, cx, cy, nx, ny, s, -1));
    var kk = cx * nx + cy * ny + s;
    L.flap.style.transform = "matrix(" + [1 - 2 * nx * nx, -2 * nx * ny, -2 * nx * ny, 1 - 2 * ny * ny, 2 * kk * nx, 2 * kk * ny].map(function (v) { return v.toFixed(4); }).join(",") + ")";
    return busy;
  }
  function stepGlass(L, dt) {
    var busy = stepToggle(L, dt, 4.5);
    if (L.p < 0.002 && L.tp === 0) { resetL(L); return false; }
    var W = L.el.clientWidth, H = L.el.clientHeight, e = 1 - Math.pow(1 - L.p, 3);
    var sz = 0.2 + 0.8 * e, ix = (1 - sz) * W / 2, iy = (1 - sz) * H / 2;
    var r = (Math.min(W, H) / 2) * (1 - Math.pow(e, 1.6)) + 6;
    L.card.style.clipPath = e > 0.999 ? "none" : "inset(" + iy.toFixed(1) + "px " + ix.toFixed(1) + "px " + iy.toFixed(1) + "px " + ix.toFixed(1) + "px round " + r.toFixed(1) + "px)";
    L.card.style.setProperty("--ef-s", (1.5 - 0.5 * e).toFixed(3));
    L.rim.style.cssText = "left:" + ix.toFixed(1) + "px;right:" + ix.toFixed(1) + "px;top:" + iy.toFixed(1) + "px;bottom:" + iy.toFixed(1) + "px;border-radius:" + r.toFixed(1) + "px;opacity:" + (e > 0.97 ? 0 : clamp(L.p * 4, 0, 1)).toFixed(2);
    return busy;
  }
  function stepLook(L, dt) {
    var fx = opts.effect;
    return fx === "sweep" ? stepSweep(L, dt) : fx === "reach" ? stepReach(L, dt) : fx === "peel" ? stepPeel(L, dt) : stepGlass(L, dt);
  }
  function loop(now) {
    raf = 0;
    var dt = Math.min(0.05, (now - last) / 1000 || 0.016);
    last = now;
    var busy = false;
    for (var i = 0; i < looks.length; i++) if (stepLook(looks[i], dt)) busy = true;
    if (busy) raf = requestAnimationFrame(loop);
  }
  function wake() { if (reduce) return; if (!raf) { last = performance.now(); raf = requestAnimationFrame(loop); } }

  // ---- pointer / keyboard ----
  function enter(L, p) {
    if (reduce) { L.el.classList.add("is-on"); return; }
    var fx = opts.effect;
    L.hover = true;
    if (fx === "sweep") {
      if (!L.from) { L.from = p.side; L.lead = L.trail = 0; }
      L.tt = 0; L.tl = 1;
    } else if (fx === "reach") {
      if (!L.from) L.from = p.side;
      L.full = L.full || !!p.key;
      L.tt = 0;
      L.rt = L.full ? 1 : inward(p, L.from);
    } else if (fx === "peel") {
      if (L.tp === 0 && L.p < 0.01) { L.cx = p.x < 0.5 ? 0 : 1; L.cy = p.y < 0.5 ? 0 : 1; }
      L.tp = 1;
    } else L.tp = 1;
    wake();
  }
  function leave(L, p) {
    if (reduce) { L.el.classList.remove("is-on"); return; }
    L.hover = false;
    var fx = opts.effect;
    if (fx === "sweep" || fx === "reach") {
      if (!L.from) return;
      var far = p.key ? true : inward(p, L.from) > 0.5;
      if (fx === "sweep") { if (far) L.tt = 1; else L.tl = 0; }
      else if (far) { L.rt = 1; L.tt = 1; } else L.rt = 0;
    } else L.tp = 0;
    wake();
  }
  function move(L, p) {
    if (opts.effect !== "reach" || !L.hover || reduce) return;
    if (!L.from) L.from = p.side;
    var d = inward(p, L.from);
    if (d > 0.5) L.full = true;
    L.rt = L.full ? 1 : d;
    wake();
  }

  function clearAll() { offs.forEach(function (f) { f(); }); offs = []; if (raf) cancelAnimationFrame(raf); raf = 0; }
  function build() {
    clearAll();
    list.textContent = "";
    looks = [];
    for (var i = 0; i < opts.looks; i++) (function (i) {
      var li = document.createElement("li");
      var fig = document.createElement("figure");
      fig.className = "ef-lhw__look";
      fig.tabIndex = 0;
      fig.setAttribute("aria-label", "Look " + String(i + 1).padStart(2, "0") + ", " + TITLES[i % TITLES.length][1]);
      var art = makeArt(i, "ef-lhw__art"), flap = makeArt(i, "ef-lhw__flap");
      var card = document.createElement("figcaption");
      card.className = "ef-lhw__card";
      var t = TITLES[i % TITLES.length];
      card.innerHTML = '<span class="ef-lhw__inner"><span class="ef-lhw__label">Look</span><span class="ef-lhw__num">' + String(i + 1).padStart(2, "0") + '</span><span class="ef-lhw__pieces">' + t[0] + "<br>" + t[1] + "</span></span>";
      var rim = document.createElement("span");
      rim.className = "ef-lhw__rim";
      rim.setAttribute("aria-hidden", "true");
      fig.appendChild(art); fig.appendChild(flap); fig.appendChild(card); fig.appendChild(rim);
      li.appendChild(fig);
      list.appendChild(li);
      var L = { el: fig, art: art, flap: flap, card: card, rim: rim, from: null, lead: 0, trail: 0, tl: 0, tt: 0, p: 0, tp: 0, cx: 0, cy: 0, full: false, rt: 0, hover: false, noise: [], pl: [], pt: [], rate: [] };
      for (var k = 0; k < N; k++) L.noise.push((Math.sin((k + 1) * 12.9898 + (i + 1) * 78.233) * 43758.5453) % 1 * 0.5 + 0.5);
      // reach: each point has its own speed; neighbours are averaged so the edge breaks into a few uneven teeth
      var rnd = [];
      for (k = 0; k < NS; k++) { rnd.push(Math.abs((Math.sin((k + 3) * 4.37 + (i + 2) * 9.11) * 9371.7) % 1)); L.pl.push(0); L.pt.push(0); }
      for (k = 0; k < NS; k++) {
        var avg = ((rnd[k - 1] == null ? rnd[k] : rnd[k - 1]) + rnd[k] + (rnd[k + 1] == null ? rnd[k] : rnd[k + 1])) / 3;
        L.rate.push(3 + avg * 7);
      }
      function pe(e) { return e.pointerType !== "touch"; }
      function onEnter(e) { if (pe(e)) enter(L, locate(L, e)); }
      function onLeave(e) { if (pe(e)) leave(L, locate(L, e)); }
      function onMove(e) { if (pe(e)) move(L, locate(L, e)); }
      function onUp(e) {
        if (e.pointerType !== "touch") return;
        if (L.hover || L.el.classList.contains("is-on")) leave(L, locate(L, e)); else enter(L, locate(L, e));
      }
      function onFocus() { enter(L, { x: 0, y: 0, side: "left", key: true }); }
      function onBlur() { leave(L, { x: 1, y: 1, side: "left", key: true }); }
      fig.addEventListener("pointerenter", onEnter);
      fig.addEventListener("pointerleave", onLeave);
      fig.addEventListener("pointermove", onMove);
      fig.addEventListener("pointerup", onUp);
      fig.addEventListener("focus", onFocus);
      fig.addEventListener("blur", onBlur);
      offs.push(function () {
        fig.removeEventListener("pointerenter", onEnter); fig.removeEventListener("pointerleave", onLeave);
        fig.removeEventListener("pointermove", onMove); fig.removeEventListener("pointerup", onUp);
        fig.removeEventListener("focus", onFocus); fig.removeEventListener("blur", onBlur);
      });
      looks.push(L);
    })(i);
  }
  function setEffect(fx) {
    opts.effect = fx;
    wrap.setAttribute("data-fx", fx);
    btns.forEach(function (b) { b.setAttribute("aria-pressed", b.getAttribute("data-fx") === fx ? "true" : "false"); });
    looks.forEach(resetL);
  }
  btns.forEach(function (b) {
    function click() { setEffect(b.getAttribute("data-fx")); }
    b.addEventListener("click", click);
    offs.push(function () { b.removeEventListener("click", click); });
  });
  // build() clears offs, so keep the button listeners separate
  var btnOffs = offs; offs = [];

  function update(next) {
    var prev = opts;
    opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", effect: "sweep", speed: 1, looks: 6, edge: 0.6 }, opts, next || {});
    opts.speed = clamp(Number(opts.speed) || 1, 0.5, 2);
    opts.edge = clamp(isNaN(Number(opts.edge)) ? 0.6 : Number(opts.edge), 0, 1);
    opts.looks = clamp(Math.round(opts.looks) || 6, 2, 6);
    if (["sweep", "reach", "peel", "glass"].indexOf(opts.effect) < 0) opts.effect = "sweep";
    wrap.style.background = opts.background;
    wrap.style.setProperty("--ef-acc", opts.accent);
    wrap.style.setProperty("--ef-card", opts.accent);
    wrap.style.setProperty("--ef-card-text", lum(opts.accent) > 0.5 ? "#0c1e18" : "#fbf8f1");
    if (prev.looks !== opts.looks) build();
    if (prev.effect !== opts.effect || !prev.effect) setEffect(opts.effect);
  }
  update(o);
  return {
    update: update,
    destroy: function () {
      clearAll();
      btnOffs.forEach(function (f) { f(); });
      btnOffs = [];
      looks = [];
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","effect":"sweep","speed":1,"edge":0.6,"looks":6});

How it works

This is a vanilla port of four of the original's hover ideas, with no GSAP and no WebGL. Each look is a puzzle-piece art tile with a colour card on top, and a shared requestAnimationFrame loop eases per-look numbers toward their targets. Sweep works out which side the pointer entered by and writes a clip-path polygon whose edge is a row of points with fixed noise, leaving off the far side or back the way it came. Reach gives each edge point its own chase speed toward the pointer depth. Peel clips the art against a diagonal fold line from the nearest corner and mirrors a copy of it with a reflection matrix to make the paper flap. Glass animates an inset clip-path with a growing corner radius over a blurred, translucent card.

Accessibility and performance

Each look is focusable, and keyboard focus brings its card in the same way hover does. The card text is always in the DOM, and clip-path only hides it visually. Effect buttons are real buttons with aria-pressed. Touch uses a tap to show and another to hide. With prefers-reduced-motion the card appears at once with no wipe. Check the card text contrast if you pick a custom card colour; the text flips between ink and cream automatically.

One rAF loop runs only while a look is animating and writes clip-path and one transform. There are no images; the art is gradients and inline SVG. Glass uses backdrop-filter on the cards, which is the costliest part, so keep the grid small or switch effect if you need more tiles.

SectionGallery
TechniquesPointer interaction, Reveal and intro
StackVanilla JS, CSS, SVG
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byLookbook Hover Effects by Codrops on github.com. 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.

Clip reveal menuText rows that open a generated pattern behind the word with a clip-path transition on hover, while the word inverts along the edge. Image compare on scrollA before and after slider whose divider is scrubbed by scroll position and can also be dragged or keyboard-operated. Unroll image on scrollA poster that unrolls like a sheet of paper from a spiral roll as you scroll or hover.

FAQ

Why do the cards leave the way they do?

In Sweep and Reach, leaving by the far half of the look sends the card off the far side, and leaving by the near half pulls it back the way it came. Come back mid way and it turns round.

Can I use real photographs?

Yes. Replace the art div's background with an image in build(). The card sits on top in every effect except Peel, where the art itself is peeled back.

Does it need WebGL?

No. The original draws some of its effects in WebGL; this port reproduces the motion with clip-paths, a reflection matrix and backdrop blur, so it runs anywhere.