Skip to content
UI Kit
Accent color

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

3 variants

Variants

Pipeline

A process from left to right with a fork. Lines draw in when the scene appears, then the packets start.

Hub

One center that talks to six services, some both ways. Good for integrations and sync.

Collect and report

Four sources pour into one store, which feeds a report and an alert. The order of the packets tells the story.

Details

What Data flow brings along, read from the registry and the source.

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

Props

The DataFlowProps interface from components/ui/data-flow.tsx, with the comments from the code. Also takes every prop of SceneMotionProps.

  • nodesrequired
    FlowNode[]
  • edgesrequired
    FlowEdge[]
  • aspectoptional
    string

    Scene ratio, width / height. Default "16 / 9".

  • narrowAspectoptional
    string

    Ratio below 34rem width, where nodes need more height. Default "1 / 1".

  • labeloptional
    string

    Accessible 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
components/ui/data-flow.tsx
'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.

Often searched

Jump to

Copied to the clipboard