Original interface components, each with a live demo and the full source. Plain HTML, CSS and JavaScript, no libraries. Pick one to see it work and copy the code.
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.
View live demo and code →A sun/moon theme switch that floods the whole page with the new theme in an expanding ink circle. Click the switch.
View live demo and code →A volume slider that is a real vibrating string: wave-equation physics, drag the knob, pull it, let go.
View live demo and code →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.
View live demo and code →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.
View live demo and code →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.
View live demo and code →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.
View live demo and code →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.
How to use: try the demo, pick a file tab, press Copy. Put the three files in one folder and open index.html.
<!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);
});
A sun/moon theme switch that floods the whole page with the new theme in an expanding ink circle. Click the switch.
How to use: try the demo, pick a file tab, press Copy. Put the three files in one folder and open index.html.
<!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 · 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();
A volume slider that is a real vibrating string: wave-equation physics, drag the knob, pull it, let go.
How to use: try the demo, pick a file tab, press Copy. Put the three files in one folder and open index.html.
<!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);
});
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.
How to use: try the demo, pick a file tab, press Copy. Put the three files in one folder and open index.html.
<!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);
});
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.
How to use: try the demo, pick a file tab, press Copy. Put the three files in one folder and open index.html.
<!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; }
});
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.
How to use: try the demo, pick a file tab, press Copy. Put the three files in one folder and open index.html.
<!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… <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…" 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>↑</kbd><kbd>↓</kbd> move</span>
<span><kbd>↵</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 “${input.value}”</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>✓</i>Ran “${c.t}”`;
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);
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.
How to use: try the demo, pick a file tab, press Copy. Put the three files in one folder and open index.html.
<!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 · 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 & 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>“We slowed down until the mountain started talking.”</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 & 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); }
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.