ZhuoQi Devzhuoqidev.comRSSllms.txt

← All entries~/design/pixel-dissolve-select

Buttons & controlsRecreatedRecreated from recent.design · Vibe selector

A Shape-Shifting Select: Genie / Tide / Pixel

Three ways to open and close a select: genie (a macOS-style minimize into the name), tide (a foamy wave washes it up and away) or pixels.

html · css · svg · canvas · jsMITAdded #select#genie effect#wave#pixel#motion#micro-interaction

Live demoOpen in a new tab ↗

I saw an 8-second recording on recent.design (the author posted only the video, no code), went through it frame by frame with an agent and wrote the pixel version from scratch. A whole block crumbling into squares still felt plain, so two more followed: “genie”, like minimizing a window on a Mac, and “tide”, a wave that recedes upwards. Switch between them at the top of the demo; genie is the default.

What makes it work

Genie (default)

  • Thin strips: the menu is cloned into strips about 3px tall (each a full clone showing only its band through clip-path: inset()), and each frame changes only their transform. The thinner the strips, the smoother the funnel’s sides, with no visible steps.
  • Two overlapping motions: during the first 45% the sides bend into a funnel whose neck sits under the middle of the name in the trigger; from 12% the strips slide up into it. Each strip is as wide as the funnel is at its current height, so it narrows on the way up and the text gets squeezed, which is what makes it feel like the Mac.
  • Funnel shape: the width follows a smoothstep of the distance to the neck and is back to full width at 55% of the menu’s height, so the menu holds its shape a little longer and the neck stays short and curved.
  • Into the name: the menu is sucked into the name, which then changes: the old name slides up and blurs, the new one rises from below in the option’s colour and fades to ink; the trigger swells a little (scale: 1.045) like a Dock icon taking in a window.
  • Opening = rewind: the same function runs with time reversed, so the menu pours out of the name.

Tide

  • The waterline is a clip-path: polygon(): only the part of the menu above the wave shows. The wave is two sines of different frequency drifting in opposite directions, so it never looks mechanical. Its amplitude follows sin(πp), flat at the start and the end.
  • Foam: along the waterline runs a bright line in the option’s colour, a band of colour fading downwards, and a few bubbles that ride under the crest, grow and pop (SVG circles whose stroke thins and fades). The SVG is clipped to the menu’s rounded rectangle, so the water stays on the card.
  • Direction: closing, the wave washes the menu away from the bottom up (slow, then faster, like water being pulled back); opening, it recedes downwards and uncovers the menu.

Pixel (the original recreation)

  • Closing = opening, rewound: opening, pixels pour down from the trigger behind a deep V-shaped front, fade into the card colour and the real menu shows through from the top; closing plays the same animation backwards in the new option’s colour, so the menu dithers back into pixels from the bottom up and is drawn back into the trigger like a curtain, while the name crumbles and re-forms.
  • Trim the rewind: the last stretch of the pour only settles bands below the menu; played first it would be a ~150ms pause after the click, so the rewind starts at 85%.
  • Real DOM + pixel mask: the menu is a real <ul role="listbox">; while it animates, each frame paints which 5px cells show on a small canvas and applies it as mask-image: url(dataURL). Cells flip in 4×4 Bayer order plus noise, so partial areas read as dither.

All three play over a real DOM menu: keyboard, screen readers and clicks work as usual; the animation is just a layer on top.

How to use it

  1. Copy the .pick rules from the <style> block and the first <script> (it exposes window.pixelPick).
  2. Create one with pixelPick.mount(element, { value, label, effect: "genie" | "tide" | "pixel", onChange }) and fill it with setItems([{ id }]); switch any time with picker.effect = "tide". Colours live in the VIBES table at the top of the script.
  3. The two name-change animations work on their own too: pixelPick.rollText(element, text, colour) (slide in) and pixelPick.swapText(…) (crumble and re-form).

Pitfalls

  • Genie clones the menu a few dozen times: with images, video or a long list inside it gets heavy, so use thicker strips (H / 3 → H / 6) or pick tide. Strip the clones’ ids and mark them aria-hidden, or the page gets duplicate ids and screen readers read the menu twice.
  • Genie strips must overlap by half a pixel (inset minus 0.6px on each side), or hairline gaps open between them once they are scaled.
  • requestAnimationFrame does not run in background tabs or hidden preview panes, so an animation can hang halfway: skip it when the page is hidden or under prefers-reduced-motion.
  • A click during an animation: restart() first stops the old one and removes the clones / SVG / canvas and the clip-path, then starts the new one; otherwise ghosts are left behind.
$For agents: index.md

Source MIT licensed. Take it.

<!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>

Say hi on WeChat

Liu ZhuoQi's WeChat QR code

Scan it in WeChat, or long-press it on your phone

Mention why you're reaching out, or I might miss it.