Agent Workflow Canvas with Nodes Lighting in Sequence

A node graph that plays a full agent run. A trigger fires, a classify step thinks, three tools run side by side and finish at different times, and a draft step produces a result card. The active node glows and lifts, finished nodes get a green check and their output, and each connector draws in blue with a bright pulse travelling along it. Wide screens get a left to right map; below the large breakpoint the same graph is redrawn top to bottom.

Code

import { useEffect, useRef, useState } from "react";
import { motion, useInView, useReducedMotion } from "framer-motion";

type Kind = "trigger" | "ai" | "tool";

/** Each step runs from `start` to `end` (milliseconds into the run) and then shows `out`. */
const nodes: { id: string; title: string; out: string; kind: Kind; start: number; end: number }[] = [
  { id: "email", title: "New support email", out: "[email protected]", kind: "trigger", start: 0, end: 450 },
  { id: "classify", title: "Classify intent", out: "Refund request, 96%", kind: "ai", start: 450, end: 1050 },
  { id: "docs", title: "Search help docs", out: "3 articles found", kind: "tool", start: 1050, end: 1650 },
  { id: "order", title: "Look up order", out: "#48213, $48.00", kind: "tool", start: 1050, end: 1900 },
  { id: "policy", title: "Check refund policy", out: "Inside 30 days", kind: "tool", start: 1050, end: 2150 },
  { id: "draft", title: "Draft reply", out: "142 words, 3 sources", kind: "ai", start: 2150, end: 2950 },
];

/** Connectors, from one node id to another. */
const edges: [string, string][] = [
  ["email", "classify"],
  ["classify", "docs"],
  ["classify", "order"],
  ["classify", "policy"],
  ["docs", "draft"],
  ["order", "draft"],
  ["policy", "draft"],
  ["draft", "result"],
];

const result = { title: "Reply ready for review", body: "Hi Dana, your refund of $48.00 is on its way and should reach your card in 3 to 5 working days." };

/** Two maps of the same graph: [x, y, width] on a W by H board. Wide screens get `wide`, phones get `tall`. */
const wide = {
  W: 1000, H: 440, across: true,
  at: { email: [110, 220, 180], classify: [370, 220, 180], docs: [630, 84, 180], order: [630, 220, 180], policy: [630, 356, 180], draft: [890, 190, 180], result: [890, 356, 196] } as Record<string, number[]>,
};
const tall = {
  W: 340, H: 640, across: false,
  at: { email: [170, 44, 212], classify: [170, 150, 212], docs: [56, 276, 98], order: [170, 276, 98], policy: [284, 276, 98], draft: [170, 400, 212], result: [170, 548, 300] } as Record<string, number[]>,
};

const legend = [
  { label: "Trigger", dot: "bg-amber-300" },
  { label: "AI step", dot: "bg-blue-600" },
  { label: "Tool call", dot: "bg-slate-300" },
];

const END = 3100;
const HOLD = 4200;
const TICK = 50;

function useClock(playing: boolean, run: number, still: boolean | null) {
  const [now, setNow] = useState(END);
  const t = useRef(END);
  useEffect(() => {
    if (still) return;
    t.current = 0;
    setNow(0);
  }, [run, still]);
  useEffect(() => {
    if (!playing || still) return;
    const id = window.setInterval(() => {
      if (document.hidden) return;
      t.current += TICK;
      if (t.current > END + HOLD) t.current = 0;
      setNow(Math.min(t.current, END));
    }, TICK);
    return () => window.clearInterval(id);
  }, [playing, still]);
  return now;
}

const icons: Record<Kind, string> = {
  trigger: "M3 6.5 10 11l7-4.5M4 5h12a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Z",
  ai: "M7 3v2M13 3v2M7 15v2M13 15v2M3 7h2M3 13h2M15 7h2M15 13h2M6.5 5h7A1.5 1.5 0 0 1 15 6.5v7a1.5 1.5 0 0 1-1.5 1.5h-7A1.5 1.5 0 0 1 5 13.5v-7A1.5 1.5 0 0 1 6.5 5ZM8.5 8.5h3v3h-3z",
  tool: "M8.5 14a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11ZM12.5 12.5 17 17",
};
const tiles: Record<Kind, string> = { trigger: "bg-amber-100 text-amber-700", ai: "bg-blue-600 text-white", tool: "bg-slate-100 text-slate-700" };

function Board({ map, now, enter, className }: { map: typeof wide; now: number; enter: boolean; className: string }) {
  const byId = Object.fromEntries(nodes.map((n) => [n.id, n]));
  const draftDone = now >= byId.draft.end;
  const place = (id: string) => {
    const [x, y, w] = map.at[id];
    return { left: `${(x / map.W) * 100}%`, top: `${(y / map.H) * 100}%`, width: `${(w / map.W) * 100}%` };
  };

  return (
    <div className={`relative mx-auto w-full ${className}`} style={{ aspectRatio: `${map.W} / ${map.H}` }}>
      <svg viewBox={`0 0 ${map.W} ${map.H}`} className="absolute inset-0 h-full w-full" fill="none" aria-hidden="true">
        {edges.map(([from, to]) => {
          const [x1, y1] = map.at[from];
          const [x2, y2] = map.at[to];
          const straight = from === "draft";
          const d = straight
            ? `M${x1} ${y1}L${x2} ${y2}`
            : map.across
              ? `M${x1} ${y1}C${(x1 + x2) / 2} ${y1} ${(x1 + x2) / 2} ${y2} ${x2} ${y2}`
              : `M${x1} ${y1}C${x1} ${(y1 + y2) / 2} ${x2} ${(y1 + y2) / 2} ${x2} ${y2}`;
          const sent = now >= byId[from].end;
          const landed = to === "result" ? draftDone : now >= byId[to].start + 380;
          return (
            <g key={`${from}-${to}`}>
              <path d={d} stroke="#cbd5e1" strokeWidth="1.5" strokeDasharray="4 6" className="agent-workflow-canvas-march" />
              <path d={d} pathLength={100} stroke="#3b82f6" strokeWidth="2" strokeLinecap="round" strokeDasharray="100" strokeDashoffset={sent ? 0 : 100} className="transition-[stroke-dashoffset] duration-500 ease-out motion-reduce:transition-none" />
              {sent && !landed && <path d={d} pathLength={100} stroke="#93c5fd" strokeWidth="5" strokeLinecap="round" strokeDasharray="10 100" className="agent-workflow-canvas-comet" />}
            </g>
          );
        })}
      </svg>

      {nodes.map((n) => {
        const state = now < n.start ? "idle" : now < n.end ? "run" : "done";
        const small = map.at[n.id][2] < 150;
        return (
          <motion.div
            key={n.id}
            initial={false}
            animate={{ scale: state === "run" && !small ? 1.04 : 1 }}
            transition={{ type: "spring", stiffness: 380, damping: 22 }}
            style={place(n.id)}
            className="absolute"
          >
            <div className={`relative -translate-x-1/2 -translate-y-1/2 rounded-xl bg-white transition-shadow duration-300 ${small ? "px-2 py-2.5 text-center" : "flex items-center gap-2.5 px-3 py-2.5"} ${state === "run" ? "agent-workflow-canvas-glow ring-2 ring-blue-500" : state === "done" ? "shadow-sm ring-1 ring-slate-300" : "shadow-sm ring-1 ring-slate-200"}`}>
              {!small && (
                <span className={`grid h-8 w-8 shrink-0 place-items-center rounded-lg ${tiles[n.kind]}`}>
                  <svg viewBox="0 0 20 20" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={icons[n.kind]} /></svg>
                </span>
              )}
              <span className="block min-w-0 flex-1">
                <span className={`block font-semibold text-slate-900 ${small ? "flex min-h-[28px] items-center justify-center text-[11px] leading-tight" : "truncate text-[13px]"}`}>{n.title}</span>
                <span className={`block truncate text-[11px] ${small ? "mt-1" : ""} ${state === "run" ? "font-medium text-blue-700" : "text-slate-600"}`}>
                  {state === "idle" ? "Waiting" : state === "run" ? "Running" : n.out}
                </span>
              </span>
              <span className="absolute -right-1.5 -top-1.5 grid h-5 w-5 place-items-center">
                {state === "run" && <span className="agent-workflow-canvas-spin h-4 w-4 rounded-full border-2 border-blue-200 border-t-blue-600 bg-white" aria-hidden="true" />}
                {state === "done" && (
                  <motion.span initial={enter ? { scale: 0, rotate: -40 } : false} animate={{ scale: 1, rotate: 0 }} transition={{ type: "spring", stiffness: 520, damping: 16 }} className="grid h-5 w-5 place-items-center rounded-full bg-emerald-500 text-white ring-2 ring-white">
                    <svg viewBox="0 0 20 20" className="h-3 w-3" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m4.5 10.5 3.5 3.5 7.5-8" /></svg>
                  </motion.span>
                )}
              </span>
            </div>
          </motion.div>
        );
      })}

      <motion.div
        initial={false}
        animate={draftDone ? { opacity: 1, scale: 1, y: 0 } : { opacity: 0, scale: 0.9, y: -14 }}
        transition={draftDone ? { type: "spring", stiffness: 300, damping: 20 } : { duration: 0.15 }}
        style={place("result")}
        className="absolute"
      >
        <div className="-translate-x-1/2 -translate-y-1/2 rounded-xl bg-slate-900 p-3 shadow-xl shadow-blue-900/20">
          <p className="flex items-center gap-1.5 text-[11px] font-semibold text-emerald-300">
            <span className="h-1.5 w-1.5 rounded-full bg-emerald-400" aria-hidden="true" />
            {result.title}
          </p>
          <p className="mt-1.5 line-clamp-3 text-[11px] leading-relaxed text-slate-200">{result.body}</p>
        </div>
      </motion.div>
    </div>
  );
}

export default function AgentWorkflowCanvasWithNodesLightingInSequence() {
  const reduce = useReducedMotion();
  const ref = useRef<HTMLDivElement>(null);
  const inView = useInView(ref, { amount: 0.2 });
  const [run, setRun] = useState(0);
  const now = useClock(inView, run, reduce);
  // Entrance animations only play once the page is live, so the server-rendered HTML shows the finished state.
  const [live, setLive] = useState(false);
  useEffect(() => setLive(true), []);
  const enter = live && !reduce;

  const done = now >= END - 150;
  const finished = nodes.filter((n) => now >= n.end).length;

  return (
    <section className="bg-slate-100 px-4 py-16 sm:px-6 sm:py-20">
      <style>{`
        @keyframes agent-workflow-canvas-march { to { stroke-dashoffset: -20; } }
        @keyframes agent-workflow-canvas-comet { from { stroke-dashoffset: 10; } to { stroke-dashoffset: -100; } }
        @keyframes agent-workflow-canvas-spin { to { transform: rotate(360deg); } }
        @keyframes agent-workflow-canvas-glow { 0%, 100% { box-shadow: 0 0 0 4px rgba(59,130,246,0.14), 0 12px 28px -8px rgba(37,99,235,0.45); } 50% { box-shadow: 0 0 0 9px rgba(59,130,246,0.07), 0 12px 28px -8px rgba(37,99,235,0.45); } }
        .agent-workflow-canvas-march { animation: agent-workflow-canvas-march 1.4s linear infinite; }
        .agent-workflow-canvas-comet { animation: agent-workflow-canvas-comet 0.6s linear infinite; }
        .agent-workflow-canvas-spin { animation: agent-workflow-canvas-spin 0.7s linear infinite; }
        .agent-workflow-canvas-glow { animation: agent-workflow-canvas-glow 1s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .agent-workflow-canvas-march, .agent-workflow-canvas-comet, .agent-workflow-canvas-spin, .agent-workflow-canvas-glow { animation: none; }
        }
      `}</style>

      <div className="mx-auto max-w-6xl">
        <div className="grid items-end gap-4 lg:grid-cols-[1.2fr_1fr] lg:gap-12">
          <div>
            <p className="text-sm font-semibold text-blue-700">Workflows</p>
            <h2 className="mt-2 text-4xl font-semibold tracking-tight text-slate-900 sm:text-5xl">Agents that show their work</h2>
          </div>
          <p className="text-lg leading-relaxed text-slate-600">Every run is a map you can read. See which step is thinking, which tools it called and what came back, before a single reply goes out.</p>
        </div>

        <div ref={ref} className="mt-10 overflow-hidden rounded-2xl bg-white shadow-xl shadow-slate-900/5 ring-1 ring-slate-900/10">
          <div className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-slate-200 px-4 py-3 sm:px-5">
            <p className="text-sm font-semibold text-slate-900">Refund triage</p>
            <p className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold transition-colors duration-300 ${done ? "bg-emerald-50 text-emerald-700" : "bg-blue-50 text-blue-700"}`} aria-live="polite">
              <span className={`h-1.5 w-1.5 rounded-full ${done ? "bg-emerald-500" : "bg-blue-500"}`} aria-hidden="true" />
              {done ? "Completed" : `Step ${Math.min(nodes.length, finished + 1)} of ${nodes.length}`}
            </p>
            <p className="ml-auto font-mono text-xs tabular-nums text-slate-600" aria-hidden="true">{(Math.min(now, END - 150) / 1000).toFixed(2)}s</p>
            <button type="button" onClick={() => setRun((r) => r + 1)} className="inline-flex h-10 items-center gap-2 rounded-lg bg-slate-900 px-3.5 text-sm font-semibold text-white transition-colors hover:bg-slate-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2">
              <svg viewBox="0 0 20 20" className="h-3.5 w-3.5" fill="currentColor" aria-hidden="true"><path d="M6 4.5v11a.8.8 0 0 0 1.2.7l9-5.5a.8.8 0 0 0 0-1.4l-9-5.5A.8.8 0 0 0 6 4.5Z" /></svg>
              Run again
            </button>
          </div>

          <div className="bg-slate-50 bg-[radial-gradient(circle,rgba(100,116,139,0.28)_1px,transparent_1px)] bg-[length:18px_18px] p-2 sm:p-6" role="img" aria-label="Workflow diagram: a new support email is classified, three tools run in parallel, then a reply is drafted for review">
            <Board map={wide} now={now} enter={enter} className="hidden lg:block" />
            <ul className="hidden gap-5 px-1 pt-1 text-xs text-slate-600 lg:flex" aria-hidden="true">
              {legend.map((l) => (
                <li key={l.label} className="flex items-center gap-2">
                  <span className={`h-2.5 w-2.5 rounded-sm ${l.dot}`} />
                  {l.label}
                </li>
              ))}
            </ul>
            <Board map={tall} now={now} enter={enter} className="max-w-[380px] lg:hidden" />
          </div>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/agent-workflow-canvas-with-nodes-lighting-in-sequence.json

When to use it

How to customize it

Accessibility

Questions

How are the connectors animated?

Each connector is three SVG paths on the same curve. A dashed grey base drifts slowly. A blue path with `pathLength` set to 100 is revealed by moving `stroke-dashoffset` from 100 to 0 when the source step finishes. A short thick dash then loops along the curve until the target step is under way, which reads as data travelling.

How do the nodes stay lined up with the lines on any screen size?

The board has a fixed aspect ratio and the SVG uses the same units in its `viewBox`. Nodes are placed with percentages worked out from those units, so the HTML cards and the SVG curves scale together.

Why are there two layouts?

A left to right graph with six nodes does not fit a phone. The component renders the same data on a second, taller map and shows one or the other with `hidden lg:block` and `lg:hidden`, so there is no resize listener and no layout jump.

Can I drive it from real run data?

Yes. Everything derives from one number, `now`. Replace the clock with your own elapsed time, or compute each node's state from your run events and pass it in place of the `start` and `end` comparison.

Does it call any AI service?

No. The steps, timings and the drafted reply are fixed demo content. Nothing is sent over the network.

Related: Animated Beam Connecting Integrations, AI Agent Working Status with Scrolling Step Log, Code Editor Window with AI Diff and Passing Tests.

More ai prompt and chat ui or all free UI components. Start for free to have a full site built around it.

More in AI Chat

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap