Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Magnetic connections

AdvancedNew

Drag a cable towards a compatible port: it pulls in, crackles with electricity and snaps into place.

magnetic-connections/magnetic-connections.tsx
Live · interactive

Awaiting inputs

conditioningconditioning
noisenoise
samplersampler
Drag an output to its matching input.

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:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/magnetic-connections.json

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

flow.tsx
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.
PropTypeDefaultDescription
snapRadiusnumber48Magnetic attraction and release radius in canvas coordinates.
defaultStateMagneticConnectionsState—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.
autoplaybooleantrueDemonstrate 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.
classNamestring—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.

magnetic-connections.tsx
// 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). */
electric-arc.tsx
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.module.css
/* 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-demo.ts
'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);
icons.tsx
/** 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.