AUG 27, 2026

An interactive dependency graph with no JavaScript, via :has()

Highlighting a node's neighbourhood looks like an event-listener problem. It is a selector problem, and CSS grew the selector — two generated rules per node, zero client JavaScript.

cssperformancetil

The graph draws five registries and every relationship between them. Hovering a node dims everything outside its neighbourhood. That reads like a job for an event listener and a state atom, and it is not one.

The trick is that a node's neighbourhood is knowable at build time. So each node's closed neighbourhood gets a shared class, and one rule per node raises it back out of the dim:

generated, two rules per node
/* Anything hovered or focused dims the whole canvas... */
.eg:has(.eg-node:hover) .eg-node,
.eg:has(.eg-node:hover) .eg-edge { opacity: 0.22 }
 
/* ...and the hovered node's own neighbourhood comes back. */
.eg:has(#n-project_zckr_dev:hover) .nb-project_zckr_dev,
.eg:has(#n-project_zckr_dev:focus-visible) .nb-project_zckr_dev { opacity: 1 }

:has() is what makes it work: the container can style itself, and therefore every one of its descendants, based on a state living several levels down. Before it, "when that is hovered, change this" only worked if this came after that in document order — which a graph's edges never do.

Two details earned their place. :focus-visible beside :hover means the nodes are <a> elements and tabbing through the graph walks the neighbourhoods in order, so the keyboard path is the same interaction rather than a lesser one. And the whole thing is inline <style> generated by the component that draws the SVG, because the selectors name node ids that only exist once the content has been loaded — the rules and the markup are emitted together and cannot disagree.

What it replaced was never written: a force-directed canvas. That would have cost a client island on a site whose architectural claim is a fixed roster of eight, produced a different picture on every load — which makes the visual baselines flap and makes the graph impossible to talk about, since "the node on the left" means nothing if it moves — and answered a question nobody had asked. Thirty-five boxes and thirty-seven lines do not need physics.

One thing axe caught that I would have shipped: the <svg> had role="img" with an aria-label, which is the reflex for a diagram. But role="img" makes an element a leaf in the accessibility tree, and the nodes inside it are links — so every one of them was a nested-interactive violation. A <title> as the first child names an SVG without flattening what is inside it, and each link gets its own <title> carrying the full label, since the drawn text is clamped to fit the box and would otherwise be read aloud with its ellipsis.