Chord Diagram
A four-state chord diagram drawn in plain SVG — entities on a ring sized by inflow plus outflow, bezier ribbons sized by volume, symmetric pairs or directed arrowheads, a keyboard-walkable ring and an sr-only flow table.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-chord.jsonPrompt
Build a React + TypeScript + Tailwind "ChartChord" card in plain SVG (no chart
library — recharts has no arc-and-ribbon primitive) with zod, lucide-react and
a cn() class merger.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
unit?: string; mode?: "symmetric" | "directed";
entities: { id: string; label: string }[];
flows: { source: string; target: string; value: number >= 0 }[] }.
Flows reference entities by id and are ALWAYS written in directed form, even
when they will be rendered symmetrically: folding a pair is a rendering
decision, never a shape the feed has to guess up front.
- value is a magnitude, so negatives are rejected at parse time — an arc's
angle is a length and a length has no sign. Signed data (net balance,
week-over-week delta) must be split into two directed flows upstream, or the
ring silently encodes the absolute value and the sign vanishes.
- Zero is accepted (a dense matrix export is full of zeros) and dropped at
layout time: a zero ribbon has no width but would still consume a chunk of
its arc. Duplicate entity ids are a parse error — flows pointing at the
second one would all land on the first.
- Component props = z.infer of the schema plus padAngle (degrees, default 2,
clamped 0-8), order ("given" | "total", default "given"), onEntitySelect,
onRetry, emptyState, className, and the native div props (Omit "title", the
contract owns it). forwardRef to the card element.
- Ship the layout as a pure function beside the schema: buildChordLayout(
entities, flows, { mode, order, padAngle }) returning arcs (id, label,
position, a0, a1, inflow, outflow, total, partners, selfFlow), ribbons (key,
source, target, value, forward, backward, self, origin, two ends each with
arc/a0/a1), total, isolated labels and the counts of ignored rows. The
renderer then owns nothing but radii.
Behavior
- THE ARC IS INFLOW + OUTFLOW. Aggregate the flows into a sparse matrix first,
summing duplicate pairs. Every unit of flow is drawn twice — once at each end
— so the angular budget is 2 * total, and entity i gets
(inflow[i] + outflow[i]) * scale where
scale = (2*PI - pad*ringSize) / (2*total). This is what makes one arc
comparable to another, and it is why the mode cannot change an arc:
symmetric puts one chunk of M[i][j] + M[j][i] on the arc where directed puts
two chunks of M[i][j] and M[j][i], and those sum to the same angle. Say so
in the UI — readers assume an arc means "outgoing" unless told otherwise.
- CHUNKS TILE THEIR ARC. Each ribbon end owns a chunk of its arc equal to the
ribbon's value; lay them all down in one walk from a0, ordered by the
partner's ring position (then outgoing before incoming, then value). Partner
ordering is what makes ribbons nest instead of braid. Do NOT floor a chunk to
a minimum width the way a Sankey floors a link: chunks that no longer sum to
their arc make the ribbons overshoot the band they leave. Instead give every
ribbon a 0.7-unit stroke in its own tone, so a 0.005% chord still has an
edge to see and to hit while its fill keeps measuring exactly what it is
worth.
- PAD CAP: gaps are the only thing separating one arc from the next, but at 40
entities a 2-degree pad eats 80 degrees of the circle. Cap the whole set of
gaps at half the ring; the arcs keep the rest.
- GEOMETRY (all of it, in one design box of SIZE units, centre translated to
the middle): point(angle, r) = (sin a * r, -cos a * r) so angle 0 is 12
o'clock and angles grow clockwise, which is also SVG sweep-flag 1. An arc
band is outer arc forward, line in, inner arc back, close. A chord is:
arc across the source chunk, quadratic bezier with its CONTROL POINT AT THE
CENTRE to the target chunk's start, arc across the target chunk, quadratic
back. Putting both control points at (0,0) is the whole trick: neighbours
come out as shallow crescents and chords across the ring pass close to the
middle, so curvature encodes distance for free.
- DIRECTED ends are not arcs but three points: both shoulders pulled back to
radius - 6 and a tip on the ring at the chunk's mid angle, i.e. an arrow
pinched into the receiving arc. That is the direction channel; it survives
greyscale and a 0.45 fill opacity, which a hue never would.
- SELF-FLOW: a flow whose source is its target takes two adjacent chunks on one
arc, and the general path degenerates there — a quadratic whose start and end
coincide runs out to the midpoint of its control point and back, filling
nothing but stroking a 50-unit spike at the centre of the ring. Draw a self
chord as one arc across both chunks plus a single bezier home.
- A FULL TURN is the other degenerate arc: with padAngle 0 and a single entity
the band's start and end points coincide, and an SVG arc between two
identical points draws nothing. Shave 1e-4 rad off the span.
- TOLERANT INTAKE, LOUD ABOUT IT: flows pointing at an unknown id, zero-valued
flows and duplicate entity ids are dropped and COUNTED, then stated in a line
above the ring. Entities with no flows at all get no arc (they would be zero
degrees wide) and are named in the list below instead. A row that vanishes
silently is a diagram lying about the traffic.
- HIGHLIGHT: pointer beats keyboard beats pin. Hovering an arc keeps it, its
partners and every chord touching it at full strength and drops the rest to
0.08; hovering a chord keeps it and its two arcs. Clicking, or Enter, pins
the same highlight so the reader can let go of the mouse and read; clicking
the pinned mark again, or Escape, drops it. The pin is a TOGGLE, so fire
onEntitySelect on the pinning half only — a callback that also fires on the
release tells a drill-down to open the entity the reader has just let go of,
and the consumer cannot tell the two apart. Guard the pointerleave handler
(only clear if the mark leaving is still the active one) — pointerleave on
the old mark arrives before pointerenter on the new one, and an unguarded
reset flashes the whole ring.
- KEYBOARD, two layers, because a chord belongs to two entities and cannot sit
in one flat ring order without lying about one of them:
Tab enter the figure (one tab stop for the whole drawing)
Left / Right previous / next entity, wrapping — a ring has no ends
Home / End first / last entity in ring order
Down step into the focused entity's chords, biggest first
Up back out to the entity
Enter / Space on an entity: pin it and fire onEntitySelect, or unpin it
again and stay quiet;
on a chord: follow it to the entity at the other end, pin
that one and fire
Escape clear the pin, without firing
preventDefault on every one of them, and never move DOM focus between SVG
children — SVG focusability is uneven across browsers.
- The four states are first-class branches of one bg-card panel: a pulsing ring
skeleton (aria-hidden, with an sr-only role=status line), an empty state, an
error state with the retry button only when onRetry exists, and ready.
"ready with nothing drawable" renders the empty branch with its own sentence,
because "entities but no traffic" is a different fact from "nothing".
- Nothing here needs cleanup: no timers, no rAF, no listeners outside React's
own, no ResizeObserver. Responsiveness comes from the viewBox, so there is
nothing to cancel on unmount and nothing to re-measure on resize.
Rendering & styling
- Semantic tokens only: bg-card, text-card-foreground, border, muted,
muted-foreground, destructive, ring, background, foreground, and
var(--chart-1..5) for the entities. No hex, no oklch(), no invented hue.
- COLOUR IS NEVER ALONE. Palette position = ring position, so neighbours never
share a colour. Lap two (entities 6-10) is overprinted with a 45-degree
stripe pattern in var(--card) and lap three with a dot pattern, which is a
channel that survives greyscale printing and colour blindness — there is no
sixth hue token to invent. On top of that every arc is labelled, every mark
names both endpoints in its accessible name and its <title>, and the list
under the ring repeats the whole thing as text.
- A ribbon takes the colour of its origin: the sender in directed mode, the
larger of the two arcs in symmetric mode, which keeps a hub's whole fan one
colour instead of a rainbow.
- LABELS are radial, anchored just outside the ring, rotated to point outward
and flipped 180 degrees on the left half so nothing reads upside down.
Radial and not horizontal because labels that point outward diverge as they
grow: two neighbours can never run into each other however tight their arcs
are. Budget characters against the reserved lane and round the character
ratio UP (the root svg clips, so an underestimate eats the tail with no
ellipsis to admit it). Drop the label entirely when the painted arc is
shorter than about one line of text, count how many were dropped and say so
under the chart — the list names every entity anyway.
- RESPONSIVE by viewBox alone: one fixed design box, svg with width 100%,
height auto and a max-width equal to the design size. No measuring pass, no
ResizeObserver, and no way to collapse to zero height in a flex parent.
- HIT AREAS: an 11-unit band is 9px on a 340px card, and a 0.015-unit chord is
nothing at all. Add a transparent band out to radius + 9 for each arc and a
transparent 5-unit stroke along each chord. fill="transparent", never
fill="none" — only a painted fill is hit-tested, however invisible it is.
- ACCESSIBILITY: the svg is role="listbox" with an aria-label and ONE tab stop;
every arc and every chord is a role="option" group with an aria-label that
spells the relation in words (arrows are read out inconsistently, so keep "→"
for the visible readout only) and aria-selected while active;
aria-activedescendant points at the focused option. The focused mark also
gets a visible double stroke — foreground over a background halo, because
ring alone does not clear 3:1 against a light card. Under the chart: a
readout line (aria-hidden, since the focused option already announces
itself), the entity list, and an sr-only div holding a summary sentence plus
two real tables — entities with total/out/in/partners/share, and chords with
from/to/value/back/share. Put sr-only on the WRAPPER DIV, never on a table:
CSS width is only a lower bound for a table box, so width:1px does not hold
one back and a 375px viewport picks up hundreds of px of horizontal scroll.
- Transitions are opacity only, 200ms, with motion-reduce:transition-none. The
chart is fully usable with motion off — dimming is a state, not an animation.
Customization levers
- mode: "directed" when the asymmetry is the finding (handoffs, migration,
trade balance) — you get twice as many chords and an arrowhead each;
"symmetric" when the pair is one relationship (co-occurrence, mutual
collaboration) — half the chords, equal ends, and the breakdown still in the
readout. Arcs are identical either way.
- order: "total" to put the busiest entity at 12 o'clock, "given" when the
contract's order carries meaning (regions west to east, stages, severity).
Ordering is the cheapest crossing-reduction knob you have.
- padAngle: 0-1 degree for many small arcs, 4-6 to make a handful of entities
read as separate blocks.
- Radii: the ring's outer radius, the band thickness and the label lane are the
three numbers that trade diagram for text. A thicker band with a shorter lane
suits short labels; the reverse suits long ones.
- Opacity: the 0.45 resting fill and the 0.08 dimmed fill set how much the
hover reads as a spotlight. Lower the dim for a stricter focus, raise the
resting fill if the chart is printed rather than pointed at.
- Interaction: onEntitySelect is the drill-down hook (open a segment, filter a
table). It reports selections only, never releases, so whatever it opens stays
open until your own UI closes it; if you want the release too, widen the
payload (add the resulting pinned state) rather than firing the bare entity on
both halves of the toggle. Wire a chord-level callback the same way if a pair,
not an entity, is what your app navigates to.
- Scale: past roughly 15 entities the chords overlap faster than colour or
texture can separate them. Aggregate the tail into an "Other" entity upstream
— the component will draw whatever it is handed, and that is exactly the
problem.Concepts
- Arc = inflow + outflow — an entity's angle is everything that touches it, so every unit of flow is painted twice, once at each end. That is what makes two arcs comparable, and it is why switching between symmetric and directed re-cuts the chords without moving a single arc.
- Chunk tiling — each ribbon end owns a slice of its arc exactly as wide as its value, laid down in one walk with no gaps. No minimum-width floor: floor a chunk and the ribbons leaving an entity stop summing to its band. A hairline chord gets a 0.7-unit stroke instead, which buys visibility without touching the fill that carries the number.
- Bezier through the centre — both curves of a chord use the circle's centre as their control point, so a chord between neighbours comes out a shallow crescent and a chord across the ring dives through the middle. Distance around the ring ends up encoded in curvature without anyone computing it.
- Arrowhead as the direction channel — a directed chord's target end is pinched back and tipped into the receiving arc. Direction survives greyscale, colour blindness and a translucent fill, none of which a second hue would.
- Palette lap — five hue tokens exist, so entity six repeats entity one. The second lap is striped and the third dotted, which separates them by texture rather than by a colour that is not there.
- Two-layer keyboard walk — left and right move around the ring, down steps into an entity's chords and Enter follows one to the other side. A chord belongs to two entities, so a single flat tab order would have to pick one of them and lie about the other.
- Pin toggles, selection is one-way — a click or Enter pins the highlight so the pointer can leave; the same click again, or Escape, releases it.
onEntitySelectonly fires on the pinning half: a callback that also fired on the release would hand a drill-down the entity at the exact moment the reader dropped it, and nothing in the payload would say which of the two just happened.
Sunburst Chart
A four-state zoomable sunburst in hand-rolled SVG — concentric rings whose sweeps compose exactly, click-to-zoom with a breadcrumb, keyboard-walkable slices and an sr-only breakdown table.
Violin Plot
A four-state violin plot that estimates each group's density itself — Silverman bandwidth, a quartile box inside the silhouette, prominence-tested peak detection and an sr-only stats table.