100 / Scenes
Connection Beam
A line between two elements with something travelling along it, measured from the elements rather than given as coordinates.
"use client"
import * as React from "react"
import { Braces, Bot, Database, FileText, Globe } from "lucide-react"
import { ConnectionBeam } from "mischief-ui/connection-beam"
function Node({
icon,
label,
ref: nodeRef,
}: {
icon: React.ReactNode
label: string
ref: React.RefObject<HTMLDivElement | null>
}) {
return (
<div
ref={nodeRef}
className="border-border bg-background text-muted-foreground z-10 flex size-11 items-center justify-center rounded-full border shadow-sm"
title={label}
>
{icon}
<span className="sr-only">{label}</span>
</div>
)
}
export function ConnectionBeamDemo() {
const container = React.useRef<HTMLDivElement>(null)
const agent = React.useRef<HTMLDivElement>(null)
const docs = React.useRef<HTMLDivElement>(null)
const db = React.useRef<HTMLDivElement>(null)
const web = React.useRef<HTMLDivElement>(null)
const out = React.useRef<HTMLDivElement>(null)
return (
<div
ref={container}
className="relative flex w-full max-w-lg items-center justify-between gap-6 p-6"
>
<div className="flex flex-col gap-6">
<Node ref={docs} icon={<FileText size={17} />} label="Documents" />
<Node ref={db} icon={<Database size={17} />} label="Database" />
<Node ref={web} icon={<Globe size={17} />} label="Web search" />
</div>
<Node ref={agent} icon={<Bot size={19} />} label="Agent" />
<Node ref={out} icon={<Braces size={17} />} label="Structured output" />
<ConnectionBeam
containerRef={container}
fromRef={docs}
toRef={agent}
curvature={-24}
/>
<ConnectionBeam
containerRef={container}
fromRef={db}
toRef={agent}
curvature={0}
delay={0.6}
/>
<ConnectionBeam
containerRef={container}
fromRef={web}
toRef={agent}
curvature={24}
delay={1.2}
/>
<ConnectionBeam
containerRef={container}
fromRef={agent}
toRef={out}
curvature={0}
delay={1.8}
beamColor="--chart-2"
/>
</div>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/connection-beamimport { ConnectionBeam } from "mischief-ui/connection-beam"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" import * as React from "react" import { cn } from "@/lib/utils" export type ConnectionBeamProps = Omit< React.SVGAttributes<SVGSVGElement>, "children"> & { /** The positioned box both endpoints live inside. */ containerRef: React.RefObject<HTMLElement | null> fromRef: React.RefObject<HTMLElement | null> toRef: React.RefObject<HTMLElement | null>Usage
export function Architecture() {
const container = useRef(null)
const retriever = useRef(null)
const model = useRef(null)
return (
<div ref={container} className="relative">
<Node ref={retriever} />
<Node ref={model} />
<ConnectionBeam
containerRef={container}
fromRef={retriever}
toRef={model}
/>
</div>
)
}It reads the elements, not a list of points
A diagram drawn from coordinates is right once, on the screen it was drawn for. Give this the two elements instead and the geometry comes from where they actually are, so the beam survives a reflow: resize the window, wrap the boxes onto another line, put the whole thing in a panel that opens, and the line follows.
Both endpoints and the box around them are watched, which covers layout changes that no resize event would report.
The container has to be a positioned element, since the beam is laid over it. Everything else is measured relative to that box, so page scrolling never enters into it.
Where it attaches
Beams are drawn between the facing edges rather than the centres, so the line meets a box instead of disappearing under it. Which pair of edges depends on how the two are arranged: side by side, it leaves the right and arrives at the left, and stacked, it leaves the bottom and arrives at the top. anchor overrides that when the automatic choice is not the one you meant.
The bow is square to the run, so two elements connected either way round curve the same amount rather than one of them bending the wrong way. A curvature of nought is a straight line, and a negative one bends the other way, which is what separates several beams landing on the same element.
The part that moves
The line and the travelling part are the same path drawn twice. The moving one is a dash, and the dashes are measured against a normalised path length, so extent is a share of the line rather than a number of pixels that would mean something different on every screen.
Stagger several beams with delay rather than giving them different durations. Different durations drift apart and eventually all arrive at once, which reads as a coincidence rather than a sequence.
{sources.map((source, index) => (
<ConnectionBeam
key={source.id}
containerRef={container}
fromRef={source.ref}
toRef={model}
curvature={(index - 1) * 24}
delay={index * 0.6}
/>
))}API
containerRefRefObject<HTMLElement>The positioned box both endpoints live inside.fromRef, toRefRefObject<HTMLElement>The two elements. Nothing is drawn until both exist.curvaturenumberPixels the path bows away from the straight line. Negative bends the other way. Defaults to 60.anchor"auto" | "horizontal" | "vertical"Which edges to use. Auto picks by the longer axis.insetnumberClearance left at each end. Defaults to 4.pathColor, beamColorstringA theme token, or any CSS colour. Default to --border and --primary.widthnumberStroke width. Defaults to 2.durationnumberSeconds for one trip. Defaults to 3.delaynumberSeconds before the first trip. Defaults to 0.reversebooleanSends it the other way along the same path.extentnumberHow much of the path the travelling part covers, from nought to one. Defaults to 0.18.Accessibility
The beam is decoration and carries aria-hidden: it illustrates a relationship that the elements it joins should already state, so nothing is lost when it is not rendered. Under prefers-reduced-motion the travelling part is not drawn at all rather than parked mid-path, where it would read as a stray dash nobody put there, and the line it runs along stays exactly as it was. Because the geometry is measured rather than fixed, a page zoomed or reflowed to a narrow width redraws correctly instead of leaving the line pointing somewhere the boxes no longer are.