react-flow-scrollbar

Quick Start

A live React Flow canvas with viewport-synced scrollbars, shown beside the exact source that runs it.

useBoundedReactFlowViewport reads the React Flow store (it calls hooks like useReactFlow internally), so the component that calls it must live under a <ReactFlowProvider>. Wire the controller's translateExtent onto <ReactFlow>, and drop <ReactFlowScrollbars> in as a child.

The demo below is a real, running component — the code shown underneath it is the exact file that renders it. Drag a scrollbar, or pan/zoom the canvas, and watch them stay in sync.

Loading demo…
'use client';import {  Background,  Controls,  ReactFlow,  ReactFlowProvider,  useNodesState,} from '@xyflow/react';import type { Edge, Node } from '@xyflow/react';import {  ReactFlowScrollbars,  useBoundedReactFlowViewport,} from 'react-flow-scrollbar';import '@xyflow/react/dist/style.css';import 'react-flow-scrollbar/styles.css';// A graph wide AND tall enough to overflow the pane on both axes, so both scrollbars appear at// zoom 1. Nodes carry no explicit width/height — React Flow measures them asynchronously after// mount, and the bars wait for that measurement before showing.const INITIAL_NODES: Node[] = [  { id: 'a', position: { x: 0, y: 0 }, data: { label: 'A · top-left' } },  { id: 'b', position: { x: 900, y: 140 }, data: { label: 'B' } },  { id: 'c', position: { x: 240, y: 720 }, data: { label: 'C' } },  { id: 'd', position: { x: 1180, y: 860 }, data: { label: 'D · bottom-right' } },  { id: 'e', position: { x: 1680, y: 420 }, data: { label: 'E · far right' } },];const INITIAL_EDGES: Edge[] = [  { id: 'a-b', source: 'a', target: 'b' },  { id: 'a-c', source: 'a', target: 'c' },  { id: 'b-d', source: 'b', target: 'd' },  { id: 'b-e', source: 'b', target: 'e' },];/** Inner flow — lives under `<ReactFlowProvider>` because the controller reads the React Flow store. */function Flow() {  const [nodes, , onNodesChange] = useNodesState(INITIAL_NODES);  // Single source of truth: feeds both `translateExtent` (the pan-clamp) and the scrollbar metrics  // from one node-bounds read, so the bars and the clamp can never disagree.  const controller = useBoundedReactFlowViewport({ nodes });  return (    <ReactFlow      nodes={nodes}      edges={INITIAL_EDGES}      onNodesChange={onNodesChange}      translateExtent={controller.translateExtent}      defaultViewport={{ x: 30, y: 30, zoom: 1 }}      minZoom={0.2}      maxZoom={2}    >      <Background />      <Controls />      <ReactFlowScrollbars controller={controller} />    </ReactFlow>  );}/** Quick Start demo: drop `<ReactFlowScrollbars>` into a provider-wrapped, fixed-size pane. */export default function QuickStart() {  return (    <div style={{ width: '100%', height: 480 }}>      <ReactFlowProvider>        <Flow />      </ReactFlowProvider>    </div>  );}

How it fits together

  1. One controller, one truth. useBoundedReactFlowViewport({ nodes }) owns the node bounds. Both the scrollbar metrics and the translateExtent pan-clamp derive from that single read, so the bars and the clamp can never disagree.
  2. translateExtent is required. Without it the scrollbars still render, but panning snaps back to center because nothing clamps the canvas to its content bounds.
  3. The bars appear once content overflows. React Flow measures unsized nodes asynchronously; the scrollbars wait for that measurement, then show on whichever axis overflows.

For programmatic scrolling, single-axis setups, theming, and focusing a node, see the Advanced guide (coming soon) and the full API reference.

On this page