Neural Network
A layered network diagram with a forward-pass pulse travelling along its strongest connections, firing each layer of nodes as the signal arrives.
Neural Network renders a fully connected, layered network as a single SVG: faint resting wires with seeded weights, an accent pulse sweeping the strongest connections layer by layer, and top-lit nodes whose cores fire as the signal reaches them. Layout and weights are seeded, so server and client render the same network. Built with Motion.
Installation
Complete the shared Setup first, then add Motion.
bun add motionCopy the component into components/ui/neural-network.tsx.
'use client';import * as React from 'react';import { motion, useReducedMotion } from 'motion/react';import { cn } from '@/lib/cn';const EASE_OUT = [0.23, 1, 0.32, 1] as const;export type NeuralNetworkProps = { layers?: number[]; inputs?: React.ReactNode[]; outputs?: React.ReactNode[]; accent?: string; duration?: number; density?: number; nodeRadius?: number; labels?: boolean; className?: string;};const W = 560;const H = 300;const TRAVEL = 0.82;function seeded(n: number): number { const x = Math.sin(n * 12.9898) * 43758.5453; return x - Math.floor(x);}type Edge = { d: string; weight: number; active: boolean;};export function NeuralNetwork({ layers = [4, 6, 6, 3], inputs, outputs, accent = '#f0883e', duration = 3.6, density = 0.3, nodeRadius = 7, labels = false, className,}: NeuralNetworkProps) { const reduce = useReducedMotion(); const id = React.useId().replace(/[:]/g, ''); const resolved = React.useMemo(() => { const out = [...layers]; if (inputs?.length) out[0] = inputs.length; if (outputs?.length) out[out.length - 1] = outputs.length; return out; }, [layers, inputs, outputs]); const gaps = resolved.length - 1; const positions = React.useMemo(() => { const padX = 56; const top = 40; const bottom = labels ? 62 : 40; const maxN = Math.max(...resolved); const rowGap = maxN === 1 ? 0 : (H - top - bottom) / (maxN - 1); const centerY = top + (H - top - bottom) / 2; return resolved.map((n, i) => { const x = resolved.length === 1 ? W / 2 : padX + (i * (W - padX * 2)) / (resolved.length - 1); return Array.from({ length: n }, (_, j) => ({ x, y: centerY + (j - (n - 1) / 2) * rowGap, })); }); }, [resolved, labels]); const edges = React.useMemo(() => { const out: Edge[][] = []; for (let g = 0; g < gaps; g++) { const layer: Edge[] = []; positions[g].forEach((from, a) => { positions[g + 1].forEach((to, b) => { const weight = seeded(g * 97.13 + a * 13.7 + b * 3.1 + 1); const mx = (from.x + to.x) / 2; layer.push({ d: `M ${from.x} ${from.y} C ${mx} ${from.y}, ${mx} ${to.y}, ${to.x} ${to.y}`, weight, active: weight >= 1 - density, }); }); }); out.push(layer); } return out; }, [positions, gaps, density]); const coreKeyframes = (layer: number, peak: number) => { const base = 0.15; if (gaps < 1) return { opacity: [base, peak, base], times: [0, 0.05, 0.4] }; if (layer === 0) return { opacity: [base, peak, base, base], times: [0, 0.04, 0.26, 1] }; const t = (layer * TRAVEL) / gaps; const rise = Math.min(t + 0.04, 0.99); const fall = Math.min(t + 0.26, 0.995); return { opacity: [base, base, peak, base, base], times: [0, t, rise, fall, 1], }; }; const chipLayer = (l: number) => (l === 0 && !!inputs?.length) || (l === resolved.length - 1 && !!outputs?.length); const renderChips = (l: number, items: React.ReactNode[]) => items.map((item, n) => { const node = positions[l][n]; const frames = coreKeyframes(l, 0.9); return ( <div key={`c-${l}-${n}`} className="absolute -translate-x-1/2 -translate-y-1/2" style={{ left: `${(node.x / W) * 100}%`, top: `${(node.y / H) * 100}%`, }} > {!reduce && ( <motion.div aria-hidden className="absolute -inset-[3px] rounded-[13px] border" style={{ borderColor: accent }} initial={{ opacity: 0 }} animate={{ opacity: frames.opacity.map((o) => (o <= 0.15 ? 0 : o)) }} transition={{ duration, times: frames.times, repeat: Infinity, ease: 'linear', }} /> )} <div className={cn( 'relative grid size-10 place-items-center rounded-[10px]', 'border border-black/[0.09] bg-white text-neutral-800', 'dark:border-white/[0.08] dark:bg-[#232320] dark:text-neutral-100', 'shadow-[0_1px_1.5px_0_rgba(27,27,25,0.05)] dark:shadow-[0_1px_1.5px_0_rgba(0,0,0,0.4)]', )} > {item} </div> </div> ); }); return ( <div className={cn('relative w-full', className)}> <svg role="img" aria-label={`Neural network with ${resolved.length} layers of ${resolved.join(', ')} nodes`} viewBox={`0 0 ${W} ${H}`} fill="none" className="block h-auto w-full text-neutral-900 dark:text-white" > <motion.g initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.5, ease: EASE_OUT }} > {edges.map((layer, g) => layer.map((edge, e) => ( <path key={`w-${g}-${e}`} d={edge.d} stroke="currentColor" strokeOpacity={0.06 + edge.weight * 0.09} strokeWidth={1.1} strokeLinecap="round" /> )), )} {edges.map((layer, g) => layer.map( (edge, e) => edge.active && ( <path key={`p-${g}-${e}`} d={edge.d} stroke={reduce ? accent : `url(#nn-${id}-${g})`} strokeOpacity={reduce ? 0.45 : 1} strokeWidth={1.5} strokeLinecap="round" /> ), ), )} </motion.g> {positions.map((layer, l) => chipLayer(l) ? null : ( <motion.g key={`l-${l}`} initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.4, ease: EASE_OUT, delay: reduce ? 0 : l * 0.06, }} > {layer.map((node, n) => { const act = seeded(l * 51.7 + n * 7.3 + 2); const peak = 0.55 + act * 0.45; const frames = coreKeyframes(l, peak); return ( <g key={n}> <circle cx={node.x} cy={node.y + 1.2} r={nodeRadius} className="fill-black/[0.10] dark:fill-black/50" /> <circle cx={node.x} cy={node.y} r={nodeRadius} strokeWidth={1} className="fill-white stroke-black/[0.10] dark:fill-[#1c1c1c] dark:stroke-white/[0.12]" /> <path d={`M ${node.x - nodeRadius * 0.707} ${node.y - nodeRadius * 0.707} A ${nodeRadius} ${nodeRadius} 0 0 1 ${node.x + nodeRadius * 0.707} ${node.y - nodeRadius * 0.707}`} strokeWidth={1} strokeLinecap="round" className="stroke-white dark:stroke-white/25" /> {reduce ? ( <circle cx={node.x} cy={node.y} r={nodeRadius * 0.42} fill={accent} opacity={0.25 + act * 0.5} /> ) : ( <motion.circle cx={node.x} cy={node.y} r={nodeRadius * 0.42} fill={accent} initial={{ opacity: 0.15 }} animate={{ opacity: frames.opacity }} transition={{ duration, times: frames.times, repeat: Infinity, ease: 'linear', }} /> )} </g> ); })} </motion.g> ))} {labels && positions.map((layer, l) => ( <text key={`t-${l}`} x={layer[0].x} y={H - 18} textAnchor="middle" fontSize={9.5} letterSpacing="0.12em" className="fill-neutral-400 uppercase dark:fill-[#8b8b8b]" > {l === 0 ? 'Input' : l === resolved.length - 1 ? 'Output' : 'Hidden'} </text> ))} {!reduce && ( <defs> {edges.map((_, g) => { const xA = positions[g][0].x; const xB = positions[g + 1][0].x; const span = (xB - xA) * 0.5; const t0 = (g * TRAVEL) / gaps; const t1 = ((g + 1) * TRAVEL) / gaps; return ( <motion.linearGradient key={`g-${g}`} id={`nn-${id}-${g}`} gradientUnits="userSpaceOnUse" y1="0" y2="0" initial={{ x1: xA - span, x2: xA }} animate={{ x1: [xA - span, xA - span, xB, xB], x2: [xA, xA, xB + span, xB + span], }} transition={{ duration, times: [0, t0, t1, 1], repeat: Infinity, ease: 'linear', }} > <stop stopColor={accent} stopOpacity="0" /> <stop offset="0.5" stopColor={accent} /> <stop offset="1" stopColor={accent} stopOpacity="0" /> </motion.linearGradient> ); })} </defs> )} </svg> {inputs?.length ? renderChips(0, inputs) : null} {outputs?.length ? renderChips(resolved.length - 1, outputs) : null} </div> );}Usage
import { NeuralNetwork } from '@/components/ui/neural-network';
export default function Example() {
return <NeuralNetwork />;
}Examples
Pass inputs and outputs to swap the edge layers for icon chips and turn the
diagram into a real pipeline: sources flow in, the model fires, results come out.
Shape the architecture with layers, raise density to light more connections,
and turn on labels for the input / hidden / output captions.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
layers | number[] | [4, 6, 6, 3] | Node count per layer, left to right. |
inputs | ReactNode[] | - | Icons rendered as chips in place of the input layer. |
outputs | ReactNode[] | - | Icons rendered as chips in place of the output layer. |
accent | string | "#f0883e" | Color of the pulse and the firing node cores. |
duration | number | 3.6 | Seconds for one full forward pass. |
density | number | 0.3 | Fraction of connections that carry the pulse (0 to 1). |
nodeRadius | number | 7 | Radius of each node in viewBox units. |
labels | boolean | false | Show input / hidden / output captions under the columns. |
className | string | - | Extra classes for the wrapper. |