Drawing Icon
A line icon that draws itself stroke by stroke while the step it stands for is running.
Thinking…
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* Stroke paths for a few 24×24 line icons (Lucide, ISC). Circles are written
* as two arcs so every shape is a `<path>` that can be "drawn".
*/
export const DRAWING_ICONS = {
origami: [
"M12 12V4a1 1 0 0 1 1-1h6.297a1 1 0 0 1 .651 1.759l-4.696 4.025",
"m12 21-7.414-7.414A2 2 0 0 1 4 12.172V6.415a1.002 1.002 0 0 1 1.707-.707L20 20.009",
"m12.214 3.381 8.414 14.966a1 1 0 0 1-.167 1.199l-1.168 1.163a1 1 0 0 1-.706.291H6.351a1 1 0 0 1-.625-.219L3.25 18.8a1 1 0 0 1 .631-1.781l4.165.027",
],
globe: [
"M2 12a10 10 0 1 0 20 0a10 10 0 1 0-20 0",
"M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20",
"M2 12h20",
],
lightbulb: [
"M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5",
"M9 18h6",
"M10 22h4",
],
pen: [
"M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z",
"M13 21h8",
],
telescope: [
"m10.065 12.493-6.18 1.318a.934.934 0 0 1-1.108-.702l-.537-2.15a1.07 1.07 0 0 1 .691-1.265l13.504-4.44",
"M16.485 5.94a2 2 0 0 1 1.455-2.425l1.09-.272a1 1 0 0 1 1.212.727l1.515 6.06a1 1 0 0 1-.727 1.213l-1.09.272a2 2 0 0 1-2.425-1.455z",
"m13.56 11.747 4.332-.924",
"m6.158 8.633 1.114 4.456",
"M10 13a2 2 0 1 0 4 0a2 2 0 1 0-4 0",
"m8 21 3.105-6.21",
"m16 21-3.105-6.21",
],
} satisfies Record<string, string[]>;
export type DrawingIconName = keyof typeof DRAWING_ICONS;
export interface DrawingIconProps {
/** A preset name, or your own list of 24×24 stroke path `d` strings, drawn in order. */
icon: DrawingIconName | string[];
/** Rendered size in px. */
size?: number;
/** Length of one draw → hold → erase loop, in seconds. */
duration?: number;
/** Delay between consecutive strokes starting, in seconds. */
stagger?: number;
/** Keep a faint copy of the full icon underneath so its shape is always readable. */
ghost?: boolean;
/** Pause on the fully drawn icon (e.g. when the work it stands for finishes). */
paused?: boolean;
/** Accessible label. Omit when a visible text label sits next to the icon. */
label?: string;
className?: string;
}
/*
* Every path gets pathLength="1", so a dasharray of 1 is exactly one stroke
* long whatever the real geometry: offset 1 hides it, 0 shows it, -1 slides
* it off the far end. One keyframe set then draws any shape.
*/
const STYLES = `
@keyframes drawing-icon-stroke {
0% { stroke-dashoffset: 1; }
45%, 65% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: -1; }
}
.drawing-icon [data-stroke] {
stroke-dasharray: 1;
stroke-dashoffset: 1;
animation: drawing-icon-stroke var(--draw-duration) ease-in-out infinite;
animation-delay: calc(var(--i) * var(--draw-stagger));
}
.drawing-icon[data-paused] [data-stroke] {
animation: none;
stroke-dashoffset: 0;
transition: stroke-dashoffset 0.3s ease-out;
}
@media (prefers-reduced-motion: reduce) {
.drawing-icon [data-stroke] {
animation: none;
stroke-dashoffset: 0;
}
.drawing-icon:not([data-paused]) {
animation: drawing-icon-pulse 1.6s ease-in-out infinite;
}
}
@keyframes drawing-icon-pulse {
50% { opacity: 0.45; }
}
`;
export function DrawingIcon({
icon,
size = 16,
duration = 3.6,
stagger = 0.3,
ghost = false,
paused = false,
label,
className,
}: DrawingIconProps) {
const paths = typeof icon === "string" ? DRAWING_ICONS[icon] : icon;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
role={label ? "img" : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
data-paused={paused ? "" : undefined}
className={cn("drawing-icon shrink-0", className)}
style={
{
"--draw-duration": `${duration}s`,
"--draw-stagger": `${stagger}s`,
} as React.CSSProperties
}
>
<style>{STYLES}</style>
{ghost && (
<g opacity={0.15}>
{paths.map((d, i) => (
<path key={i} d={d} />
))}
</g>
)}
{paths.map((d, i) => (
<path
key={i}
d={d}
pathLength={1}
data-stroke=""
style={{ "--i": i } as React.CSSProperties}
/>
))}
</svg>
);
}
A UX spec for this pattern — written for agents implementing or reusing it, not the code.
# Drawing Icon
## Summary
A line icon that draws itself stroke by stroke, holds for a beat, then erases and starts again — used as the "working" glyph for a specific kind of activity (searching, thinking, writing, making an image). Because it's the activity's own icon rather than a generic spinner, it says *what* is happening as well as *that* something is happening.
## When to use
- Next to a status label while an agent runs a step whose kind you know ("Searching the web", "Creating image").
- In tool-call chips, step lists or traces where each step type already has an icon — animate the icon of the step that is running.
- When duration is unknown and you want a calmer, more characterful signal than a spinner.
## When not to use
- When you know real progress — use a progress bar.
- For more than one running step in the same view; several icons drawing at once reads as noise. Animate only the active one and show the rest static.
- With dense, filled or very detailed icons — the effect needs a few clean strokes to read.
- As decoration on idle UI. Motion should mean "working right now".
## Anatomy
- Icon: a 24×24 stroke icon split into its individual paths (2–7 strokes works best), drawn in path order.
- Optional ghost: a faint, static copy of the full icon underneath, so the shape is recognisable at every frame, including the empty one.
- Label (outside the component): the present-participle verb for the step.
## Behavior
- Each stroke animates its dash offset: draw on (~45% of the loop), hold fully drawn (~20%), then slide off the far end (~35%), easing in and out. Default loop is 3.6s.
- Strokes start staggered (default 0.3s apart), so the icon builds up in sequence rather than all at once.
- Works for any path: every stroke is normalised with `pathLength="1"`, so one keyframe set fits every shape.
- When the step changes, swap the icon (remount it so it starts drawing from empty) and the label together.
- When the step finishes, pause it: it settles on the fully drawn icon, which then becomes the step's static "done" glyph.
## Content guidelines
- Pick the icon that matches the actual activity; don't show a globe unless something is actually being searched.
- Pair it with a short label — the icon alone is a hint, not a status.
- Order paths so the drawing reads naturally: outline first, details last.
## Accessibility
- The icon is decorative (`aria-hidden`) when a visible label sits next to it; otherwise pass a `label` so it's announced as an image.
- Put the status label in an `aria-live="polite"` region so step changes are announced.
- With `prefers-reduced-motion`, the icon is shown fully drawn and only gently pulses its opacity.
## Related patterns
- **Thinking Loader** — Use it as well, replacing its static glyph with a Drawing Icon for the step that is running.
- **Tool Call Chip** — Use it as well to animate the chip's icon while the call runs.
- **Analysis List** — Use it as well to animate only the active step's icon.
Do’s and don’ts
Do use it for
- Next to a status label while an agent runs a step whose kind you know ("Searching the web", "Creating image").
- In tool-call chips, step lists or traces where each step type already has an icon — animate the icon of the step that is running.
- When duration is unknown and you want a calmer, more characterful signal than a spinner.
Don’t use it for
- When you know real progress — use a progress bar.
- For more than one running step in the same view; several icons drawing at once reads as noise. Animate only the active one and show the rest static.
- With dense, filled or very detailed icons — the effect needs a few clean strokes to read.
- As decoration on idle UI. Motion should mean "working right now".