Orbits, Beams & Illustrationsv0.4.0New
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.jsonManual
Install the dependencies
npm install clsx tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/line-art/figures.tsimport { 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.tsimport 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); }, }; }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
| Prop | Type | Default | Description |
|---|---|---|---|
figure | FigureKind | "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. |
speed | number | 1 | Pace multiplier. 1 is a natural walk. |
accent | string | "#c8553d" | The one flat color, used for the scarf, cap, running top or bicycle frame. |
title | ReactNode | – | Short title above the strip. |
description | ReactNode | – | 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.