Skip to content

Ink Walker Card

A line-art figure walks across a strip of paper, and you can poke it.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/ink-walker-card.json

Manual

  1. Install the dependencies

    npm install clsx tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/line-art/figures.ts
    import { gait, joint, standing, GROUND, type GaitPose } from "@/lib/line-art/gait";
    import { DETAIL_PEN, PEN, el, limb, line, paint, poly, shape, type Point } from "@/lib/line-art/ink";
    import { pendulum, type Pendulum } from "@/lib/line-art/physics";
    
    /**
     * Faceless, open-stroke figures in figure space (about 95 units tall, feet on y = 120, facing
     * +x). Each is one <g>: place it with a single transform. Poses come from the gait solver; hanging
     * things are pendulums; reactions are short keyed arm moves.
     */
    
    export type FigureKind = "commuter" | "courier" | "jogger" | "cyclist";
    
    export interface Figure {
      group: SVGGElement;
      /** Advances the figure by `dt` seconds at `factor` times its usual pace, with `wind` on its loose ends. Returns how far it travelled, in figure units, so feet and wheels never slip. */
      step(dt: number, factor: number, wind: number): number;
      /** Starts the figure's reaction: a tipped hat, a wave, a look at a watch or a rung bell. */
      react(): void;
      /** Draws a still pose. `reacting` holds the reaction at its peak. */
      still(reacting?: boolean): void;
      /** True while a reaction is playing. */
      busy(): boolean;
      /** What the reaction is, for labels: "tips a hat". */
      reaction: string;
      /** Rough half-width of the figure, for hit areas. */
      reach: number;
    }
    
    export interface FigureOptions {
      /** Flat accent for one small area: the scarf, the cap, the vest or the frame. */
      accent?: string;
      /** Leave out what the figure carries (the parcel, the briefcase and scarf), for a plain passer-by. */
      bare?: boolean;
    }
    
    const UPPER = 16;
    const FORE = 15;
    const circle = (cx: number, cy: number, r: number) => `M${(cx - r).toFixed(2)} ${cy.toFixed(2)}a${r} ${r} 0 1 0 ${r * 2} 0a${r} ${r} 0 1 0 ${-r * 2} 0`;
    const ease = (t: number) => t * t * (3 - 2 * t);
    /** 0 → 1 → 0 over a reaction: quick in, hold, quick out. */
    const envelope = (t: number, length: number) => ease(Math.min(1, Math.max(0, t / 0.3))) * ease(Math.min(1, Math.max(0, (length - t) / 0.35)));
    
    const REACTIONS = {
      commuter: { label: "tips a hat", length: 1.5 },
      courier: { label: "waves", length: 1.7 },
      jogger: { label: "checks a watch", length: 1.7 },
      cyclist: { label: "rings the bell", length: 1.1 },
    } as const;
    
    function walker(parent: Element, kind: Exclude<FigureKind, "cyclist">, accent: string, bare: boolean): Figure {
      const group = el("g", {}, parent);
      const running = kind === "jogger";
      const options = running ? { stride: 40, duty: 0.36, lift: 7 } : { stride: 26, duty: 0.62 };
      const cadence = running ? 1.3 : 0.95;
      const lean = running ? 6 : 1.6;
      const torsoLength = 30;
    
      // Far side first, so the near side draws over it.
      const farArm = limb(group, 4.2);
      const farLeg = limb(group, 5.4);
      const farFoot = line(group);
      const bag = kind === "commuter" && !bare ? shape(group) : null;
      const bagHandle = bag ? line(group, "", DETAIL_PEN) : null;
      const parcel = kind === "courier" && !bare ? shape(group) : null;
      const parcelTape = parcel ? line(group, "", DETAIL_PEN) : null;
      const torso = kind === "jogger" ? paint(group, accent) : shape(group);
      const nearLeg = limb(group, 5.4);
      const nearFoot = line(group);
      const scarf = kind === "commuter" && !bare ? limb(group, 3.4, accent) : null;
      const head = shape(group);
      const hat = kind === "commuter" ? shape(group) : kind === "courier" ? paint(group, accent) : line(group);
      const nearArm = limb(group, 4.2);
      const watch = kind === "jogger" ? line(group, "", PEN * 1.6) : null;
    
      let phase = 0.1;
      let reactionAt = -1;
      const scarfSwing: Pendulum = { angle: -0.4, velocity: 0 };
      const bagSwing: Pendulum = { angle: 0, velocity: 0 };
      const reaction = REACTIONS[kind];
    
      function draw(pose: GaitPose, moving: number, wind: number, dt: number) {
        const hip = pose.hip;
        const shoulder: Point = [hip[0] + lean, hip[1] - torsoLength];
        const headAt: Point = [shoulder[0] + 1.6, shoulder[1] - 9.5];
        const w = reactionAt >= 0 ? envelope(reactionAt, reaction.length) : 0;
    
        // Legs and feet.
        for (const [leg, legLimb, footLine] of [[pose.far, farLeg, farFoot], [pose.near, nearLeg, nearFoot]] as const) {
          legLimb.set([leg.hip, leg.knee, leg.ankle]);
          footLine.setAttribute("d", poly([[leg.ankle[0] - 1.5, leg.ankle[1] + 2.4], leg.toe]));
        }
    
        // Torso: a coat with a hem, or a short running top.
        const hem = running ? 1 : 6;
        const [sx, sy] = shoulder;
        const [hx, hy] = hip;
        torso.setAttribute(
          "d",
          `M${sx - 6.5} ${sy + 1.5}Q${sx} ${sy - 3.5} ${sx + 6.5} ${sy + 1.5}L${hx + 8} ${hy + hem}Q${hx} ${hy + hem + 2} ${hx - 8} ${hy + hem}Z`,
        );
    
        // Arms swing against the legs. A reaction takes the near arm to a target with IK.
        const arm = (angle: number, bend: number): [Point, Point] => {
          const elbow: Point = [sx + Math.sin(angle) * UPPER, sy + 2 + Math.cos(angle) * UPPER];
          return [elbow, [elbow[0] + Math.sin(angle + bend) * FORE, elbow[1] + Math.cos(angle + bend) * FORE]];
        };
        const amplitude = (running ? 0.75 : 0.42) * moving;
        const bend = running ? 1.35 : 0.22;
        const [farElbow, farHand] = arm(amplitude * pose.swing, bend + Math.max(0, pose.swing) * 0.3 * moving);
        farArm.set([[sx, sy + 2], farElbow, farHand]);
        // The courier's near arm holds the parcel instead of swinging.
        let [nearElbow, nearHand] = parcel ? arm(0.25, 1.25) : arm(-amplitude * pose.swing, bend + Math.max(0, -pose.swing) * 0.3 * moving);
        if (w > 0) {
          const t = reactionAt;
          const target: Point =
            kind === "commuter"
              ? [headAt[0] + 9, headAt[1] - 5]
              : kind === "courier"
                ? [sx + 15 + Math.sin(t * 11) * 4, sy - 15]
                : [sx + 10, sy + 9];
          nearHand = [nearHand[0] + (target[0] - nearHand[0]) * w, nearHand[1] + (target[1] - nearHand[1]) * w];
          nearElbow = joint([sx, sy + 2], nearHand, UPPER, FORE, 1);
        }
        nearArm.set([[sx, sy + 2], nearElbow, nearHand]);
    
        // Head, looking down a little while checking a watch.
        const nod = kind === "jogger" ? w * 1.6 : 0;
        const hxp = headAt[0] + nod;
        const hyp = headAt[1] + nod;
        head.setAttribute("d", circle(hxp, hyp, 7));
        if (kind === "commuter") {
          // A brimmed hat that lifts off the head when tipped.
          const up = w * 4.5;
          hat.setAttribute("d", `M${hxp - 9.5} ${hyp - 4 - up}L${hxp + 10} ${hyp - 5 - up}M${hxp - 6} ${hyp - 4.3 - up}Q${hxp} ${hyp - 14 - up} ${hxp + 6.5} ${hyp - 4.8 - up}Z`);
        } else if (kind === "courier") {
          hat.setAttribute("d", `M${hxp - 6.6} ${hyp - 2.5}Q${hxp} ${hyp - 12} ${hxp + 6.8} ${hyp - 3}L${hxp + 12.5} ${hyp - 2}L${hxp + 6.8} ${hyp - 1.2}Z`);
        } else {
          hat.setAttribute("d", `M${hxp - 6.8} ${hyp - 2}L${hxp + 6.8} ${hyp - 3.2}`);
        }
    
        // Loose ends: the scarf trails behind and the briefcase swings from the far hand.
        if (scarf) {
          const a = pendulum(scarfSwing, -(0.5 + moving * 2.4) + wind * 9, dt, 9, 3);
          const root: Point = [sx - 3.5, sy + 1.5];
          const mid: Point = [root[0] + Math.sin(a) * 8, root[1] + Math.cos(a) * 8];
          scarf.set([root, mid, [mid[0] + Math.sin(a * 1.45) * 8, mid[1] + Math.cos(a * 1.45) * 8]]);
        }
        if (bag && bagHandle) {
          const a = pendulum(bagSwing, -pose.swing * 2.4 * moving + wind * 6, dt, 10, 2.6);
          const cos = Math.cos(a);
          const sin = Math.sin(a);
          const at = (x: number, y: number): Point => [farHand[0] + x * cos - y * sin, farHand[1] + x * sin + y * cos];
          bag.setAttribute("d", poly([at(-7, 3), at(7, 3), at(7, 13), at(-7, 13)], true));
          bagHandle.setAttribute("d", poly([at(-3, 3), at(-3, 0.5), at(3, 0.5), at(3, 3)]));
        }
        if (parcel && parcelTape) {
          const px = sx + 2;
          const py = sy + 12;
          parcel.setAttribute("d", poly([[px, py], [px + 13, py - 1], [px + 13.5, py + 9], [px + 0.5, py + 10]], true));
          parcelTape.setAttribute("d", poly([[px + 6.5, py - 0.5], [px + 7, py + 9.5]]));
        }
        if (watch) watch.setAttribute("d", poly([[nearHand[0] - 2.2, nearHand[1] - 3], [nearHand[0] + 1.2, nearHand[1] - 4.4]]));
      }
    
      return {
        group,
        reaction: reaction.label,
        reach: 16,
        step(dt, factor, wind) {
          const before = phase;
          phase += cadence * factor * dt;
          if (reactionAt >= 0) {
            reactionAt += dt;
            if (reactionAt > reaction.length) reactionAt = -1;
          }
          draw(gait(phase, options), Math.min(1, factor), wind, dt);
          return ((phase - before) * options.stride) / options.duty;
        },
        react() {
          reactionAt = 0;
        },
        still(reacting = false) {
          reactionAt = reacting ? reaction.length / 2 : -1;
          scarfSwing.angle = -0.25;
          scarfSwing.velocity = 0;
          bagSwing.angle = 0;
          bagSwing.velocity = 0;
          draw(standing(), 0, 0, 0);
          reactionAt = -1;
        },
        busy: () => reactionAt >= 0,
      };
    }
    
    function cyclist(parent: Element, accent: string): Figure {
      const group = el("g", {}, parent);
      const R = 13;
      const rear: Point = [-18, GROUND - R];
      const front: Point = [20, GROUND - R];
      const bracket: Point = [-1, GROUND - R + 1.5];
      const saddle: Point = [-8, 80];
      const bars: Point = [14, 79];
      const headTube: Point = [13, 86];
      const hip: Point = [-7, 77.5];
      const shoulder: Point = [6.5, 55];
      const headAt: Point = [11, 46.5];
      const CRANK = 6.5;
    
      const farArm = limb(group, 4);
      const farLeg = limb(group, 5.2);
      const farPedal = line(group);
      // Wheels turn as groups, spokes and all.
      const wheels = [rear, front].map((hub) => {
        const wheel = el("g", {}, group);
        shape(wheel, circle(hub[0], hub[1], R));
        for (let i = 0; i < 3; i++) {
          const a = (i * Math.PI) / 3;
          line(wheel, poly([[hub[0] - Math.cos(a) * (R - 1), hub[1] - Math.sin(a) * (R - 1)], [hub[0] + Math.cos(a) * (R - 1), hub[1] + Math.sin(a) * (R - 1)]]), DETAIL_PEN);
        }
        return { wheel, hub };
      });
      // Frame: one flat accent, the rest ink.
      paint(group, accent, poly([bracket, [saddle[0] + 1, saddle[1] + 3], headTube], true), PEN);
      line(group, poly([rear, bracket]) + poly([rear, [saddle[0] + 1, saddle[1] + 3]]) + poly([headTube, front]) + poly([headTube, bars]) + poly([[bars[0] - 2, bars[1] - 1], [bars[0] + 3, bars[1] + 1.5]]));
      line(group, poly([[saddle[0] - 4, saddle[1]], [saddle[0] + 4, saddle[1] - 0.6]]), PEN * 1.5);
      const cranks = line(group);
      const lamp = paint(group, "var(--la-glow, #f6c453)", circle(headTube[0] + 3.5, headTube[1] - 2, 1.8), DETAIL_PEN);
      lamp.setAttribute("data-lamp", "");
      const torso = shape(group);
      const nearLeg = limb(group, 5.2);
      const nearPedal = line(group);
      const head = shape(group);
      const helmet = paint(group, accent);
      const nearArm = limb(group, 4);
      const bell = line(group, "", DETAIL_PEN);
    
      let distance = 0;
      let reactionAt = -1;
      const reaction = REACTIONS.cyclist;
    
      function draw() {
        // Rolling without slipping: the wheel's angle is the distance travelled over its radius.
        const wheelAngle = (distance / R) * (180 / Math.PI);
        for (const { wheel, hub } of wheels) wheel.setAttribute("transform", `rotate(${wheelAngle.toFixed(2)} ${hub[0]} ${hub[1]})`);
        const crankAngle = (distance / R) * 0.42;
        const pedal = (offset: number): Point => [bracket[0] + Math.cos(crankAngle + offset) * CRANK, bracket[1] + Math.sin(crankAngle + offset) * CRANK];
        const near = pedal(0);
        const far = pedal(Math.PI);
        cranks.setAttribute("d", poly([far, near]));
        for (const [foot, legLimb, pedalLine] of [[far, farLeg, farPedal], [near, nearLeg, nearPedal]] as const) {
          const ankle: Point = [foot[0] - 1.5, foot[1] - 3];
          legLimb.set([hip, joint(hip, ankle, 21, 21, -1), ankle]);
          pedalLine.setAttribute("d", poly([[foot[0] - 3.5, foot[1]], [foot[0] + 4, foot[1] - 0.6]]));
        }
        torso.setAttribute("d", `M${hip[0] - 5.5} ${hip[1] + 2}Q${hip[0] - 5} ${shoulder[1] + 4} ${shoulder[0] - 3} ${shoulder[1] - 2}Q${shoulder[0] + 3} ${shoulder[1] - 3} ${shoulder[0] + 5} ${shoulder[1] + 3}L${hip[0] + 6} ${hip[1] + 3}Z`);
        head.setAttribute("d", circle(headAt[0], headAt[1], 6.8));
        helmet.setAttribute("d", `M${headAt[0] - 7} ${headAt[1] - 1.5}Q${headAt[0]} ${headAt[1] - 12} ${headAt[0] + 7.4} ${headAt[1] - 1}Z`);
        const grip: Point = [bars[0] + 0.5, bars[1]];
        const start: Point = [shoulder[0] + 1, shoulder[1] + 2];
        farArm.set([start, joint(start, grip, 15, 14, -1), grip]);
        // Ringing the bell: the thumb works and three short marks flash by the bars.
        const w = reactionAt >= 0 ? envelope(reactionAt, reaction.length) : 0;
        const press: Point = [grip[0] - 1.5 + Math.sin(reactionAt * 24) * 0.8 * w, grip[1] - 1.5 * w];
        nearArm.set([start, joint(start, press, 15, 14, -1), press]);
        const flash = w > 0 && Math.floor(reactionAt * 9) % 2 === 0;
        bell.setAttribute("d", flash ? `M${bars[0] + 5} ${bars[1] - 5}l3 -3M${bars[0] + 6.5} ${bars[1] - 1.5}l4 -0.6M${bars[0] + 2.5} ${bars[1] - 7}l1 -4` : "");
      }
    
      return {
        group,
        reaction: reaction.label,
        reach: 34,
        step(dt, factor) {
          const moved = 118 * factor * dt;
          distance += moved;
          if (reactionAt >= 0) {
            reactionAt += dt;
            if (reactionAt > reaction.length) reactionAt = -1;
          }
          draw();
          return moved;
        },
        react() {
          reactionAt = 0;
        },
        still(reacting = false) {
          reactionAt = reacting ? 0.34 : -1;
          draw();
          reactionAt = -1;
        },
        busy: () => reactionAt >= 0,
      };
    }
    
    /** Builds a figure inside `parent`. It draws nothing until its first `step` or `still`. */
    export function createFigure(parent: Element, kind: FigureKind, options: FigureOptions = {}): Figure {
      const accent = options.accent ?? "#c8553d";
      return kind === "cyclist" ? cyclist(parent, accent) : walker(parent, kind, accent, options.bare ?? false);
    }
    lib/line-art/gait.ts
    import type { Point } from "@/lib/line-art/ink";
    
    /**
     * A walk and run solver in figure space: about 95 units tall, feet on y = 120, facing +x.
     * Feet are placed first and everything else follows from them: a planted toe never slides, the
     * hip's height comes from the planted leg, and knees are solved with two-bone IK.
     */
    
    export const GROUND = 120;
    export const THIGH = 22;
    export const SHIN = 22;
    /** How high the ankle sits above the sole, and how far behind the toe. */
    const ANKLE_UP = 3.2;
    const FOOT = 6.5;
    
    /**
     * Two-bone IK: the middle joint between `a` and `b` for bones of length `l1` and `l2`.
     * `bend` picks the side the joint folds to (1 or -1). Out of reach, the chain straightens.
     */
    export function joint(a: Point, b: Point, l1: number, l2: number, bend = 1): Point {
      const dx = b[0] - a[0];
      const dy = b[1] - a[1];
      const distance = Math.min(Math.hypot(dx, dy), l1 + l2 - 0.001) || 0.001;
      const along = (l1 * l1 - l2 * l2 + distance * distance) / (2 * distance);
      const off = Math.sqrt(Math.max(0, l1 * l1 - along * along)) * bend;
      const length = Math.hypot(dx, dy) || 0.001;
      const nx = dx / length;
      const ny = dy / length;
      return [a[0] + nx * along - ny * off, a[1] + ny * along + nx * off];
    }
    
    /** Minimum-jerk easing: starts and ends with zero velocity and acceleration. */
    const minJerk = (u: number) => u * u * u * (10 - 15 * u + 6 * u * u);
    
    export interface GaitOptions {
      /** Distance a foot travels per step, in figure units. @default 26 */
      stride?: number;
      /** Share of the cycle each foot spends on the ground: about 0.62 walking, 0.36 running. @default 0.62 */
      duty?: number;
      /** How high a swinging foot lifts. Kept low. @default 3.2 */
      lift?: number;
    }
    
    export interface Leg {
      hip: Point;
      knee: Point;
      ankle: Point;
      toe: Point;
      /** True while the toe is on the ground. */
      planted: boolean;
    }
    
    export interface GaitPose {
      /** Hip joint, x relative to the figure's origin. */
      hip: Point;
      near: Leg;
      far: Leg;
      /** -1 to 1, in step with the near leg: drive the arms against it. */
      swing: number;
    }
    
    function foot(phase: number, stride: number, duty: number, lift: number) {
      const p = ((phase % 1) + 1) % 1;
      if (p < duty) {
        // Stance: the toe is fixed on the ground, so relative to the hip it slides straight back.
        const u = p / duty;
        const toeX = stride / 2 - stride * u + FOOT * 0.5;
        // Heel-off: over the last 14% of stance the foot pivots on the toe and the heel rises.
        const heel = Math.max(0, (u - 0.86) / 0.14);
        const angle = heel * heel * 0.7;
        return { toe: [toeX, GROUND] as Point, ankle: [toeX - FOOT * Math.cos(angle) + ANKLE_UP * Math.sin(angle) * 0.4, GROUND - ANKLE_UP - FOOT * Math.sin(angle)] as Point, planted: true };
      }
      // Swing: a minimum-jerk reach from behind to in front, with a low lift.
      const u = (p - duty) / (1 - duty);
      const reach = minJerk(u);
      const toeX = -stride / 2 + stride * reach + FOOT * 0.5;
      const raise = Math.sin(Math.PI * u) * lift;
      const angle = 0.7 * (1 - u) * (1 - u) - 0.18 * Math.sin(Math.PI * u) * u;
      return { toe: [toeX, GROUND - raise] as Point, ankle: [toeX - FOOT * Math.cos(angle), GROUND - raise - ANKLE_UP - FOOT * Math.sin(angle)] as Point, planted: false };
    }
    
    /** Solves one instant of the cycle. `phase` runs 0 to 1 per two steps. */
    export function gait(phase: number, options: GaitOptions = {}): GaitPose {
      const stride = options.stride ?? 26;
      const duty = options.duty ?? 0.62;
      const lift = options.lift ?? 3.2;
      const near = foot(phase, stride, duty, lift);
      const far = foot(phase + 0.5, stride, duty, lift);
      // The hip is as high as the planted leg allows when almost straight, and never higher.
      const reach = (THIGH + SHIN) * 0.985;
      const hipOver = (ankle: Point) => ankle[1] - Math.sqrt(Math.max(reach * reach - ankle[0] * ankle[0], 1));
      let hipY = -Infinity;
      for (const leg of [near, far]) if (leg.planted) hipY = Math.max(hipY, hipOver(leg.ankle));
      if (hipY === -Infinity) {
        // Flight (running): both feet are off the ground. The hip rides a shallow arc from where the
        // pushing leg left it to where the landing leg will take it.
        const p = ((phase % 0.5) + 0.5) % 0.5;
        const u = Math.min(1, Math.max(0, (p - duty) / (0.5 - duty)));
        const takeoff = hipOver(foot(duty - 1e-6, stride, duty, lift).ankle);
        const landing = hipOver(foot(0, stride, duty, lift).ankle);
        hipY = takeoff + (landing - takeoff) * u - Math.sin(Math.PI * u) * 2.2;
      }
      const hip: Point = [0, hipY];
      const leg = (f: typeof near): Leg => {
        // A swinging foot that reaches past the leg's length is pulled back in, so bones never stretch.
        const dx = f.ankle[0] - hip[0];
        const dy = f.ankle[1] - hip[1];
        const over = Math.hypot(dx, dy) / (THIGH + SHIN - 0.05);
        if (f.planted || over <= 1) return { hip, knee: joint(hip, f.ankle, THIGH, SHIN, -1), ankle: f.ankle, toe: f.toe, planted: f.planted };
        const ankle: Point = [hip[0] + dx / over, hip[1] + dy / over];
        const toe: Point = [f.toe[0] + ankle[0] - f.ankle[0], f.toe[1] + ankle[1] - f.ankle[1]];
        return { hip, knee: joint(hip, ankle, THIGH, SHIN, -1), ankle, toe, planted: false };
      };
      return { hip, near: leg(near), far: leg(far), swing: Math.sin(phase * Math.PI * 2 + Math.PI * duty) };
    }
    
    /** Standing still, feet a little apart. */
    export function standing(): GaitPose {
      const reach = (THIGH + SHIN) * 0.992;
      const hip: Point = [0, GROUND - ANKLE_UP - reach];
      const leg = (toeX: number): Leg => {
        const ankle: Point = [toeX - FOOT, GROUND - ANKLE_UP];
        return { hip, knee: joint(hip, ankle, THIGH, SHIN, -1), ankle, toe: [toeX, GROUND], planted: true };
      };
      return { hip, near: leg(6), far: leg(3), swing: 0 };
    }
    lib/line-art/ink.ts
    /**
     * Ink: a tiny, dependency-free SVG drawing kit for line art. Thin, slightly wobbly strokes in
     * `currentColor` over paper-colored fills, so a scene inverts cleanly at night by changing two
     * CSS colors. Browser only: call these from effects.
     */
    
    export type Point = readonly [x: number, y: number];
    
    /** Pen width in figure units (a figure is about 95 units tall). */
    export const PEN = 1.35;
    /** Width of the detail pen, for mullions, brick courses and other fine marks. */
    export const DETAIL_PEN = PEN * 0.5;
    /** Day colors. Night swaps them through CSS variables. */
    export const INK = "#1c1c1c";
    export const PAPER = "#fdfcf8";
    
    const SVG_NS = "http://www.w3.org/2000/svg";
    
    /** Creates an SVG element, sets its attributes and appends it to `parent` when given. */
    export function el<K extends keyof SVGElementTagNameMap>(name: K, attributes: Record<string, string | number> = {}, parent?: Element): SVGElementTagNameMap[K] {
      const node = document.createElementNS(SVG_NS, name);
      for (const key in attributes) node.setAttribute(key, String(attributes[key]));
      parent?.appendChild(node);
      return node;
    }
    
    /**
     * Adds the one wobble filter a drawing needs and returns its `url(#…)` reference: light
     * turbulence displaces the strokes by under half a unit and a hair of blur softens them, so
     * lines read as ink on paper, not as vectors. Keep it on the drawing group only.
     */
    export function inkDefs(svg: SVGSVGElement, id: string, seed = 3) {
      const defs = el("defs", {}, svg);
      const filter = el("filter", { id, x: "-5%", y: "-5%", width: "110%", height: "110%" }, defs);
      el("feTurbulence", { type: "fractalNoise", baseFrequency: 0.6, numOctaves: 1, seed, result: "noise" }, filter);
      el("feDisplacementMap", { in: "SourceGraphic", in2: "noise", scale: 0.45, xChannelSelector: "R", yChannelSelector: "G", result: "moved" }, filter);
      el("feGaussianBlur", { in: "moved", stdDeviation: 0.12 }, filter);
      return `url(#${id})`;
    }
    
    const STROKE = { fill: "none", stroke: "currentColor", "stroke-linecap": "round", "stroke-linejoin": "round" } as const;
    
    /** An open ink line. */
    export function line(parent: Element, d = "", width = PEN) {
      return el("path", { d, ...STROKE, "stroke-width": width }, parent);
    }
    
    /** A closed outline filled with paper, so it hides what is behind it. */
    export function shape(parent: Element, d = "", width = PEN) {
      return el("path", { d, ...STROKE, fill: "var(--la-paper)", "stroke-width": width }, parent);
    }
    
    /** A solid ink fill. */
    export function blot(parent: Element, d = "") {
      return el("path", { d, fill: "currentColor", stroke: "currentColor", "stroke-width": PEN * 0.5, "stroke-linejoin": "round" }, parent);
    }
    
    /** A flat color fill with an ink outline. One or two of these per scene, in small areas. */
    export function paint(parent: Element, color: string, d = "", width = PEN) {
      return el("path", { d, ...STROKE, fill: color, "stroke-width": width }, parent);
    }
    
    const n = (value: number) => value.toFixed(2);
    
    /** A smooth open path through points (quadratic segments through the midpoints). */
    export function through(points: readonly Point[]) {
      if (points.length < 2) return "";
      let d = `M${n(points[0][0])} ${n(points[0][1])}`;
      for (let i = 1; i < points.length - 1; i++) {
        const mx = (points[i][0] + points[i + 1][0]) / 2;
        const my = (points[i][1] + points[i + 1][1]) / 2;
        d += `Q${n(points[i][0])} ${n(points[i][1])} ${n(mx)} ${n(my)}`;
      }
      const last = points[points.length - 1];
      return `${d}L${n(last[0])} ${n(last[1])}`;
    }
    
    /** A straight polyline. */
    export function poly(points: readonly Point[], close = false) {
      return points.map(([x, y], i) => `${i ? "L" : "M"}${n(x)} ${n(y)}`).join("") + (close ? "Z" : "");
    }
    
    /** The points of a curve pushed sideways by `offset` (positive is to the left of travel). */
    function offsetPoints(points: readonly Point[], offset: number): Point[] {
      return points.map((point, i) => {
        const before = points[Math.max(0, i - 1)];
        const after = points[Math.min(points.length - 1, i + 1)];
        const dx = after[0] - before[0];
        const dy = after[1] - before[1];
        const length = Math.hypot(dx, dy) || 1;
        return [point[0] + (dy / length) * offset, point[1] - (dx / length) * offset] as const;
      });
    }
    
    export interface Limb {
      /** Redraws the limb through its joints, root first. */
      set(joints: readonly Point[]): void;
      group: SVGGElement;
    }
    
    /**
     * A limb drawn the way a pen would: two open strokes, one down each side of a curve through the
     * joints, with round caps and a paper fill between them. `fill` paints the inside a flat color.
     */
    export function limb(parent: Element, width: number, fill = "var(--la-paper)"): Limb {
      const group = el("g", {}, parent);
      const inside = el("path", { fill, stroke: "none" }, group);
      const left = line(group);
      const right = line(group);
      return {
        group,
        set(joints) {
          const a = offsetPoints(joints, width / 2);
          const b = offsetPoints(joints, -width / 2);
          const da = through(a);
          const db = through(b);
          left.setAttribute("d", da);
          right.setAttribute("d", db);
          inside.setAttribute("d", `${da}${through([...b].reverse()).replace("M", "L")}Z`);
        },
      };
    }
    lib/line-art/loop.ts
    /**
     * The one animation loop a line-art scene runs: it ticks only while the host is on screen, the
     * tab is visible, nothing has paused it and the user has not asked for reduced motion. Under
     * reduced motion it calls `still` once instead, and again whenever asked to redraw. Browser only.
     */
    export interface SceneLoopOptions {
      /** Element watched for visibility. */
      host: Element;
      /** Advance by `dt` seconds (capped at 0.05) and draw. */
      tick: (dt: number, time: number) => void;
      /** Draw a still picture. Called under reduced motion. */
      still: () => void;
      /** Read before every frame; while true the loop rests. */
      isPaused?: () => boolean;
    }
    
    export interface SceneLoop {
      /** Re-checks everything and starts, stops or redraws. Call it when props change. */
      wake(): void;
      /** True when the user prefers reduced motion. */
      reduced(): boolean;
      dispose(): void;
    }
    
    export function runSceneLoop({ host, tick, still, isPaused }: SceneLoopOptions): SceneLoop {
      const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
      let frame = 0;
      let last = 0;
      let time = 0;
      let inView = false;
      let disposed = false;
    
      const shouldRun = () => !disposed && inView && !document.hidden && !reducedMotion.matches && !isPaused?.();
      const step = (now: number) => {
        frame = 0;
        if (!shouldRun()) return;
        const dt = last ? Math.min(0.05, (now - last) / 1000) : 0;
        last = now;
        time += dt;
        tick(dt, time);
        frame = requestAnimationFrame(step);
      };
      const wake = () => {
        if (disposed) return;
        if (shouldRun()) {
          if (!frame) {
            last = 0;
            frame = requestAnimationFrame(step);
          }
          return;
        }
        if (frame) cancelAnimationFrame(frame);
        frame = 0;
        if (reducedMotion.matches) still();
      };
    
      const observer = new IntersectionObserver(
        ([entry]) => {
          inView = entry?.isIntersecting ?? true;
          wake();
        },
        { rootMargin: "80px" },
      );
      observer.observe(host);
      document.addEventListener("visibilitychange", wake);
      reducedMotion.addEventListener("change", wake);
      if (reducedMotion.matches) still();
    
      return {
        wake,
        reduced: () => reducedMotion.matches,
        dispose() {
          disposed = true;
          if (frame) cancelAnimationFrame(frame);
          observer.disconnect();
          document.removeEventListener("visibilitychange", wake);
          reducedMotion.removeEventListener("change", wake);
        },
      };
    }
    lib/line-art/physics.ts
    /**
     * Small physics for line art: a seeded generator, a spring, a pendulum for things that hang,
     * pointer speed as wind, and keyed pose timelines for reactions. Framework-free.
     */
    
    /** A seeded generator (mulberry32): the same seed always gives the same scene. */
    export function rng(seed: number) {
      let a = seed >>> 0;
      return () => {
        a = (a + 0x6d2b79f5) >>> 0;
        let t = a;
        t = Math.imul(t ^ (t >>> 15), t | 1);
        t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
        return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
      };
    }
    
    export interface Spring {
      value: number;
      velocity: number;
    }
    
    /** Steps a damped spring toward `target`. Stable for the frame times a canvas loop produces. */
    export function spring(state: Spring, target: number, stiffness: number, damping: number, dt: number) {
      const step = Math.min(dt, 1 / 30);
      state.velocity += ((target - state.value) * stiffness - state.velocity * damping) * step;
      state.value += state.velocity * step;
      return state.value;
    }
    
    export interface Pendulum {
      /** Angle from hanging straight down, in radians. */
      angle: number;
      velocity: number;
    }
    
    /**
     * Steps something that hangs: scarves, bags, signs, lanterns. `push` is a sideways acceleration
     * (wind, or the carrier speeding up), `length` sets how slowly it swings.
     */
    export function pendulum(state: Pendulum, push: number, dt: number, length = 12, damping = 2.2) {
      const step = Math.min(dt, 1 / 30);
      state.velocity += (-(60 / length) * Math.sin(state.angle) + push * Math.cos(state.angle) - state.velocity * damping) * step;
      state.angle += state.velocity * step;
      return state.angle;
    }
    
    export interface PointerWind {
      /** Signed wind right now: positive blows toward +x. Decays to 0 when the pointer rests. */
      read(dt: number): number;
      dispose(): void;
    }
    
    /** Turns pointer speed over `target` into wind. */
    export function pointerWind(target: HTMLElement | SVGElement, strength = 0.02): PointerWind {
      let lastX = 0;
      let lastTime = 0;
      let gust = 0;
      const onMove = (event: Event) => {
        const pointer = event as PointerEvent;
        const now = performance.now();
        if (lastTime && now - lastTime < 120) {
          const speed = (pointer.clientX - lastX) / Math.max(1, now - lastTime);
          gust += Math.max(-1, Math.min(1, speed * strength * 16)) * 0.35;
          gust = Math.max(-1.5, Math.min(1.5, gust));
        }
        lastX = pointer.clientX;
        lastTime = now;
      };
      target.addEventListener("pointermove", onMove, { passive: true });
      return {
        read(dt) {
          gust *= Math.pow(0.08, dt);
          return gust;
        },
        dispose() {
          target.removeEventListener("pointermove", onMove);
        },
      };
    }
    
    export type Key<T extends readonly number[]> = readonly [time: number, value: T];
    
    /**
     * A keyed timeline of poses: `sample(t)` eases between the keys on either side of `t`.
     * Times are in seconds; before the first key and after the last, the nearest key holds.
     */
    export function sequence<T extends readonly number[]>(keys: readonly Key<T>[]) {
      const end = keys[keys.length - 1][0];
      return {
        duration: end,
        sample(t: number): number[] {
          if (t <= keys[0][0]) return [...keys[0][1]];
          if (t >= end) return [...keys[keys.length - 1][1]];
          let i = 1;
          while (keys[i][0] < t) i++;
          const [t0, a] = keys[i - 1];
          const [t1, b] = keys[i];
          const u = (t - t0) / (t1 - t0);
          const eased = u * u * (3 - 2 * u);
          return a.map((value, index) => value + (b[index] - value) * eased);
        },
      };
    }
  3. Copy the source

    components/ui/ink-walker-card/ink-walker-card.tsx
    "use client";
    
    import { useEffect, useId, useRef, type ComponentProps, type CSSProperties, type ReactNode } from "react";
    import { cn } from "@/lib/utils";
    import { createFigure, type FigureKind } from "@/lib/line-art/figures";
    import { GROUND } from "@/lib/line-art/gait";
    import { DETAIL_PEN, el, inkDefs, line, paint, poly, shape, through, type Point } from "@/lib/line-art/ink";
    import { runSceneLoop } from "@/lib/line-art/loop";
    import { pointerWind, rng } from "@/lib/line-art/physics";
    
    export interface InkWalkerCardProps extends Omit<ComponentProps<"div">, "children" | "title" | "prop"> {
      /** Who walks across the strip. @default "commuter" */
      figure?: FigureKind;
      /** The one thing standing on the strip. @default "lamp" */
      prop?: "lamp" | "bench" | "mailbox";
      /** Day ink on paper, night ink on indigo, or "auto" to follow the page's dark mode. @default "auto" */
      tone?: "day" | "night" | "auto";
      /** Pace multiplier. 1 is a natural walk. @default 1 */
      speed?: number;
      /** The one flat color, used for the scarf, cap, running top or bicycle frame. @default "#c8553d" */
      accent?: string;
      /** Short title above the strip. */
      title?: ReactNode;
      /** Muted line under the title. */
      description?: ReactNode;
      /** Called when the figure reacts to a click or key press. */
      onReact?: (figure: FigureKind) => void;
    }
    
    /** The strip is this many figure units tall: ground at 120, a little room below. */
    const VIEW_H = 132;
    const NAMES: Record<FigureKind, string> = { commuter: "commuter", courier: "courier", jogger: "jogger", cyclist: "cyclist" };
    const PROP_ACTION = { lamp: "Light the lamp", bench: "Shake the tree over the bench", mailbox: "Post a letter" } as const;
    
    export function InkWalkerCard({
      figure = "commuter",
      prop = "lamp",
      tone = "auto",
      speed = 1,
      accent = "#c8553d",
      title,
      description,
      onReact,
      className,
      style,
      ...props
    }: InkWalkerCardProps) {
      const id = useId().replace(/[^a-zA-Z0-9]/g, "");
      const stripRef = useRef<HTMLDivElement>(null);
      const svgRef = useRef<SVGSVGElement>(null);
      const figureButton = useRef<HTMLButtonElement>(null);
      const propButton = useRef<HTMLButtonElement>(null);
      const actions = useRef({ react: () => {}, gag: () => {} });
      const latest = useRef({ speed, onReact });
      useEffect(() => {
        latest.current = { speed, onReact };
      });
    
      useEffect(() => {
        const strip = stripRef.current;
        const svg = svgRef.current;
        if (!strip || !svg) return;
        svg.replaceChildren();
        const filter = inkDefs(svg, `${id}-ink`, 4);
        const drawing = el("g", { filter }, svg);
        const backdrop = el("g", {}, drawing);
        const propGroup = el("g", {}, drawing);
        const walker = createFigure(drawing, figure, { accent });
        const wind = pointerWind(strip);
        const random = rng(236);
        const marks = Array.from({ length: 14 }, () => [random(), random()] as const);
    
        let width = 300;
        let scale = 1;
        let x = 40;
        let pace = 1;
        let hovering = false;
        let gagAt = -1;
        let lit = false;
        let stillTimer = 0;
        const propX = () => width * 0.72;
    
        // The prop: one object, drawn once per layout. Its moving part is redrawn by the gag.
        let gagPart: SVGElement | null = null;
        function layout() {
          const rect = strip!.getBoundingClientRect();
          scale = rect.height / VIEW_H;
          width = Math.max(120, rect.width / scale);
          svg!.setAttribute("viewBox", `0 0 ${width.toFixed(1)} ${VIEW_H}`);
          backdrop.replaceChildren();
          propGroup.replaceChildren();
          // Ground: one long line that is not quite straight, and a few stray marks.
          const ground: Point[] = [];
          for (let gx = -4; gx <= width + 4; gx += width / 9) ground.push([gx, GROUND + 0.8 + Math.sin(gx * 0.07) * 0.5]);
          line(backdrop, through(ground));
          for (const [mx, my] of marks) line(backdrop, poly([[mx * width, GROUND + 3 + my * 5], [mx * width + 4 + my * 6, GROUND + 3 + my * 5]]), DETAIL_PEN);
          const px = propX();
          if (prop === "lamp") {
            line(propGroup, `M${px} ${GROUND}L${px} 34Q${px} 22 ${px + 11} 22`);
            line(propGroup, poly([[px - 3, GROUND], [px + 3, GROUND]]));
            gagPart = paint(propGroup, "var(--la-glow)", `M${px + 5} 22h12l-2.4 10h-7.2z`);
            gagPart.setAttribute("fill-opacity", lit ? "1" : "0");
            line(propGroup, poly([[px + 9, 32], [px + 13, 32]]), DETAIL_PEN);
          } else if (prop === "bench") {
            shape(propGroup, `M${px - 16} ${GROUND - 16}h34v4h-34z`);
            line(propGroup, `M${px - 14} ${GROUND - 12}v12M${px + 16} ${GROUND - 12}v12M${px - 15} ${GROUND - 16}L${px - 17} ${GROUND - 31}M${px + 17} ${GROUND - 16}L${px + 19} ${GROUND - 31}`);
            shape(propGroup, `M${px - 18} ${GROUND - 33}h38v4h-38z`);
            // A branch reaching in from above, where a leaf waits.
            line(propGroup, `M${px + 44} 6Q${px + 22} 12 ${px + 4} 26M${px + 18} 16l-5 -6M${px + 30} 11l-3 7`, DETAIL_PEN * 1.4);
            gagPart = paint(propGroup, accent, "");
          } else {
            line(propGroup, poly([[px, GROUND], [px, GROUND - 26]]));
            paint(propGroup, accent, `M${px - 9} ${GROUND - 46}q9 -9 18 0v20h-18z`);
            line(propGroup, poly([[px - 5, GROUND - 39], [px + 5, GROUND - 39]]), DETAIL_PEN * 1.6);
            gagPart = shape(propGroup, "", DETAIL_PEN * 1.4);
          }
          const button = propButton.current;
          if (button) button.style.transform = `translateX(${(px * scale - 22).toFixed(1)}px)`;
        }
    
        function drawGag(dt: number) {
          if (gagAt < 0 || !gagPart) return;
          gagAt += dt;
          const px = propX();
          if (prop === "lamp") {
            // A flicker, then it settles on or off.
            const flicker = gagAt < 0.45 ? (Math.floor(gagAt * 22) % 2 ? 0.9 : 0.15) : lit ? 1 : 0;
            gagPart.setAttribute("fill-opacity", String(flicker));
            if (gagAt > 0.5) gagAt = -1;
          } else if (prop === "bench") {
            // The leaf lets go and sways down onto the seat.
            const t = Math.min(1, gagAt / 1.6);
            const lx = px + 6 + Math.sin(t * 9) * 5 * (1 - t);
            const ly = 26 + (GROUND - 44) * t * t * (3 - 2 * t);
            gagPart.setAttribute("d", `M${lx.toFixed(2)} ${ly.toFixed(2)}q4 -4 8 0q-4 4 -8 0z`);
            if (gagAt > 3.2) {
              gagPart.setAttribute("d", "");
              gagAt = -1;
            }
          } else {
            // A letter drops into the slot.
            const t = Math.min(1, gagAt / 0.7);
            const ly = GROUND - 62 + 21 * t * t;
            gagPart.setAttribute("d", t < 1 ? `M${px - 4} ${ly.toFixed(2)}h8v${Math.max(0.5, 6 * (1 - t)).toFixed(2)}h-8z` : "");
            if (t >= 1) gagAt = -1;
          }
        }
    
        function place() {
          walker.group.setAttribute("transform", `translate(${x.toFixed(2)} 0)`);
          const button = figureButton.current;
          if (button) button.style.transform = `translateX(${(x * scale - 24).toFixed(1)}px)`;
        }
    
        const loop = runSceneLoop({
          host: strip,
          tick: (dt) => {
            // Hovering slows the walk; the pointer's speed is wind on the scarf and the bag.
            pace += ((hovering ? 0.35 : 1) - pace) * Math.min(1, dt * 5);
            x += walker.step(dt, pace * latest.current.speed, wind.read(dt));
            if (x > width + 44) x = -44;
            place();
            drawGag(dt);
          },
          still: () => {
            x = Math.min(width * 0.36, width - 60);
            walker.still();
            place();
          },
        });
    
        const observer = new ResizeObserver(() => {
          layout();
          if (loop.reduced()) {
            walker.still();
            x = Math.min(width * 0.36, width - 60);
          }
          place();
        });
        observer.observe(strip);
        layout();
        if (loop.reduced()) walker.still();
        else walker.step(0, 1, 0);
        place();
    
        const onEnter = (event: PointerEvent) => {
          hovering = event.pointerType === "mouse";
        };
        const onLeave = () => {
          hovering = false;
        };
        strip.addEventListener("pointerenter", onEnter);
        strip.addEventListener("pointerleave", onLeave);
    
        actions.current = {
          react: () => {
            latest.current.onReact?.(figure);
            if (!loop.reduced()) {
              walker.react();
              return;
            }
            // Reduced motion: swap to the reaction pose for a moment, then back.
            walker.still(true);
            window.clearTimeout(stillTimer);
            stillTimer = window.setTimeout(() => walker.still(), 1200);
          },
          gag: () => {
            if (prop === "lamp") lit = !lit;
            if (loop.reduced()) {
              if (prop === "lamp") gagPart?.setAttribute("fill-opacity", lit ? "1" : "0");
              return;
            }
            gagAt = 0;
          },
        };
    
        return () => {
          actions.current = { react: () => {}, gag: () => {} };
          window.clearTimeout(stillTimer);
          observer.disconnect();
          loop.dispose();
          wind.dispose();
          strip.removeEventListener("pointerenter", onEnter);
          strip.removeEventListener("pointerleave", onLeave);
          svg.replaceChildren();
        };
      }, [figure, prop, accent, id]);
    
      const hit = "absolute inset-y-1 left-0 cursor-pointer rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-(--la-ink) focus-visible:ring-offset-2 focus-visible:ring-offset-(--la-paper)";
    
      return (
        <div
          style={{ "--la-glow": "#f6c453", ...style } as CSSProperties}
          className={cn(
            "@container w-full max-w-md overflow-hidden rounded-2xl border border-(--la-ink)/15 bg-(--la-paper) text-(--la-ink)",
            tone === "night" ? "[--la-ink:#e9e6f2] [--la-paper:#1d2140]" : "[--la-ink:#1c1c1c] [--la-paper:#fdfcf8]",
            tone === "auto" && "dark:[--la-ink:#e9e6f2] dark:[--la-paper:#1d2140]",
            className,
          )}
          {...props}
        >
          {title || description ? (
            <div className="px-5 pt-5">
              {title ? <h3 className="text-base font-semibold tracking-tight">{title}</h3> : null}
              {description ? <p className="mt-1 text-sm leading-5 opacity-75">{description}</p> : null}
            </div>
          ) : null}
          <div ref={stripRef} className="relative mt-2 h-[clamp(112px,34cqw,180px)] w-full overflow-hidden">
            <svg
              ref={svgRef}
              role="img"
              aria-label={`A line drawing of a ${NAMES[figure]} passing a ${prop}`}
              preserveAspectRatio="xMinYMax meet"
              className="absolute inset-0 size-full text-(--la-ink) [&_path]:[vector-effect:non-scaling-stroke]"
            />
            <button ref={propButton} type="button" aria-label={PROP_ACTION[prop]} onClick={() => actions.current.gag()} className={cn(hit, "w-11")} />
            <button ref={figureButton} type="button" aria-label={`Say hi to the ${NAMES[figure]}`} onClick={() => actions.current.react()} className={cn(hit, "w-12")} />
          </div>
        </div>
      );
    }

Usage

import { InkWalkerCard } from "@/components/ui/ink-walker-card/ink-walker-card";

export function EmptyOrders() {
  return (
    <InkWalkerCard
      figure="courier"
      prop="mailbox"
      title="Nothing here yet"
      description="New orders will show up on this street."
    />
  );
}

API reference

PropTypeDefaultDescription
figureFigureKind"commuter"Who walks across the strip.
prop"lamp" | "bench" | "mailbox""lamp"The one thing standing on the strip.
tone"day" | "night" | "auto""auto"Day ink on paper, night ink on indigo, or "auto" to follow the page's dark mode.
speednumber1Pace multiplier. 1 is a natural walk.
accentstring"#c8553d"The one flat color, used for the scarf, cap, running top or bicycle frame.
titleReactNode–Short title above the strip.
descriptionReactNode–Muted line under the title.
onReact(figure: FigureKind) => void–Called when the figure reacts to a click or key press.

Also accepts every native <div> attribute, including ref and className.

  • Everything is SVG built by a small toolkit in src/lib/line-art: one wobble filter on the drawing, limbs drawn as two open strokes around a curve, ink as currentColor over paper-colored fills.
  • The walk is solved, not keyframed: planted toes stay put, the heel lifts over the last part of each step, the swinging foot follows a minimum-jerk curve with a low lift, the hip's height comes from the planted leg and knees use two-bone IK. The jogger adds a flight phase; the cyclist's wheels roll without slipping and the cranks turn.
  • Scarves and bags are pendulums, and pointer speed over the strip is wind. Hovering slows the figure to about a third of its pace.
  • The strip's height follows the card's width, and strokes use vector-effect: non-scaling-stroke, so the pen stays the same width at every size.
  • tone="auto" follows the page's dark mode through two CSS variables (--la-ink and --la-paper); night inverts to pale ink on indigo.
  • One animation frame loop per card, paused offscreen and in hidden tabs. The line style and motion rules learn from line-art-animation by JDhruv14 (MIT); the code and scenes here are our own.

Accessibility

  • The drawing is an image with a label that names the figure and the prop. The title and description are real text above it.
  • The figure is a button ("Say hi to the courier") that travels with it, so its reaction works from the keyboard; the prop is a second button ("Light the lamp"). Both show a focus ring in the ink color.
  • Nothing in the strip is needed to understand the card: it is decoration with two optional pokes.
  • With reduced motion enabled the figure stands still. Pressing it swaps to the reaction pose for a moment, and the lamp switches on or off without a flicker.