<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>会变形的下拉框:神灯 / 退潮 / 像素</title>
<style>
:root {
--paper: #fbf8f1;
--card: #fffdf8;
--ink: #1c2230;
--ink-2: #4a5163;
--ink-3: #636877;
--line: #e6dfd0;
--dot: #d8d0bf;
--blue: #2f5bd3;
--shadow: 0 1px 0 rgba(28, 34, 48, 0.04), 0 10px 28px -14px rgba(28, 34, 48, 0.28);
--f-sans: system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
--f-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root {
--paper: #14161b;
--card: #1e2129;
--ink: #ece7dc;
--ink-2: #bab6ac;
--ink-3: #959aa8;
--line: #31353f;
--dot: #2c3039;
--blue: #8eaaff;
--shadow: 0 1px 0 rgba(0, 0, 0, 0.25), 0 10px 28px -14px rgba(0, 0, 0, 0.8);
color-scheme: dark;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
padding: 64px 16px 32px;
background: var(--paper) radial-gradient(var(--dot) 1px, transparent 1.2px) 0 0 / 22px 22px;
color: var(--ink);
font: 16px/1.7 var(--f-sans);
}
.hint { margin: 0; max-width: 420px; text-align: center; font-size: 13px; color: var(--ink-3); }
.stage { min-height: 300px; }
.fxs { display: inline-flex; gap: 2px; padding: 3px; border: 1px solid var(--line); border-radius: 999px; background: var(--card); }
.fxs button { border: 0; padding: 7px 14px; border-radius: 999px; background: transparent; color: var(--ink-2); font: 500 13px/1 var(--f-sans); cursor: pointer; }
.fxs button[aria-checked="true"] { background: var(--ink); color: var(--paper); }
.fxs button:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
.hint { min-height: 3.4em; }
/* ---------- the picker (uses the page tokens --card, --line, --ink*, --blue, --shadow, --f-*) ---------- */
.pick {
position: relative;
display: inline-block;
}
/* Stacking inside .pick: menu (5) < pixel cloud canvas (6) < trigger (7), so the cloud that
flows out of the trigger and is pulled back into it passes behind the trigger. */
.pick-btn {
position: relative;
z-index: 7;
display: inline-flex;
align-items: center;
gap: 10px;
min-width: 260px;
padding: 9px 14px 9px 18px;
border: 1px solid var(--line);
border-radius: 99px;
background: var(--card);
color: var(--ink);
box-shadow: var(--shadow);
font: 400 14px/1.3 var(--f-sans);
}
.pick-btn:focus-visible,
.pick-menu:focus-visible {
outline: 2px solid var(--blue);
outline-offset: 2px;
}
.pick-btn .k {
color: var(--ink-3);
}
.pick-btn .v {
flex: 1;
text-align: left;
font: 500 14px/1.3 var(--f-mono);
}
.pick-btn svg {
transition: transform 0.2s;
}
.pick-btn[aria-expanded="true"] svg {
transform: rotate(180deg);
}
/* The padding keeps the menu's shadow inside the masked box while it animates. */
.pick-pop {
position: absolute;
z-index: 5;
top: 100%;
left: -12px;
padding: 4px 12px 16px;
}
.pick-fx {
position: absolute;
z-index: 6;
pointer-events: none;
}
.pick-pop[hidden] {
display: none;
}
.pick-menu {
list-style: none;
margin: 0;
padding: 6px;
min-width: 260px;
background: var(--card);
border: 1px solid var(--line);
border-radius: 16px;
box-shadow: var(--shadow);
}
.pick-menu li {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 10px;
font: 14px/1.3 var(--f-mono);
color: var(--ink-2);
cursor: pointer;
}
.pick-menu li.active {
background: color-mix(in srgb, var(--ink) 7%, transparent);
}
.pick-menu li[aria-selected="true"] {
color: var(--ink);
font-weight: 600;
}
.pick-menu .sw {
width: 8px;
height: 8px;
background: var(--vibe);
}
.pick-menu li span:nth-child(2) {
flex: 1;
}
.pick-menu li svg {
visibility: hidden;
}
.pick-menu li[aria-selected="true"] svg {
visibility: visible;
}
</style>
</head>
<body>
<div class="fxs" role="radiogroup" aria-label="收起动画">
<button type="button" role="radio" aria-checked="true" data-fx="genie">神灯</button>
<button type="button" role="radio" aria-checked="false" data-fx="tide">退潮</button>
<button type="button" role="radio" aria-checked="false" data-fx="pixel">像素</button>
</div>
<p class="hint" id="fx-hint"></p>
<div class="stage"><div class="pick" id="pick"></div></div>
<script>
// A select with a pixel menu. Each option has a colour ("vibe"). Opening pours the menu out of the trigger as a curtain
// of pixels in the current colour that fades into the card; closing rewinds that pour in the new
// colour, drawing the curtain back up into the trigger while the name re-forms.
// Three ways to open / close: genie (default), tide and pixel (below). Exposes window.pixelPick = { mount, swapText, rollText, vibe }.
(() => {
// id → [colour on light paper, colour on dark paper]
const VIBES = {
"Tidecaller": ["#2f5bd3", "#8eaaff"],
"Verdant Sigil": ["#2f8a3e", "#7ed38f"],
"Obsidian Omen": ["#1c2230", "#ece7dc"],
"Ember Hymn": ["#e8590c", "#ffa94d"],
"Rose Cipher": ["#c2255c", "#f783ac"],
};
const darkScheme = matchMedia("(prefers-color-scheme: dark)");
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)");
const vibe = (id, onDark = darkScheme.matches) => {
const hit = VIBES[id] ?? ["#636877", "#959aa8"];
return onDark ? hit[1] : hit[0];
};
const CELL = 5;
// 4×4 Bayer matrix: the order in which cells of an area flip, so partial areas read as dither.
const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map((v) => (v + 0.5) / 16);
const bayer = (c, r) => BAYER[(r & 3) * 4 + (c & 3)];
const clamp = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
const ease = (t) => t * t * (3 - 2 * t);
const easeOut = (t) => 1 - (1 - t) ** 3;
const easeIn = (t) => t * t * t;
const probe = document.createElement("canvas").getContext("2d");
const rgb = (css) => {
probe.fillStyle = "#000";
probe.fillStyle = css;
const s = probe.fillStyle;
if (s.startsWith("#")) return [1, 3, 5].map((i) => parseInt(s.slice(i, i + 2), 16));
return s
.match(/[\d.]+/g)
.slice(0, 3)
.map(Number);
};
const mix = (a, b, t) => `rgb(${a.map((v, i) => Math.round(v + (b[i] - v) * t)).join(",")})`;
const still = () => reduceMotion.matches || document.hidden;
// Whether (x, y) lies on the menu card `M` (a DOMRect) with its rounded corners.
const onCard =
(M, radius = 16) =>
(x, y) => {
if (x < M.left || x > M.right || y < M.top || y > M.bottom) return false;
const dx = Math.max(M.left + radius - x, x - (M.right - radius), 0);
const dy = Math.max(M.top + radius - y, y - (M.bottom - radius), 0);
return dx * dx + dy * dy <= radius * radius;
};
// A canvas over `rect` (page coordinates): inside `host` (absolute, so it stacks with the
// picker's parts) or fixed on the page. Returns its 2D context in CSS pixels of that rect.
const layer = (rect, host) => {
const fx = document.createElement("canvas");
fx.className = "pick-fx";
const dpr = devicePixelRatio || 1;
const origin = host ? host.getBoundingClientRect() : { left: 0, top: 0 };
Object.assign(fx.style, {
position: host ? "absolute" : "fixed",
left: `${rect.left - origin.left}px`,
top: `${rect.top - origin.top}px`,
width: `${rect.width}px`,
height: `${rect.height}px`,
zIndex: host ? "" : "60",
});
fx.width = Math.ceil(rect.width * dpr);
fx.height = Math.ceil(rect.height * dpr);
const ctx = fx.getContext("2d");
ctx.scale(dpr, dpr);
(host ?? document.body).append(fx);
return ctx;
};
// Shows `el` only where cells have been painted on a mask canvas of its size.
const masker = (el) => {
const box = el.getBoundingClientRect();
const c = document.createElement("canvas");
c.width = Math.ceil(box.width);
c.height = Math.ceil(box.height);
const ctx = c.getContext("2d");
el.style.maskSize = el.style.webkitMaskSize = `${c.width}px ${c.height}px`;
el.style.maskRepeat = el.style.webkitMaskRepeat = "no-repeat";
return {
box,
ctx,
apply: () => (el.style.maskImage = el.style.webkitMaskImage = `url(${c.toDataURL()})`),
clear: () => (el.style.maskImage = el.style.webkitMaskImage = ""),
};
};
// Runs `step(t)` for t in 0..1 over `ms` on animation frames. `token.stop()` ends it early
// (the caller cleans up). Resolves true when it ran to the end.
const play = (ms, step, token) =>
new Promise((resolve) => {
const start = performance.now();
const frame = (now) => {
if (token.stopped) return resolve(false);
const t = clamp((now - start) / ms);
step(t);
if (t < 1) requestAnimationFrame(frame);
else resolve(true);
};
requestAnimationFrame(frame);
});
// Opening: the menu pours out of the trigger. A front with a sagging, ragged edge runs down;
// right behind it pixels are the accent colour, further back they dither into the card colour,
// and then the real menu shows through, top row first. The pour starts wider than the menu and
// narrows onto it. Closing (dir "out") plays the very same pour backwards: the menu dithers back
// into pixels from the bottom up and the curtain is drawn up into the trigger.
const pour = (root, btn, pop, menu, accent, dir, token) => {
const R = root.getBoundingClientRect();
const B = btn.getBoundingClientRect();
const M = menu.getBoundingClientRect();
const mask = masker(pop);
const card = rgb(getComputedStyle(menu).backgroundColor);
const acc = rgb(accent);
const extra = Math.round(M.width * 0.16);
const left = M.left - extra;
const top = B.bottom - 6;
const width = M.width + extra * 2;
const height = M.bottom + 10 - top;
const ctx = layer({ left, top, width, height }, root);
const cols = Math.ceil(width / CELL);
const rows = Math.ceil(height / CELL);
const rnd = Float32Array.from({ length: cols * rows }, Math.random);
const phase = [Math.random() * 6, Math.random() * 6];
const jag = Float32Array.from(
{ length: cols },
(_, c) => Math.sin(c * 0.45 + phase[0]) * 5 + Math.sin(c * 1.3 + phase[1]) * 3,
);
const inCard = onCard(M);
const cardCss = mix(card, card, 0);
// Band depths follow the reference recording: the cloud covers most of the menu, the text only
// shows once the front has nearly reached the bottom.
const sag = height * 0.32; // the front is a deep V: the middle leads, the sides lag
const lead = height * 0.16; // accent band right behind the front
const fade = height * 0.42; // accent dithers into the card colour
const settle = height * 0.32; // the real menu shows through
const travel = height + sag + lead + fade + settle;
const cx = left + width / 2;
mask.apply();
return play(
dir === "in" ? 720 : 560,
(time) => {
// Backwards it starts at 85%: the pour's last stretch only settles bands below the menu, and
// played first it would read as a pause after the click.
const t = dir === "in" ? time : (1 - time) * 0.85;
const front = (t * 0.7 + easeOut(t) * 0.3) * travel;
const half = width / 2 - extra * ease(clamp((t - 0.2) / 0.55));
ctx.clearRect(0, 0, width, height);
mask.ctx.clearRect(0, 0, mask.box.width, mask.box.height);
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const x = left + c * CELL + CELL / 2;
const y = top + r * CELL + CELL / 2;
const u = (x - cx) / (width / 2);
const d = front - sag * u * u + jag[c] - (y - top);
if (d <= 0) continue;
const card_ = inCard(x, y);
if (!card_ && Math.abs(x - cx) > half) continue;
const n = rnd[r * cols + c];
const th = bayer(c, r) * 0.35 + n * 0.65;
// Ragged sides: off the card, pixels thin out towards the edge of the pour.
if (!card_ && th < (Math.abs(x - cx) / half - 0.7) * 3.3) continue;
const px = c * CELL;
const py = r * CELL;
if (d < lead) {
if (th > (d / lead) * 1.6) continue;
ctx.fillStyle = mix(acc, card, n * 0.25);
} else if (d < lead + fade) {
const k = (d - lead) / fade;
if (th < k) {
if (!card_) continue;
ctx.fillStyle = cardCss;
} else ctx.fillStyle = mix(acc, card, k * 0.7 + n * 0.15);
} else if (card_) {
const k = (d - lead - fade) / settle;
if (k > th) {
mask.ctx.fillRect(px + left - mask.box.left, py + top - mask.box.top, CELL, CELL);
continue;
}
ctx.fillStyle = cardCss;
} else continue;
// Loose pixels keep a hairline gap; on the card they close up into a solid surface.
const size = card_ ? CELL : CELL - 1;
ctx.fillRect(px, py, size, size);
}
}
mask.apply();
},
token,
).then((done) => {
ctx.canvas.remove();
// Fully drawn back, the mask is empty: keep it until the caller hides the menu (restart() clears it).
if (dir === "in" || !done) mask.clear();
return done;
});
};
// Name change: the old text crumbles into specks of ink and accent, the new one assembles in
// dithered steps with a little pixel dust trailing under it. `delay` lets it meet the pixels coming back.
const swapText = async (el, text, color, { delay = 0, token = {} } = {}) => {
if (still()) {
el.textContent = text;
return;
}
const ink = rgb(getComputedStyle(el).color);
const acc = rgb(color);
const run = (dir, ms) => {
const mask = masker(el);
const box = mask.box;
const padX = 6;
const padY = 10;
const ctx = layer(
{ left: box.left - padX, top: box.top - padY, width: box.width + padX * 2, height: box.height + padY * 2 },
null,
);
const cols = Math.ceil(box.width / CELL);
const rows = Math.ceil(box.height / CELL);
const rnd = Float32Array.from({ length: cols * rows * 2 }, Math.random);
mask.apply();
return play(
ms,
(t) => {
ctx.clearRect(0, 0, box.width + padX * 2, box.height + padY * 2);
mask.ctx.clearRect(0, 0, box.width, box.height);
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const i = r * cols + c;
const th = bayer(c, r) * 0.55 + rnd[i * 2] * 0.45;
const k = (dir === "out" ? 1 - t : t) * 1.25 - 0.12;
const shown = k > th;
if (shown) mask.ctx.fillRect(c * CELL, r * CELL, CELL, CELL);
const near = Math.abs(k - th);
if (near > 0.16 || rnd[i * 2 + 1] > 0.55) continue;
const j = rnd[i * 2 + 1];
const dy = dir === "out" ? (j - 0.5) * 6 : 2 + j * 7;
ctx.globalAlpha = 1 - near / 0.16;
ctx.fillStyle = mix(ink, acc, j < 0.18 ? 1 : 0);
ctx.fillRect(padX + c * CELL + (j - 0.5) * 4, padY + r * CELL + dy, 2, 2);
}
}
mask.apply();
},
token,
).then((done) => {
ctx.canvas.remove();
if (dir === "in" || !done) mask.clear();
return done;
});
};
// Cut short, it still ends on the new name.
if (delay) await new Promise((r) => setTimeout(r, delay));
if (token.stopped) {
el.textContent = text;
return;
}
const crumbled = await run("out", 170);
el.textContent = text;
if (crumbled) await run("in", 230);
};
// ---------- Genie: the menu is sucked into the name like a macOS window into the Dock ----------
// The menu is cut into thin horizontal strips (clones clipped to a band each). Two motions overlap:
// the sides bend into a funnel whose neck sits under the name, and the strips slide up through
// it, each as wide as the funnel is at its current height. Played backwards it is the restore.
const easeInOut = (t) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
const genie = (root, btn, pop, dir, token) => {
const P = pop.getBoundingClientRect();
const R = root.getBoundingClientRect();
const B = btn.getBoundingClientRect();
const name = btn.querySelector(".v").getBoundingClientRect();
const W = P.width;
const H = P.height;
// The funnel's neck, in pop coordinates: under the middle of the name, which then changes.
const tx = Math.min(name.left + name.width / 2, B.left + B.width / 2) - P.left;
const ty = B.top + B.height / 2 - P.top;
const tw = 26;
const wrap = document.createElement("div");
wrap.className = "pick-fx";
Object.assign(wrap.style, { left: `${P.left - R.left}px`, top: `${P.top - R.top}px`, width: `${W}px`, height: `${H}px` });
const N = Math.max(40, Math.round(H / 3));
const h = H / N;
const strips = [];
for (let i = 0; i < N; i++) {
const c = pop.cloneNode(true);
c.removeAttribute("hidden");
c.setAttribute("aria-hidden", "true");
c.querySelectorAll("[id]").forEach((e) => e.removeAttribute("id"));
c.style.cssText = `left:0;top:0;margin:0;clip-path:inset(${Math.max(0, i * h - 0.6)}px 0 ${Math.max(0, H - (i + 1) * h - 0.6)}px 0);will-change:transform`;
const y0 = (i + 0.5) * h;
c.style.transformOrigin = `${W / 2}px ${y0}px`;
wrap.append(c);
strips.push({ c, y0 });
}
root.append(wrap);
pop.style.opacity = "0";
const D = H - ty + h; // far enough that the last strip reaches the neck
const step = (t) => {
const bend = easeInOut(clamp(t / 0.45));
const q = clamp((t - 0.12) / 0.88);
const slide = q * q * 0.75 + q * 0.25;
for (const s of strips) {
const y = s.y0 - slide * D;
const f = ease(clamp((y - ty) / ((H - ty) * 0.55)));
const w = W + (tw + (W - tw) * f - W) * bend;
const cx = W / 2 + (tx + (W / 2 - tx) * f - W / 2) * bend;
s.c.style.transform = `translate(${cx - W / 2}px, ${y - s.y0}px) scaleX(${w / W})`;
s.c.style.opacity = String(clamp((y - ty + 2) / 12));
}
};
const ms = dir === "out" ? 560 : 460;
return play(ms, (t) => step(dir === "out" ? t : 1 - t), token).then((done) => {
wrap.remove();
pop.style.opacity = "";
return done;
});
};
// ---------- Tide: a wave line sweeps up the menu and washes it away (or rolls down to reveal it) ----------
// The menu is clipped to the part above a moving wave (clip-path polygon, two sine waves drifting
// against each other). Along the edge runs a crest in the option's colour: a soft band, a bright
// line and a few bubbles that pop as the water goes.
const tide = (root, pop, menu, accent, dir, token) => {
const P = pop.getBoundingClientRect();
const R = root.getBoundingClientRect();
const M = menu.getBoundingClientRect();
const W = P.width;
const H = P.height;
const top = M.top - P.top;
const bottom = M.bottom - P.top;
const NS = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(NS, "svg");
svg.setAttribute("class", "pick-fx");
svg.setAttribute("width", W);
svg.setAttribute("height", H);
svg.setAttribute("aria-hidden", "true");
Object.assign(svg.style, { left: `${P.left - R.left}px`, top: `${P.top - R.top}px`, overflow: "visible" });
const gid = `tide${Math.random().toString(36).slice(2, 8)}`;
// Everything is clipped to the card's rounded rectangle: the water is on the card, not the page.
const cl = M.left - P.left;
svg.innerHTML = `<defs><linearGradient id="${gid}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${accent}" stop-opacity=".55"/><stop offset="1" stop-color="${accent}" stop-opacity="0"/></linearGradient><clipPath id="${gid}c"><rect x="${cl}" y="${top - 1}" width="${M.width}" height="${bottom - top + 2}" rx="16"/></clipPath></defs><g clip-path="url(#${gid}c)"><path class="band" fill="url(#${gid})"/><path class="crest" fill="none" stroke="${accent}" stroke-width="2" stroke-linecap="round"/><g class="bubbles" fill="none" stroke="${accent}"></g></g>`;
const band = svg.querySelector(".band");
const crest = svg.querySelector(".crest");
const bubbles = svg.querySelector(".bubbles");
const foam = Array.from({ length: 9 }, () => {
const c = document.createElementNS(NS, "circle");
bubbles.append(c);
return { c, x: M.left - P.left + 14 + Math.random() * (M.width - 28), r: 1.2 + Math.random() * 2.2, lag: Math.random() * 0.5, life: 0.25 + Math.random() * 0.3 };
});
root.append(svg);
const ph = [Math.random() * 6, Math.random() * 6];
const A0 = 9;
const pts = 36;
const edge = (x, t, amp, base) =>
base + amp * (0.62 * Math.sin(x * 0.045 + ph[0] + t * 9) + 0.38 * Math.sin(x * 0.11 + ph[1] - t * 13));
const step = (t) => {
// Closing: the edge climbs from below the menu to above it; opening is the same run backwards.
const p = dir === "out" ? t : 1 - t;
const e = dir === "out" ? p * p * (3 - 2 * p) * 0.6 + p * p * 0.4 : easeInOut(p);
const base = bottom + A0 + 4 - e * (bottom - top + A0 * 2 + 8);
const amp = A0 * Math.sin(Math.PI * clamp(p * 1.1)) ** 0.6;
const xs = Array.from({ length: pts + 1 }, (_, i) => (i / pts) * W);
const ys = xs.map((x) => edge(x, t, amp, base));
pop.style.clipPath = `polygon(0 0, ${W}px 0, ${xs.map((x, i) => `${x.toFixed(1)}px ${Math.max(0, ys[i]).toFixed(1)}px`).reverse().join(", ")})`;
const line = xs.map((x, i) => `${i ? "L" : "M"}${x.toFixed(1)} ${ys[i].toFixed(1)}`).join(" ");
const fade = clamp(Math.min(p, 1 - p) * 6);
crest.setAttribute("d", line);
crest.style.opacity = String(fade);
const deep = 16 + 10 * Math.sin(Math.PI * p);
band.setAttribute("d", `${line} L${W} ${(ys[pts] + deep).toFixed(1)} ${xs.map((x, i) => `L${x.toFixed(1)} ${(ys[i] + deep * (0.7 + 0.3 * Math.sin(i * 0.9 + t * 8))).toFixed(1)}`).reverse().join(" ")} Z`);
band.style.opacity = String(fade);
// Bubbles ride just under the crest, grow, and pop (stroke widens and fades).
for (const b of foam) {
const k = clamp((p - b.lag) / b.life);
const y = edge(b.x, t, amp, base) + 5 + k * 10;
b.c.setAttribute("cx", b.x.toFixed(1));
b.c.setAttribute("cy", y.toFixed(1));
b.c.setAttribute("r", (b.r * (0.6 + k)).toFixed(2));
b.c.setAttribute("stroke-width", (1.4 - k).toFixed(2));
b.c.style.opacity = k > 0 && k < 1 ? String((1 - k) * fade) : "0";
}
};
const ms = dir === "out" ? 640 : 520;
return play(ms, step, token).then((done) => {
svg.remove();
pop.style.clipPath = "";
return done;
});
};
// The trigger takes the menu back in: a small swell, like a Dock icon receiving a window.
const gulp = (btn) =>
btn.animate([{ scale: 1 }, { scale: 1.045, offset: 0.35 }, { scale: 0.995, offset: 0.7 }, { scale: 1 }], {
duration: 420,
easing: "ease-out",
});
// Name change for genie and tide: the old name slides up and blurs away, the new one rises from
// below in the option's colour and settles into the ink colour.
const rollText = async (el, text, color, { delay = 0, token = {} } = {}) => {
if (still()) {
el.textContent = text;
return;
}
if (delay) await new Promise((r) => setTimeout(r, delay));
if (token.stopped) {
el.textContent = text;
return;
}
await el.animate(
[{ transform: "none", opacity: 1, filter: "blur(0)" }, { transform: "translateY(-55%)", opacity: 0, filter: "blur(3px)" }],
{ duration: 140, easing: "ease-in" },
).finished;
el.textContent = text;
el.animate(
[
{ transform: "translateY(60%)", opacity: 0, filter: "blur(3px)", color },
{ transform: "translateY(-8%)", opacity: 1, filter: "blur(0)", color, offset: 0.55 },
{ transform: "none", opacity: 1, filter: "blur(0)", color: getComputedStyle(el).color },
],
{ duration: 520, easing: "cubic-bezier(.2,.8,.3,1)" },
);
};
const CHEVRON =
'<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><path d="M2.5 4.5 6 8l3.5-3.5" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>';
const CHECK =
'<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><path d="M2 6.5 4.8 9 10 3" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>';
let mounted = 0;
// Builds the picker inside `root` (a `.pick` element). Fill it with setItems([{ id }]);
// `onChange(id)` runs after the user picks a different option.
const mount = (root, { value, label = "Vibe", effect = "genie", onChange = () => {} }) => {
const uid = `pick${++mounted}`;
root.innerHTML = `<button type="button" class="pick-btn" aria-haspopup="listbox" aria-expanded="false" aria-controls="${uid}-menu"><span class="k"></span><span class="v"></span>${CHEVRON}</button><div class="pick-pop" hidden><ul class="pick-menu" id="${uid}-menu" role="listbox" tabindex="-1"></ul></div>`;
const btn = root.querySelector(".pick-btn");
const valueEl = root.querySelector(".v");
const pop = root.querySelector(".pick-pop");
const menu = root.querySelector(".pick-menu");
root.querySelector(".k").textContent = label;
menu.setAttribute("aria-label", label);
valueEl.textContent = value;
let chosen = value;
let open = false;
let fx = effect; // "genie" | "tide" | "pixel"
let token = { stopped: true };
// Only one animation per picker: a new one cuts the old one off and leaves things tidy.
const restart = () => {
token.stopped = true;
root.querySelectorAll(".pick-fx").forEach((c) => c.remove());
pop.style.maskImage = pop.style.webkitMaskImage = "";
pop.style.clipPath = pop.style.opacity = "";
token = { stopped: false };
return token;
};
const options = () => [...menu.children];
const setActive = (li) => {
options().forEach((o) => o.classList.toggle("active", o === li));
if (li) menu.setAttribute("aria-activedescendant", li.id);
};
const showMenu = () => {
if (open || !menu.children.length) return;
open = true;
const tk = restart();
btn.setAttribute("aria-expanded", "true");
pop.hidden = false;
setActive(options().find((o) => o.dataset.id === chosen));
menu.focus();
if (still()) return;
if (fx === "genie") genie(root, btn, pop, "in", tk);
else if (fx === "tide") tide(root, pop, menu, vibe(chosen), "in", tk);
else pour(root, btn, pop, menu, vibe(chosen), "in", tk);
};
const hideMenu = (refocus) => {
if (!open) return null;
open = false;
const tk = restart();
btn.setAttribute("aria-expanded", "false");
if (refocus) btn.focus();
if (still()) {
pop.hidden = true;
return tk;
}
const run =
fx === "genie"
? genie(root, btn, pop, "out", tk)
: fx === "tide"
? tide(root, pop, menu, vibe(chosen), "out", tk)
: pour(root, btn, pop, menu, vibe(chosen), "out", tk);
run.then((done) => {
if (!open) pop.hidden = true;
if (done) gulp(btn);
});
return tk;
};
const choose = (id) => {
const changed = id !== chosen;
chosen = id;
const tk = hideMenu(true) ?? restart();
if (!changed) return;
options().forEach((o) => o.setAttribute("aria-selected", String(o.dataset.id === id)));
// The old name leaves as the menu goes and the new one arrives with it.
if (fx === "pixel") swapText(valueEl, id, vibe(id), { delay: 330, token: tk });
else rollText(valueEl, id, vibe(id), { delay: fx === "genie" ? 330 : 380, token: tk });
onChange(id);
};
const setItems = (items) => {
menu.replaceChildren(
...items.map((m, i) => {
const li = document.createElement("li");
li.id = `${uid}-${i}`;
li.setAttribute("role", "option");
li.dataset.id = m.id;
li.setAttribute("aria-selected", String(m.id === chosen));
li.style.setProperty("--vibe", vibe(m.id));
const name = document.createElement("span");
name.textContent = m.id;
li.append(Object.assign(document.createElement("span"), { className: "sw" }), name);
li.insertAdjacentHTML("beforeend", CHECK);
return li;
}),
);
};
darkScheme.addEventListener("change", () =>
options().forEach((li) => li.style.setProperty("--vibe", vibe(li.dataset.id))),
);
btn.addEventListener("click", () => (open ? hideMenu(true) : showMenu()));
btn.addEventListener("keydown", (event) => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
showMenu();
}
});
menu.addEventListener("keydown", (event) => {
const list = options();
const at = list.findIndex((o) => o.classList.contains("active"));
const moves = { ArrowDown: at + 1, ArrowUp: at - 1, Home: 0, End: list.length - 1 };
if (event.key in moves) {
event.preventDefault();
const li = list[Math.max(0, Math.min(list.length - 1, moves[event.key]))];
setActive(li);
li.scrollIntoView({ block: "nearest" });
} else if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
if (list[at]) choose(list[at].dataset.id);
} else if (event.key === "Escape" || event.key === "Tab") {
if (event.key === "Escape") event.preventDefault();
hideMenu(event.key === "Escape");
}
});
menu.addEventListener("pointermove", (event) => {
const li = event.target.closest("li");
if (li) setActive(li);
});
menu.addEventListener("click", (event) => {
const li = event.target.closest("li");
if (li) choose(li.dataset.id);
});
document.addEventListener("pointerdown", (event) => {
if (open && !root.contains(event.target)) hideMenu(false);
});
return {
setItems,
get effect() {
return fx;
},
set effect(name) {
fx = name;
},
get value() {
return chosen;
},
};
};
window.pixelPick = { mount, swapText, rollText, vibe };
})();
</script>
<script>
const picker = pixelPick.mount(document.getElementById("pick"), { value: "Obsidian Omen", label: "Vibe" });
picker.setItems(["Tidecaller", "Verdant Sigil", "Obsidian Omen", "Ember Hymn", "Rose Cipher"].map((id) => ({ id })));
const HINTS = {
genie: "神灯:像 Mac 最小化窗口,菜单两侧弯成漏斗、一条条被吸进名字里,名字随即换新;打开时再从名字里「倒放」出来。",
tide: "退潮:一道带浪花的波浪线从下往上把菜单冲走,浪尖是选中项的颜色;打开时浪从上往下退,露出菜单。",
pixel: "像素:打开时像素从按钮里往下「倒」出来;收起时整段倒放,菜单退回成新颜色的像素、被收回按钮里,名字碎一下再拼好(复现 recent.design 的录屏)。",
};
const hint = document.getElementById("fx-hint");
const radios = [...document.querySelectorAll("[data-fx]")];
const pickFx = (name) => {
picker.effect = name;
radios.forEach((r) => r.setAttribute("aria-checked", String(r.dataset.fx === name)));
hint.textContent = HINTS[name];
};
radios.forEach((r) => r.addEventListener("click", () => pickFx(r.dataset.fx)));
pickFx(new URLSearchParams(location.search).get("fx") ?? "genie");
</script>
</body>
</html>