Flowchart

Controls & Canvas

Workflow trigger and condition steps on a dotted canvas.

Interactive Live Preview
components/ui/flowchart.tsxTypeScript · React 19
1"use client";
2
3import { useEffect, useRef, useState } from "react";
4import { useLayoutEffect } from "react";
5
6/* ─────────────────────────────────────────────────────────
7 * FLOWCHART — an agent workflow on a dotted editor canvas.
8 * Two steps: a Trigger card and an If/Else condition card,
9 * joined by a measured connector. Cards drag anywhere on
10 * the canvas; the connector follows. Condition chips open
11 * real dropdowns (same menu as the PromptBar model picker).
12 * ───────────────────────────────────────────────────────── */
13
14const PURPLE = "#9a5cff";
15const AMBER = "#f09a2f";
16
17const mix = (hue: string, pct: number, base = "var(--surface)") =>
18 `color-mix(in srgb, ${hue} ${pct}%, ${base})`;
19
20/* ── layout constants ── */
21const PAD_Y = 24;
22const ROW_GAP = 64;
23const PILL_OFFSET = 30; // kind pill + gap above a card
24
25type StepNode = {
26 id: string;
27 row: number;
28 x: number; // 0–1 center of the node
29 w: number;
30 kind?: { label: string; hue: string };
31 hue?: string;
32 title?: string;
33 caption?: string;
34 condition?: boolean; // renders the if/else chip rows instead
35};
36
37const NODES: StepNode[] = [
38 {
39 id: "trigger",
40 row: 0,
41 x: 0.5,
42 w: 300,
43 kind: { label: "Trigger", hue: PURPLE },
44 hue: PURPLE,
45 title: "New order created",
46 caption: "Trigger when a new order is created",
47 },
48 {
49 id: "cond",
50 row: 1,
51 x: 0.5,
52 w: 356,
53 kind: { label: "If / Else", hue: AMBER },
54 condition: true,
55 },
56];
57
58const EDGES = [{ from: "trigger", to: "cond" }];
59
60/* estimated heights for the first paint; measured immediately after */
61const EST_H: Record<string, number> = { trigger: 92, cond: 134 };
62
63const PROPERTIES = ["flavor", "topping", "size", "scoops"];
64const FLAVORS = [
65 { name: "Rocky Road", tag: "Classic" },
66 { name: "Mint Chip", tag: "Classic" },
67 { name: "Pistachio", tag: "Seasonal" },
68 { name: "Bubblegum", tag: "Retro" },
69];
70const TOPPINGS = [
71 { name: "Brown butter bourbon brittle crunch" },
72 { name: "Rainbow sprinkles" },
73 { name: "Hot fudge" },
74 { name: "Candied pecans" },
75];
76
77/* ── icons ── */
78function ConeIcon({ size = 16 }: { size?: number }) {
79 return (
80 <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
81 <path d="m7 11 4.08 10.35a1 1 0 0 0 1.84 0L17 11" />
82 <path d="M17 7A5 5 0 0 0 7 7" />
83 <path d="M17 7a2 2 0 0 1 0 4H7a2 2 0 0 1 0-4" />
84 </svg>
85 );
86}
87
88function Chevron() {
89 return (
90 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" className="shrink-0 text-ink-3">
91 <path d="m6 9 6 6 6-6" />
92 </svg>
93 );
94}
95
96function Handle() {
97 return (
98 <svg width="10" height="16" viewBox="0 0 10 16" className="shrink-0 cursor-grab text-ink-3/70">
99 {[3, 8, 13].flatMap((y) => [
100 <circle key={`l${y}`} cx="3" cy={y} r="1.1" fill="currentColor" />,
101 <circle key={`r${y}`} cx="7.5" cy={y} r="1.1" fill="currentColor" />,
102 ])}
103 </svg>
104 );
105}
106
107function CheckIcon() {
108 return (
109 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
110 <path d="M20 6L9 17l-5-5" />
111 </svg>
112 );
113}
114
115/* ── dropdown menu — same pattern as the PromptBar model picker ── */
116function Menu({
117 items,
118 value,
119 width,
120 align,
121 onPick,
122}: {
123 items: { name: string; tag?: string }[];
124 value: string;
125 width: string;
126 align: "left" | "right";
127 onPick: (name: string) => void;
128}) {
129 const [hovered, setHovered] = useState<number | null>(null);
130 const rowRefs = useRef<(HTMLButtonElement | null)[]>([]);
131 const [box, setBox] = useState<{ top: number; height: number } | null>(null);
132
133 const valueIndex = items.findIndex((item) => item.name === value);
134 useLayoutEffect(() => {
135 const row = rowRefs.current[hovered ?? valueIndex];
136 if (row) setBox({ top: row.offsetTop, height: row.offsetHeight });
137 }, [hovered, valueIndex]);
138
139 return (
140 <div
141 onMouseLeave={() => setHovered(null)}
142 className={`absolute bottom-full z-20 mb-1.5 rounded-[10px] bg-surface p-1 shadow-raised ${width}
143 ${align === "right" ? "right-0" : "left-0"}`}
144 style={{
145 animation: "pop-in 180ms cubic-bezier(0.23,1,0.32,1) both",
146 transformOrigin: align === "right" ? "bottom right" : "bottom left",
147 }}
148 >
149 <span
150 aria-hidden
151 className="pointer-events-none absolute inset-x-1 rounded-[6px] bg-hover"
152 style={{
153 top: box?.top ?? 0,
154 height: box?.height ?? 0,
155 opacity: box && hovered !== null ? 1 : 0,
156 transition:
157 "top 220ms cubic-bezier(0.23,1,0.32,1), height 220ms cubic-bezier(0.23,1,0.32,1), opacity 150ms ease",
158 }}
159 />
160 {items.map((item, i) => (
161 <button
162 key={item.name}
163 type="button"
164 ref={(el) => {
165 rowRefs.current[i] = el;
166 }}
167 onMouseEnter={() => setHovered(i)}
168 onClick={() => onPick(item.name)}
169 className="relative z-10 flex h-7.5 w-full cursor-pointer items-center gap-2 rounded-[6px] px-2 text-left"
170 >
171 <span className="min-w-0 flex-1 truncate text-[12.5px] font-medium text-ink">{item.name}</span>
172 {item.tag && <span className="shrink-0 text-[11px] text-ink-3">{item.tag}</span>}
173 <span className={`shrink-0 text-ink ${item.name === value ? "" : "invisible"}`}>
174 <CheckIcon />
175 </span>
176 </button>
177 ))}
178 </div>
179 );
180}
181
182/* ── chips used inside the condition card ── */
183function SourceChip() {
184 return (
185 <span
186 data-ui
187 className="inline-flex h-6 shrink-0 items-center gap-1 rounded-[6px] bg-surface px-1.5 text-[12px] font-medium text-ink shadow-btn"
188 >
189 <span className="text-ink-2">
190 <ConeIcon size={12} />
191 </span>
192 order
193 </span>
194 );
195}
196
197function SelectChip({
198 id,
199 value,
200 dot,
201 items,
202 width,
203 align = "left",
204 open,
205 onToggle,
206 onPick,
207}: {
208 id: string;
209 value: string;
210 dot?: boolean;
211 items: { name: string; tag?: string }[];
212 width: string;
213 align?: "left" | "right";
214 open: boolean;
215 onToggle: (id: string) => void;
216 onPick: (id: string, name: string) => void;
217}) {
218 return (
219 <span data-ui className="relative inline-flex min-w-0">
220 <button
221 type="button"
222 aria-expanded={open}
223 onClick={() => onToggle(id)}
224 className={`inline-flex h-6 min-w-0 cursor-pointer items-center gap-1 rounded-[6px] px-1.5
225 text-[12px] font-medium text-ink transition-colors duration-100
226 ${open ? "bg-hover-2" : "bg-field hover:bg-hover-2"}`}
227 >
228 {dot && <span className="size-1.5 shrink-0 rounded-full" style={{ background: AMBER }} />}
229 <span className="min-w-0 truncate">{value}</span>
230 <Chevron />
231 </button>
232 {open && (
233 <Menu
234 items={items}
235 value={value}
236 width={width}
237 align={align}
238 onPick={(name) => onPick(id, name)}
239 />
240 )}
241 </span>
242 );
243}
244
245function ConditionBody() {
246 const [values, setValues] = useState<Record<string, string>>({
247 prop1: "flavor",
248 val1: "Rocky Road",
249 prop2: "topping",
250 val2: "Brown butter bourbon brittle crunch",
251 });
252 const [open, setOpen] = useState<string | null>(null);
253
254 /* click anywhere else closes the menu */
255 useEffect(() => {
256 if (!open) return;
257 const close = (event: PointerEvent) => {
258 if (!(event.target as Element).closest("[data-ui]")) setOpen(null);
259 };
260 document.addEventListener("pointerdown", close);
261 return () => document.removeEventListener("pointerdown", close);
262 }, [open]);
263
264 const toggle = (id: string) => setOpen((current) => (current === id ? null : id));
265 const pick = (id: string, name: string) => {
266 setValues((current) => ({ ...current, [id]: name }));
267 setOpen(null);
268 };
269
270 const chip = (id: string, items: { name: string; tag?: string }[], width: string, extra?: object) => (
271 <SelectChip
272 id={id}
273 value={values[id]}
274 items={items}
275 width={width}
276 open={open === id}
277 onToggle={toggle}
278 onPick={pick}
279 {...extra}
280 />
281 );
282
283 return (
284 <div className="flex flex-col gap-1.5 px-3 py-2.5">
285 <div className="flex min-w-0 items-center gap-1.5">
286 <Handle />
287 <span className="w-7 text-[12.5px] text-ink-2">If</span>
288 <SourceChip />
289 {chip("prop1", PROPERTIES.map((name) => ({ name })), "w-36")}
290 <span className="text-[12.5px] text-ink-2">is</span>
291 {chip("val1", FLAVORS, "w-44", { dot: true, align: "right" })}
292 </div>
293 <div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1.5">
294 <Handle />
295 <span className="w-7 text-[12.5px] text-ink-2">and</span>
296 <SourceChip />
297 {chip("prop2", PROPERTIES.map((name) => ({ name })), "w-36")}
298 <span className="text-[12.5px] text-ink-2">is</span>
299 <span className="max-w-full pl-[49px]">
300 {chip("val2", TOPPINGS, "w-64", { dot: true })}
301 </span>
302 </div>
303 </div>
304 );
305}
306
307function StepBody({ node }: { node: StepNode }) {
308 return (
309 <div className="flex items-center gap-2.5 px-3 py-2.5">
310 <span
311 className="flex size-9 shrink-0 items-center justify-center rounded-[8px]"
312 style={{
313 background: mix(node.hue!, 12),
314 color: node.hue,
315 boxShadow: `0 0 0 1px ${mix(node.hue!, 20)}`,
316 }}
317 >
318 <ConeIcon />
319 </span>
320 <span className="min-w-0 text-left">
321 <span className="block truncate text-[13px] font-semibold text-ink">{node.title}</span>
322 <span className="mt-0.5 block text-[12px] leading-snug text-ink-2">{node.caption}</span>
323 </span>
324 </div>
325 );
326}
327
328/* ── the canvas ── */
329export default function Flowchart() {
330 const canvasRef = useRef<HTMLDivElement>(null);
331 const nodeRefs = useRef(new Map<string, HTMLElement>());
332 const [width, setWidth] = useState(0);
333 const [heights, setHeights] = useState<Record<string, number>>(EST_H);
334 const [selected, setSelected] = useState<string | null>(null);
335 const [offsets, setOffsets] = useState<Record<string, { dx: number; dy: number }>>({});
336 const drag = useRef<{
337 id: string;
338 startX: number;
339 startY: number;
340 baseDx: number;
341 baseDy: number;
342 moved: boolean;
343 } | null>(null);
344
345 useLayoutEffect(() => {
346 const canvas = canvasRef.current;
347 if (!canvas) return;
348
349 const measure = () => {
350 setWidth(canvas.clientWidth);
351 setHeights((prev) => {
352 const next = { ...prev };
353 let changed = false;
354 nodeRefs.current.forEach((el, id) => {
355 const h = el.offsetHeight;
356 if (h && Math.abs(h - (next[id] ?? 0)) > 0.5) {
357 next[id] = h;
358 changed = true;
359 }
360 });
361 return changed ? next : prev;
362 });
363 };
364
365 measure();
366 const observer = new ResizeObserver(measure);
367 observer.observe(canvas);
368 nodeRefs.current.forEach((el) => observer.observe(el));
369 return () => observer.disconnect();
370 }, []);
371
372 /* rows → y offsets from measured node heights */
373 const rows = [...new Set(NODES.map((n) => n.row))].sort((a, b) => a - b);
374 const rowH = rows.map((r) =>
375 Math.max(...NODES.filter((n) => n.row === r).map((n) => heights[n.id] ?? 90)),
376 );
377 const rowY: number[] = [];
378 rows.forEach((_, i) => {
379 rowY[i] = i === 0 ? PAD_Y : rowY[i - 1] + rowH[i - 1] + ROW_GAP;
380 });
381 const canvasH = rowY[rows.length - 1] + rowH[rows.length - 1] + PAD_Y;
382
383 const cw = width || 480;
384 const place = (n: StepNode) => {
385 const w = Math.min(n.w, cw * 0.92);
386 const off = offsets[n.id];
387 return {
388 w,
389 cx: n.x * cw + (off?.dx ?? 0),
390 top: rowY[rows.indexOf(n.row)] + (off?.dy ?? 0),
391 };
392 };
393
394 /* card anchor points (pills sit above the card, so offset the top) */
395 const anchors = (n: StepNode) => {
396 const { cx, top } = place(n);
397 return {
398 top: { x: cx, y: top + (n.kind ? PILL_OFFSET : 0) },
399 bottom: { x: cx, y: top + (heights[n.id] ?? 90) },
400 };
401 };
402
403 const bezier = (edge: { from: string; to: string }) => {
404 const from = anchors(NODES.find((n) => n.id === edge.from)!).bottom;
405 const to = anchors(NODES.find((n) => n.id === edge.to)!).top;
406 const k = Math.min(Math.max(Math.abs(to.y - from.y) * 0.55, 24), 84);
407 return `M ${from.x} ${from.y} C ${from.x} ${from.y + k}, ${to.x} ${to.y - k}, ${to.x} ${to.y}`;
408 };
409
410 /* ── dragging ── */
411 const onPointerDown = (node: StepNode) => (event: React.PointerEvent<HTMLDivElement>) => {
412 if ((event.target as Element).closest("[data-ui]")) return;
413 const off = offsets[node.id];
414 drag.current = {
415 id: node.id,
416 startX: event.clientX,
417 startY: event.clientY,
418 baseDx: off?.dx ?? 0,
419 baseDy: off?.dy ?? 0,
420 moved: false,
421 };
422 (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
423 };
424
425 const onPointerMove = (node: StepNode) => (event: React.PointerEvent<HTMLDivElement>) => {
426 const d = drag.current;
427 if (!d || d.id !== node.id) return;
428 const dx = d.baseDx + event.clientX - d.startX;
429 const dy = d.baseDy + event.clientY - d.startY;
430 if (!d.moved && Math.hypot(dx - d.baseDx, dy - d.baseDy) < 3) return;
431 d.moved = true;
432
433 /* keep the card inside the canvas */
434 const { w } = place(node);
435 const h = heights[node.id] ?? 90;
436 const baseCx = node.x * cw;
437 const baseTop = rowY[rows.indexOf(node.row)];
438 const cx = Math.min(Math.max(baseCx + dx, w / 2 + 8), cw - w / 2 - 8);
439 const top = Math.min(Math.max(baseTop + dy, 8), canvasH - h - 8);
440 setOffsets((current) => ({ ...current, [node.id]: { dx: cx - baseCx, dy: top - baseTop } }));
441 };
442
443 const onPointerUp = (node: StepNode) => () => {
444 const d = drag.current;
445 if (d?.id === node.id) {
446 /* a real drag shouldn't also toggle selection */
447 if (d.moved) setTimeout(() => (drag.current = null), 0);
448 else drag.current = null;
449 }
450 };
451
452 const wasDragged = () => drag.current?.moved === true;
453
454 const isLit = (edge: { from: string; to: string }) =>
455 selected === edge.from || selected === edge.to;
456
457 return (
458 <div
459 ref={canvasRef}
460 className="relative w-full select-none overflow-hidden rounded-card bg-page shadow-hairline"
461 style={{
462 height: canvasH,
463 backgroundImage: "radial-gradient(var(--line-strong) 1px, transparent 1.25px)",
464 backgroundSize: "22px 22px",
465 backgroundPosition: "center",
466 }}
467 >
468 {/* connectors */}
469 <svg width={cw} height={canvasH} className="pointer-events-none absolute inset-0">
470 {EDGES.map((edge) => (
471 <path
472 key={`${edge.from}-${edge.to}`}
473 d={bezier(edge)}
474 fill="none"
475 stroke={isLit(edge) ? "var(--accent)" : "var(--line-strong)"}
476 strokeWidth="1.25"
477 className="transition-[stroke] duration-150"
478 />
479 ))}
480 </svg>
481
482 {/* nodes */}
483 {NODES.map((node) => {
484 const { w, cx, top } = place(node);
485 const active = selected === node.id;
486 return (
487 <div
488 key={node.id}
489 ref={(el) => {
490 if (el) nodeRefs.current.set(node.id, el);
491 else nodeRefs.current.delete(node.id);
492 }}
493 onPointerDown={onPointerDown(node)}
494 onPointerMove={onPointerMove(node)}
495 onPointerUp={onPointerUp(node)}
496 className="absolute flex -translate-x-1/2 touch-none flex-col items-start gap-1.5"
497 style={{ left: cx, top, width: w, zIndex: drag.current?.id === node.id ? 2 : 1 }}
498 >
499 {node.kind && (
500 <span
501 className="inline-flex h-6 items-center rounded-[6px] px-2 text-[11.5px] font-medium"
502 style={{
503 background: mix(node.kind.hue, 14, "var(--page)"),
504 color: mix(node.kind.hue, 80, "var(--ink)"),
505 }}
506 >
507 {node.kind.label}
508 </span>
509 )}
510 {node.condition ? (
511 <div className="w-full rounded-card bg-surface shadow-card transition-shadow duration-150 hover:shadow-raised">
512 <ConditionBody />
513 </div>
514 ) : (
515 <button
516 type="button"
517 onClick={() => {
518 if (wasDragged()) return;
519 setSelected(active ? null : node.id);
520 }}
521 aria-pressed={active}
522 className={`w-full cursor-pointer rounded-card bg-surface text-left outline-none
523 transition-shadow duration-150 focus-visible:shadow-[0_0_0_1.5px_var(--accent)]
524 ${
525 active
526 ? "shadow-[0_0_0_1.5px_var(--accent),0_2px_10px_rgba(0,0,0,0.045)]"
527 : "shadow-card hover:shadow-raised"
528 }`}
529 >
530 <StepBody node={node} />
531 </button>
532 )}
533 </div>
534 );
535 })}
536 </div>
537 );
538}
539

Props & Specification

PropTypeDefaultDescription
variantstring&quot;default&quot;Visual state and layout presentation mode.
classNamestring""Optional Tailwind CSS wrapper class overrides.

Related Components in Controls & Canvas