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

← 全部灵感~/design/agent-sleep-toggle

卡片与小部件复现复现自 recent.design · Agent Sleep Toggle(Michael Filipiuk)

会睡觉的 Agent 卡片

关掉 Agent,蓝色六边形吉祥物缩成灰色圆球、眼睛眯成两道线、头顶冒 z;打开又弹回来。醒着时眼睛跟着鼠标转。

html · css · svg · jsMIT收录于 #吉祥物#开关#形变动画#眼睛跟随#微交互#弹簧

在线演示新窗口打开 ↗

在 recent.design 看到 Michael Filipiuk 的一段 7.7 秒录屏(只有视频),我和 Agent 把关键几帧(睡着前后各 0.6 秒、每 40ms 一帧)抽出来,对着原片一帧一帧调出来的复现。

看点

  • 一条路径在六边形和圆之间变形:六边形和圆都写成「每个角度一个半径」,各取 120 个点,任意比例混合都是一个合法形状,所以能平滑地从六边形捏成圆,中间那帧是一个蓝灰色的圆角团子,和原片一样。
  • 圆角六边形用 p-范数:六边形 = 六个面法向 x·nᵢ ≤ a;把「取最大」换成 p-范数(P = 16),角就圆了、边还是直的。P 越小越圆,太小边会鼓出来。
  • 睡着是三件事一起发生(0.19 秒):形状变圆并缩小(半径约为六边形的 0.76)、颜色从亮蓝经灰蓝变成灰、眼睛从竖着的胶囊变成小圆点再变成两道短线(分两段插值,中间帧才是「点」),两道线还一高一低、各歪一点,看起来是犯困。
  • 缩下去还会回弹,睡着还在呼吸(逐帧量原片的宽度得出):收缩时先冲过头到最终大小的 95%(0.19 秒),再慢慢鼓回 101%(0.45 秒),0.6 秒落定;睡着以后约 4.4 秒一呼一吸,在 100% 和 95% 之间缓慢起伏。不是「啪」一下缩成定死的大小,所以看着是活的。
  • 醒来带弹簧:0.13 秒变回六边形,0.19 秒冲到 103%,0.4 秒落定;从呼吸到一半的大小直接接上,不会跳。两段都写成关键帧(时间、缩放),段与段之间用 smoothstep 连接。
  • 头顶的 z:三个粗体 z 轮流从头的右上方往右上飘、变大、淡出(CSS 动画,相差 1/3 周期)。
  • 醒着会看你,而且是「转头」不是「歪头」:眼睛跟着鼠标走,往侧边看时胶囊基本保持竖直、整体平移过去;靠那一侧的眼睛变小约 13%、变窄约 18%,另一只略大一点,两眼间距收 18%,看起来是脸转了过去(近侧的眼睛转到了侧面)。静止时只有约 6° 的倾斜;快速扫过去时眼睛会按移动速度往反方向「甩」一下再回正(最多 22°)。往下看的位移比往上大,因为眼睛平时就偏上。每 2.6–5.2 秒眨一次眼。

怎么用

  1. 拷 .agent、.mascot、.zs、.state / .switch 的 CSS 和整段脚本。
  2. 形状参数在脚本顶部:A(六边形高的一半)、R(睡着的圆半径)、P(圆角);颜色在 SKIN(),浅色、深色各一套。
  3. 开关是真的 role="switch" 按钮,外面包一层 <label>,点「Active」字样也能切换;状态变化写进 aria-live 区域,读屏会念「已停用,正在睡觉」。

坑

  • <label> 里放按钮时,别再给 label 加一个「点了就 button.click()」的监听:label 自己就会转发点击,会切换两次。
  • 渐变放在会变形的路径上,用默认的 objectBoundingBox 坐标(y1=0 y2=1),渐变跟着形状的外框走;写成 -1…1 会只露出中间一段颜色。
  • 每帧重算 120 个点的路径很便宜,但别在一页上放几十个;看不见时可以停掉 requestAnimationFrame。
  • 眼睛跟随只在 pointermove 时更新目标,真正的位置每帧往目标靠 16%,这样鼠标抖动也不会让眼睛跳。prefers-reduced-motion 下不跟随、不眨眼、形状直接切换,z 静止。
$给 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>会睡觉的 Agent 卡片</title>
    <style>
      :root {
        --paper: #f3f3f2;
        --card: #fff;
        --ink: #17191e;
        --ink-2: #6b6f78;
        --line: #e6e6e4;
        --track-off: #dcdcdf;
        --on: #2b7bf0;
        --z: #9a9a9a;
        --shadow: 0 1px 2px rgba(20, 22, 30, 0.05), 0 18px 40px -18px rgba(20, 22, 30, 0.25);
        --spring: linear(0, 0.062, 0.215, 0.411, 0.615, 0.799, 0.95, 1.059, 1.126, 1.158, 1.161, 1.145, 1.116, 1.083, 1.051, 1.023, 1.001, 0.986, 0.977, 0.974, 0.974, 0.978, 0.983, 0.988, 0.994, 0.998, 1.001, 1.003, 1.004, 1.004, 1);
        --f-sans: system-ui, -apple-system, "Inter", "PingFang SC", "Microsoft YaHei", sans-serif;
        color-scheme: light;
      }
      @media (prefers-color-scheme: dark) {
        :root {
          --paper: #121318;
          --card: #1d1f26;
          --ink: #eceae4;
          --ink-2: #a3a6af;
          --line: #30333c;
          --track-off: #3a3d47;
          --on: #5b9bff;
          --z: #8c8f98;
          --shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 18px 40px -18px rgba(0, 0, 0, 0.8);
          color-scheme: dark;
        }
      }
      * { box-sizing: border-box; }
      body {
        margin: 0;
        min-height: 100vh;
        display: grid;
        place-items: center;
        padding: 32px 16px;
        background: var(--paper);
        color: var(--ink);
        font: 15px/1.6 var(--f-sans);
      }

      /* ---------- the card ---------- */
      .agent {
        width: min(340px, 100%);
        padding: 36px 28px 30px;
        border-radius: 28px;
        background: var(--card);
        box-shadow: var(--shadow);
        text-align: center;
      }
      .agent h2 { margin: 18px 0 4px; font-size: 22px; line-height: 1.3; font-weight: 650; letter-spacing: -0.01em; }
      .agent p { margin: 0 auto; max-width: 250px; color: var(--ink-2); font-size: 15px; }

      /* ---------- the mascot ----------
         One SVG path whose outline is recomputed every frame (JS): a rounded hexagon and a circle are
         both sampled as a radius per angle, so any mix of the two is a valid shape. The Z's are HTML. */
      .mascot { position: relative; width: 184px; height: 184px; margin: -6px auto 0; }
      .mascot svg { display: block; width: 100%; height: 100%; overflow: visible; }
      .zs { position: absolute; left: 72%; top: 8%; width: 70px; height: 80px; pointer-events: none; }
      .zs b {
        position: absolute;
        left: 0;
        bottom: 0;
        font: 800 17px/1 var(--f-sans);
        color: var(--z);
        opacity: 0;
      }
      /* Each Z rises up and to the right, grows, and fades; three of them, a third of a cycle apart. */
      .sleeping .zs b { animation: z-drift 2.4s linear infinite; }
      .sleeping .zs b:nth-child(2) { animation-delay: -0.8s; }
      .sleeping .zs b:nth-child(3) { animation-delay: -1.6s; }
      @keyframes z-drift {
        0% { opacity: 0; transform: translate(0, 0) scale(0.7) rotate(-8deg); }
        12% { opacity: 1; }
        72% { opacity: 0.85; }
        100% { opacity: 0; transform: translate(30px, -62px) scale(1.7) rotate(6deg); }
      }

      /* ---------- the switch ---------- */
      .state {
        display: inline-flex;
        align-items: center;
        gap: 10px;
        margin-top: 22px;
        padding: 7px 8px 7px 16px;
        border: 1px solid var(--line);
        border-radius: 999px;
        font-weight: 600;
        cursor: pointer;
        user-select: none;
      }
      .switch {
        position: relative;
        width: 44px;
        height: 26px;
        padding: 0;
        border: 0;
        border-radius: 99px;
        background: var(--track-off);
        cursor: pointer;
        transition: background-color 0.2s;
      }
      .switch::after {
        content: "";
        position: absolute;
        top: 3px;
        left: 3px;
        width: 20px;
        height: 20px;
        border-radius: 50%;
        background: #fff;
        box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
        transition: translate 0.45s var(--spring), scale 0.2s;
      }
      .switch[aria-checked="true"] { background: var(--on); }
      .switch[aria-checked="true"]::after { translate: 18px 0; }
      .switch:active::after { scale: 1.12 0.9; }
      .switch:focus-visible { outline: 2px solid var(--on); outline-offset: 3px; }
      .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }

      @media (prefers-reduced-motion: reduce) {
        .sleeping .zs b { animation: none; opacity: 0.8; }
        .sleeping .zs b:nth-child(1) { transform: translate(4px, -8px); }
        .sleeping .zs b:nth-child(2) { transform: translate(14px, -26px) scale(1.2); }
        .sleeping .zs b:nth-child(3) { transform: translate(24px, -46px) scale(1.4); }
        .switch::after { transition: none; }
      }
    </style>
  </head>
  <body>
    <article class="agent" aria-labelledby="agent-name">
      <div class="mascot" id="mascot">
        <svg viewBox="-100 -100 200 200" aria-hidden="true">
          <defs>
            <linearGradient id="skin" x1="0" y1="0" x2="0" y2="1">
              <stop offset="0" id="skin-top" />
              <stop offset="1" id="skin-bottom" />
            </linearGradient>
          </defs>
          <g id="body">
            <path id="shape" fill="url(#skin)" />
            <g fill="#fff"><rect id="eye-l" /><rect id="eye-r" /></g>
          </g>
        </svg>
        <div class="zs" aria-hidden="true"><b>z</b><b>z</b><b>z</b></div>
      </div>
      <h2 id="agent-name">Scout</h2>
      <p>盯着机票价格,跌进你的差旅预算就帮你订好。</p>
      <label class="state">
        <span>Active</span>
        <button class="switch" type="button" role="switch" aria-checked="true" aria-labelledby="agent-name" id="switch"></button>
      </label>
      <span class="sr" aria-live="polite" id="said"></span>
    </article>

    <script>
      // ---- shape: radius per angle ----
      // Rounded hexagon (flat top and bottom): the hexagon is "every face normal n_i: x·n_i ≤ a"; swapping
      // the max over faces for a p-norm rounds the corners. Along a ray u that gives r = a / ‖(u·n_i)₊‖_p.
      const A = 72; // apothem: half the hexagon's height, in viewBox units
      const R = 55; // the sleeping circle is smaller
      const P = 16; // larger = sharper corners; the reference has straight edges and modest corners
      const NORMALS = [90, 30, -30, -90, -150, 150].map((d) => [Math.cos((d * Math.PI) / 180), Math.sin((d * Math.PI) / 180)]);
      const N = 120;
      const hexR = Array.from({ length: N }, (_, i) => {
        const t = (i / N) * Math.PI * 2;
        const u = [Math.cos(t), Math.sin(t)];
        const s = NORMALS.reduce((acc, n) => acc + Math.max(0, u[0] * n[0] + u[1] * n[1]) ** P, 0);
        return A / s ** (1 / P);
      });
      const outline = (k, scale) =>
        hexR
          .map((h, i) => {
            const t = (i / N) * Math.PI * 2;
            const r = (h + (R - h) * k) * scale;
            return `${i ? "L" : "M"}${(r * Math.cos(t)).toFixed(2)} ${(r * Math.sin(t)).toFixed(2)}`;
          })
          .join("") + "Z";

      // ---- colours: awake blue → sleeping grey, through a dusty blue halfway ----
      const dark = matchMedia("(prefers-color-scheme: dark)");
      const SKIN = () =>
        dark.matches
          ? { awake: [[88, 166, 255], [42, 114, 222]], asleep: [[122, 124, 130], [88, 90, 96]] }
          : { awake: [[78, 162, 255], [40, 118, 219]], asleep: [[196, 196, 196], [154, 154, 154]] };
      const mixRgb = (a, b, k) => `rgb(${a.map((v, i) => Math.round(v + (b[i] - v) * k)).join(",")})`;

      // ---- eyes: tall pills awake, dots halfway, short dashes asleep ----
      const lerp = (a, b, k) => a + (b - a) * k;
      const clamp = (v, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v));
      const eyeAt = (k) => {
        // two stages so the halfway frame reads as "dots", like the reference
        const a = clamp(k * 2);
        const b = clamp(k * 2 - 1);
        return {
          w: lerp(lerp(13, 8.5, a), 12, b),
          h: lerp(lerp(30, 8, a), 2.8, b),
          y: lerp(lerp(-14, 14, a), 7, b),
          gap: lerp(lerp(20.5, 18, a), 17, b),
          // asleep the two dashes sit a little crooked, which reads as drowsy
          tilt: [5 * b, -7 * b],
          drop: [0, -1.5 * b],
        };
      };

      const $ = (id) => document.getElementById(id);
      const shape = $("shape");
      const eyes = [$("eye-l"), $("eye-r")];
      const mascot = $("mascot");
      const sw = $("switch");
      const still = matchMedia("(prefers-reduced-motion: reduce)");

      // state
      let k = 0; // 0 awake … 1 asleep
      let scale = 1;
      let anim = null; // { from, to, start, ms }
      const look = { x: 0, y: 0, tx: 0, ty: 0 };
      let blink = 0;
      let swing = 0; // degrees of lean from moving the eyes, decays to 0 at rest
      let nextBlink = performance.now() + 2500;

      const draw = () => {
        shape.setAttribute("d", outline(k, scale));
        const skin = SKIN();
        $("skin-top").setAttribute("stop-color", mixRgb(skin.awake[0], skin.asleep[0], k));
        $("skin-bottom").setAttribute("stop-color", mixRgb(skin.awake[1], skin.asleep[1], k));
        const e = eyeAt(k);
        const awake = 1 - k;
        // Looking aside is a head turn, not a tilt: the pills stay nearly upright and slide over, the eye on
        // the side it turns to gets smaller (it is further round the face) and the other a touch larger, and
        // the two move closer together. They only lean while moving (swing, below) and by a few degrees at rest.
        const lx = look.x * 38 * awake;
        const ly = look.y * (look.y > 0 ? 32 : 24) * awake; // the eyes rest high, so looking down travels further
        const turn = Math.abs(look.x) * awake;
        const lean = (-look.x * 6 + swing) * awake;
        eyes.forEach((el, i) => {
          const side = i ? 1 : -1; // -1 left eye, +1 right eye
          const near = Math.sign(look.x) === side ? 1 : -1; // +1 for the eye on the side being looked at
          const size = 1 - turn * (near > 0 ? 0.13 : -0.04);
          const w = e.w * (1 - turn * (near > 0 ? 0.18 : -0.03));
          const h = e.h * size * (1 - blink * 0.85);
          const cx = side * e.gap * (1 - turn * 0.18) + lx;
          const cy = e.y * scale + ly + e.drop[i];
          el.setAttribute("x", (cx - w / 2).toFixed(2));
          el.setAttribute("y", (cy - h / 2).toFixed(2));
          el.setAttribute("width", w.toFixed(2));
          el.setAttribute("height", h.toFixed(2));
          el.setAttribute("rx", (Math.min(w, h) / 2).toFixed(2));
          el.setAttribute("transform", `rotate(${(lean + e.tilt[i]).toFixed(2)} ${cx.toFixed(2)} ${cy.toFixed(2)})`);
        });
      };

      const easeInOut = (t) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
      // A damped spring for the wake-up pop: overshoots to about 1.07 and settles.
      // Both transitions are keyframed on the reference (time in s, scale), joined with smoothstep so each
      // leg starts and ends gently. Falling asleep the body shrinks past its final size to 95% in 0.19 s,
      // slowly swells back to 101% by 0.45 s and settles at 0.6 s. Waking it is a hexagon again by 0.13 s,
      // pops to 103% at 0.19 s and settles by 0.4 s.
      const SLEEP = [
        [0, 1],
        [0.19, 0.95],
        [0.45, 1.01],
        [0.6, 1],
      ];
      const WAKE = (s0) => [
        [0, s0],
        [0.13, 0.99],
        [0.19, 1.03],
        [0.3, 0.995],
        [0.4, 1],
      ];
      const keyed = (frames, sec) => {
        for (let i = 1; i < frames.length; i++) {
          const [t1, v1] = frames[i];
          const [t0, v0] = frames[i - 1];
          if (sec <= t1) {
            const q = (sec - t0) / (t1 - t0);
            return v0 + (v1 - v0) * q * q * (3 - 2 * q);
          }
        }
        return frames[frames.length - 1][1];
      };
      // Asleep it breathes: down to 95% and back, about 4.4 s a breath (the reference shrinks 5% in ~2 s).
      const BREATH = 4400;
      let asleepSince = 0;

      const frame = (now) => {
        if (anim) {
          const t = clamp((now - anim.start) / anim.ms);
          const sec = (now - anim.start) / 1000;
          if (anim.to === 1) {
            k = easeInOut(clamp(sec / 0.19));
            scale = keyed(SLEEP, sec);
          } else {
            // starts from wherever the breath left it, so there is no jump
            k = 1 - easeInOut(clamp(sec / 0.13));
            scale = keyed(WAKE(anim.s0), sec);
          }
          if (t >= 1) {
            k = anim.to;
            scale = 1;
            if (anim.to === 1) asleepSince = now;
            anim = null;
          }
        } else if (k === 1 && !still.matches) {
          scale = 1 - 0.025 * (1 - Math.cos(((now - asleepSince) / BREATH) * Math.PI * 2));
        }
        // eyes ease towards where the pointer is; blink now and then while awake
        const before = look.x;
        look.x += (look.tx - look.x) * 0.16;
        look.y += (look.ty - look.y) * 0.16;
        // Swing: a quick glance sideways makes the pills trail (lean against the motion) and settle upright.
        const v = look.x - before;
        swing += (clamp(-v * 260, -22, 22) - swing) * 0.25;
        if (now > nextBlink && k === 0 && !still.matches) {
          const b = (now - nextBlink) / 160;
          blink = b < 1 ? Math.sin(b * Math.PI) : 0;
          if (b >= 1) nextBlink = now + 2600 + Math.random() * 2600;
        }
        draw();
        requestAnimationFrame(frame);
      };

      // ---- pointer: where to look ----
      addEventListener(
        "pointermove",
        (e) => {
          if (still.matches) return;
          const r = mascot.getBoundingClientRect();
          const dx = e.clientX - (r.left + r.width / 2);
          const dy = e.clientY - (r.top + r.height / 2);
          // full deflection about 260px away; nearer looks less far
          const d = Math.hypot(dx, dy) || 1;
          const m = Math.min(1, d / 260);
          look.tx = (dx / d) * m;
          look.ty = (dy / d) * m;
        },
        { passive: true },
      );
      document.documentElement.addEventListener("pointerleave", () => {
        look.tx = 0;
        look.ty = 0;
      });

      // ---- the switch ----
      const set = (on) => {
        sw.setAttribute("aria-checked", String(on));
        mascot.classList.toggle("sleeping", !on);
        $("said").textContent = on ? "Scout 已启用" : "Scout 已停用,正在睡觉";
        if (still.matches) {
          k = on ? 0 : 1;
          anim = null;
          return;
        }
        anim = { from: k, to: on ? 0 : 1, start: performance.now(), ms: on ? 400 : 600, s0: scale };
      };
      sw.addEventListener("click", () => set(sw.getAttribute("aria-checked") !== "true"));
      // The <label> around it makes the whole pill the hit area: clicking "Active" clicks the switch.

      draw();
      requestAnimationFrame(frame);
    </script>
  </body>
</html>

来加个微信吧

刘卓琪的微信二维码

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

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