Magnetic connections
AdvancedNewDrag a cable towards a compatible port: it pulls in, crackles with electricity and snaps into place.
magnetic-connections/magnetic-connections.tsxAwaiting inputs
The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- A node editor or pipeline builder where people wire outputs to inputs by hand: prompts, filters, audio patches.
- Each output has one right input, so the editor can pull the cable towards it and refuse the rest.
- A handful of nodes on one canvas, where seeing every cable at once explains the flow.
- Onboarding or a demo that teaches a connection model: the autoplay draws each cable once, then hands over.
When not to use
- Choosing one input from a list. Use a select or a combobox: no dragging, and it reads well to screen readers.
- Graphs of dozens of nodes, with zoom, pan and many-to-many links. Use a full graph editor.
- An order of steps with no real branching. Use a sortable list or a stepper.
- Where precise pointer work is hard, as on small touch screens, as the only way in: offer a menu of connections beside it.
You can add this component with the shadcn CLI or by hand:
Into components/magnetic-connections of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/magnetic-connections.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { MagneticConnections } from './magnetic-connections';export function Flow() { return ( <MagneticConnections autoplay={false} onConnectionsChange={(channels) => console.log(channels)} /> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
Prompt, noise and sampler nodes feed a processing node on a dotted canvas. A cable follows your pointer, its matching port pulls it in as it approaches, and an unsuccessful drop springs back. Move the nodes and the cables follow; connect all three inputs to complete the flow.
- The nodes and sockets share one coordinate model. SVG cubic Bézier paths join the output positions to their connected inputs, so moving a node moves every attached cable.
- Pointer capture keeps a cable under control outside its small socket. Browser coordinates are converted into the canvas viewBox before measuring distances.
- Inside the snap radius, a non-linear attraction draws the cable towards its matching input. The socket expands into a warm ring and the cable takes its channel colour on connection.
- Near the matching socket, four jagged contours alternate over the cable’s last segment to form an electric arc: a sharp core inside a coloured halo, and a charging ring. A short discharge completes the connection. The effect uses SVG and CSS, runs only during attraction or the brief connection burst, and is hidden under reduced motion.
- An incompatible or distant drop springs the cable back using a decaying oscillation. A connected input can be pulled off; Escape cancels an unfinished connection.
- The flow is a local interactive demonstration. The prompt, seed and sampler controls are editable; it does not call an AI service. The optional callback receives the channels connected by the reader.
- Below a narrow container the nodes move into a taller composition, preserving readable controls and large touch targets. The demonstration pauses while hidden and stops when someone takes over.
- Every colour, each channel’s included, is a light-dark() token on the root, so the editor follows the page’s color-scheme, or the colorScheme prop. On light the cables are a shade deeper and their glow a soft tint, and the arc’s core turns from near-white to a deep tone of the cable’s colour.
| Prop | Type | Default | Description |
|---|---|---|---|
snapRadius | number | 48 | Magnetic attraction and release radius in canvas coordinates. |
defaultState | MagneticConnectionsState | — | Opens in a state, with the demo off: the channels already connected, an output picked (armed), and a cable held mid-drag (dragging, near its socket or not). |
onConnectionsChange | (channels: string[]) => void | — | Called after a reader connects, disconnects or resets the channels. |
autoplay | boolean | true | Demonstrate the three connections while visible and untouched. |
colorScheme | 'light' | 'dark' | 'system' | — | Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one. |
className | string | — | An additional class for the node editor shell. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type PointerEvent,} from 'react';import { Icon } from './icons';import { useDemo } from './use-demo';import { cableTail, ElectricArc } from './electric-arc';import styles from './magnetic-connections.module.css';type Point = { x: number; y: number };type Drag = { index: number; point: Point; near: boolean; demo?: boolean };/** * The channels. Their colours are tokens of the stylesheet, light-dark() pairs, so they follow the * page's color-scheme, or the one the colorScheme prop sets: the cable's colour, and the core of * its electric arc (near white at night, a deep tone of the cable's colour on light). */import type { CSSProperties } from 'react';import styles from './magnetic-connections.module.css';type Point = { x: number; y: number };/** The last section of the cable: the discharge follows its actual approach to the socket. */export function cableTail(from: Point, to: Point): Point { const bend = Math.max(52, Math.abs(to.x - from.x) * 0.48); const t = 0.8; const u = 1 - t; return { x: u ** 3 * from.x + 3 * u ** 2 * t * (from.x + bend) + 3 * u * t ** 2 * (to.x - bend) + t ** 3 * to.x, y: (u ** 3 + 3 * u ** 2 * t) * from.y + (3 * u * t ** 2 + t ** 3) * to.y, };}/** Four distinct jagged contours alternate in CSS; no JavaScript animation loop is needed. */function discharge(from: Point, to: Point, frame: number) { const dx = to.x - from.x; const dy = to.y - from.y;/* Magnetic connections (magnetic-connections.tsx): a node editor on a light canvas, or at night a dark one. The cables are SVG paths in their channel's colour; near its socket a cable crackles with an electric arc (electric-arc.tsx). Colours are the tokens below, light-dark(light, dark): the component takes the color-scheme it inherits from the page (light unless the page says otherwise), or the one its colorScheme prop sets (data-color-scheme). The channels' colours are tokens too, which the component's CHANNELS name as var(--wire-…). */.root { /* The canvas and its dots; the shell's edge. */ --surface: #f4f4f5; --dots: rgb(0 0 0 / 0.16); --edge: #e4e4e7; /* The nodes: their face, edge and shadow; their text; the drag handles. */ --surface-2: #fff; --line: #e4e4e7; --shadow: rgb(0 0 0 / 0.06); --ink: #27272a; --handle: #8e8e96; /* The prompt's field: its face, text and edge; the seed; the sampler's line and its select. */ --field: #f7f7f8; --field-ink: #3f3f46; --field-line: #e4e4e7;'use client';import { useCallback, useEffect, useRef, useState } from 'react';/** A visible demo may play until someone takes over. Restarting the preview resets it. */export function useDemo<T extends HTMLElement>() { const root = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [manual, setManual] = useState(false); useEffect(() => { const element = root.current; if (!element) return; let intersecting = false; const update = () => setVisible(intersecting && !document.hidden); const observer = new IntersectionObserver( ([entry]) => { intersecting = entry.isIntersecting; update(); }, { threshold: 0.15 }, ); observer.observe(element); document.addEventListener('visibilitychange', update);/** The small line icons this component draws, on a 24-unit grid in the current colour. */export function Icon({ name, size = 16 }: { name: string; size?: number }) { const paths: Record<string, React.ReactNode> = { grid: ( <> <rect x="3" y="3" width="6" height="6" rx="1" /> <rect x="13" y="3" width="6" height="6" rx="1" /> <rect x="3" y="13" width="6" height="6" rx="1" /> <rect x="13" y="13" width="6" height="6" rx="1" /> </> ), list: ( <> <path d="M9 5h12M9 12h12M9 19h12" /> <path d="M3 5h1M3 12h1M3 19h1" /> </> ), replay: ( <> <path d="M4 10a8 8 0 1 1 1 7M4 4v6h6" /> </> ), cpu: ( <>- Every socket is a labelled button. Keyboard users select an output and then activate its matching input; Escape cancels selection. Connections are announced through a polite status region.
- The prompt, seed and sampler fields have labels. The source node handles support arrow-key movement, and Reset restores the nodes and clears their connections.
- Cable paths and the demonstration cursor are decorative. Reduced motion stops the demonstration and removes the unsuccessful-drop spring.