Skip to main content

TechMendly

← All components
01

Tilt Card

A card that tilts in 3D toward your pointer, with a holographic sheen that slides as you move. Click it to flip. Let go and it wobbles to rest.

3D tiltspring physicshover effect

How to use: try the demo, pick a file tab, press Copy. Put the three files in one folder and open index.html.

Live demo
<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Tilt Card</title>
  <link rel="stylesheet" href="style.css" />
</head>
<body>
  <main class="stage">
    <!-- move over it to tilt, click it to flip -->
    <div class="rig"><div class="card" tabindex="0" aria-label="Aurora pass, click to flip">
      <section class="face front">
        <div class="holo"></div>
        <div class="glare"></div>
        <header class="top"><span class="mark"></span><b>AURORA</b><small>PASS</small></header>
        <div class="chip"></div>
        <div class="who">
          <small>MEMBER</small>
          <strong>Mira Okafor</strong>
        </div>
        <footer class="meta"><span>No. 0427</span><span>Valid 12/28</span></footer>
      </section>
      <section class="face back">
        <div class="glare"></div>
        <div class="stripe"></div>
        <div class="code"></div>
        <p>Hold near the reader to enter</p>
      </section>
    </div></div>
    <p class="hint">Move over the card. <u>Click</u> to flip.</p>
  </main>
  <script src="app.js"></script>
</body>
</html>
:root { --h: 200; --mx: 50%; --my: 50%; --bx: 50%; --lift: 0; --ink: #f2f4fa; }
* { box-sizing: border-box; margin: 0; }

body {
  min-height: 100vh; display: grid; place-items: center; overflow: hidden;
  background: radial-gradient(ellipse at 50% 40%, #151a33, #070810 72%);
  color: var(--ink); font-family: Inter, system-ui, sans-serif;
}
.stage { width: 100%; min-height: 100vh; display: grid; place-items: center; align-content: center; gap: 26px; }

/* the shadow lives on a wrapper: a filter on the card itself would flatten the 3D */
.rig { perspective: 900px;
  filter: drop-shadow(0 calc(30px + var(--lift) * 24px) calc(40px + var(--lift) * 20px) #000a); }

.card { position: relative; width: 290px; height: 400px; transform-style: preserve-3d;
  will-change: transform; cursor: pointer; outline: none; }

.face { position: absolute; inset: 0; border-radius: 22px;
  backface-visibility: hidden; transform-style: preserve-3d;
  background: linear-gradient(150deg, #1c2244, #0d1022 60%, #171236);
  border: 1px solid #ffffff22; padding: 24px; }
.back { transform: rotateY(180deg); background: linear-gradient(210deg, #1a1d3a, #0c0e1d); }

/* the rainbow sheen slides with the tilt */
.holo { position: absolute; inset: 0; border-radius: inherit; mix-blend-mode: color-dodge; opacity: .55;
  background:
    linear-gradient(115deg, transparent 22%, hsl(var(--h) 100% 62% / .75) 38%,
      hsl(calc(var(--h) + 70) 100% 64% / .75) 50%, hsl(calc(var(--h) + 140) 100% 62% / .75) 62%, transparent 78%);
  background-size: 260% 260%; background-position: var(--bx) var(--bx); }
.glare { position: absolute; inset: 0; border-radius: inherit; mix-blend-mode: overlay; pointer-events: none;
  background: radial-gradient(circle at var(--mx) var(--my), #ffffffcc, #ffffff00 55%); }

/* layers float at different depths: that is the parallax */
.top, .chip, .who, .meta { position: relative; }
.top { display: flex; align-items: center; gap: 10px; transform: translateZ(34px); }
.top b { letter-spacing: .28em; font-size: 15px; }
.top small { margin-left: auto; letter-spacing: .3em; color: #9aa3c7; font-size: 10px; }
.mark { width: 18px; height: 18px; border-radius: 50%;
  background: conic-gradient(from 200deg, #35e0ff, #8b5cff, #ff4fd8, #35e0ff); box-shadow: 0 0 14px #8b5cff; }
.chip { width: 56px; height: 42px; margin-top: 44px; border-radius: 9px; transform: translateZ(60px);
  background: linear-gradient(135deg, #ffe39a, #d9a441 55%, #fff1c1); box-shadow: 0 6px 14px #0008; }
.who { margin-top: 74px; transform: translateZ(48px); display: grid; gap: 4px; }
.who small { letter-spacing: .3em; font-size: 10px; color: #9aa3c7; }
.who strong { font-size: 26px; letter-spacing: -.01em; }
.meta { position: absolute; left: 24px; right: 24px; bottom: 22px; display: flex; justify-content: space-between;
  font-size: 12px; letter-spacing: .12em; color: #c9d0ee; transform: translateZ(26px); }

.stripe { height: 56px; margin: 18px -25px 0; background: #05060d; }
.code { width: 150px; height: 150px; margin: 38px auto 0; border-radius: 12px; background-color: #f2f4fa;
  background-image: linear-gradient(90deg, #0b0d1a 50%, transparent 50%), linear-gradient(#0b0d1a 50%, transparent 50%);
  background-size: 20px 20px, 14px 14px; -webkit-mask: radial-gradient(#000 62%, #0007); mask: radial-gradient(#000 62%, #0007); }
.back p { text-align: center; margin-top: 26px; color: #9aa3c7; font-size: 13px; }

.hint { color: #7d8497; font-size: 14px; }
.hint u { color: var(--ink); text-decoration-style: dashed; }
// Tilt card: springs chase the pointer, so letting go makes the card wobble to rest.
const $ = (s) => document.querySelector(s);
const stage = $(".stage"), card = $(".card"), root = document.documentElement;

const MAX = 17;                                   // max tilt in degrees
const s = { rx: 0, ry: 0, vx: 0, vy: 0, tx: 0, ty: 0,        // tilt: position, speed, target
            fl: 0, vf: 0, tf: 0,                              // flip angle
            sc: 1, vs: 0, ts: 1,                              // scale (pops up on hover)
            mx: 50, my: 50 };                                 // glare position, in %

const chase = (x, v, to, k, d, dt) => { v += ((to - x) * k - v * d) * dt; return [x + v * dt, v]; };

function aim(e) {                                 // pointer -> targets
  const r = card.getBoundingClientRect(), cx = r.left + r.width / 2, cy = r.top + r.height / 2;
  const nx = Math.max(-1, Math.min(1, (e.clientX - cx) / (innerWidth / 2.4)));
  const ny = Math.max(-1, Math.min(1, (e.clientY - cy) / (innerHeight / 2.4)));
  s.ty = nx * MAX; s.tx = -ny * MAX; s.ts = 1.05;
  s.mx = Math.max(0, Math.min(100, ((e.clientX - r.left) / r.width) * 100));
  s.my = Math.max(0, Math.min(100, ((e.clientY - r.top) / r.height) * 100));
}
stage.addEventListener("pointermove", aim);
stage.addEventListener("pointerleave", () => { s.tx = s.ty = 0; s.ts = 1; s.mx = s.my = 50; });
card.addEventListener("click", () => { s.tf += 180; });           // each click: half a turn
card.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") s.tf += 180; });

let last = null;
requestAnimationFrame(function frame(now) {
  last ??= now;
  const dt = Math.min((now - last) / 1000, 0.04); last = now;
  [s.rx, s.vx] = chase(s.rx, s.vx, s.tx, 170, 11, dt);
  [s.ry, s.vy] = chase(s.ry, s.vy, s.ty, 170, 11, dt);
  [s.fl, s.vf] = chase(s.fl, s.vf, s.tf, 95, 9.5, dt);      // a little overshoot = it feels heavy
  [s.sc, s.vs] = chase(s.sc, s.vs, s.ts, 260, 18, dt);

  card.style.transform = `rotateX(${s.rx}deg) rotateY(${s.ry + s.fl}deg) scale(${s.sc})`;
  root.style.setProperty("--mx", s.mx + "%");
  root.style.setProperty("--my", s.my + "%");
  root.style.setProperty("--bx", (50 + s.ry * 2.6 - s.rx * 1.2).toFixed(1) + "%");   // sheen slides as you tilt
  root.style.setProperty("--h", (200 + s.ry * 5 + s.rx * 3).toFixed(0));             // and shifts colour
  root.style.setProperty("--lift", ((s.sc - 1) * 12).toFixed(2));
  requestAnimationFrame(frame);
});
02

Ink Toggle

A sun/moon theme switch that floods the whole page with the new theme in an expanding ink circle. Click the switch.

dark modetheme toggleclip-path

How to use: try the demo, pick a file tab, press Copy. Put the three files in one folder and open index.html.

Live demo
<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Ink Toggle</title>
  <link rel="stylesheet" href="style.css" />
</head>
<body>
  <div class="app theme-dark">
    <header class="bar"><span class="logo"><i></i>Aura</span></header>
    <main class="page">
      <p class="eyebrow">TODAY</p>
      <h1>Good evening</h1>
      <p class="sub">Your day at a glance.</p>

      <section class="cards">
        <article class="card"><span>Focus</span><b>4h 20m</b><div class="meter"><i style="--v:72%"></i></div></article>
        <article class="card"><span>Steps</span><b>8,420</b><div class="meter"><i style="--v:84%"></i></div></article>
        <article class="card"><span>Sleep</span><b>7h 05m</b><div class="meter"><i style="--v:61%"></i></div></article>
      </section>

      <article class="next">
        <div class="orb"></div>
        <div><span>NEXT UP</span><b>Design review</b><small>4:30 PM &middot; 3 people</small></div>
      </article>
    </main>
  </div>

  <!-- the switch lives OUTSIDE the themed page, so the flood never covers it -->
  <button class="toggle" aria-label="Toggle theme">
    <svg viewBox="0 0 72 36" width="88" height="44">
      <defs>
        <mask id="moon">
          <rect x="-12" y="-12" width="24" height="24" fill="#fff" />
          <circle class="cut" r="6" cy="-3" fill="#000" />
        </mask>
      </defs>
      <rect class="track" width="72" height="36" rx="18" />
      <g class="knob">
        <circle r="14" fill="#fff" />
        <g class="rays" stroke="#ff9d2e" stroke-width="2" stroke-linecap="round">
          <path d="M0 -11V-8M0 8V11M-11 0H-8M8 0H11M-7.8 -7.8L-5.7 -5.7M5.7 5.7L7.8 7.8M-7.8 7.8L-5.7 5.7M5.7 -5.7L7.8 -7.8" />
        </g>
        <circle class="disc" r="5" fill="#ff9d2e" mask="url(#moon)" />
      </g>
    </svg>
  </button>
  <script src="app.js"></script>
</body>
</html>
* { box-sizing: border-box; margin: 0; }
html, body { height: 100%; overflow: hidden; font-family: Inter, system-ui, sans-serif; background: #0b0d14; }

/* two themes = two sets of variables */
.theme-dark  { --bg: #0b0d14; --card: #151a28; --ink: #f1f3fa; --muted: #8a91a8; --a: #ff7a59; --b: #ffb36b;
               --orb: radial-gradient(circle at 35% 30%, #fff, #bcd0ff 40%, #5b6bd6); }
.theme-light { --bg: #f6f1e7; --card: #ffffff; --ink: #1b1a22; --muted: #7b7686; --a: #ff5a3c; --b: #ffa24a;
               --orb: radial-gradient(circle at 35% 30%, #fff6c9, #ffc247 45%, #ff7a2f); }

.app { position: absolute; inset: 0; padding: 22px 24px; background: var(--bg); color: var(--ink); }
.flood { z-index: 5; }                       /* the copy that gets revealed */

.logo { display: inline-flex; align-items: center; gap: 9px; font-weight: 800; letter-spacing: .02em; }
.logo i { width: 14px; height: 14px; border-radius: 50%; background: linear-gradient(135deg, var(--a), var(--b)); }
.page { margin-top: 36px; }
.eyebrow { font-size: 12px; letter-spacing: .3em; color: var(--muted); }
h1 { font-size: 46px; letter-spacing: -.04em; margin-top: 8px; }
.sub { color: var(--muted); margin-top: 6px; }

.cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-top: 26px; }
.card { background: var(--card); border-radius: 18px; padding: 16px; display: grid; gap: 6px; box-shadow: 0 8px 24px #0002; }
.card span, .next span { font-size: 11px; letter-spacing: .16em; text-transform: uppercase; color: var(--muted); }
.card b { font-size: 21px; letter-spacing: -.02em; }
.meter { height: 6px; border-radius: 6px; background: #8884; margin-top: 6px; overflow: hidden; }
.meter i { display: block; height: 100%; width: var(--v); border-radius: 6px; background: linear-gradient(90deg, var(--a), var(--b)); }

.next { display: flex; align-items: center; gap: 16px; margin-top: 12px; padding: 16px; background: var(--card); border-radius: 18px; box-shadow: 0 8px 24px #0002; }
.next div:last-child { display: grid; gap: 3px; }
.next b { font-size: 18px; } .next small { color: var(--muted); }
.orb { width: 54px; height: 54px; border-radius: 50%; background: var(--orb); box-shadow: 0 0 30px var(--b); }

.toggle { position: fixed; top: 14px; right: 18px; z-index: 10; border: 0; background: none; cursor: pointer; padding: 0; filter: drop-shadow(0 4px 10px #0006); }
.ring { position: fixed; z-index: 6; pointer-events: none; border-radius: 50%; border: 3px solid #ffc98a;
        box-shadow: 0 0 34px #ffb36b, inset 0 0 34px #ffb36b88; }
// Ink toggle: a copy of the page in the other theme is revealed by a growing circle.
const app = document.querySelector(".app");
const toggle = document.querySelector(".toggle");
const knob = toggle.querySelector(".knob"), track = toggle.querySelector(".track");
const rays = toggle.querySelector(".rays"), disc = toggle.querySelector(".disc"), cut = toggle.querySelector(".cut");

let dark = true, busy = false, m = 1;            // m: 0 = sun, 1 = moon
const mix = (a, b, t) => a + (b - a) * t;
const ease = (k) => (k < 0.5 ? 4 * k * k * k : 1 - (-2 * k + 2) ** 3 / 2);

function paint() {                               // the little sun <-> moon switch
  knob.setAttribute("transform", `translate(${mix(18, 54, m)} 18)`);
  track.style.fill = `rgb(${mix(255, 35, m)} ${mix(212, 42, m)} ${mix(138, 77, m)})`;
  rays.setAttribute("transform", `rotate(${m * 90}) scale(${1 - m * 0.4})`);
  rays.style.opacity = 1 - m;
  disc.setAttribute("r", mix(5, 7, m));
  cut.setAttribute("cx", mix(14, 3, m));        // slides in and carves the crescent
}

function flood() {
  if (busy) return;
  busy = true;
  const r0 = toggle.getBoundingClientRect();
  const ox = r0.left + r0.width / 2, oy = r0.top + r0.height / 2;
  const reach = Math.hypot(Math.max(ox, innerWidth - ox), Math.max(oy, innerHeight - oy));

  const copy = app.cloneNode(true);              // same page...
  copy.className = `app flood theme-${dark ? "light" : "dark"}`;   // ...other theme
  const ring = document.createElement("div");
  ring.className = "ring";
  document.body.append(copy, ring);

  const from = m, to = dark ? 0 : 1;
  let t0 = null;
  requestAnimationFrame(function grow(now) {
    t0 ??= now;
    const k = Math.min(1, (now - t0) / 900), r = reach * ease(k);
    copy.style.clipPath = `circle(${r}px at ${ox}px ${oy}px)`;
    Object.assign(ring.style, { left: ox - r + "px", top: oy - r + "px",
                                width: 2 * r + "px", height: 2 * r + "px", opacity: 1 - k });
    m = mix(from, to, ease(k));
    paint();
    if (k < 1) return requestAnimationFrame(grow);
    dark = !dark;                                // make the new theme permanent
    app.className = `app theme-${dark ? "dark" : "light"}`;
    copy.remove(); ring.remove(); busy = false;
  });
}

toggle.addEventListener("click", flood);
paint();
03

Pluck Slider

A volume slider that is a real vibrating string: wave-equation physics, drag the knob, pull it, let go.

sliderphysicsSVG

How to use: try the demo, pick a file tab, press Copy. Put the three files in one folder and open index.html.

Live demo
<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Pluck Slider</title>
  <link rel="stylesheet" href="style.css" />
</head>
<body>
  <main class="stage">
    <p class="eyebrow"><i></i>STRING / UI</p>
    <h1 class="value">50</h1>
    <p class="sub">Volume</p>

    <!-- the whole slider is ONE svg: a string, a lit half, a knob -->
    <svg class="rig" viewBox="0 0 500 220" tabindex="0" role="slider"
         aria-valuemin="0" aria-valuemax="100" aria-valuenow="50">
      <defs>
        <filter id="glow" x="-10%" y="-90%" width="120%" height="280%">
          <feGaussianBlur stdDeviation="6" />
        </filter>
      </defs>
      <g class="ticks"></g>
      <path class="string dim" />
      <path class="string lit halo" filter="url(#glow)" />
      <path class="string lit core" />
      <circle class="knob" r="16" />
    </svg>

    <p class="hint">Drag the knob, pull it, <u>let go</u>.</p>
  </main>
  <script src="app.js"></script>
</body>
</html>
:root { --h: 190; --ink: #f2f4fa; --muted: #7d8497; }
* { box-sizing: border-box; margin: 0; }

body {
  min-height: 100vh; display: grid; place-items: center; overflow: hidden;
  background: radial-gradient(ellipse at 50% 85%, hsl(var(--h) 55% 15%), #07080d 70%);
  color: var(--ink); font-family: Inter, system-ui, sans-serif;
}
.stage { width: min(100%, 500px); text-align: center; }

.eyebrow { display: inline-flex; gap: 10px; align-items: center;
  font-size: 12px; letter-spacing: .32em; color: var(--muted); }
.eyebrow i { width: 9px; height: 9px; border-radius: 50%;
  background: hsl(var(--h) 100% 62%); box-shadow: 0 0 14px hsl(var(--h) 100% 62%); }

.value { font-size: 120px; line-height: 1; font-weight: 800; letter-spacing: -.06em;
  margin-top: 12px; font-variant-numeric: tabular-nums; }
.sub { color: var(--muted); letter-spacing: .24em; text-transform: uppercase;
  font-size: 13px; margin-top: 4px; }

.rig { width: 100%; height: auto; margin-top: 18px; overflow: visible;
  touch-action: none; user-select: none; cursor: grab; outline: none; }
.rig:active { cursor: grabbing; }

.string { fill: none; stroke-linecap: round; stroke-linejoin: round; }
.dim  { stroke: #343a4e; stroke-width: 3; }
.lit  { stroke: hsl(var(--h) 100% 64%); }
.core { stroke-width: 4; }
.halo { stroke-width: 10; }

.ticks circle { fill: #2a3043; }
.ticks circle.on { fill: hsl(var(--h) 100% 70%); }

.knob { fill: #f6f8ff; stroke: hsl(var(--h) 100% 64%); stroke-width: 5;
  filter: drop-shadow(0 0 12px hsl(var(--h) 100% 60%)); }

.hint { color: var(--muted); font-size: 14px; margin-top: 18px; }
.hint u { color: var(--ink); text-decoration-style: dashed; }
// Pluck: the slider is a real string. Pull it, let go, it rings.
const $ = (s) => document.querySelector(s);
const svg = $(".rig"), out = $(".value"), knob = $(".knob");
const dim = $(".dim"), halo = $(".halo"), lit = [...document.querySelectorAll(".lit")];
const ticks = $(".ticks");

const X0 = 40, X1 = 460, BASE = 110, N = 72;  // the string runs X0 -> X1 at y = BASE
const K = 186000, D = 1.1, B = 60;            // tension, air drag, stiffness
const y = new Float32Array(N + 1);            // how far each point is from rest
const v = new Float32Array(N + 1);            // how fast each point is moving
const acc = new Float32Array(N + 1);

let target = 50, shown = 50, sv = 0;          // slider value + the knob chasing it
let held = false, hi = 0, hy = 0;             // the point your finger is holding

for (let t = 0; t <= 100; t += 10) {          // little notches under the string
  const c = document.createElementNS("http://www.w3.org/2000/svg", "circle");
  c.setAttribute("cx", X0 + ((X1 - X0) * t) / 100);
  c.setAttribute("cy", BASE + 58);
  c.setAttribute("r", 3);
  c.dataset.t = t;
  ticks.append(c);
}

// your finger pins a few points around the touch, like a real pluck
function pin(i) {
  const w = Math.max(0, 1 - Math.abs(i - hi) / 2);
  return w * (4e4 * (hy - y[i]) - 400 * v[i]);
}

function step(dt) {
  sv += ((target - shown) * 700 - sv * 38) * dt;     // knob = springy chase
  shown += sv * dt;
  const sub = Math.ceil(dt / 0.0011), h = dt / sub;  // tiny steps keep the wave stable
  for (let s = 0; s < sub; s++) {
    for (let i = 1; i < N; i++) {
      // wave equation: each point is pulled toward the average of its neighbours
      let a = K * (y[i - 1] + y[i + 1] - 2 * y[i]) - D * v[i];
      a += B * (v[i - 1] + v[i + 1] - 2 * v[i]);    // stiffness kills the harsh overtones
      if (held) a += pin(i);
      acc[i] = a;
    }
    for (let i = 1; i < N; i++) { v[i] += acc[i] * h; y[i] += v[i] * h; }
  }
}

function draw() {
  const f = (Math.min(Math.max(shown, 0), 100) / 100) * N, i0 = Math.floor(f);
  let all = "", part = "", amp = 0;
  for (let i = 0; i <= N; i++) {
    const seg = (i ? "L" : "M") + (X0 + ((X1 - X0) * i) / N).toFixed(1) + " " + (BASE + y[i]).toFixed(1);
    all += seg;
    if (i <= i0) part += seg;
    amp = Math.max(amp, Math.abs(y[i]));
  }
  const kx = X0 + ((X1 - X0) * f) / N;
  const ky = BASE + y[i0] + (y[Math.min(i0 + 1, N)] - y[i0]) * (f - i0); // string height under the knob
  part += "L" + kx.toFixed(1) + " " + ky.toFixed(1);

  dim.setAttribute("d", all);
  lit.forEach((p) => p.setAttribute("d", part));
  knob.setAttribute("cx", kx);
  knob.setAttribute("cy", ky);
  halo.style.opacity = 0.25 + Math.min(amp / 40, 0.75);   // louder string, brighter glow
  document.documentElement.style.setProperty("--h", (190 + shown * 1.4).toFixed(0));
  const val = Math.round(Math.min(Math.max(shown, 0), 100));
  out.textContent = val;
  svg.setAttribute("aria-valuenow", val);
  ticks.childNodes.forEach((c) => c.classList.toggle("on", +c.dataset.t <= shown));
}

const local = (e) => {
  const r = svg.getBoundingClientRect(), s = 500 / r.width;
  return [(e.clientX - r.left) * s, (e.clientY - r.top) * s];
};
function aim(e) {                                 // finger -> value, and how far it pulls
  const [px, py] = local(e);
  target = Math.min(100, Math.max(0, ((px - X0) / (X1 - X0)) * 100));
  hi = Math.round((target / 100) * N);
  hy = Math.max(-85, Math.min(85, py - BASE));
}
svg.addEventListener("pointerdown", (e) => { svg.setPointerCapture(e.pointerId); held = true; aim(e); });
svg.addEventListener("pointermove", (e) => held && aim(e));
svg.addEventListener("pointerup", () => (held = false));      // let go -> it rings
svg.addEventListener("pointercancel", () => (held = false));

svg.addEventListener("keydown", (e) => {          // keyboard: arrows flick the string
  const d = { ArrowRight: 5, ArrowUp: 5, ArrowLeft: -5, ArrowDown: -5 }[e.key];
  if (!d) return;
  target = Math.min(100, Math.max(0, target + d));
  hi = Math.round((target / 100) * N); hy = 40; held = true;
  setTimeout(() => (held = false), 90);
});

let last = 0;
requestAnimationFrame(function frame(ts) {
  const dt = Math.min((ts - last) / 1000, 0.05); last = ts;
  step(dt); draw();
  requestAnimationFrame(frame);
});
04

Magnet Button

A button that leans toward your cursor on springs while its label leans even further. Click for a burst of sparks and a state change.

magneticspring physicscanvas

How to use: try the demo, pick a file tab, press Copy. Put the three files in one folder and open index.html.

Live demo
<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Magnet Button</title>
  <link rel="stylesheet" href="style.css" />
</head>
<body>
  <main class="stage">
    <p class="eyebrow"><i></i>MAGNETIC / UI</p>
    <!-- the button leans toward your pointer, the label leans further -->
    <button class="mag" type="button">
      <span class="glow"></span>
      <span class="label"><b class="txt">Join the waitlist</b></span>
    </button>
    <p class="hint">Move close. <u>Click</u> it.</p>
  </main>
  <canvas class="fx"></canvas>
  <script src="app.js"></script>
</body>
</html>
:root { --gx: 50%; --gy: 50%; --hue: 258; --ink: #f2f4fa; }
* { box-sizing: border-box; margin: 0; }

body {
  min-height: 100vh; display: grid; place-items: center; overflow: hidden;
  background: radial-gradient(ellipse at 50% 45%, #171a35, #07080f 72%);
  color: var(--ink); font-family: Inter, system-ui, sans-serif;
}
.stage { display: grid; justify-items: center; gap: 30px; }
.eyebrow { display: inline-flex; gap: 10px; align-items: center;
  font-size: 12px; letter-spacing: .32em; color: #7d8497; }
.eyebrow i { width: 9px; height: 9px; border-radius: 50%;
  background: hsl(var(--hue) 100% 68%); box-shadow: 0 0 14px hsl(var(--hue) 100% 68%); }

.mag { position: relative; width: 250px; height: 74px; border: 0; border-radius: 11px;
  cursor: pointer; color: #fff; overflow: hidden; will-change: transform; outline: none;
  background: linear-gradient(135deg, hsl(var(--hue) 90% 62%),
                                      hsl(calc(var(--hue) + 55) 95% 62%));
  font: 700 18px Inter, system-ui, sans-serif; letter-spacing: .01em;
  box-shadow: 0 18px 40px hsl(var(--hue) 90% 50% / .38), inset 0 1px 0 #ffffff55; }
.mag:focus-visible { box-shadow: 0 0 0 3px #fff8; }

/* a soft light that sits under your pointer */
.glow { position: absolute; inset: 0; pointer-events: none; mix-blend-mode: soft-light;
  background: radial-gradient(circle 110px at var(--gx) var(--gy), #fff, #fff0 70%);
  opacity: .75; }
.label { position: absolute; inset: 0; display: grid; place-items: center;
  will-change: transform; }
.txt { font-weight: 700; white-space: nowrap; display: inline-block; }

.hint { color: #7d8497; font-size: 14px; }
.hint u { color: var(--ink); text-decoration-style: dashed; }
.fx { position: fixed; inset: 0; width: 100%; height: 100%; pointer-events: none; }
// Magnet button: springs pull the button toward your pointer.
// The label moves further than the button, which gives it depth.
const $ = (s) => document.querySelector(s);
const btn = $(".mag"), label = $(".label"), txt = $(".txt");
const cv = $(".fx"), g = cv.getContext("2d"), root = document.documentElement;

const FIELD = 190;                      // how far away it starts to feel you (px)
const s = { x: 0, y: 0, vx: 0, vy: 0, sc: 1, vs: 0,
            lx: 0, ly: 0, lvx: 0, lvy: 0, hue: 258, vh: 0 };      // spring state
const T = { x: 0, y: 0, sc: 1, hue: 258 };                        // where it wants to be
let home = null, ptr = null, done = false, bits = [], pulse = 0;

const chase = (x, v, to, k, d, dt) => { v += ((to - x) * k - v * d) * dt; return [x + v * dt, v]; };

function measure() {
  const r = btn.getBoundingClientRect();
  home = { x: r.left - s.x + r.width / 2, y: r.top - s.y + r.height / 2 };
}
function size() {
  cv.width = innerWidth * devicePixelRatio; cv.height = innerHeight * devicePixelRatio;
  measure();
}
addEventListener("pointermove", (e) => { ptr = { x: e.clientX, y: e.clientY }; });
root.addEventListener("pointerleave", () => { ptr = null; });
addEventListener("resize", size);

function aim() {                        // pointer -> target position
  if (!ptr) { T.x = T.y = 0; T.sc = 1; return 0; }
  const dx = ptr.x - home.x, dy = ptr.y - home.y, d = Math.hypot(dx, dy);
  const f = Math.max(0, 1 - d / FIELD) ** 1.4;      // 0 far away, 1 right on top of it
  T.x = dx * 0.46 * f; T.y = dy * 0.46 * f; T.sc = 1 + 0.1 * f;
  return f;
}

btn.addEventListener("click", () => {
  done = !done; pulse = 1;
  T.hue = done ? 152 : 258;
  txt.textContent = done ? "You're in ✓" : "Join the waitlist";
  const r = btn.getBoundingClientRect(), cx = r.left + r.width / 2, cy = r.top + r.height / 2;
  for (let i = 0; i < 34; i++) {        // a burst of sparks
    const a = Math.random() * 6.283, sp = 160 + Math.random() * 360;
    bits.push({ x: cx, y: cy, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 90,
                life: 1, r: 2 + Math.random() * 3, h: T.hue + Math.random() * 60 });
  }
});

let last = null;
requestAnimationFrame(function frame(now) {
  last ??= now; const dt = Math.min((now - last) / 1000, 0.04); last = now;
  if (!home) size();
  const f = aim();
  [s.x, s.vx] = chase(s.x, s.vx, T.x, 240, 15, dt);
  [s.y, s.vy] = chase(s.y, s.vy, T.y, 240, 15, dt);
  [s.sc, s.vs] = chase(s.sc, s.vs, T.sc + pulse * 0.12, 300, 16, dt);
  [s.lx, s.lvx] = chase(s.lx, s.lvx, T.x * 1.9, 210, 13, dt);    // label leans further
  [s.ly, s.lvy] = chase(s.ly, s.lvy, T.y * 1.9, 210, 13, dt);
  [s.hue, s.vh] = chase(s.hue, s.vh, T.hue, 60, 9, dt);
  pulse *= 0.9;

  const skew = Math.max(-9, Math.min(9, -s.vx * 0.012));         // fast moves smear it
  btn.style.transform = `translate(${s.x}px,${s.y}px) scale(${s.sc}) skewX(${skew}deg)`;
  label.style.transform = `translate(${s.lx - s.x}px,${s.ly - s.y}px)`;
  root.style.setProperty("--hue", s.hue.toFixed(0));
  if (ptr) {
    const r = btn.getBoundingClientRect();
    root.style.setProperty("--gx", ptr.x - r.left + "px");
    root.style.setProperty("--gy", ptr.y - r.top + "px");
  }

  g.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
  g.clearRect(0, 0, innerWidth, innerHeight);
  g.strokeStyle = `hsl(${s.hue} 100% 70% / ${(0.1 + 0.5 * f).toFixed(2)})`;   // the field
  g.lineWidth = 1.2; g.setLineDash([3, 7]); g.beginPath();
  g.arc(home.x, home.y, FIELD * (0.96 + 0.04 * f), 0, 6.283); g.stroke();
  for (const b of bits) {                                         // sparks
    b.vy += 620 * dt; b.x += b.vx * dt; b.y += b.vy * dt; b.life -= dt * 1.3;
    g.fillStyle = `hsl(${b.h} 100% 70% / ${Math.max(0, b.life).toFixed(2)})`;
    g.beginPath(); g.arc(b.x, b.y, b.r * Math.max(0.2, b.life), 0, 6.283); g.fill();
  }
  bits = bits.filter((b) => b.life > 0);
  requestAnimationFrame(frame);
});
05

Pulse Dashboard

A full analytics dashboard layout: KPI tiles with sparklines, a hover chart, a traffic donut, top pages and a busiest-hours heatmap. The range tabs redraw every panel at once.

dashboardlayoutcharts

How to use: try the demo, pick a file tab, press Copy. Put the three files in one folder and open index.html.

Live demo
<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Pulse Dashboard</title>
  <link rel="stylesheet" href="style.css" />
</head>
<body>
  <div class="app">
    <!-- 1. navigation lives on the left: always there, never in the way -->
    <aside class="side">
      <div class="logo"><i></i><b>Lumen</b></div>
      <nav>
        <a class="on" href="#"><svg viewBox="0 0 24 24"><path d="M4 13h6V4H4zM14 20h6V4h-6zM4 20h6v-4H4z"/></svg><span>Overview</span></a>
        <a href="#"><svg viewBox="0 0 24 24"><path d="M5 20V10M12 20V4M19 20v-7"/></svg><span>Reports</span></a>
        <a href="#"><svg viewBox="0 0 24 24"><circle cx="9" cy="8" r="3.5"/><path d="M2.5 20c.6-4 3.2-6 6.5-6s5.9 2 6.5 6M16 5.5a3.5 3.5 0 0 1 0 6M21.5 20c-.3-2.6-1.4-4.4-3.2-5.4"/></svg><span>Audience</span></a>
        <a href="#"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M18.4 5.6l-2.1 2.1M7.7 16.3l-2.1 2.1"/></svg><span>Settings</span></a>
      </nav>
    </aside>

    <main class="main">
      <!-- 2. header: the title, the live signal, and the one control that changes everything -->
      <header class="top" data-in>
        <div><h1>Overview</h1><p>How the store is doing</p></div>
        <div class="tools">
          <span class="live"><i></i><b id="online">128</b> online</span>
          <div class="seg" role="group" aria-label="Date range">
            <button data-r="7">7D</button><button data-r="30" class="on">30D</button><button data-r="90">90D</button>
          </div>
        </div>
      </header>

      <!-- 3. KPIs first: the answer in two seconds -->
      <section class="kpis" id="kpis"></section>

      <!-- 4. one hero chart, one breakdown -->
      <section class="row2">
        <article class="card chart" data-in>
          <div class="ch"><h2>Revenue</h2>
            <span class="key"><i></i>This period <i class="prev"></i>Previous</span></div>
          <div class="plot" id="plot" role="img" aria-label="Revenue over time"></div>
        </article>
        <article class="card donut" data-in>
          <div class="ch"><h2>Traffic sources</h2></div>
          <div class="ring" id="ring"></div>
          <ul class="legend" id="legend"></ul>
        </article>
      </section>

      <!-- 5. details last -->
      <section class="row3">
        <article class="card pages" data-in><div class="ch"><h2>Top pages</h2></div><ul id="pages"></ul></article>
        <article class="card heat" data-in><div class="ch"><h2>Busiest hours</h2></div><div id="heat"></div></article>
      </section>
    </main>
  </div>
  <div class="tip" id="tip"></div>
  <script src="app.js"></script>
</body>
</html>
:root {
  --bg: #0b0e17; --panel: #121726; --line: #1f2639; --ink: #eef1fa; --mut: #8a93ab; --dim: #5d667e;
  --s1: #3987e5; --s2: #d95926; --s3: #199e70; --s4: #c98500;     /* categorical, in fixed order */
  --up: #3ecf8e; --down: #ff7b72;
}
* { box-sizing: border-box; margin: 0; }
html, body { background: var(--bg); color: var(--ink); font: 14px/1.45 Inter, system-ui, sans-serif; }
button { font: inherit; color: inherit; }
ul { list-style: none; padding: 0; }

/* the whole layout is two columns: a narrow rail and everything else */
.app { display: grid; grid-template-columns: 56px minmax(0, 1fr); min-height: 100vh; }
.side { border-right: 1px solid var(--line); padding: 18px 8px; display: grid; align-content: start; gap: 26px;
  background: #0d111c; }
.logo { display: flex; align-items: center; gap: 10px; justify-content: center; font-weight: 800; }
.logo i { width: 22px; height: 22px; border-radius: 7px; background: conic-gradient(#3987e5, #9085e9, #19a07a, #3987e5); }
.logo b, nav span { display: none; }
nav { display: grid; gap: 4px; }
nav a { display: flex; align-items: center; gap: 12px; justify-content: center; padding: 10px; border-radius: 11px;
  color: var(--mut); text-decoration: none; font-weight: 600; }
nav a.on { background: #1a2136; color: var(--ink); }
nav svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
@media (min-width: 1000px) {
  .app { grid-template-columns: 210px minmax(0, 1fr); }
  .side { padding: 22px 14px; } .logo { justify-content: flex-start; padding: 0 8px; }
  .logo b, nav span { display: inline; } nav a { justify-content: flex-start; }
}

.main { padding: 22px; display: grid; gap: 16px; align-content: start; min-width: 0; }
.top { display: flex; align-items: center; justify-content: space-between; gap: 14px; flex-wrap: wrap; }
h1 { font-size: 24px; letter-spacing: -.02em; } .top p { color: var(--mut); font-size: 13px; }
.tools { display: flex; align-items: center; gap: 12px; }
.live { display: inline-flex; align-items: center; gap: 7px; color: var(--mut); font-size: 13px; }
.live i { width: 8px; height: 8px; border-radius: 50%; background: var(--up); box-shadow: 0 0 0 4px #3ecf8e22; }
.live b { color: var(--ink); font-variant-numeric: tabular-nums; }
.seg { display: inline-flex; padding: 3px; border: 1px solid var(--line); border-radius: 11px; background: var(--panel); }
.seg button { border: 0; background: none; padding: 6px 12px; border-radius: 8px; color: var(--mut); font-weight: 700; cursor: pointer; }
.seg button.on { background: #222a42; color: var(--ink); }

.card { background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 16px; min-width: 0; }
.ch { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.ch h2 { font-size: 13px; color: var(--mut); font-weight: 700; letter-spacing: .02em; }

/* KPI row: four equal tiles that wrap to two-by-two on small screens */
.kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
@media (max-width: 560px) { .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.kpi { display: grid; gap: 4px; }
.kpi span { color: var(--mut); font-size: 12px; font-weight: 600; }
.kpi b { font-size: 26px; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
.kpi .foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 4px; }
.chip { white-space: nowrap; font-style: normal; font-size: 12px; font-weight: 700; padding: 2px 8px; border-radius: 99px; background: #3ecf8e1c; color: var(--up); }
.chip.dn { background: #ff7b721c; color: var(--down); }
.kpi svg { width: 64px; height: 26px; flex: none; overflow: visible; }

/* hero chart (2/3) next to the breakdown (1/3) */
.row2 { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); gap: 12px; }
.row3 { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; }
@media (max-width: 560px) { .row2, .row3 { grid-template-columns: minmax(0, 1fr); } }
.key { display: inline-flex; align-items: center; gap: 6px; color: var(--mut); font-size: 12px; }
.key i { width: 14px; height: 3px; border-radius: 2px; background: var(--s1); }
.key i.prev { background: none; border-top: 2px dashed var(--dim); height: 0; margin-left: 8px; }
.chart { display: flex; flex-direction: column; }
.plot { position: relative; flex: 1; min-height: 230px; touch-action: none; }
.plot svg { display: block; width: 100%; height: 100%; overflow: visible; }
.plot text { fill: var(--dim); font-size: 11px; }

.ring { display: grid; place-items: center; height: 150px; position: relative; }
.ring svg { width: 150px; height: 150px; transform: rotate(-90deg); }
.ring .mid { position: absolute; text-align: center; pointer-events: none; }
.ring .mid b { display: block; font-size: 22px; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
.ring .mid small { color: var(--mut); font-size: 11px; }
.legend { display: grid; gap: 4px; margin-top: 8px; }
.legend li { display: flex; align-items: center; gap: 8px; padding: 4px 6px; border-radius: 8px; color: var(--mut); font-size: 12.5px; cursor: default; }
.legend li.hot { background: #ffffff0d; color: var(--ink); }
.legend i { width: 9px; height: 9px; border-radius: 3px; }
.legend em { margin-left: auto; font-style: normal; color: var(--ink); font-weight: 700; font-variant-numeric: tabular-nums; }

.pages ul { display: grid; gap: 11px; }
.pages li { display: grid; gap: 5px; font-size: 12.5px; }
.pages .row { display: flex; justify-content: space-between; color: var(--mut); }
.pages .row b { color: var(--ink); font-variant-numeric: tabular-nums; }
.pages .bar { height: 8px; border-radius: 4px; background: #1b2236; overflow: hidden; }
.pages .bar i { display: block; height: 100%; border-radius: 0 4px 4px 0; background: var(--s1); }

/* heatmap = a plain CSS grid: a label column, then 8 equal columns */
.hgrid { display: grid; grid-template-columns: 34px repeat(8, minmax(0, 1fr)); gap: 4px; align-items: center; }
.hgrid small { color: var(--dim); font-size: 11px; text-align: center; }
.hgrid small:nth-child(9n + 1) { text-align: left; }
.hgrid i { height: 18px; border-radius: 5px; background: var(--s1); }

.tip { position: fixed; z-index: 9; pointer-events: none; left: 0; top: 0; opacity: 0; padding: 8px 11px; border-radius: 10px;
  background: #0b0e17f2; border: 1px solid #2b3550; box-shadow: 0 10px 30px #0008; font-size: 12px; white-space: nowrap; }
.tip b { display: block; font-size: 14px; font-variant-numeric: tabular-nums; }
.tip span { color: var(--mut); }
// Pulse dashboard: layout first, then data. Every number below is made up.
const $ = (s, el = document) => el.querySelector(s);
const NS = "http://www.w3.org/2000/svg";
const el = (tag, attrs = {}, parent) => {
  const n = document.createElementNS(NS, tag);
  for (const k in attrs) n.setAttribute(k, attrs[k]);
  if (parent) parent.append(n);
  return n;
};

/* tiny animation engine: one rAF loop runs every tween (so it also works with a fake clock) */
const jobs = []; let clock = 0;
function tween(ms, fn, delay = 0) {
  jobs.push({ t0: clock + delay, ms, fn });
}
requestAnimationFrame(function loop(now) {
  clock = now;
  for (let i = jobs.length - 1; i >= 0; i--) {
    const j = jobs[i], k = Math.min(1, Math.max(0, (now - j.t0) / j.ms));
    if (now >= j.t0) j.fn(1 - Math.pow(1 - k, 3));          // ease-out
    if (k >= 1) jobs.splice(i, 1);
  }
  requestAnimationFrame(loop);
});

/* ---------- fake data: 180 days, seeded so it always looks the same ---------- */
let seed = 7;
const rnd = () => { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
const DAYS = 180, rev = [], usr = [], ord = [];
for (let i = 0; i < DAYS; i++) {
  const wave = Math.sin((i * 2 * Math.PI) / 7 + 1) * 260 + Math.sin(i * 0.21) * 170;
  rev.push(1500 + i * 8 + wave + (rnd() - 0.5) * 260);
  usr.push(300 + i * 0.9 + wave * 0.12 + (rnd() - 0.5) * 50);
  ord.push(rev[i] / (58 + 8 * Math.sin(i * 0.3)));
}
const END = new Date(2026, 9, 12);
const dayLabel = (back) => { const d = new Date(END); d.setDate(d.getDate() - back);
  return d.toLocaleDateString("en", { month: "short", day: "numeric" }); };
const sum = (a) => a.reduce((x, y) => x + y, 0);
function slice(r) {                                   // current period and the one before it
  const cut = (a, from, to) => a.slice(from, to);
  const pick = (a) => [cut(a, DAYS - r, DAYS), cut(a, DAYS - 2 * r, DAYS - r)];
  return { rev: pick(rev), usr: pick(usr), ord: pick(ord) };
}

/* ---------- formatting ---------- */
const money = (n) => (n >= 1000 ? "$" + (n / 1000).toFixed(1) + "k" : "$" + Math.round(n));
const int = (n) => Math.round(n).toLocaleString("en");

/* ---------- KPI tiles ---------- */
const kpisEl = $("#kpis");
const KPIS = [
  { name: "Revenue",      fmt: money,                      get: (d) => [sum(d.rev[0]), sum(d.rev[1])], line: (d) => d.rev[0] },
  { name: "Active users", fmt: int,                        get: (d) => [sum(d.usr[0]) / d.usr[0].length * 28, sum(d.usr[1]) / d.usr[1].length * 28], line: (d) => d.usr[0] },
  { name: "Conversion",   fmt: (n) => n.toFixed(1) + "%",  get: (d) => [sum(d.ord[0]) / sum(d.usr[0]) * 100 * 0.44, sum(d.ord[1]) / sum(d.usr[1]) * 100 * 0.47],
    line: (d) => d.ord[0].map((o, i) => o / d.usr[0][i]) },
  { name: "Avg. order",   fmt: (n) => "$" + n.toFixed(0),  get: (d) => [sum(d.rev[0]) / sum(d.ord[0]), sum(d.rev[1]) / sum(d.ord[1])],
    line: (d) => d.rev[0].map((r, i) => r / d.ord[0][i]) },
];
const tiles = KPIS.map((k) => {
  const a = document.createElement("article");
  a.className = "card kpi"; a.dataset.in = "";
  a.innerHTML = `<span>${k.name}</span><b>0</b><div class="foot"><em class="chip"></em><svg viewBox="0 0 74 26"></svg></div>`;
  kpisEl.append(a); return a;
});
function spark(svg, vals) {                           // 74x26 sparkline with an end dot
  svg.innerHTML = "";
  const lo = Math.min(...vals), hi = Math.max(...vals);
  const pts = vals.map((v, i) => [(i / (vals.length - 1)) * 72 + 1, 24 - ((v - lo) / (hi - lo || 1)) * 22]);
  el("path", { d: "M" + pts.map((p) => p.join(" ")).join("L"), fill: "none", stroke: "var(--s1)",
               "stroke-width": 2, "stroke-linecap": "round", "stroke-linejoin": "round" }, svg);
  el("circle", { cx: pts.at(-1)[0], cy: pts.at(-1)[1], r: 3, fill: "var(--s1)", stroke: "var(--panel)", "stroke-width": 2 }, svg);
}
const shown = KPIS.map(() => 0);
function drawKpis(d, animate) {
  KPIS.forEach((k, i) => {
    const [now, prev] = k.get(d), pct = ((now - prev) / prev) * 100, up = +pct.toFixed(1) >= 0, from = shown[i];
    const chip = $(".chip", tiles[i]);
    chip.className = "chip" + (up ? "" : " dn");
    chip.textContent = (up ? "▲ +" : "▼ −") + Math.abs(pct).toFixed(1) + "%";   // arrow + sign, never colour alone
    const v = $("b", tiles[i]);
    if (animate) tween(800, (e) => { v.textContent = k.fmt(from + (now - from) * e); });
    else v.textContent = k.fmt(now);
    shown[i] = now;
    const line = k.line(d); spark($("svg", tiles[i]), line.slice(-Math.min(line.length, 24)));
  });
}

/* ---------- hero chart ---------- */
const plot = $("#plot"), tip = $("#tip");
let range = 30, data = slice(range), reveal = 1, hoverAt = -1;
const PAD = { l: 40, r: 8, t: 10, b: 22 };
function spline(p) {                                   // smooth curve through the points
  let d = "M" + p[0].join(" ");
  for (let i = 0; i < p.length - 1; i++) {
    const a = p[i - 1] || p[i], b = p[i], c = p[i + 1], e = p[i + 2] || c;
    d += `C${b[0] + (c[0] - a[0]) / 6} ${b[1] + (c[1] - a[1]) / 6},${c[0] - (e[0] - b[0]) / 6} ${c[1] - (e[1] - b[1]) / 6},${c[0]} ${c[1]}`;
  }
  return d;
}
function drawChart() {
  const W = plot.clientWidth, H = plot.clientHeight, cur = data.rev[0], prev = data.rev[1], n = cur.length;
  const peak = Math.max(...cur, ...prev);
  const step = [250, 500, 1000, 2000, 2500].find((v) => v * 4 >= peak), top = Math.ceil(peak / step) * step;
  const X = (i) => PAD.l + (i / (n - 1)) * (W - PAD.l - PAD.r);
  const Y = (v) => H - PAD.b - (v / top) * (H - PAD.t - PAD.b);
  plot.innerHTML = "";
  const svg = el("svg", { viewBox: `0 0 ${W} ${H}` }, plot);
  const defs = el("defs", {}, svg);
  const grad = el("linearGradient", { id: "fill", x1: 0, y1: 0, x2: 0, y2: 1 }, defs);
  el("stop", { offset: "0%", "stop-color": "#3987e5", "stop-opacity": 0.32 }, grad);
  el("stop", { offset: "100%", "stop-color": "#3987e5", "stop-opacity": 0 }, grad);
  const clip = el("clipPath", { id: "rv" }, defs);
  const clipRect = el("rect", { x: 0, y: -20, width: W * reveal, height: H + 40 }, clip);
  for (let v = 0; v <= top; v += step) {               // quiet grid + y labels
    const y = Y(v);
    el("line", { x1: PAD.l, x2: W - PAD.r, y1: y, y2: y, stroke: "#1f2639", "stroke-width": 1 }, svg);
    el("text", { x: PAD.l - 8, y: y + 4, "text-anchor": "end" }, svg).textContent = "$" + v / 1000 + "k";
  }
  const ticks = 5;
  for (let t = 0; t < ticks; t++) {                    // x labels, evenly spaced
    const i = Math.round((t / (ticks - 1)) * (n - 1));
    el("text", { x: X(i), y: H - 4, "text-anchor": t === 0 ? "start" : t === ticks - 1 ? "end" : "middle" }, svg)
      .textContent = dayLabel(n - 1 - i);
  }
  const P = (a) => a.map((v, i) => [X(i), Y(v)]);
  el("path", { d: spline(P(prev)), fill: "none", stroke: "#5d667e", "stroke-width": 2, "stroke-dasharray": "4 5" }, svg);
  const g = el("g", { "clip-path": "url(#rv)" }, svg);
  const line = spline(P(cur));
  el("path", { d: line + `L${X(n - 1)} ${Y(0)}L${X(0)} ${Y(0)}Z`, fill: "url(#fill)" }, g);
  el("path", { d: line, fill: "none", stroke: "#3987e5", "stroke-width": 2.5, "stroke-linecap": "round" }, g);
  const guide = el("line", { y1: PAD.t, y2: H - PAD.b, stroke: "#5d667e", "stroke-width": 1, opacity: 0 }, svg);
  const dot = el("circle", { r: 5, fill: "#3987e5", stroke: "#121726", "stroke-width": 2, opacity: 0 }, svg);
  plot.clip = clipRect;
  const hit = el("rect", { x: PAD.l, y: 0, width: W - PAD.l - PAD.r, height: H, fill: "transparent" }, svg);
  hit.addEventListener("pointermove", (e) => {         // hover layer: crosshair + exact numbers
    const r = plot.getBoundingClientRect();
    const i = Math.max(0, Math.min(n - 1, Math.round(((e.clientX - r.left - PAD.l) / (W - PAD.l - PAD.r)) * (n - 1))));
    guide.setAttribute("x1", X(i)); guide.setAttribute("x2", X(i)); guide.setAttribute("opacity", 1);
    dot.setAttribute("cx", X(i)); dot.setAttribute("cy", Y(cur[i])); dot.setAttribute("opacity", 1);
    const diff = ((cur[i] - prev[i]) / prev[i]) * 100;
    show(e, `<span>${dayLabel(n - 1 - i)}</span><b>$${int(cur[i])}</b><span>${diff >= 0 ? "▲ +" : "▼ "}${diff.toFixed(1)}% vs previous</span>`);
  });
  hit.addEventListener("pointerleave", () => { guide.setAttribute("opacity", 0); dot.setAttribute("opacity", 0); hide(); });
}
function show(e, html) {
  tip.innerHTML = html; tip.style.opacity = 1;
  const w = tip.offsetWidth, x = e.clientX + 14 + w > innerWidth ? e.clientX - 14 - w : e.clientX + 14;
  tip.style.transform = `translate(${x}px,${Math.max(6, e.clientY - 54)}px)`;
}
function hide() { tip.style.opacity = 0; }
new ResizeObserver(drawChart).observe(plot);

/* ---------- traffic sources donut ---------- */
const SRC = [["Search", 46], ["Direct", 27], ["Social", 17], ["Email", 10]], COL = ["--s1", "--s2", "--s3", "--s4"];
const ringEl = $("#ring"), legend = $("#legend");
const svgR = el("svg", { viewBox: "0 0 150 150" }); ringEl.append(svgR);
const mid = document.createElement("div"); mid.className = "mid"; ringEl.append(mid);
const R = 54, C = 2 * Math.PI * R;
const segs = SRC.map((s, i) => {
  const c = el("circle", { cx: 75, cy: 75, r: R, fill: "none", "stroke-width": 16, stroke: `var(${COL[i]})` }, svgR);
  c.addEventListener("pointerenter", () => hot(i)); c.addEventListener("pointerleave", () => hot(-1));
  const li = document.createElement("li");
  li.innerHTML = `<i style="background:var(${COL[i]})"></i>${s[0]}<em>${s[1]}%</em>`;
  li.addEventListener("pointerenter", () => hot(i)); li.addEventListener("pointerleave", () => hot(-1));
  legend.append(li); return c;
});
let sessions = 12480, sweep = 1;
function hot(i) {
  segs.forEach((c, k) => c.setAttribute("stroke-width", k === i ? 21 : 16));
  [...legend.children].forEach((li, k) => li.classList.toggle("hot", k === i));
  mid.innerHTML = i < 0 ? `<b>${int(sessions)}</b><small>sessions</small>` : `<b>${SRC[i][1]}%</b><small>${SRC[i][0]}</small>`;
}
function drawRing() {
  let at = 0;
  SRC.forEach((s, i) => {                              // 2px gap between segments, drawn with a sweep
    const len = Math.max(0, (s[1] / 100) * C - 2) * sweep;
    segs[i].setAttribute("stroke-dasharray", `${len} ${C}`);
    segs[i].setAttribute("stroke-dashoffset", -at); at += (s[1] / 100) * C;
  });
}

/* ---------- top pages (bars) ---------- */
const PAGES = [["/pricing", 9420], ["/blog/launch-guide", 7310], ["/", 6880], ["/features", 4120], ["/docs/start", 2960]];
const pagesEl = $("#pages");
const rows = PAGES.map(([name]) => {
  const li = document.createElement("li");
  li.innerHTML = `<div class="row"><span>${name}</span><b></b></div><div class="bar"><i></i></div>`;
  pagesEl.append(li); return li;
});
function drawPages(k) {
  const scale = range / 30;
  PAGES.forEach(([, v], i) => {
    $("b", rows[i]).textContent = int(v * scale);
    $("i", rows[i]).style.width = (v / PAGES[0][1]) * 100 * k + "%";
  });
}

/* ---------- busiest hours (heatmap) ---------- */
const DAYS7 = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], BLOCKS = ["12a", "3a", "6a", "9a", "12p", "3p", "6p", "9p"];
const heat = (d, b) => Math.max(0.04, Math.min(1,
  0.12 + 0.55 * Math.exp(-((b - 6.2) ** 2) / 1.6) + 0.4 * Math.exp(-((b - 4) ** 2) / 1.2) * (d < 5 ? 1 : 0.5)
  + (d === 4 || d === 5 ? 0.18 : 0) + Math.sin(d * 3.1 + b * 1.7) * 0.06));
const heatEl = $("#heat");
heatEl.className = "hgrid";
heatEl.append(document.createElement("span"));
BLOCKS.forEach((b) => { const h = document.createElement("small"); h.textContent = b; heatEl.append(h); });
DAYS7.forEach((day, r) => {
  const label = document.createElement("small"); label.textContent = day; heatEl.append(label);
  BLOCKS.forEach((b, c) => {
    const v = heat(r, c), cell = document.createElement("i");
    cell.style.opacity = (0.12 + 0.88 * v).toFixed(2);
    cell.addEventListener("pointermove", (e) => show(e, `<span>${day}, ${b}</span><b>${int(v * 420)} orders</b>`));
    cell.addEventListener("pointerleave", hide);
    heatEl.append(cell);
  });
});

/* ---------- range tabs change every panel at once ---------- */
function setRange(r, animate) {
  range = r; data = slice(r); sessions = 12480 * (r / 30);
  document.querySelectorAll(".seg button").forEach((b) => b.classList.toggle("on", +b.dataset.r === r));
  drawKpis(data, animate); reveal = animate ? 0 : 1; drawChart(); hot(-1);
  if (animate) {
    tween(900, (k) => { reveal = k; plot.clip && plot.clip.setAttribute("width", plot.clientWidth * k); });
    tween(900, (k) => { sweep = k; drawRing(); });
    tween(800, (k) => drawPages(k));
  }
}
document.querySelector(".seg").addEventListener("click", (e) => {
  const b = e.target.closest("button"); if (b && +b.dataset.r !== range) setRange(+b.dataset.r, true);
});

/* ---------- first paint: the sections arrive one after another ---------- */
setRange(30, false);
sweep = 0; drawRing(); drawPages(0); shown.fill(0);
const order = [...document.querySelectorAll("[data-in]")];
order.forEach((n, i) => {
  const delay = [0, 200, 300, 400, 500, 800, 950, 1150, 1300][i] ?? 1300;
  n.style.opacity = 0;
  tween(520, (k) => { n.style.opacity = k; n.style.transform = `translateY(${(1 - k) * 18}px)`; }, delay);
});
drawKpis(data, true);
tween(1100, (k) => { reveal = k; plot.clip && plot.clip.setAttribute("width", plot.clientWidth * k); }, 800);
tween(1000, (k) => { sweep = k; drawRing(); }, 950);
tween(900, (k) => drawPages(k), 1150);

/* a live number, just to prove the page is alive */
let online = 128, nextTick = 1800;
tween(1e12, () => {
  if (clock > nextTick) { nextTick += 1700; online = Math.max(90, online + Math.round((rnd() - 0.45) * 7));
    $("#online").textContent = online; }
});
06

Command Palette

A Ctrl+K command palette with fuzzy search, highlighted matches, keyboard navigation and a spring-gliding selection. Press Ctrl+K, type a few letters, use the arrows and Enter.

command palettefuzzy searchkeyboard

How to use: try the demo, pick a file tab, press Copy. Put the three files in one folder and open index.html.

Live demo
<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Command Palette</title>
  <link rel="stylesheet" href="style.css" />
</head>
<body>
  <!-- a pretend app behind the palette, so you can see it blur -->
  <div class="app">
    <aside class="rail"><i></i><i></i><i></i><i></i></aside>
    <section class="page">
      <h1>Acme Studio</h1>
      <p>Everything you can do, one shortcut away.</p>
      <button class="trigger" type="button">
        Search or jump to&hellip; <kbd>Ctrl</kbd><kbd>K</kbd>
      </button>
      <div class="cards"><i></i><i></i><i></i></div>
    </section>
  </div>

  <div class="scrim"></div>

  <!-- the palette: a search field, a scrolling list, a footer of hints -->
  <div class="pal" role="dialog" aria-label="Command palette">
    <label class="field">
      <svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="6.5" /><path d="M16 16l4.5 4.5" /></svg>
      <input type="text" placeholder="Type a command or search&hellip;" autocomplete="off"
             role="combobox" aria-expanded="true" aria-controls="list" />
      <kbd>esc</kbd>
    </label>
    <div class="list" id="list" role="listbox">
      <div class="pill"></div>
      <div class="rows"></div>
    </div>
    <footer class="foot">
      <span><kbd>&uarr;</kbd><kbd>&darr;</kbd> move</span>
      <span><kbd>&crarr;</kbd> run</span>
      <span><kbd>esc</kbd> close</span>
    </footer>
  </div>

  <div class="toast"></div>
  <script src="app.js"></script>
</body>
</html>
:root { --bg: #0b0d16; --panel: #121524; --line: #232842; --ink: #eef0fb;
        --mut: #8a90aa; --ac: #8b7cff; --hi: #ffb36b; }
* { box-sizing: border-box; margin: 0; }
html, body { height: 100%; overflow: hidden; }
body { background: var(--bg); color: var(--ink); font: 14px/1.4 Inter, system-ui, sans-serif; }

/* the pretend app */
.app { position: absolute; inset: 0; display: grid; grid-template-columns: 56px 1fr;
  background: radial-gradient(ellipse at 70% 0, #1a1f45, transparent 60%), var(--bg); }
.rail { border-right: 1px solid var(--line); display: grid; align-content: start;
  justify-items: center; gap: 14px; padding-top: 18px; }
.rail i { width: 24px; height: 24px; border-radius: 8px; background: #1d2238; }
.rail i:first-child { background: conic-gradient(#8b7cff, #ff7ab6, #ffb36b, #8b7cff); }
.page { padding: 26px 24px; display: grid; align-content: start; gap: 8px; }
h1 { font-size: 22px; letter-spacing: -.02em; }
.page p { color: var(--mut); }
.trigger { display: flex; align-items: center; gap: 6px; width: 100%; max-width: 340px;
  margin-top: 12px; padding: 11px 14px; border-radius: 11px; border: 1px solid var(--line);
  background: var(--panel); color: var(--mut); font: inherit; cursor: pointer; text-align: left; }
.trigger kbd:first-of-type { margin-left: auto; }
.cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-top: 14px; }
.cards i { height: 84px; border-radius: 14px; background: var(--panel); border: 1px solid var(--line); }

kbd { font: 600 11px/1 Inter, system-ui, sans-serif; min-width: 20px; height: 20px; padding: 0 6px;
  display: inline-grid; place-items: center; border-radius: 6px; background: #1c2138;
  border: 1px solid #2c3354; color: #b9bfd8; box-shadow: 0 1px 0 #0006; }

.scrim { position: absolute; inset: 0; background: #05060cb0; opacity: 0; pointer-events: none; }

/* the palette */
.pal { position: absolute; left: 0; right: 0; margin: 0 auto; top: 62px;
  width: min(440px, calc(100% - 28px)); background: var(--panel); border: 1px solid #2b3152;
  border-radius: 16px; overflow: hidden; opacity: 0; visibility: hidden;
  transform-origin: 50% 0; box-shadow: 0 30px 80px #000c; }
.field { display: flex; align-items: center; gap: 10px; padding: 0 14px; height: 56px;
  border-bottom: 1px solid var(--line); }
.field svg, .ic svg { fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.field svg { width: 18px; height: 18px; stroke: var(--mut); }
input { flex: 1; min-width: 0; background: none; border: 0; outline: 0; color: var(--ink);
  font: 500 16px Inter, system-ui, sans-serif; caret-color: var(--ac); }
input::placeholder { color: #5f6682; }

.list { position: relative; max-height: 300px; overflow: auto; padding: 6px; scrollbar-width: none; }
.list::-webkit-scrollbar { display: none; }
.pill { position: absolute; left: 6px; right: 6px; top: 0; height: 50px; border-radius: 11px;
  background: linear-gradient(90deg, #8b7cff33, #8b7cff12); box-shadow: inset 0 0 0 1px #8b7cff55;
  pointer-events: none; }
.rows { position: relative; }
.gh { padding: 10px 10px 4px; font-size: 11px; letter-spacing: .12em; text-transform: uppercase;
  color: #656c88; font-weight: 700; }
.row { display: flex; align-items: center; gap: 12px; height: 50px; padding: 0 10px;
  border-radius: 11px; cursor: pointer; }
.ic { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 9px;
  background: #1b2036; color: #c9cdea; flex: none; }
.row.on .ic { background: var(--ac); color: #fff; }
.ic svg { width: 17px; height: 17px; stroke: currentColor; }
.tx { display: grid; gap: 1px; min-width: 0; }
.tx b { font-weight: 600; font-size: 14px; }
.tx small { color: var(--mut); font-size: 12px; }
.tx mark { background: none; color: var(--hi); }
.ks { margin-left: auto; display: flex; gap: 4px; }
.empty { padding: 34px 10px; text-align: center; color: var(--mut); }

.foot { display: flex; gap: 16px; align-items: center; height: 40px; padding: 0 14px;
  border-top: 1px solid var(--line); color: #6b7390; font-size: 12px; }
.foot span { display: inline-flex; gap: 5px; align-items: center; }

.toast { position: absolute; left: 0; right: 0; margin: 0 auto; bottom: 22px; width: max-content;
  max-width: calc(100% - 28px); padding: 10px 16px; border-radius: 99px; background: #1b2036;
  border: 1px solid #3a4268; box-shadow: 0 14px 40px #000a; opacity: 0; font-weight: 600; }
.toast i { color: #52d6a0; font-style: normal; margin-right: 8px; }
// Command palette: fuzzy search, keyboard first. Every motion runs on one spring loop.
const $ = (s) => document.querySelector(s);
const pal = $(".pal"), scrim = $(".scrim"), app = $(".app"), input = $("input");
const list = $("#list"), rowsEl = $(".rows"), pill = $(".pill"), toast = $(".toast");

const ICON = {
  plus: "M12 5v14M5 12h14", up: "M7 17L17 7M8 7h9v9", user: "M15 19a5 5 0 0 0-10 0M10 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6M19 8v6M16 11h6",
  moon: "M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z", grid: "M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z",
  chart: "M5 20V10M12 20V4M19 20v-7", card: "M3 6h18v12H3zM3 10h18", users: "M16 19c0-3-2-5-5-5s-5 2-5 5M11 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6",
  key: "M15 7a4 4 0 1 0-3.9 5L4 19v2h3v-2h2v-2h2l1.9-1.9A4 4 0 0 0 15 7z", bell: "M6 17h12l-1.5-2V10a4.5 4.5 0 0 0-9 0v5zM10 20h4",
  keys: "M3 7h18v10H3zM7 11h1M11 11h1M15 11h1M8 14h8", out: "M10 5H5v14h5M14 8l4 4-4 4M18 12H9",
};
const CMDS = [   // g = group, t = title, s = subtitle, k = shortcut, w = hidden search words
  { g: "Actions", t: "New project", s: "Start from a blank canvas", i: "plus", k: ["N"], w: "create add" },
  { g: "Actions", t: "Deploy to production", s: "Ship the latest build", i: "up", k: ["Ctrl", "D"], w: "ship release publish" },
  { g: "Actions", t: "Invite teammate", s: "Send an email invite", i: "user", k: ["I"], w: "add member people" },
  { g: "Actions", t: "Toggle dark mode", s: "Switch the theme", i: "moon", k: ["Ctrl", "T"], w: "theme light" },
  { g: "Pages", t: "Dashboard", s: "Overview and KPIs", i: "grid", k: ["G", "D"], w: "home" },
  { g: "Pages", t: "Analytics", s: "Traffic and conversions", i: "chart", k: ["G", "A"], w: "charts reports" },
  { g: "Pages", t: "Billing", s: "Plans and invoices", i: "card", k: ["G", "B"], w: "payment" },
  { g: "Pages", t: "Team members", s: "Roles and access", i: "users", k: ["G", "T"], w: "people" },
  { g: "Settings", t: "API keys", s: "Create and revoke tokens", i: "key", k: [], w: "token secret" },
  { g: "Settings", t: "Notifications", s: "Email and push alerts", i: "bell", k: [], w: "alerts" },
  { g: "Settings", t: "Keyboard shortcuts", s: "See every hotkey", i: "keys", k: [], w: "hotkeys" },
  { g: "Settings", t: "Sign out", s: "End this session", i: "out", k: [], w: "logout" },
];

// fuzzy match: letters must appear in order. Runs and word starts score higher.
function fuzzy(q, text) {
  const t = text.toLowerCase(), idx = [];
  let from = 0, score = 0, run = 0;
  for (const ch of q) {
    const at = t.indexOf(ch, from);
    if (at < 0) return null;
    run = idx.length && at === from ? run + 1 : 0;
    score += 1 + run * 2 + (at === 0 || t[at - 1] === " " ? 3 : 0) - (at - from) * 0.2;
    idx.push(at); from = at + 1;
  }
  return { score, idx };
}
function search(raw) {
  const q = raw.toLowerCase().replace(/\s+/g, "");
  if (!q) return CMDS.map((c) => ({ c, idx: [], score: 0 }));
  const hits = [];
  for (const c of CMDS) {
    const m = fuzzy(q, c.t);
    if (m) hits.push({ c, idx: m.idx, score: m.score });
    else if (c.w.includes(q)) hits.push({ c, idx: [], score: 0.5 });   // matched a hidden keyword
  }
  return hits.sort((a, b) => b.score - a.score);
}
const mark = (text, idx) =>                                           // wrap matched letters
  [...text].map((ch, i) => (idx.includes(i) ? `<mark>${ch}</mark>` : ch)).join("");

let hits = [], active = 0, isOpen = false;
function render() {
  hits = search(input.value);
  let html = "", n = 0;
  for (const g of ["Actions", "Pages", "Settings"]) {
    const mine = hits.filter((h) => h.c.g === g);
    if (!mine.length) continue;
    html += `<div class="gh">${g}</div>`;
    for (const h of mine) {
      h.n = n++;
      const keys = h.c.k.map((k) => `<kbd>${k}</kbd>`).join("");
      html += `<div class="row" role="option" data-n="${h.n}"><span class="ic"><svg viewBox="0 0 24 24">` +
        `<path d="${ICON[h.c.i]}"/></svg></span><span class="tx"><b>${mark(h.c.t, h.idx)}</b>` +
        `<small>${h.c.s}</small></span><span class="ks">${keys}</span></div>`;
    }
  }
  hits = hits.filter((h) => h.n !== undefined).sort((a, b) => a.n - b.n);
  rowsEl.innerHTML = html || `<div class="empty">No results for &ldquo;${input.value}&rdquo;</div>`;
  setActive(0);
}
function setActive(i) {
  active = hits.length ? (i + hits.length) % hits.length : 0;
  const rows = rowsEl.querySelectorAll(".row");
  rows.forEach((r, k) => r.classList.toggle("on", k === active));
  const r = rows[active];
  if (!r) return (S.th = 0);
  S.ty = rowsEl.offsetTop + r.offsetTop; S.th = r.offsetHeight;
  if (active === 0) list.scrollTop = 0;                                // keep the group title in view
  else if (r.offsetTop < list.scrollTop) list.scrollTop = r.offsetTop - 6;
  else if (r.offsetTop + 50 > list.scrollTop + list.clientHeight - 8)
    list.scrollTop = r.offsetTop + 58 - list.clientHeight;
}

const S = { o: 0, vo: 0, y: 0, vy: 0, h: 50, vh: 0, ty: 0, th: 50, tt: 0, vt: 0, to: 0 };
let toastUntil = 0, clock = 0;
function open() {
  isOpen = true; S.to = 1; input.value = "";
  pal.style.visibility = "visible";                                  // a hidden input cannot take focus
  render(); input.focus();
}
function close() { isOpen = false; S.to = 0; input.blur(); }
function run(c) {
  toast.innerHTML = `<i>&#10003;</i>Ran &ldquo;${c.t}&rdquo;`;
  toastUntil = clock + 1900; close();
}

addEventListener("keydown", (e) => {
  if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
    e.preventDefault(); isOpen ? close() : open(); return;
  }
  if (!isOpen) return;
  if (e.key === "Escape") close();
  else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
    e.preventDefault(); setActive(active + (e.key === "ArrowDown" ? 1 : -1));
  }
  else if (e.key === "Enter") { e.preventDefault(); if (hits[active]) run(hits[active].c); }
});
input.addEventListener("input", render);
rowsEl.addEventListener("mousemove", (e) => {
  const r = e.target.closest(".row");
  if (r && +r.dataset.n !== active) setActive(+r.dataset.n);
});
rowsEl.addEventListener("click", (e) => {
  const r = e.target.closest(".row");
  if (r) run(hits[+r.dataset.n].c);
});
scrim.addEventListener("click", close);
$(".trigger").addEventListener("click", open);

const spring = (x, v, to, k, d, dt) => {
  v += ((to - x) * k - v * d) * dt;
  return [x + v * dt, v];
};
let last = null;
requestAnimationFrame(function frame(now) {
  last ??= now; clock = now;
  const dt = Math.min((now - last) / 1000, 0.04); last = now;
  [S.o, S.vo] = spring(S.o, S.vo, S.to, 330, 24, dt);
  [S.y, S.vy] = spring(S.y, S.vy, S.ty, 520, 34, dt);                 // the highlight glides
  [S.h, S.vh] = spring(S.h, S.vh, S.th, 520, 34, dt);
  [S.tt, S.vt] = spring(S.tt, S.vt, clock < toastUntil ? 1 : 0, 300, 22, dt);

  const o = Math.max(0, Math.min(1.2, S.o));
  pal.style.visibility = isOpen || S.o > 0.01 ? "visible" : "hidden";
  pal.style.opacity = Math.min(1, o * 1.6);
  pal.style.transform = `translateY(${(1 - o) * -14}px) scale(${0.95 + 0.05 * o})`;
  scrim.style.opacity = Math.min(1, o);
  app.style.filter = `blur(${(o * 3).toFixed(2)}px)`;
  pill.style.transform = `translateY(${S.y}px)`;
  pill.style.height = S.h + "px";
  pill.style.display = hits.length ? "block" : "none";
  toast.style.opacity = Math.min(1, S.tt);
  toast.style.transform = `translateY(${(1 - S.tt) * 18}px)`;
  requestAnimationFrame(frame);
});

// open on load so the demo shows itself
if (location.hash !== "#closed") setTimeout(open, 400);
07

Page Curl Book

A two-page book you can turn by dragging a corner. The sheet rolls round a curved edge with light and shadow, and the flap shows the back of the page. Tap a corner or press the arrow keys to turn.

page curldragflipbook

How to use: try the demo, pick a file tab, press Copy. Put the three files in one folder and open index.html.

Live demo
<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Page Curl Book</title>
  <link rel="stylesheet" href="style.css" />
</head>
<body>
  <main class="stage">
    <p class="eyebrow"><i></i>PAGE CURL / UI</p>

    <div class="book" id="book">
      <div class="slot l"></div>       <!-- the two pages you see at rest -->
      <div class="slot r"></div>

      <!-- the page being turned, built from layers (all of them are clipped by JS) -->
      <div class="turn">
        <div class="face under"></div>                     <!-- the page revealed underneath -->
        <div class="wrap" id="rev"><i class="strip" id="s-rev"></i></div>
        <div class="wrap" id="keep">                       <!-- the part still lying flat -->
          <div class="face" id="front"></div><i class="strip" id="s-keep"></i>
        </div>
        <div class="box"></div>                            <!-- the rolled and folded flap, built by app.js -->
      </div>
    </div>

    <p class="hint">Drag a page corner. <u>Let go</u> to turn it.</p>
  </main>
  <script src="app.js"></script>
</body>
</html>
:root { --paper: #f5f0e6; --ink: #1d1b22; --acc: #e8553d; }
* { box-sizing: border-box; margin: 0; }
html, body { height: 100%; overflow: hidden; }
body { background: radial-gradient(ellipse at 50% 38%, #2a2140, #0b0a12 74%); color: #f2f4fa;
  font-family: Inter, system-ui, sans-serif; }
.stage { position: absolute; inset: 0; }
.eyebrow { position: absolute; top: 20px; left: 0; right: 0; text-align: center; display: flex;
  gap: 10px; align-items: center; justify-content: center; font-size: 12px; letter-spacing: .32em; color: #7d8497; }
.eyebrow i { width: 9px; height: 9px; border-radius: 50%; background: #ff8a5c; box-shadow: 0 0 14px #ff8a5c; }
.hint { position: absolute; bottom: 22px; left: 0; right: 0; text-align: center; color: #7d8497; font-size: 14px; }
.hint u { color: #f2f4fa; text-decoration-style: dashed; }

/* the book is 2 pages of 220 x 300; JS scales it to fit */
.book { position: absolute; left: 50%; top: 50%; width: 440px; height: 300px; margin: -146px 0 0 -220px;
  touch-action: none; cursor: grab; }
.book::before { content: ""; position: absolute; inset: -7px -9px -11px; border-radius: 9px; background: #1a1322;
  box-shadow: 0 30px 60px #000a, inset 0 0 0 1px #ffffff12; }
.slot, .turn { position: absolute; top: 0; width: 220px; height: 300px; pointer-events: none; }
.slot.l { left: 0; box-shadow: -1px 1px 0 #e4ddcd, -2px 2px 0 #cdc5b2, -3px 3px 0 #e1dac9, -4px 4px 0 #c6bda9; }
.slot.r { left: 220px; box-shadow: 1px 1px 0 #e4ddcd, 2px 2px 0 #cdc5b2, 3px 3px 0 #e1dac9, 4px 4px 0 #c6bda9; }
.turn { display: none; }
.face, .wrap, .box, .flap { position: absolute; inset: 0; }
.face { overflow: visible; }
.box { filter: drop-shadow(-2px 4px 5px rgba(30, 20, 10, .34)) drop-shadow(-6px 12px 14px rgba(30, 20, 10, .22)); }
.flap { transform-origin: 0 0; }
.sh { position: absolute; inset: 0; pointer-events: none; }
.strip { position: absolute; left: 0; top: 0; height: 1400px; transform-origin: 0 0; pointer-events: none; }
#s-keep { background: linear-gradient(90deg, rgba(20, 14, 8, .34), rgba(20, 14, 8, .12) 40%, rgba(20, 14, 8, 0)); }
#s-rev { background: linear-gradient(90deg, rgba(14, 8, 3, .62), rgba(14, 8, 3, .3) 28%, rgba(14, 8, 3, .1) 62%, rgba(14, 8, 3, 0)); }

/* ---- the pages: plain HTML and CSS, no images ---- */
.page { position: absolute; inset: 0; overflow: hidden; background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 .42 0 0 0 0 .34 0 0 0 0 .22 0 0 0 .1 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"), linear-gradient(135deg, #f8f4ea, #f1ebdd); color: var(--ink); padding: 20px 18px; }
.page::after { content: ""; position: absolute; top: 0; bottom: 0; width: 36px; pointer-events: none; }
.page[data-side="r"]::after { left: 0; background: linear-gradient(90deg, #0000003a, #0000000d 45%, #0000); }
.page[data-side="l"]::after { right: 0; background: linear-gradient(270deg, #0000003a, #0000000d 45%, #0000); }
.no { position: absolute; bottom: 10px; font: 700 8px Inter, sans-serif; letter-spacing: .14em; color: #9a9385; }
[data-side="r"] .no { right: 16px; } [data-side="l"] .no { left: 16px; }
h2 { font: 700 25px/1.06 "Playfair Display", Georgia, serif; letter-spacing: -.01em; margin: 6px 0 10px; }
.kick { font: 700 7px Inter, sans-serif; letter-spacing: .22em; text-transform: uppercase; color: var(--acc); }
p { font-size: 8.4px; line-height: 1.6; color: #4a463f; margin-bottom: 7px; }
.lead::first-letter { float: left; font: 700 34px/.9 "Playfair Display", Georgia, serif; padding: 3px 5px 0 0; color: var(--acc); }
blockquote { margin: 10px 0; padding-left: 10px; border-left: 2px solid var(--acc);
  font: italic 600 12px/1.35 "Playfair Display", Georgia, serif; }
.toc { list-style: none; padding: 0; margin-top: 14px; display: grid; gap: 10px; }
.toc li { display: flex; gap: 10px; align-items: baseline; font: 600 10px Inter, sans-serif; border-bottom: 1px solid #0000001a; padding-bottom: 8px; }
.toc b { color: var(--acc); font-size: 9px; }
.fine { position: absolute; left: 18px; right: 40px; bottom: 28px; }

.art { position: absolute; inset: 0; }
.art::before, .art::after { content: ""; position: absolute; inset: 0; }
.over { position: absolute; left: 18px; right: 18px; bottom: 30px; color: #fff; text-shadow: 0 2px 14px #0005; }
.over h2 { color: #fff; font-size: 24px; } .over .kick { color: #ffd7b0; }
.a1 { background: linear-gradient(180deg, #2b1b4d, #b4476a 48%, #ff9a5a 74%, #ffd9a0); }
.a1::before { background: #1b1233; clip-path: polygon(0 100%, 0 62%, 18% 50%, 34% 64%, 55% 36%, 74% 60%, 88% 48%, 100% 58%, 100% 100%); }
.a1::after { background: #2c1f4c; clip-path: polygon(0 100%, 0 80%, 25% 70%, 45% 82%, 70% 68%, 100% 82%, 100% 100%); }
.sun { position: absolute; left: 56%; top: 36%; width: 62px; height: 62px; border-radius: 50%;
  background: radial-gradient(circle, #fff6d6, #ffcf7a 60%, #ff9a5a); box-shadow: 0 0 60px 20px #ffb36b88; }
.a3 { background: linear-gradient(180deg, #0f2a4a, #2a6f97 50%, #9bdcee 51%, #1d557d 52%, #0b2a45); }
.a3::before { background: repeating-linear-gradient(180deg, #ffffff00 0 5px, #ffffff1c 5px 7px); top: 52%; }
.a3::after { background: #0c1d2e; clip-path: polygon(0 52%, 12% 40%, 26% 52%, 44% 34%, 60% 52%, 78% 38%, 100% 52%, 100% 53%, 0 53%); }
.a6 { background: linear-gradient(180deg, #f8e0b0, #f2b66d); }
.a6::before { background: #d98d4c; clip-path: polygon(0 100%, 0 56%, 30% 46%, 58% 62%, 100% 44%, 100% 100%); }
.a6::after { background: #b86a36; clip-path: polygon(0 100%, 0 76%, 40% 66%, 70% 80%, 100% 70%, 100% 100%); }
.bars { display: flex; align-items: flex-end; gap: 7px; height: 110px; margin: 14px 0 10px; padding-bottom: 4px; border-bottom: 1px solid #0003; }
.bars i { flex: 1; height: var(--h); border-radius: 3px 3px 0 0; background: var(--acc); }
.bars i:nth-child(6) { background: #1d1b22; }
.notes { list-style: none; padding: 0; display: grid; gap: 9px; margin-top: 10px; }
.notes li { font-size: 9px; line-height: 1.4; padding-left: 16px; position: relative; }
.notes li::before { content: ""; position: absolute; left: 0; top: 2px; width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid var(--acc); }
.big { font: 700 70px/1 "Playfair Display", Georgia, serif; color: var(--acc); margin-top: 24px; }
.pill { display: inline-block; margin-top: 12px; padding: 7px 14px; border-radius: 99px; background: var(--ink); color: #fff; font: 700 8px Inter, sans-serif; letter-spacing: .12em; }
// Page curl. The sheet leaves the table on a line between the corner and your pointer,
// rolls round a small cylinder, then lies back over itself as the flap.
const $ = (s) => document.querySelector(s);
const PW = 220, PH = 300, ZONE = 84;                  // page size, and how close a corner grab must be
const book = $("#book"), turn = $(".turn");
const slotL = $(".slot.l"), slotR = $(".slot.r"), keep = $("#keep"), rev = $("#rev");
const strip = { keep: $("#s-keep"), rev: $("#s-rev") };

const PAGES = [
  `<span class="kick">Issue 04 &middot; Autumn</span><h2>In this<br>issue</h2><ol class="toc">
    <li><b>02</b>Chasing the last light</li><li><b>04</b>Seven ridges, one night</li>
    <li><b>06</b>Field notes</li><li><b>07</b>Sand &amp; silence</li></ol>
    <p class="fine">Wanderlight is an imaginary magazine. Every page here is plain HTML and CSS.</p>`,
  `<div class="art a1"><i class="sun"></i></div>
    <div class="over"><span class="kick">Cover story</span><h2>Chasing the last light</h2></div>`,
  `<span class="kick">Chapter one</span><h2>The long way up</h2>
    <p class="lead">We left before sunrise, when the valley was still holding its breath. The trail was
    steep and the air thin, and nobody spoke for the first hour.</p>
    <blockquote>&ldquo;We slowed down until the mountain started talking.&rdquo;</blockquote>
    <p>By noon the ridge opened up. Cloud moved below us like slow water, and the only sound was boots on
    stone. We ate standing, watching the light change.</p>`,
  `<div class="art a3"></div>
    <div class="over"><span class="kick">Lofoten, 6:42 pm</span><h2>Where the day goes to rest</h2></div>`,
  `<span class="kick">By the numbers</span><h2>Seven ridges, one night</h2>
    <div class="bars">${[38, 52, 44, 71, 63, 88, 57].map((h) => `<i style="--h:${h}%"></i>`).join("")}</div>
    <p>Each bar is a ridge we crossed. The tall dark one was the last, and the coldest.</p>`,
  `<span class="kick">Field notes</span><h2>What we<br>packed</h2><ul class="notes">
    <li>One warm layer more than you think you need</li><li>A paper map, because batteries die</li>
    <li>Salt, a knife and a very small pan</li><li>Patience, mostly</li></ul>`,
  `<div class="art a6"></div>
    <div class="over"><span class="kick">The last chapter</span><h2>Sand &amp; silence</h2></div>`,
  `<span class="kick">Coming soon</span><h2>Next<br>issue</h2><div class="big">05</div>
    <p>Rivers, rooftops and the slowest trains in Europe.</p><span class="pill">KEEP TURNING</span>`,
];
const N = PAGES.length / 2;                           // number of spreads
const page = (i) => `<div class="page" data-side="${i % 2 ? "r" : "l"}">${PAGES[i]}<em class="no">${i + 1}</em></div>`;
const put = (el, html, mirror) => { el.innerHTML = html; el.style.transform = mirror ? "scaleX(-1)" : ""; };

/* ---------- geometry ---------- */
// clip a polygon to one side of a line (Sutherland-Hodgman)
function clip(poly, f, sign) {
  const out = [];
  for (let i = 0; i < poly.length; i++) {
    const a = poly[i], b = poly[(i + 1) % poly.length];
    const fa = sign * f(a), fb = sign * f(b);
    if (fa >= 0) out.push(a);
    if (fa >= 0 !== fb >= 0) {
      const t = fa / (fa - fb);
      out.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]);
    }
  }
  return out;
}
const RECT = [[0, 0], [PW, 0], [PW, PH], [0, PH]];
const px = (q) => `${q[0].toFixed(1)}px ${q[1].toFixed(1)}px`;
const poly = (p) => (p.length < 3 ? "polygon(0 0)" : `polygon(${p.map(px).join(",")})`);

const R_MAX = 30, BANDS = 12;             // roll radius, and how many strips draw it
function curl(P, cy) {          // P = where the corner (PW, cy) has been dragged to
  const dx = PW - P.x, dy = cy - P.y, dist = Math.hypot(dx, dy);
  if (dist < 0.4) return null;
  const ux = dx / dist, uy = dy / dist;               // unit vector: cursor -> corner
  const rest = Math.hypot(P.x + PW, P.y - cy);        // how far the page still has to travel
  const R = Math.min(R_MAX * Math.min(1, rest / 70), dist / Math.PI);   // tight, then relaxes
  const tC = PW * ux + cy * uy;                       // the corner's distance along u
  const t0 = tC - dist / 2 - (Math.PI * R) / 2;       // where the sheet leaves the table
  return { ux, uy, R, t0, dist };
}
function limit(p, cy) {         // paper cannot stretch, so stay within reach of the spine
  let x = Math.min(p.x, PW), y = cy ? Math.min(p.y, PH) : Math.max(p.y, 0);
  const cap = (cx, cy2, r) => {
    const d = Math.hypot(x - cx, y - cy2);
    if (d > r) { x = cx + ((x - cx) * r) / d; y = cy2 + ((y - cy2) * r) / d; }
  };
  cap(0, cy, PW);                                     // near hinge: one page width
  cap(0, PH - cy, Math.hypot(PW, PH));                // far hinge: the page diagonal
  return { x, y };
}

const box = $(".box");
const strip1 = '<div class="flap"><div class="face back"></div><i class="sh"></i></div>';
box.innerHTML = strip1.repeat(BANDS + 1);                  // one strip per slice of the roll
const flaps = [...box.children], backs = box.querySelectorAll(".back");
const shade = box.querySelectorAll(".sh");

// light on the roll: n is the surface normal at angle a, the light comes from the upper right
function light(a) {
  const dif = 0.55 * Math.sin(a) - 0.83 * Math.cos(a);
  const edge = Math.max(0, 1 - (a - Math.PI / 2) / 0.45) * 0.24;    // the turning-away side is darker
  return dif > 0.88 ? `rgba(255,252,244,${((dif - 0.88) * 3.2).toFixed(3)})`
                    : `rgba(24,14,6,${((0.88 - dif) * 0.9 + edge).toFixed(3)})`;
}
// an affine map along u: new t = b * t + c, as a CSS matrix
const along = (ux, uy, b, c) =>
  `matrix(${1 + (b - 1) * ux * ux},${(b - 1) * ux * uy},${(b - 1) * ux * uy},` +
  `${1 + (b - 1) * uy * uy},${c * ux},${c * uy})`;

function draw() {
  const g = curl(P, cy), on = !!g;
  box.style.display = rev.style.display = on ? "block" : "none";
  if (!on) { keep.style.clipPath = "none"; return; }
  const { ux, uy, R, t0 } = g, arc = Math.PI * R;
  const beyond = (t) => (p) => p[0] * ux + p[1] * uy - t;           // > 0 past distance t along u
  keep.style.clipPath = poly(clip(RECT, (p) => t0 - p[0] * ux - p[1] * uy, 1));   // still flat
  rev.style.clipPath = poly(clip(RECT, beyond(t0), 1));                           // page underneath
  // 1. the flat part of the flap is the page mirrored over the crest of the roll
  const last = flaps[BANDS];
  last.style.clipPath = poly(clip(RECT, beyond(t0 + arc), 1));
  last.style.transform = along(ux, uy, -1, 2 * t0 + arc);
  shade[BANDS].style.background = "rgba(24,14,6,.05)";
  // 2. the roll: the sheet bends round a cylinder, seen from above as thin strips
  for (let i = 0; i < BANDS; i++) {
    const a0 = Math.PI / 2 + ((Math.PI / 2) * i) / BANDS, a1 = a0 + Math.PI / 2 / BANDS;
    const b = (Math.sin(a1) - Math.sin(a0)) / (a1 - a0);            // squash along u
    const upTo = (t) => (p) => t - p[0] * ux - p[1] * uy;           // > 0 before distance t
    const slice = clip(clip(RECT, beyond(t0 + R * a0), 1), upTo(t0 + R * a1 + 0.7), 1);
    flaps[i].style.clipPath = poly(slice);
    flaps[i].style.transform = along(ux, uy, b, t0 + R * Math.sin(a0) - b * (t0 + R * a0));
    shade[i].style.background = light((a0 + a1) / 2);
    flaps[i].style.display = R < 0.5 ? "none" : "block";
  }
  const lay = (el, w, t, dir) => {                    // gradient strip starting at distance t
    const deg = (Math.atan2(dir * uy, dir * ux) * 180) / Math.PI;
    el.style.width = w + "px";
    el.style.transform = `translate(${t * ux}px,${t * uy}px) rotate(${deg}deg) translate(0,-700px)`;
  };
  lay(strip.keep, 30, t0, -1);                        // contact shadow on the flat page
  lay(strip.rev, 3.4 * R + 8, t0, 1);                 // the roll's shadow on the page beneath
  book.dataset.fold = Math.round(g.dist);
}

/* ---------- state ---------- */
let spread = 0, mode = "idle", dir = 0, cy = PH, scale = 1, clock = 0, last = null;
let P = { x: PW, y: PH }, V = { x: 0, y: 0 }, T = { x: PW, y: PH }, ptr = null, down = null, auto = null;
const canGo = (d) => (d > 0 ? spread < N - 1 : spread > 0);
const drawStatic = () => { put(slotL, page(2 * spread)); put(slotR, page(2 * spread + 1)); };

function setup(d, y) {                                  // fill the turning page with the right faces
  const s = 2 * spread;
  dir = d; cy = y; P = { x: PW, y: y }; V = { x: 0, y: 0 }; T = { x: PW, y: y };
  put($(".under"), page(d > 0 ? s + 3 : s - 2), d < 0);
  put($("#front"), page(d > 0 ? s + 1 : s), d < 0);
  backs.forEach((el) => put(el, page(d > 0 ? s + 2 : s - 1), d > 0));   // the back of a sheet is a mirror image
  turn.style.left = d > 0 ? PW + "px" : "0px";
  turn.style.transform = d > 0 ? "" : "scaleX(-1)";      // turning the left page is the same maths, mirrored
  turn.style.display = "block";
}
function release() { dir = 0; mode = "idle"; turn.style.display = "none"; P = { x: PW, y: PH }; V = { x: 0, y: 0 }; }
function commit() { spread += dir; drawStatic(); release(); book.dataset.spread = spread; }
function startAuto() { mode = "auto"; auto = { t0: clock, from: { ...P } }; }

/* ---------- pointer ---------- */
function toBook(e) {
  const r = book.getBoundingClientRect(); scale = r.width / (2 * PW);
  return { x: (e.clientX - r.left) / scale, y: (e.clientY - r.top) / scale };
}
const toStage = (b, d) => ({ x: d > 0 ? b.x - PW : PW - b.x, y: b.y });
function pick(b) {                                      // which corner is the pointer near?
  let best = null;
  for (const d of [1, -1]) for (const y of [0, PH]) {
    if (!canGo(d)) continue;
    const dist = Math.hypot(b.x - (d > 0 ? 2 * PW : 0), b.y - y);
    if (dist < ZONE && (!best || dist < best.dist)) best = { d, y, dist };
  }
  return best;
}
book.addEventListener("pointermove", (e) => {
  ptr = toBook(e);
  if (mode === "drag") { T = limit(toStage(ptr, dir), cy); down.moved = Math.max(down.moved, Math.hypot(e.clientX - down.x, e.clientY - down.y)); return; }
  if (mode === "finish" || mode === "auto") return;
  const hit = pick(ptr);
  if (!hit) { if (mode === "peek") { mode = "back"; T = { x: PW, y: cy }; } return; }
  if (dir !== hit.d || cy !== hit.y) setup(hit.d, hit.y);
  const f = Math.pow(1 - hit.dist / ZONE, 0.8);         // closer to the corner = a bigger lift
  mode = "peek"; T = { x: PW - 30 * f, y: cy ? cy - 24 * f : 24 * f };
});
book.addEventListener("pointerleave", () => { ptr = null; if (mode === "peek") { mode = "back"; T = { x: PW, y: cy }; } });
book.addEventListener("pointerdown", (e) => {
  ptr = toBook(e);
  const hit = pick(ptr);
  if (!hit || mode === "finish" || mode === "auto") return;
  if (dir !== hit.d || cy !== hit.y || mode === "idle") setup(hit.d, hit.y);
  mode = "drag"; down = { x: e.clientX, y: e.clientY, moved: 0 };
  book.setPointerCapture(e.pointerId); T = limit(toStage(ptr, dir), cy);
});
book.addEventListener("pointerup", () => {
  if (mode !== "drag") return;
  if (down.moved < 7) return startAuto();               // a tap turns the page by itself
  if (P.x < PW * 0.42 || V.x < -450) { mode = "finish"; T = { x: -PW, y: cy }; }   // dragged far enough
  else { mode = "back"; T = { x: PW, y: cy }; }
});
addEventListener("keydown", (e) => {
  const d = { ArrowRight: 1, ArrowLeft: -1 }[e.key];
  if (!d || !canGo(d) || mode === "finish" || mode === "auto") return;
  setup(d, PH); startAuto();
});

/* ---------- loop ---------- */
const spring = (x, v, to, k, c, dt) => { v += ((to - x) * k - v * c) * dt; return [x + v * dt, v]; };
requestAnimationFrame(function frame(now) {
  last ??= now; clock = now;
  const dt = Math.min((now - last) / 1000, 0.04); last = now;
  if (dir) {
    if (mode === "auto") {
      const u = Math.min(1, (clock - auto.t0) / 950), e = u * u * (3 - 2 * u);
      P = { x: auto.from.x + (-PW - auto.from.x) * e,
            y: auto.from.y + (cy - auto.from.y) * e + (cy ? -1 : 1) * Math.sin(Math.PI * e) * 70 };
      if (u >= 1) commit();
    } else {
      const k = mode === "drag" ? 900 : mode === "peek" ? 240 : 170, c = mode === "drag" ? 62 : mode === "peek" ? 26 : 21;
      const n = Math.ceil(dt / 0.006);                    // small steps keep the stiff spring stable
      for (let i = 0; i < n; i++) {
        [P.x, V.x] = spring(P.x, V.x, T.x, k, c, dt / n);
        [P.y, V.y] = spring(P.y, V.y, T.y, k, c, dt / n);
        P = limit(P, cy);
      }
      if (mode === "finish" && Math.hypot(P.x - T.x, P.y - T.y) < 1.5 && Math.hypot(V.x, V.y) < 25) commit();
      if (mode === "back" && Math.hypot(P.x - PW, P.y - cy) < 0.6) release();
    }
    if (dir) draw();
  }
  requestAnimationFrame(frame);
});

function fit() {                                        // scale the whole book to the window
  const k = Math.min(1.35, (innerWidth - 24) / (2 * PW), (innerHeight - 120) / PH);
  book.style.transform = `scale(${k})`;
}
addEventListener("resize", fit); fit();
drawStatic();

// a little tease on load so people know the corner moves (skipped with #still)
function tease() {
  if (mode !== "idle" || ptr) return;
  setup(1, PH); mode = "peek"; T = { x: PW - 74, y: PH - 62 };
  setTimeout(() => { if (mode === "peek" && !ptr) { mode = "back"; T = { x: PW, y: cy }; } }, 1700);
}
if (location.hash !== "#still") { setTimeout(tease, 500); setInterval(tease, 4600); }
← All components

Seen one of these in a TechMendly Reel on Instagram? This is where the code lives. Need a custom component for your own site? Get in touch.