COORD: 44.21.90
OFFSET: +12.5°
SYS.READY
BUFFER: 99%
FOCAL_PT
BACK TO DEVLOG
BRUBKR

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.

2026-01-23 // RAW LEARNING CAPTURE
PROJECTBRUBKR

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

  1. nopan/nodrag/nowheel classes — These only work when pointer events are already reaching the element. They tell React Flow "don't interpret this as a pan/drag." But with pointer-events: none on the wrapper, events never reach the element to be interpreted.

  2. stopPropagation — Same issue. The handler never fires because the event never reaches the div.

  3. panOnDrag={false} — This disables canvas panning but doesn't affect the pointer-events: none on node wrappers.

  4. CSS [&_.react-flow__node]:!pointer-events-auto — Tailwind's !important should theoretically override inline styles, but in practice with Tailwind v4's CSS layers, the specificity battle was lost.

  5. pointer-events-auto directly on the node's root div — Works because CSS inheritance for pointer-events allows 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:

  1. Viewport-fixed decorations (absolute positioned, not React Flow nodes) — removed as they competed with the canvas
  2. Evenly distributed columns (left/right of graph) — too uniform, felt lifeless
  3. Scattered positions (above/below/sides, 25 nodes) — too chaotic, zoomed out too far
  4. 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
  • Handle components on nodes — edges render between categories
  • fitViewOptions.nodes targeting top-tier categories — zoomed-in initial view
  • No decorator nodes — clean graph only

Takeaways

  1. React Flow + nodesDraggable={false} + elementsSelectable={false} = pointer-events: none on all nodes. This is the single most important thing to know when building non-interactive canvases with interactive node content.

  2. The fix is always pointer-events-auto on the custom node's root div. Not on a parent, not via CSS specificity hacks, not via event handlers. Directly on the node content.

  3. The nopan/nodrag/nowheel utility classes are for AFTER events reach your element. They're not a substitute for pointer-events-auto.

  4. fitViewOptions.nodes accepts an array of { id: string } objects to restrict which nodes are considered for the initial viewport calculation.

  5. 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: none in the inline styles.

LOG.ENTRY_END
ref:brubkr
RAW