---
title: "An Agent Card That Falls Asleep"
summary: "Off: the blue hexagon mascot shrinks into a grey ball, eyes shut, z's rising. On: it pops back. Awake, its eyes follow you."
category: "card (Cards & widgets)"
origin: "recreated"
inspired_by: "recent.design · Agent Sleep Toggle（Michael Filipiuk） https://recent.design/i/zslnemt-agent-sleep-state-toggle"
stack: ["html","css","svg","js"]
tags: ["mascot","switch","morph","eye tracking","micro-interaction","spring"]
demo: "https://zhuoqidev.com/design/demos/agent-sleep-toggle/"
url: "https://zhuoqidev.com/en/design/agent-sleep-toggle/"
license: "MIT"
added: "2026-10-19"
translation: "https://zhuoqidev.com/design/agent-sleep-toggle/index.md"
---

# An Agent Card That Falls Asleep

> Off: the blue hexagon mascot shrinks into a grey ball, eyes shut, z's rising. On: it pops back. Awake, its eyes follow you.

A recreation of a 7.7-second recording by Michael Filipiuk on recent.design (video only). With an agent I pulled the key frames (0.6 s around each switch, one every 40 ms) and tuned it against the original frame by frame.

## What makes it work

- **One path morphs between a hexagon and a circle**: both are written as a radius per angle, sampled at 120 points, so any mix of the two is a valid shape. The hexagon pinches smoothly into a circle, and the middle frame is a dusty-blue rounded blob, as in the original.
- **A rounded hexagon from a p-norm**: a hexagon is six face normals with `x·nᵢ ≤ a`; replacing the max with a p-norm (`P = 16`) rounds the corners while the edges stay straight. Lower `P` is rounder; too low and the edges bulge.
- **Falling asleep is three things at once** (0.19 s): the shape rounds and shrinks (radius about 0.76 of the hexagon), the colour goes from bright blue through grey-blue to grey, and the eyes go from upright pills to dots to two short dashes (interpolated in two stages, so the middle frame shows dots). The dashes sit one a little higher and both slightly tilted, which reads as drowsy.
- **It rebounds as it shrinks, and breathes while asleep** (measured on the reference, frame by frame): shrinking, it overshoots to 95% of its final size (0.19 s), slowly swells back to 101% (0.45 s) and settles at 0.6 s; asleep it breathes, easing between 100% and 95% about every 4.4 s. It never just snaps to a fixed size, which is what makes it feel alive.
- **Waking has a spring**: a hexagon again by 0.13 s, 103% at 0.19 s, settled by 0.4 s, starting from wherever the breath left it, so nothing jumps. Both are keyframes (time, scale) joined with smoothstep.
- **Z's overhead**: three bold z's take turns drifting up and right from the top of the head, growing and fading (a CSS animation, a third of a cycle apart).
- **Awake, it watches you, by turning rather than tilting**: the eyes follow the mouse; looking aside, the pills stay nearly upright and slide over, **the eye on that side gets about 13% shorter and 18% narrower** while the other grows a little, and they move 18% closer together, so the face reads as turned (the near eye has gone round the side). At rest they lean only about 6°; a quick glance makes them swing against the motion in proportion to its speed (up to 22°) and settle upright. Looking down travels further than looking up, since the eyes sit high. It blinks every 2.6–5.2 s.

## How to use it

1. Copy the CSS for `.agent`, `.mascot`, `.zs`, `.state` / `.switch` and the whole script.
2. Shape parameters are at the top of the script: `A` (half the hexagon's height), `R` (the sleeping circle's radius), `P` (corner roundness); colours are in `SKIN()`, one set for light and one for dark.
3. The switch is a real `role="switch"` button wrapped in a `<label>`, so clicking the word "Active" toggles it too; each change is written to an `aria-live` region, so screen readers announce it.

## Pitfalls

- With a button inside a `<label>`, don't also add a listener on the label that calls `button.click()`: the label already forwards the click, and it toggles twice.
- For a gradient on a shape that changes, keep the default `objectBoundingBox` units (`y1=0 y2=1`) so it follows the shape's box; writing -1…1 shows only the middle of the colour range.
- Recomputing a 120-point path each frame is cheap, but don't put dozens on a page; you can stop the `requestAnimationFrame` loop while it is off screen.
- Eye tracking only updates a target on `pointermove`; the actual position moves 16% of the way there each frame, so a jittery mouse never makes the eyes jump. Under `prefers-reduced-motion` the eyes don't follow or blink, the shape switches instantly and the z's hold still.

## Reference implementation (full source)

Live demo: https://zhuoqidev.com/design/demos/agent-sleep-toggle/. One file: save it as .html and open it.

### index.html

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