- 01.Starting Point
- 02.First attempt: nopan/nodrag/nowheel classes
- 03.Second attempt: panOnDrag={false}
- 04.Third attempt: stopPropagation on the node
- 05.Fourth attempt: CSS override on parent
- 06.The breakthrough: inspecting the DOM
- 07.Research: why does React Flow do this?
- 08.The fix: pointer-events-auto on the custom node's root div
- 09.Why our earlier attempts failed
- 10.Handle components for edges
- 11.Decorator nodes experiment
- 12.fitView targeting specific nodes
- 13.Where We Landed
- 14.Takeaways
React Flow Interactive Nodes — The pointer-events: none Saga
Debugging why clickable buttons inside React Flow nodes were unresponsive — pointer-events inheritance from the canvas layer.
Devlog: React Flow Interactive Nodes — The pointer-events: none Saga
Date: 2026-01-23
Starting Point
The design system index page (src/app/(standalone)/design-system/page.tsx) uses React Flow (@xyflow/react v12) to render a DAG of category nodes. Each node has clickable buttons that should navigate to section canvases. The buttons rendered fine visually but were completely unclickable — always showing the grab cursor, always dragging the canvas.
The ReactFlow was configured with:
<ReactFlow
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
// ...
>First attempt: nopan/nodrag/nowheel classes
Added React Flow's documented utility classes to the node wrapper and button container:
<div className="nopan nodrag nowheel relative border ...">
<div className="nopan nodrag nowheel flex flex-wrap gap-1.5">
<button onClick={() => onOpen(section.id)} ...>Result: No change. Buttons still unclickable.
Second attempt: panOnDrag={false}
Disabled canvas panning entirely:
<ReactFlow panOnDrag={false} ...>Result: No change. Still can't click buttons.
Third attempt: stopPropagation on the node
Added event handlers to prevent React Flow's node wrapper from seeing events:
<div
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>Result: No change. The handlers never fired.
Fourth attempt: CSS override on parent
Targeted the React Flow node wrapper from a parent div:
<div className="[&_.react-flow__node]:!pointer-events-auto">Result: No change. Tailwind's !important didn't override the inline style.
The breakthrough: inspecting the DOM
User shared the actual rendered DOM. The smoking gun:
<div class="react-flow__node react-flow__node-category"
style="... pointer-events: none; visibility: visible;"React Flow was setting pointer-events: none as an inline style on every node wrapper div. This completely blocks all events from reaching any child elements. None of our fixes could work because events never entered the subtree.
Research: why does React Flow do this?
Searched the xyflow source code (NodeWrapper/index.tsx):
const hasPointerEvents = isSelectable || isDraggable || onClick || onMouseEnter || onMouseMove || onMouseLeave;
style={{
pointerEvents: hasPointerEvents ? 'all' : 'none',
}}When nodesDraggable={false} + elementsSelectable={false} + no click handlers on ReactFlow = hasPointerEvents is false = pointer-events: none on every node.
React Flow's logic: "If this node can't be selected or dragged, it doesn't need pointer events." Makes sense for performance, but breaks interactive content inside nodes.
The fix: pointer-events-auto on the custom node's root div
The officially recommended pattern from xyflow maintainers (GitHub Discussion #2417):
function CategoryNode({ data }: { data: CategoryNodeData }) {
return (
<div className="pointer-events-auto nopan nodrag nowheel ...">
{/* content is now clickable */}
</div>
);
}A child with pointer-events: auto overrides the parent's pointer-events: none. The nopan nodrag nowheel classes then prevent accidental canvas pan/drag/zoom when interacting with node content.
Commit: 182cbb2 — 6 lines changed. That's all it took.
Why our earlier attempts failed
-
nopan/nodrag/nowheelclasses — These only work when pointer events are already reaching the element. They tell React Flow "don't interpret this as a pan/drag." But withpointer-events: noneon the wrapper, events never reach the element to be interpreted. -
stopPropagation— Same issue. The handler never fires because the event never reaches the div. -
panOnDrag={false}— This disables canvas panning but doesn't affect thepointer-events: noneon node wrappers. -
CSS
[&_.react-flow__node]:!pointer-events-auto— Tailwind's!importantshould theoretically override inline styles, but in practice with Tailwind v4's CSS layers, the specificity battle was lost. -
pointer-events-autodirectly on the node's root div — Works because CSS inheritance forpointer-eventsallows a child to opt back in even when the parent opts out.
Handle components for edges
Separately, edges weren't rendering between nodes. The fix was adding React Flow's Handle components:
<Handle type="target" position={Position.Top} className="!bg-white/20 !w-1.5 !h-1.5 !border-0" />
<Handle type="source" position={Position.Bottom} className="!bg-white/20 !w-1.5 !h-1.5 !border-0" />Without these, React Flow has no connection points to draw edges between.
Decorator nodes experiment
After fixing interactivity, we tried adding ambient "decorator" specimen nodes around the graph — marks, loading spinners, gauges, connectors from other categories. Went through several iterations:
- Viewport-fixed decorations (absolute positioned, not React Flow nodes) — removed as they competed with the canvas
- Evenly distributed columns (left/right of graph) — too uniform, felt lifeless
- Scattered positions (above/below/sides, 25 nodes) — too chaotic, zoomed out too far
- Satellite positioning (attached to parent categories) — still looked off
Ultimately removed all decorator nodes. The category graph is cleaner without them.
Commits: 0ae4365 (add decorators) → 9124edd (remove decorators)
fitView targeting specific nodes
When decorators were present, fitView zoomed out to fit everything. The fix:
const fitNodes = useMemo(
() => ['Canvas', 'Charts', 'Decorators', 'Feedback', 'Typography']
.map((label) => ({ id: `cat-${label}` })),
[],
);
<ReactFlow fitViewOptions={{ padding: 0.3, nodes: fitNodes }} ...>This tells React Flow to only consider specific nodes when computing the initial viewport. Kept this even after removing decorators for a tighter zoom on the top of the graph.
Where We Landed
src/app/(standalone)/design-system/page.tsx:
- Category nodes with
pointer-events-auto nopan nodrag nowheel— clickable buttons work Handlecomponents on nodes — edges render between categoriesfitViewOptions.nodestargeting top-tier categories — zoomed-in initial view- No decorator nodes — clean graph only
Takeaways
-
React Flow +
nodesDraggable={false}+elementsSelectable={false}=pointer-events: noneon all nodes. This is the single most important thing to know when building non-interactive canvases with interactive node content. -
The fix is always
pointer-events-autoon the custom node's root div. Not on a parent, not via CSS specificity hacks, not via event handlers. Directly on the node content. -
The
nopan/nodrag/nowheelutility classes are for AFTER events reach your element. They're not a substitute forpointer-events-auto. -
fitViewOptions.nodesaccepts an array of{ id: string }objects to restrict which nodes are considered for the initial viewport calculation. -
Inspecting the actual DOM was the breakthrough. All our assumptions about what React Flow was doing were wrong until we looked at the rendered output and saw
pointer-events: nonein the inline styles.