Agent-Draw
Named cursors draw your page's skeleton while its data loads, then the real content fades in. Use it on slow screens, like a dashboard or profile.
npx shadcn@latest add https://impeccableui.vercel.app/r/agent-draw.jsonRun it in a project set up with shadcn. No other packages needed. Or open and copy the file into components/ui/agent-draw.tsx.
Prompts
Paste one into Claude Code, Cursor or any AI coding tool. Install adds this exact component and asks only which screen should load with it. Blend asks four short questions (where, names, colors, speed), then fits it to your brand without changing how it draws.
# Agent-Draw
A 1-file React component from Impeccable. Add it to this project so it works and looks exactly like the source below, on the one screen I choose, and change nothing else.
Instead of a spinner or a skeleton, a few named cursors draw the screen's skeleton live (rectangles and circles), top to bottom, while the data loads. Then the real content fades in.
## Before you build: one question
Look through this project first: its pages, and where data is fetched and a loading state is shown. Then ask me this in one message and wait for my answer:
- **Which screen should load with it?** Suggest the 1–3 screens or sections that fit best (ones that fetch data and currently show a spinner, a skeleton or nothing) and name the loading state you'd pass in. I can pick one of them or name another.
*Why: it only makes sense where data really loads. On a screen that is already instant, it just delays the content.*
Ask nothing else. If I say *skip*, use the best suggestion.
## Keep it as it is
The drawing is the design. Keep the shapes (only rectangles and circles), the order (top to bottom, one cursor per shape), the cursor names, colors and count, the size tags, the 3.5 second timing and the shimmer exactly as in the source. If something doesn't fit my project, tell me instead of changing it.
- Add only the component file and the wrapper on the screen I picked. If that screen already has a spinner or skeleton for the same loading state, remove it so the two don't show together. Don't add packages, change other files, or restyle anything.
- Don't add demo pieces from the Impeccable website (the "Redraw this page" button, Replay buttons, sliders or "Slow data" toggles). My users only ever see the drawing.
## Fastest path
If this project has a `components.json` (shadcn), run `npx shadcn@latest add https://impeccableui.vercel.app/r/agent-draw.json`.
If the command succeeds, only fix import paths to match this project and skip ahead. If it fails or there is no `components.json`, build it by hand as below.
## Build it
- Stack: React 18 or 19 with TypeScript. No other packages: it only uses the DOM, `requestAnimationFrame` and an IntersectionObserver.
- JavaScript project? Name the file `agent-draw.jsx` and remove the type annotations; nothing else changes.
- Create this file, in this project's usual place for components:
- `components/ui/agent-draw.tsx`
- Keep these exports and their names: `AgentDraw`, `AgentDrawProps`, `AGENT_NAMES`, `AGENT_COLORS`.
- Keep `"use client"` at the top in Next.js App Router projects. In other setups it is harmless.
- Keep the timings, the easing, the shape reading (`measure`), the shared cursor pool and the cap of 9 cursors exactly as written. They are the design.
- Keep the fallbacks: `prefers-reduced-motion: reduce` gets a plain fade (or a still skeleton while loading), and `once` skips the cursors on later visits.
## Use it
Wrap the part of the screen that loads and pass its loading state:
```tsx
"use client";
import { AgentDraw } from "@/components/ui/agent-draw";
function ProfilePage() {
const { data, isLoading } = useProfile(); // whatever this project uses
return (
<AgentDraw loading={isLoading}>
<ProfileHeader user={data?.user} />
<Projects items={data?.projects} />
</AgentDraw>
);
}
```
- The children must render their normal layout while loading (empty or placeholder text is fine), because the shapes are read from that layout. If a component returns `null` while loading, render its layout with placeholders instead.
- Layout classes that were on the wrapped element go in `className` on `<AgentDraw>`.
- Add `data-agent-draw` to an element to draw it as one solid shape (good for image placeholders), or `data-agent-skip` to leave it out.
- On a long page, wrap each section separately. Each draws when it scrolls into view, and they share the same cursors.
## How the drawing works
Read this before you touch anything, so you know what the code does and why it must stay as it is.
1. **Read the layout.** When the wrapped block scrolls into view, it measures what's inside: images, icons, buttons, chips and inputs become one shape each; text becomes one bar per line; cards and panels become an outline. Round things become circles. Only rectangles and circles, like a skeleton.
2. **Hand out shapes.** Shapes are sorted top to bottom. Each goes to the cursor that is free first, so it draws as a wave down the page. One cursor per shape, never two on the same one.
3. **Draw.** A cursor flies to the shape's top-left corner on a slight curve, then drags it out to the bottom-right corner, like a Figma selection: an outline in its own color, a light tint and a "W × H" size tag. When it lets go, the shape settles into a quiet gray skeleton block.
4. **Cursors.** Up to 8 on desktop and 4 on phones, shared by every Agent-Draw on the page, so there are never 10 or more. Each has its own light color and a name tag (Vraj, Sensei, Impeccable, then Agent 4, Agent 5…).
5. **Finish.** After about 3.5 seconds the cursors drift off and fade. If the data has arrived, the real content fades in over the skeleton. If not, the skeleton shimmers until `loading` turns false, then the content fades in.
The skeleton gray, outlines and shimmer adapt to the page: lighter on dark pages, darker on light ones. The content stays mounted (just invisible) while it draws, so it's ready the moment the drawing ends.
## Check
- It type-checks and the page renders with no console errors.
- Loading the screen: named cursors fly in and drag out rectangles and circles top to bottom, one cursor per shape, never more than 8 (4 on a phone). After about 3.5 seconds the real content fades in.
- With slow data (throttle the network), the skeleton shimmers after the drawing and the content fades in when the data arrives.
- It looks right in light and dark mode, and stays smooth on a phone.
- With `prefers-reduced-motion: reduce`, there are no cursors, just a fade.
- Nothing else on the site looks or behaves differently.
- Finish with a short report: the files you added or changed, where it's wired, and every place where you had to do something differently from the source, and why.
## Source
The reference implementation. Copy it as-is, then apply only the changes listed above.
### `components/ui/agent-draw.tsx`
```tsx
"use client";
import { useEffect, useRef, type CSSProperties, type ReactNode } from "react";
/**
* Agent-Draw — a loader that draws the page instead of spinning.
*
* Wrap any part of your UI. While it loads, a few named cursor "agents" fly in
* and drag out skeleton shapes (rectangles and circles) for every block inside,
* top to bottom, one cursor per shape. After the drawing (~3.5s) the real
* content fades in. If the data still isn't there, the skeleton shimmers until
* `loading` turns false.
*/
export type AgentDrawProps = {
children: ReactNode;
/** True while your data is loading. The drawing runs either way; if this is
* still true when it ends, the skeleton shimmers until it turns false. */
loading?: boolean;
/** Length of the drawing, in ms. */
duration?: number;
/** Most cursors on screen at once, on desktop. Hard cap: 9. */
maxAgents?: number;
/** Most cursors on screen at once, on phones (≤ 640px wide). */
maxAgentsMobile?: number;
/** Cursor names, in order. Missing ones become "Agent 4", "Agent 5"… */
names?: string[];
/** Cursor colors, in order. Light colors read best (text on tags is dark). */
colors?: string[];
/** Show the "W × H" size tag while a box is being dragged. */
sizeTags?: boolean;
/** A key, e.g. "profile". When set, the drawing plays only on the first
* visit; later visits just fade in. */
once?: string;
/** Called after the real content is fully shown. */
onDone?: () => void;
/** Goes on the box that holds your content (your layout classes). */
className?: string;
style?: CSSProperties;
};
export const AGENT_NAMES = ["Vraj", "Sensei", "Impeccable"];
export const AGENT_COLORS = [
"#a5b4fc", // periwinkle
"#f9a8d4", // pink
"#86efac", // mint
"#fcd34d", // butter
"#7dd3fc", // sky
"#fdba74", // peach
"#c4b5fd", // lilac
"#5eead4", // aqua
"#fca5a5", // coral
"#d9f99d", // lime
];
const HARD_CAP = 9;
const TICK_MS = 400; // how long the content fade and skeleton fade take
/* ------------------------------------------------------------------------ */
/* Shared cursor pool: every Agent-Draw on the page shares the same cursors, */
/* so the total on screen never goes over the cap. */
/* ------------------------------------------------------------------------ */
const busy = new Set<number>();
const waiters = new Set<() => void>();
function takeSlots(want: number, cap: number) {
const got: number[] = [];
for (let i = 0; i < cap && got.length < want; i++) {
if (!busy.has(i)) {
busy.add(i);
got.push(i);
}
}
return got;
}
function giveSlots(slots: number[]) {
slots.forEach((s) => busy.delete(s));
if (slots.length) waiters.forEach((w) => w());
}
/* ------------------------------------------------------------------------ */
/* Reading the layout into skeleton shapes */
/* ------------------------------------------------------------------------ */
type Kind = "box" | "circle" | "text" | "frame";
type Shape = { x: number; y: number; w: number; h: number; r: number; kind: Kind };
const ATOMIC =
"img,svg,video,canvas,picture,iframe,input,textarea,select,button,progress,meter,[data-agent-draw]";
function alpha(color: string) {
if (!color || color === "transparent") return 0;
const m = color.match(/rgba?\(([^)]+)\)/);
if (!m) return 1;
const parts = m[1].split(/[\s,/]+/).filter(Boolean);
return parts.length > 3 ? parseFloat(parts[3]) : 1;
}
/** Has its own background, border or shadow (a card, chip or button). */
function hasPaint(cs: CSSStyleDeclaration) {
if (alpha(cs.backgroundColor) > 0.02 || cs.backgroundImage !== "none") return true;
if (cs.boxShadow !== "none") return true;
return (["Top", "Right", "Bottom", "Left"] as const).some(
(side) =>
parseFloat(cs.getPropertyValue(`border-${side.toLowerCase()}-width`)) > 0 &&
alpha(cs.getPropertyValue(`border-${side.toLowerCase()}-color`)) > 0.02,
);
}
function radiusOf(cs: CSSStyleDeclaration, w: number, h: number) {
const raw = cs.borderTopLeftRadius;
const n = parseFloat(raw) || 0;
return raw.endsWith("%") ? (Math.min(w, h) * n) / 100 : n;
}
function isInline(el: Element) {
return getComputedStyle(el).display.startsWith("inline") && !el.matches(ATOMIC);
}
function measure(root: HTMLElement): Shape[] {
const base = root.getBoundingClientRect();
const rootArea = base.width * base.height;
const out: Shape[] = [];
const push = (left: number, top: number, w: number, h: number, kind: Kind, r: number) => {
if (w < 4 || h < 4) return;
out.push({ x: left - base.left, y: top - base.top, w, h, r: Math.min(r, Math.min(w, h) / 2), kind });
};
// One bar per line of text, like a skeleton.
const textLines = (node: Node) => {
const range = document.createRange();
range.selectNodeContents(node);
const rects = Array.from(range.getClientRects()).filter((r) => r.width > 1 && r.height > 1);
const rows: { l: number; t: number; r: number; b: number }[] = [];
for (const rc of rects) {
const mid = rc.top + rc.height / 2;
const row = rows.find((w) => mid > w.t && mid < w.b);
if (row) {
row.l = Math.min(row.l, rc.left);
row.r = Math.max(row.r, rc.right);
} else rows.push({ l: rc.left, t: rc.top, r: rc.right, b: rc.bottom });
}
for (const row of rows) {
const h = (row.b - row.t) * 0.68;
push(row.l, row.t + (row.b - row.t - h) / 2, row.r - row.l, h, "text", 4);
}
};
const visit = (el: Element) => {
for (const node of Array.from(el.childNodes)) {
if (node.nodeType === Node.TEXT_NODE) {
if (node.textContent?.trim()) textLines(node);
continue;
}
if (!(node instanceof Element) || node.hasAttribute("data-agent-skip")) continue;
const cs = getComputedStyle(node);
if (cs.display === "none" || cs.visibility === "hidden" || cs.opacity === "0") continue;
if (cs.display === "contents") {
visit(node);
continue;
}
const rect = node.getBoundingClientRect();
if (rect.width < 2 || rect.height < 2) continue;
const r = radiusOf(cs, rect.width, rect.height);
const round = r >= Math.min(rect.width, rect.height) / 2 - 0.5 && Math.abs(rect.width - rect.height) < 3;
const paint = hasPaint(cs);
// Images, icons, inputs, buttons, chips and badges: one solid shape.
if (node.matches(ATOMIC) || (paint && rect.height <= 72 && rect.width <= 360)) {
push(rect.left, rect.top, rect.width, rect.height, round ? "circle" : "box", r);
continue;
}
// A paragraph or heading (text plus inline bits): bars per line.
if (node.textContent?.trim() && Array.from(node.children).every(isInline)) {
// A round badge with initials is one circle, like an avatar.
if (paint && round) push(rect.left, rect.top, rect.width, rect.height, "circle", r);
else {
if (paint) push(rect.left, rect.top, rect.width, rect.height, "frame", r);
textLines(node);
}
continue;
}
// A card or panel: its outline, then whatever is inside it.
if (paint && rect.width * rect.height < rootArea * 0.9) {
push(rect.left, rect.top, rect.width, rect.height, round ? "circle" : "frame", r);
}
visit(node);
}
};
visit(root);
// Top to bottom, then left to right.
return out.sort((a, b) => (Math.abs(a.y - b.y) < 6 ? a.x - b.x : a.y - b.y));
}
/* ------------------------------------------------------------------------ */
/* Small helpers */
/* ------------------------------------------------------------------------ */
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const easeInOut = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
const easeOut = (t: number) => 1 - Math.pow(1 - t, 3);
/** True when the page behind the component is dark. */
function onDarkPage(el: HTMLElement) {
let node: HTMLElement | null = el;
while (node) {
const bg = getComputedStyle(node).backgroundColor;
if (alpha(bg) > 0.5) {
const [r, g, b] = (bg.match(/[\d.]+/g) ?? ["255", "255", "255"]).map(Number);
return 0.2126 * r + 0.7152 * g + 0.0722 * b < 128;
}
node = node.parentElement;
}
return matchMedia("(prefers-color-scheme: dark)").matches;
}
const STYLE_ID = "agent-draw-style";
const CSS = `
.ad-shape{position:absolute;left:0;top:0;box-sizing:border-box;border:1.5px solid transparent;opacity:0;
transition:background-color .3s ease,border-color .3s ease,opacity ${TICK_MS}ms ease}
.ad-shape.ad-frame{background:transparent}
.ad-shimmer{background-image:linear-gradient(100deg,transparent 30%,var(--ad-shine) 50%,transparent 70%);
background-size:300% 100%;animation:ad-shimmer 1.4s linear infinite}
@keyframes ad-shimmer{from{background-position:120% 0}to{background-position:-20% 0}}
.ad-cursor{position:absolute;left:0;top:0;opacity:0;will-change:transform,opacity;z-index:2}
.ad-cursor svg{display:block;filter:drop-shadow(0 1px 1.5px rgba(0,0,0,.25))}
.ad-name{position:absolute;left:14px;top:16px;transition:transform .25s ease;padding:2px 7px;border-radius:999px;white-space:nowrap;
font:500 11px/16px ui-sans-serif,system-ui,sans-serif;color:#0a0a0a}
.ad-size{position:absolute;padding:1px 5px;border-radius:4px;white-space:nowrap;opacity:0;
font:500 10px/14px ui-monospace,SFMono-Regular,Menlo,monospace;color:#0a0a0a;transform:translate(-50%,6px)}
`;
function ensureStyle() {
if (document.getElementById(STYLE_ID)) return;
const tag = document.createElement("style");
tag.id = STYLE_ID;
tag.textContent = CSS;
document.head.appendChild(tag);
}
const ARROW =
'<svg width="16" height="20" viewBox="0 0 16 20" aria-hidden="true"><path d="M1.5 1.5v15.2l4.1-3.9 2.7 6 2.6-1.2-2.7-5.8h5.6z" fill="currentColor" stroke="rgba(10,10,10,.75)" stroke-width="1.2" stroke-linejoin="round"/></svg>';
/* ------------------------------------------------------------------------ */
/* The component */
/* ------------------------------------------------------------------------ */
export function AgentDraw({
children,
loading = false,
duration = 3500,
maxAgents = 8,
maxAgentsMobile = 4,
names = AGENT_NAMES,
colors = AGENT_COLORS,
sizeTags = true,
once,
onDone,
className,
style,
}: AgentDrawProps) {
const contentRef = useRef<HTMLDivElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
const loadingRef = useRef(loading);
const revealRef = useRef<(() => void) | null>(null);
const onDoneRef = useRef(onDone);
onDoneRef.current = onDone;
// Keep the latest options without restarting the drawing.
const opts = useRef({ duration, maxAgents, maxAgentsMobile, names, colors, sizeTags, once });
opts.current = { duration, maxAgents, maxAgentsMobile, names, colors, sizeTags, once };
useEffect(() => {
loadingRef.current = loading;
if (!loading) revealRef.current?.();
}, [loading]);
useEffect(() => {
const content = contentRef.current;
const overlay = overlayRef.current;
if (!content || !overlay) return;
ensureStyle();
const o = opts.current;
let cancelled = false;
let raf = 0;
let slots: number[] = [];
let shapeEls: HTMLDivElement[] = [];
const timers: number[] = [];
const cleanups: (() => void)[] = [];
const showContent = (fade: number) => {
content.style.transition = `opacity ${fade}ms ease`;
content.style.opacity = "1";
};
const finish = () => {
revealRef.current = null;
showContent(TICK_MS);
overlay.style.transition = `opacity ${TICK_MS}ms ease`;
overlay.style.opacity = "0";
timers.push(
window.setTimeout(() => {
overlay.replaceChildren();
content.style.transition = "";
if (o.once) {
try {
localStorage.setItem(`agent-draw:${o.once}`, "1");
} catch {}
}
onDoneRef.current?.();
}, TICK_MS + 50),
);
};
/** Drawing is over: show the content, or shimmer until the data arrives. */
const settle = () => {
if (!loadingRef.current) return finish();
shapeEls.forEach((el) => {
if (!el.classList.contains("ad-frame")) el.classList.add("ad-shimmer");
});
revealRef.current = finish;
};
const dark = onDarkPage(overlay);
overlay.style.setProperty("--ad-skel", dark ? "rgba(255,255,255,.08)" : "rgba(10,10,10,.07)");
overlay.style.setProperty("--ad-line", dark ? "rgba(255,255,255,.10)" : "rgba(10,10,10,.09)");
overlay.style.setProperty("--ad-shine", dark ? "rgba(255,255,255,.07)" : "rgba(255,255,255,.65)");
overlay.style.opacity = "1";
overlay.style.transition = "";
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
let seen = false;
if (o.once) {
try {
seen = localStorage.getItem(`agent-draw:${o.once}`) === "1";
} catch {}
}
// Draw the skeleton shapes for the given list (static, no cursors).
const makeShapes = (shapes: Shape[]) => {
shapeEls = shapes.map((s) => {
const el = document.createElement("div");
el.className = s.kind === "frame" ? "ad-shape ad-frame" : "ad-shape";
el.style.transform = `translate(${s.x}px,${s.y}px)`;
el.style.width = `${s.w}px`;
el.style.height = `${s.h}px`;
el.style.borderRadius = s.kind === "circle" ? "50%" : `${s.r}px`;
overlay.appendChild(el);
return el;
});
};
const settleShape = (el: HTMLDivElement, s: Shape) => {
el.style.width = `${s.w}px`;
el.style.height = `${s.h}px`;
el.style.opacity = "1";
if (s.kind === "frame") {
el.style.borderColor = "var(--ad-line)";
el.style.backgroundColor = "transparent";
} else {
el.style.borderColor = "transparent";
el.style.backgroundColor = "var(--ad-skel)";
}
};
// Reduced motion, or already seen: no cursors.
if (reduced || seen) {
if (!loadingRef.current) {
showContent(reduced ? 200 : TICK_MS);
timers.push(window.setTimeout(() => onDoneRef.current?.(), TICK_MS));
} else {
const shapes = measure(content);
makeShapes(shapes);
shapeEls.forEach((el, i) => settleShape(el, shapes[i]));
revealRef.current = finish;
}
return () => {
timers.forEach(clearTimeout);
revealRef.current = null;
};
}
const run = (shapes: Shape[], mySlots: number[]) => {
slots = mySlots;
makeShapes(shapes);
const W = content.offsetWidth;
const A = mySlots.length;
const S = shapes.length;
const per = clamp(((o.duration - 450) * A) / Math.max(S, 1), 180, 1100);
const move = per * 0.42;
type Task = { s: number; start: number; fx: number; fy: number };
const agents = mySlots.map((slot, i) => {
const color = o.colors[slot % o.colors.length] ?? AGENT_COLORS[slot % AGENT_COLORS.length];
const name = o.names[slot] ?? `Agent ${slot + 1}`;
const el = document.createElement("div");
el.className = "ad-cursor";
el.style.color = color;
el.innerHTML = ARROW;
const tag = document.createElement("span");
tag.className = "ad-name";
tag.style.background = color;
tag.textContent = name;
el.appendChild(tag);
overlay.appendChild(el);
const size = document.createElement("span");
size.className = "ad-size";
size.style.background = color;
overlay.appendChild(size);
const sx = ((i + 0.5) / A) * W;
return {
el,
size,
tag,
flipped: false,
color,
outline: dark ? color : `color-mix(in oklab, ${color} 60%, #0a0a0a)`,
tint: `color-mix(in srgb, ${color} 18%, transparent)`,
tasks: [] as Task[],
free: i * 70,
x: sx,
y: -24,
sx,
};
});
// Hand out shapes top to bottom; each goes to the cursor that's free first.
shapes.forEach((s, k) => {
let best = agents[0];
for (const a of agents) {
if (a.free < best.free - 1) best = a;
else if (Math.abs(a.free - best.free) <= 1 && Math.hypot(a.x - s.x, a.y - s.y) < Math.hypot(best.x - s.x, best.y - s.y)) best = a;
}
best.tasks.push({ s: k, start: best.free, fx: best.x, fy: best.y });
best.free += per;
best.x = s.x + s.w;
best.y = s.y + s.h;
});
const done = new Array<boolean>(S).fill(false);
const exitMs = 320;
const end = Math.max(...agents.map((a) => a.free)) + exitMs;
let released = false;
const t0 = performance.now();
const frame = (now: number) => {
if (cancelled) return;
const e = now - t0;
for (const a of agents) {
const first = a.tasks[0];
const last = a.tasks[a.tasks.length - 1];
let x = a.sx;
let y = -24;
let op = 0;
let showSize = false;
if (!first || e < first.start) {
op = 0;
} else if (e >= last.start + per) {
// Leaving: drift down-right and fade.
const q = clamp((e - last.start - per) / exitMs, 0, 1);
const s = shapes[last.s];
x = s.x + s.w + 14 * q;
y = s.y + s.h + 10 * q;
op = 1 - q;
} else {
const task = a.tasks.find((t) => e >= t.start && e < t.start + per) ?? last;
const s = shapes[task.s];
const el = shapeEls[task.s];
const local = e - task.start;
op = task === first ? clamp(local / 180, 0, 1) : 1;
if (local < move) {
// Fly to the shape's top-left corner, on a slight curve.
const p = easeInOut(local / move);
const dx = s.x - task.fx;
const dy = s.y - task.fy;
const bend = Math.sin(p * Math.PI) * Math.min(40, Math.hypot(dx, dy) * 0.12);
const len = Math.hypot(dx, dy) || 1;
x = task.fx + dx * p + (-dy / len) * bend;
y = task.fy + dy * p + (dx / len) * bend;
} else {
// Drag the shape out from corner to corner.
const q = easeOut(clamp((local - move) / (per - move), 0, 1));
const w = s.w * q;
const h = s.h * q;
x = s.x + w;
y = s.y + h;
el.style.opacity = "1";
el.style.width = `${w}px`;
el.style.height = `${h}px`;
el.style.borderColor = a.outline;
el.style.backgroundColor = s.kind === "frame" ? "transparent" : a.tint;
if (o.sizeTags && s.kind !== "text" && s.w >= 40) {
showSize = true;
a.size.textContent = `${Math.round(w)} × ${Math.round(h)}`;
const cx = clamp(s.x + w / 2, 34, W - 34);
a.size.style.transform = `translate(${cx}px,${s.y + h}px) translate(-50%,6px)`;
}
}
}
// Settle every shape whose drag has finished.
for (const t of a.tasks) {
if (!done[t.s] && e >= t.start + per) {
done[t.s] = true;
settleShape(shapeEls[t.s], shapes[t.s]);
}
}
// Near the right edge, the name tag flips to the cursor's left.
const flip = x > W - 96;
if (flip !== a.flipped) {
a.flipped = flip;
a.tag.style.transform = flip ? `translateX(calc(-100% - 22px))` : "";
}
a.el.style.opacity = String(op);
a.el.style.transform = `translate3d(${x}px,${y}px,0)`;
a.size.style.opacity = showSize ? "1" : "0";
}
if (e < end) raf = requestAnimationFrame(frame);
else {
agents.forEach((a) => {
a.el.remove();
a.size.remove();
});
if (!released) {
released = true;
giveSlots(slots);
slots = [];
}
settle();
}
};
raf = requestAnimationFrame(frame);
};
const start = async () => {
// Let web fonts settle so text lines are measured at their final size.
await Promise.race([document.fonts?.ready, new Promise((r) => setTimeout(r, 300))]);
if (cancelled) return;
const shapes = measure(content);
if (!shapes.length) return settle();
const mobile = matchMedia("(max-width: 640px)").matches;
const cap = clamp(Math.round(mobile ? o.maxAgentsMobile : o.maxAgents), 1, HARD_CAP);
// Few shapes, few cursors: each cursor gets about three shapes at least.
const want = Math.min(cap, Math.ceil(shapes.length / 3));
const tryStart = () => {
if (cancelled) return;
const got = takeSlots(want, cap);
if (!got.length) return; // all cursors busy elsewhere: wait for a free one
waiters.delete(tryStart);
run(shapes, got);
};
waiters.add(tryStart);
cleanups.push(() => waiters.delete(tryStart));
tryStart();
};
// Start when the block scrolls into view, so sections draw top to bottom.
if ("IntersectionObserver" in window) {
const io = new IntersectionObserver(
(entries) => {
if (entries.some((en) => en.isIntersecting)) {
io.disconnect();
start();
}
},
{ threshold: 0.12 },
);
io.observe(content);
cleanups.push(() => io.disconnect());
} else start();
return () => {
cancelled = true;
cancelAnimationFrame(raf);
timers.forEach(clearTimeout);
cleanups.forEach((c) => c());
giveSlots(slots);
revealRef.current = null;
overlay.replaceChildren();
};
}, []);
return (
<div style={{ position: "relative" }} aria-busy={loading || undefined}>
<div ref={contentRef} className={className} style={{ ...style, opacity: 0 }}>
{children}
</div>
<div
ref={overlayRef}
aria-hidden="true"
// Clip sideways so tags near the edge never widen the page on phones.
style={{ position: "absolute", inset: 0, pointerEvents: "none", overflowX: "clip", overflowY: "visible" }}
/>
</div>
);
}
```
Usage
Wrap the part of the page that loads, and pass your loading state. It reads the layout inside and turns every block into a rectangle or circle.
"use client";
import { AgentDraw } from "@/components/ui/agent-draw";
export function ProfilePage() {
const { data, isLoading } = useProfile(); // any data fetching
return (
<AgentDraw loading={isLoading}>
<ProfileHeader user={data?.user} />
<About text={data?.about} />
<Projects items={data?.projects} />
</AgentDraw>
);
}Add data-agent-draw to draw an element as one solid shape (good for image placeholders), or data-agent-skip to leave it out. On a long page, wrap each section.
// Long page? Wrap each section. Each one draws when it scrolls
// into view, and they share the same cursors (never more than 8).
<AgentDraw loading={isLoading}><Hero /></AgentDraw>
<AgentDraw loading={isLoading}><Projects /></AgentDraw>
<AgentDraw loading={isLoading}><Footer /></AgentDraw>Props
| Prop | Default | What it does |
|---|---|---|
loading | false | True while your data loads. The drawing runs either way; if it's still true at the end, the skeleton shimmers until it turns false. |
duration | 3500 | Length of the drawing, in ms. |
maxAgents | 8 | Most cursors on screen at once on desktop (never more than 9). |
maxAgentsMobile | 4 | Most cursors on screen at once on phones. |
names | ["Vraj", "Sensei", "Impeccable"] | Cursor names, in order. The rest become "Agent 4", "Agent 5"… |
colors | 10 light colors | Cursor colors, in order. Keep them light: the name tags use dark text. |
sizeTags | true | Show the W × H tag while a box is dragged. |
once | — | A key like "profile". Draws only on the first visit; later visits just fade in. |
onDone | — | Called when the real content is fully shown. |
className | — | Your layout classes, for the box that holds the content. |