卓琪的开发笔记zhuoqidev.comRSSllms.txt

← 全部灵感~/design/pixel-dissolve-select

按钮与控件复现复现自 recent.design · Vibe selector

会变形的下拉框:神灯 / 退潮 / 像素

下拉框的展开 / 收起三选一:神灯(像 Mac 最小化,菜单弯成漏斗被吸进名字)、退潮(带浪花的波浪线向上把菜单冲走)、像素溶解。

html · css · svg · canvas · jsMIT收录于 #下拉框#神灯效果#波浪#像素#动效#微交互

在线演示新窗口打开 ↗

在 recent.design 看到一段 8 秒的录屏(作者只发了视频、没给代码),我和 Agent 逐帧看完后从零写了像素版;又觉得整块方块碎掉不够好看,加了两种:像 Mac 最小化窗口的「神灯」,和像波浪往上退的「退潮」。演示顶上可以切换,默认神灯。

看点

神灯(默认)

  • 切成细条:把菜单克隆成约 3px 高的一条条(每条是整块菜单的克隆,用 clip-path: inset() 只露出自己那一条),每帧只改每条的 transform。条越细,漏斗的边越顺,不会出现台阶。
  • 两段动作叠在一起:前 45% 时间两侧弯成漏斗(漏斗口在按钮里名字的正中间),从 12% 开始各条往上滑进漏斗;每一条的宽度 = 漏斗在它当前高度的宽度,所以越往上越窄、文字被挤扁,就是 Mac 的感觉。
  • 漏斗形状:宽度按到漏斗口的距离走 smoothstep,在菜单高度的 55% 处就恢复全宽,让菜单多「撑」一会儿,脖子短而有弧度。
  • 收进名字里:菜单是被吸进名字的,名字随即换掉:旧名往上滑走并模糊,新名从下面浮上来,先是选项的颜色,再慢慢变回墨色;按钮像 Dock 图标接住窗口一样鼓一下(scale: 1.045)。
  • 打开 = 倒放:同一个函数把时间反过来跑,菜单从名字里「倒」出来。

退潮

  • 波浪线是 clip-path: polygon():菜单只露出波浪线以上的部分;波浪是两条方向相反、频率不同的正弦叠加,所以不是死板的规则波。振幅随进度 sin(πp) 先大后小,起点和终点都是平的。
  • 浪花:沿着波浪线画一条选项颜色的亮线、一段往下渐隐的水色,再加几个跟着浪尖跑、长大后「破掉」的泡泡(SVG 圆,描边变细并淡出);整层 SVG 裁到菜单的圆角矩形里,水只在卡片上,不会漫到页面。
  • 方向:收起时浪从下往上把菜单冲走(先慢后快,像水被拉回去);打开时浪从上往下退,露出菜单。

像素(原版复现)

  • 收起 = 打开倒放:打开时像素从按钮下沿往下「倒」,前沿是一个深 V 形,后面渐渐变成卡片底色、真菜单从上往下显出来;收起时同一段动画倒着放(换成新选项的颜色),菜单从下往上退回成像素、像帘子一样被收回按钮里,名字碎一下再拼好。
  • 倒放要掐头:打开的最后一段只是在菜单下方收尾,倒放时会变成点击后约 150ms 的停顿,所以倒放从 85% 处开始。
  • 真 DOM + 像素遮罩:菜单是真的 <ul role="listbox">,动画期间每帧在小 canvas 上按 5px 格子画哪些格子该露出来,转成 mask-image: url(dataURL);格子顺序用 4×4 Bayer 矩阵加随机数,看起来是规则网点。

三种都是真 DOM 菜单上的效果:键盘、读屏、点击照常,动画只是叠在上面的一层。

怎么用

  1. 拷 <style> 里 .pick 开头的规则和第一段 <script>(它挂出 window.pixelPick)。
  2. pixelPick.mount(元素, { value, label, effect: "genie" | "tide" | "pixel", onChange }) 建一个,setItems([{ id }]) 填选项;之后可以随时 picker.effect = "tide" 换效果。颜色写在脚本顶部的 VIBES 表里。
  3. 换名字的两种动画也能单独用:pixelPick.rollText(元素, 新文字, 颜色)(滑入)、pixelPick.swapText(…)(像素碎掉再拼好)。

坑

  • 神灯是把菜单克隆几十份:菜单里有图片、视频或很长的列表时会重,这时把条加粗(H / 3 改成 H / 6)或换退潮。克隆出来的元素要删掉 id、加 aria-hidden,否则页面里会有重复 id、读屏会读两遍。
  • 神灯的条要互相重叠半个像素(inset 两边各减 0.6px),不然缩放后条与条之间会露出细缝。
  • requestAnimationFrame 在后台标签页、被隐藏的预览窗格里不跑,动画会挂在半路:页面 hidden 或 prefers-reduced-motion 时直接跳过动画。
  • 动画进行中又点了一次:先 restart() 把旧动画停掉、清掉克隆 / SVG / canvas 和 clip-path,再开始新的,否则会留下残影。
$给 Agent 读这篇:index.md

源码 MIT 开源,拿去改。

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

来加个微信吧

刘卓琪的微信二维码

微信扫码,或在手机上长按识别

加好友请备注来意,不然可能会漏掉哦~