Data flow
Nodes joined by hairlines, with packets of light running along them and a ring where they arrive. Pauses out of view.
One of 3 components in Data animations
Details
What Data flow brings along, read from the registry and the source.
- Category
- Data animations
- Area
- Visuals
- Variants
- 3
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/data-flow.tsx
- Brings along
- utilskitmotion-scene
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The DataFlowProps interface from components/ui/data-flow.tsx, with the comments from the code. Also takes every prop of SceneMotionProps.
nodesrequiredFlowNode[]edgesrequiredFlowEdge[]aspectoptionalstringScene ratio, width / height. Default "16 / 9".
narrowAspectoptionalstringRatio below 34rem width, where nodes need more height. Default "1 / 1".
labeloptionalstringAccessible description of what flows where. Without it the scene is decorative.
Accessibility
Read from the source of the component.
- Sets aria-label, so screen readers announce purpose and state.
- Uses the role label.
- Needs no client state. The server writes it in full, so it reads without JavaScript too.
Motion
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 208 lines
'use client';
import { cn, round } from '@/lib/kit';
import { PlayToggle, SCENE_CSS, keyframes, useSceneId, useScenePlay, type SceneMotionProps } from '@/components/ui/motion-scene';
/*
* Data that travels between nodes: hairline connections, light packets that
* run along them and a soft ring where a packet arrives. One CSS timeline,
* packets are a short dash moved with stroke-dashoffset. Server and reduced
* motion show the calm map with small direction marks on every connection.
*/
export interface FlowNode {
id: string;
label: string;
/** A second, quieter line under the label */
meta?: string;
/** An icon or mark in the tile */
icon?: React.ReactNode;
/** Position of the node center in percent of the scene */
x: number;
y: number;
/** The main node: larger tile in the accent */
emphasis?: boolean;
}
export interface FlowEdge {
from: string;
to: string;
/** Packets run both ways */
both?: boolean;
/** Bend of the connection: horizontal leaves sideways, vertical leaves up or down, straight is a line */
shape?: 'horizontal' | 'vertical' | 'straight';
}
export interface DataFlowProps extends SceneMotionProps {
nodes: FlowNode[];
edges: FlowEdge[];
/** Scene ratio, width / height. Default "16 / 9". */
aspect?: string;
/** Ratio below 34rem width, where nodes need more height. Default "1 / 1". */
narrowAspect?: string;
/** Accessible description of what flows where. Without it the scene is decorative. */
label?: string;
className?: string;
}
const VW = 1000;
function geometry(a: FlowNode, b: FlowNode, shape: FlowEdge['shape'], vh: number) {
const x1 = (a.x / 100) * VW;
const y1 = (a.y / 100) * vh;
const x2 = (b.x / 100) * VW;
const y2 = (b.y / 100) * vh;
const s = shape ?? (Math.abs(x2 - x1) >= Math.abs(y2 - y1) ? 'horizontal' : 'vertical');
let c1: [number, number];
let c2: [number, number];
if (s === 'straight') {
c1 = [x1 + (x2 - x1) / 3, y1 + (y2 - y1) / 3];
c2 = [x1 + ((x2 - x1) * 2) / 3, y1 + ((y2 - y1) * 2) / 3];
} else if (s === 'horizontal') {
const k = (x2 - x1) * 0.55;
c1 = [x1 + k, y1];
c2 = [x2 - k, y2];
} else {
const k = (y2 - y1) * 0.55;
c1 = [x1, y1 + k];
c2 = [x2, y2 - k];
}
const d = `M${round(x1)} ${round(y1)} C${round(c1[0])} ${round(c1[1])} ${round(c2[0])} ${round(c2[1])} ${round(x2)} ${round(y2)}`;
// point and tangent at t = 0.5 for the direction mark
const t = 0.5;
const mt = 1 - t;
const px = mt ** 3 * x1 + 3 * mt * mt * t * c1[0] + 3 * mt * t * t * c2[0] + t ** 3 * x2;
const py = mt ** 3 * y1 + 3 * mt * mt * t * c1[1] + 3 * mt * t * t * c2[1] + t ** 3 * y2;
const dx = 3 * mt * mt * (c1[0] - x1) + 6 * mt * t * (c2[0] - c1[0]) + 3 * t * t * (x2 - c2[0]);
const dy = 3 * mt * mt * (c1[1] - y1) + 6 * mt * t * (c2[1] - c1[1]) + 3 * t * t * (y2 - c2[1]);
const angle = (Math.atan2(dy, dx) * 180) / Math.PI;
const dr = `M${round(x2)} ${round(y2)} C${round(c2[0])} ${round(c2[1])} ${round(c1[0])} ${round(c1[1])} ${round(x1)} ${round(y1)}`;
return { d, dr, mx: round(px), my: round(py), angle: round(angle, 1) };
}
/**
* Nodes connected by lines with packets of light running along them.
* <DataFlow nodes={[{ id: 'form', label: 'Form', x: 12, y: 50 }, …]} edges={[{ from: 'form', to: 'crm' }]} />
*/
export function DataFlow({ nodes, edges, aspect = '16 / 9', narrowAspect = '1 / 1', speed = 1, autoPlay = 'in-view', loop = true, controls = true, label, className }: DataFlowProps) {
const sid = useSceneId('kvdf');
const play = useScenePlay<HTMLDivElement>(autoPlay);
const [aw, ah] = aspect.split('/').map((n) => Number(n.trim()) || 1);
const VH = round((VW * ah) / aw);
const byId = new Map(nodes.map((n) => [n.id, n]));
const n = Math.max(1, edges.length);
// One cycle lasts base seconds at speed 1, a packet travels 1.25 s of it
const base = Math.max(3.4, n * 0.62);
const T = round(base / Math.max(0.1, speed), 2);
const f = round((1.25 / base) * 100, 2);
const lanes = edges
.map((e, i) => {
const a = byId.get(e.from);
const b = byId.get(e.to);
if (!a || !b) return null;
return { e, i, a, b, g: geometry(a, b, e.shape, VH), delay: round((i * T) / n, 2) };
})
.filter(Boolean) as { e: FlowEdge; i: number; a: FlowNode; b: FlowNode; g: ReturnType<typeof geometry>; delay: number }[];
const ease = 'animation-timing-function:cubic-bezier(.5,0,.3,1)';
const css = `${SCENE_CSS}
${keyframes(`${sid}-p`, [[0, `stroke-dashoffset:6;opacity:0;${ease}`], [1.5, 'opacity:1'], [f, 'stroke-dashoffset:-100;opacity:1'], [f + 1, 'opacity:0'], [100, 'stroke-dashoffset:-100;opacity:0']])}
${keyframes(`${sid}-t`, [[0, `stroke-dashoffset:22;opacity:0;${ease}`], [3, 'opacity:.5'], [f + 2, 'stroke-dashoffset:-100;opacity:.35'], [f + 4, 'opacity:0'], [100, 'stroke-dashoffset:-100;opacity:0']])}
${keyframes(`${sid}-r`, [[0, 'opacity:0;transform:scale(.92)'], [f - 1, 'opacity:0;transform:scale(.92)'], [f + 2, 'opacity:.7;transform:scale(1)'], [Math.min(99, f + 26), 'opacity:0;transform:scale(1.32)'], [100, 'opacity:0;transform:scale(1.32)']])}
${keyframes(`${sid}-in`, [[0, 'opacity:0;transform:translate(-50%,-50%) scale(.94)'], [100, 'opacity:1;transform:translate(-50%,-50%) scale(1)']])}
${keyframes(`${sid}-line`, [[0, 'stroke-dashoffset:1'], [100, 'stroke-dashoffset:0']])}
.${sid}:not([data-kv="off"]) .${sid}-p { animation: ${sid}-p ${T}s linear var(--d) ${loop ? 'infinite' : '1'} both; }
.${sid}:not([data-kv="off"]) .${sid}-t { animation: ${sid}-t ${T}s linear var(--d) ${loop ? 'infinite' : '1'} both; }
.${sid}:not([data-kv="off"]) .${sid}-r { animation: ${sid}-r ${T}s linear var(--d) ${loop ? 'infinite' : '1'} both; }
.${sid}:not([data-kv="off"]) .${sid}-n { animation: ${sid}-in .7s var(--ease-kit) var(--i) 1 both; }
.${sid}:not([data-kv="off"]) .${sid}-l { stroke-dasharray: 1 1; animation: ${sid}-line 1.1s var(--ease-kit) var(--i) 1 both; }`;
return (
<div className={cn('@container relative w-full', className)}>
<div
ref={play.ref}
data-kv={play.state}
role={label ? 'img' : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
className={cn(sid, 'relative w-full select-none [aspect-ratio:var(--df-ar)] @max-[34rem]:[aspect-ratio:var(--df-ar-sm)]')}
style={{ '--df-ar': aspect, '--df-ar-sm': narrowAspect } as React.CSSProperties}
>
<style dangerouslySetInnerHTML={{ __html: css }} />
<svg key={play.cycle} viewBox={`0 0 ${VW} ${VH}`} preserveAspectRatio="none" className="absolute inset-0 size-full overflow-visible" aria-hidden>
{lanes.map(({ g, i }) => (
<g key={`l${i}`}>
<path className={`${sid}-l`} pathLength={1} d={g.d} fill="none" stroke="color-mix(in oklab, var(--foreground) 17%, transparent)" strokeWidth="1.25" vectorEffect="non-scaling-stroke" style={{ '--i': `${round(0.15 + i * 0.08, 2)}s` } as React.CSSProperties} />
<path
className="@max-[34rem]:hidden"
d="M-4 -4.5 L2 0 L-4 4.5"
transform={`translate(${g.mx} ${g.my}) rotate(${g.angle})`}
fill="none"
stroke="color-mix(in oklab, var(--foreground) 30%, transparent)"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
</g>
))}
{lanes.flatMap(({ g, e, i, delay }) =>
(e.both ? [g.d, g.dr] : [g.d]).map((d, k) => {
const style = { '--d': `${round(delay + k * (T / 2), 2)}s` } as React.CSSProperties;
return (
<g key={`p${i}-${k}`} style={style}>
<path className={`${sid}-t`} pathLength={100} d={d} fill="none" stroke="var(--primary)" strokeWidth="2" strokeLinecap="round" strokeDasharray="16 200" strokeDashoffset="22" opacity="0" vectorEffect="non-scaling-stroke" />
<path className={`${sid}-p`} pathLength={100} d={d} fill="none" stroke="var(--primary)" strokeWidth="9" strokeLinecap="round" strokeDasharray="2.2 200" strokeDashoffset="6" opacity="0" strokeOpacity="0.28" vectorEffect="non-scaling-stroke" />
<path className={`${sid}-p`} pathLength={100} d={d} fill="none" stroke="color-mix(in oklab, var(--primary) 70%, #fff)" strokeWidth="3.2" strokeLinecap="round" strokeDasharray="2.2 200" strokeDashoffset="6" opacity="0" vectorEffect="non-scaling-stroke" />
</g>
);
}),
)}
</svg>
{nodes.map((node, k) => {
const incoming = lanes.filter((l) => l.b.id === node.id || (l.e.both && l.a.id === node.id));
return (
<div
key={node.id}
className={`${sid}-n absolute z-10`}
style={{ left: `${node.x}%`, top: `${node.y}%`, transform: 'translate(-50%,-50%)', '--i': `${round(k * 0.07, 2)}s` } as React.CSSProperties}
>
<div
className={cn(
'relative flex items-center gap-2.5 rounded-xl border bg-card text-left shadow-[var(--highlight),var(--shadow-md)] @max-[34rem]:flex-col @max-[34rem]:gap-1 @max-[34rem]:border-0 @max-[34rem]:bg-transparent @max-[34rem]:p-0 @max-[34rem]:shadow-none',
node.emphasis ? 'border-[color-mix(in_oklab,var(--primary)_45%,var(--border))] p-2.5 pr-4 shadow-[var(--highlight),0_0_0_4px_color-mix(in_oklab,var(--primary)_10%,transparent),var(--shadow-lg)]' : 'border-border p-2 pr-3.5',
)}
>
{incoming.map((l) => (
<span
key={`${l.i}`}
aria-hidden
className={`${sid}-r pointer-events-none absolute -inset-1 rounded-[14px] border-2 border-[color-mix(in_oklab,var(--primary)_70%,transparent)] opacity-0 @max-[34rem]:inset-auto @max-[34rem]:left-1/2 @max-[34rem]:top-0 @max-[34rem]:size-10 @max-[34rem]:-translate-x-1/2`}
style={{ '--d': `${round(l.delay + (l.e.both && l.a.id === node.id ? T / 2 : 0), 2)}s` } as React.CSSProperties}
/>
))}
<span
className={cn(
'grid shrink-0 place-items-center rounded-lg [&_svg]:size-[55%] @max-[34rem]:shadow-[var(--highlight),var(--shadow-md)]',
node.emphasis ? 'size-10 bg-primary text-primary-foreground @max-[34rem]:size-11' : 'size-8 border border-border bg-surface text-foreground @max-[34rem]:size-10 @max-[34rem]:bg-card',
)}
>
{node.icon ?? <span className="size-2 rounded-full bg-current" />}
</span>
<span className="min-w-0 @max-[34rem]:text-center">
<span className={cn('block whitespace-nowrap font-semibold tracking-[-0.01em] text-foreground @max-[34rem]:max-w-[5.5rem] @max-[34rem]:whitespace-normal @max-[34rem]:leading-tight', node.emphasis ? 'text-[14px]' : 'text-[12.5px] @max-[34rem]:text-[10.5px] @max-[34rem]:font-medium')}>{node.label}</span>
{node.meta && <span className="block whitespace-nowrap text-[11px] text-muted-foreground @max-[44rem]:hidden">{node.meta}</span>}
</span>
</div>
</div>
);
})}
</div>
{controls && (
<div className="mt-2 flex justify-end">
<PlayToggle inline state={play.state} reduced={play.reduced} onToggle={play.toggle} />
</div>
)}
</div>
);
}Frequent questions
How do I install Data flow?
With aiw add @ai-website/data-flow. The command puts components/ui/data-flow.tsx into your project, the code is yours from then on. It brings along motion-scene. It needs no further npm packages.
Which variants does Data flow have?
3 variants, Pipeline, Hub and Collect and report.
Can an AI assistant build with Data flow?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Data flow with aiw_components and install it with aiw_add @ai-website/data-flow.