Custom, viewport-synced scrollbars and bounded panning (translateExtent) for
React Flow / @xyflow/react v12.
React Flow ships a MiniMap, Controls, Background, and Panel β but no scrollbar. This
package adds draggable, click-to-jump scrollbars that stay in two-way sync with the React
Flow viewport, plus the translateExtent pan-clamp that keeps the canvas inside its bounds
so the bars never lie about where you are.
npm install react-flow-scrollbar
# or: pnpm add react-flow-scrollbar
# or: yarn add react-flow-scrollbarPeer dependencies:
@xyflow/react >=12andreact ^18 || ^19. They are not bundled β you already have them in a React Flow app.
Full docs live in docs/, organized by the DiΓ‘taxis
framework:
- π Tutorial β from an empty canvas to working scrollbars in six steps.
- π οΈ How-to guides β recipes: programmatic scroll, theming, focus a node, test the bars.
- π Reference β the canonical, exhaustive API: every export, type, default, CSS variable.
- π‘ Explanation β why a single controller, how the zoom-aware pan-clamp works.
The sections below are a quick-start summary; the docs are the source of truth.
- π±οΈ Draggable thumbs β grab and drag, exactly like a native scrollbar.
- π Click-to-jump β click an empty part of the track to page toward it.
- π Two-way viewport sync β pan or zoom the canvas and the bars follow; drag a bar and the canvas follows.
- π Zoom-aware bounds β the
translateExtentpan-clamp is computed in world coordinates at the current zoom, so panning doesn't snap or drift when zoomed in/out. - π§© Subflow-correct β bounds resolve absolute node positions through React Flow's internal node lookup, so nodes inside groups are measured where they actually render.
- π¨ Themeable β plain CSS variables on a single root class; no CSS-in-JS, no runtime style engine.
- β‘ One pass per change β the O(n) bounds calculation runs once per node change; per-frame updates are O(1) and coalesced into a single
requestAnimationFrame. - π¦ Tiny & typed β ships ESM + CJS +
.d.ts, tree-shakeable, side-effect-free except the stylesheet.
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> yourself, and drop <ReactFlowScrollbars>
in as a child.
import { ReactFlow, ReactFlowProvider, useNodesState } from '@xyflow/react';
import {
ReactFlowScrollbars,
useBoundedReactFlowViewport,
} from 'react-flow-scrollbar';
import '@xyflow/react/dist/style.css';
import 'react-flow-scrollbar/styles.css';
const INITIAL_NODES = [
{ id: '1', position: { x: 0, y: 0 }, data: { label: 'A' } },
{ id: '2', position: { x: 480, y: 320 }, data: { label: 'B' } },
{ id: '3', position: { x: 1200, y: 720 }, data: { label: 'C' } },
];
function Flow() {
const [nodes, , onNodesChange] = useNodesState(INITIAL_NODES);
// The single source of truth: scrollbars, pan-clamp, and focus all derive from these node bounds.
const controller = useBoundedReactFlowViewport({ nodes });
return (
<ReactFlow
nodes={nodes}
onNodesChange={onNodesChange}
// β οΈ Required β without this the scrollbars work but panning snaps back to center.
translateExtent={controller.translateExtent}
>
<ReactFlowScrollbars controller={controller} />
</ReactFlow>
);
}
export default function App() {
return (
// The provider must wrap the component that calls useBoundedReactFlowViewport.
<ReactFlowProvider>
<div style={{ width: '100%', height: 600 }}>
<Flow />
</div>
</ReactFlowProvider>
);
}That's it β both bars appear as soon as the content overflows the pane (React Flow measures unsized nodes asynchronously; the bars wait for that and then show). For a step-by-step walkthrough, see the Tutorial.
The scrollbars, the pan-clamp (translateExtent), and "focus this node" all derive from the
same node bounds. If they read from different sources they drift apart β the bar says one
thing, the clamp does another, and panning snaps. So a single controller hook β
useBoundedReactFlowViewport β owns the bounds and is the source of truth. You pass its
translateExtent to <ReactFlow> and hand the whole controller to <ReactFlowScrollbars>.
One calculation, one truth, no drift. β Explanation.
This is a condensed summary of the two main exports. The canonical, exhaustive API β including the four pure helpers, every type, the default constants, and the development warnings β lives in docs/reference.md.
Returns a BoundedReactFlowViewportController. Call it inside a component under
<ReactFlowProvider>.
| Option | Type | Default | Description |
|---|---|---|---|
nodes |
Node[] |
store | Nodes whose bounds define the scroll area. Defaults to the store nodes (useNodes()). Pass your controlled array for the most robust first-render bounds. |
margin |
number |
30 |
Content-edge inset in flow coordinates β breathing room around the outermost nodes. |
trackSize |
number |
8 |
Scrollbar track thickness in px. The component renders the track at this size so geometry matches. |
minThumbSize |
number |
32 |
Minimum thumb length in px, so the thumb stays grabbable on huge canvases. |
| Member | Type | Description |
|---|---|---|
translateExtent |
CoordinateExtent | undefined |
Pass to <ReactFlow translateExtent={...}>. undefined when there's nothing to bound. |
metrics |
ReactFlowScrollMetrics |
Current scrollbar geometry + scroll range, recomputed reactively. |
trackSize |
number |
The configured bar thickness (px), echoed back for the component. |
scrollTo |
(ratios: { xRatio?: number; yRatio?: number }) => void |
Scroll to absolute ratios (0..1); an omitted axis keeps its position. |
scrollBy |
(delta: { x?: number; y?: number }) => void |
Scroll by a flow-coordinate delta on either axis. |
The viewport writes from scrollTo / scrollBy are coalesced into a single
requestAnimationFrame, so the new transform lands a frame or two later β handy to know when
asserting on it in tests (use expect.poll).
Renders the overlay with the bars. Returns null until there is something to scroll. Render
it as a child of <ReactFlow>.
| Prop | Type | Default | Description |
|---|---|---|---|
controller |
BoundedReactFlowViewportController |
β | Required. The controller from the hook above. |
orientation |
'both' | 'horizontal' | 'vertical' |
'both' |
Which bars to render. |
className |
string |
β | Extra class on the overlay root (e.g. for theming). |
style |
CSSProperties |
β | Inline style merged onto the overlay root (CSS vars welcome). |
Dev warning: if you pass a
controllerbut forgettranslateExtent={controller.translateExtent}on<ReactFlow>, the component logs a one-time console warning in development β panning would otherwise snap back to center.
The math is exported framework-free, so you can compute bounds, metrics, focus viewports, or the pan-clamp without the hook (useful for tests or custom UIs):
import {
getReactFlowScrollMetrics,
getReactFlowTranslateExtent,
getReactFlowFocusViewport,
getReactFlowViewportFromScrollRatios,
DEFAULT_SCROLL_AREA_MARGIN_PX,
} from 'react-flow-scrollbar';Each helper takes an optional getBounds seam; pass useReactFlow().getNodesBounds if your
graph has subflows, or nested nodes resolve to the wrong place. Full signatures β
reference; a worked focus/standalone recipe β
how-to.
The static look β colors, radius, track thickness β is plain CSS variables on the
.react-flow-scrollbars root. Per-frame geometry (track length, thumb size/offset) is applied
inline by the component and is intentionally not themeable. Override the variables with your
own rule, the className / style props, or per-axis [data-axis] selectors.
| Variable | Default | Controls |
|---|---|---|
--rf-scrollbar-track-size |
8px |
Track thickness |
--rf-scrollbar-hit-size |
--rf-scrollbar-track-size |
Thumb grab area (touch) |
--rf-scrollbar-track-color |
rgba(0,0,0,0.04) |
Track background |
--rf-scrollbar-thumb-color |
rgba(0,0,0,0.35) |
Thumb fill |
--rf-scrollbar-thumb-color-hover |
rgba(0,0,0,0.5) |
Thumb fill on hover |
--rf-scrollbar-radius |
6px |
Thumb corner radius |
Dark mode ships automatically. When a .dark ancestor is present (the Tailwind / shadcn /
next-themes convention) the bars switch to white-translucent β no configuration needed. On coarse
(touch) pointers the thumb grab area widens to ~24px (--rf-scrollbar-hit-size) while the bar stays
thin. Override any variable to customize either palette:
/* Customize the dark palette (already applied automatically under `.dark`). */
.dark .react-flow-scrollbars {
--rf-scrollbar-thumb-color: rgba(255, 255, 255, 0.4);
}Or inline per instance, since CSS variables are valid style values:
<ReactFlowScrollbars
controller={controller}
style={{ '--rf-scrollbar-thumb-color': '#6366f1' } as React.CSSProperties}
/>More theming recipes (per-axis, inline) β how-to; the full variable and selector tables β reference.
For E2E tests or advanced styling, the component sets stable data-attributes you can target without depending on class names:
| Selector | Element |
|---|---|
[data-rf-scrollbars] |
Overlay root |
[data-rf-scrollbar-track][data-axis="x"|"y"] |
A track (horizontal/vertical) |
[data-rf-scrollbar-thumb][data-axis="x"|"y"] |
A thumb (horizontal/vertical) |
Programmatic scroll β drive the canvas from your own buttons:
const controller = useBoundedReactFlowViewport({ nodes });
<button onClick={() => controller.scrollTo({ xRatio: 0 })}>Scroll to start</button>
<button onClick={() => controller.scrollTo({ xRatio: 1 })}>Scroll to end</button>
<button onClick={() => controller.scrollBy({ y: 200 })}>Nudge down</button>One axis only β e.g. a wide timeline that scrolls horizontally:
<ReactFlowScrollbars controller={controller} orientation="horizontal" />More breathing room around the content edges:
const controller = useBoundedReactFlowViewport({ nodes, margin: 120 });More recipes β focus a node, use the math without the hook, target the bars in tests β How-to guides.
translateExtent tells React Flow how far the canvas may pan. This package computes it in
world coordinates at the current zoom: each axis is padded so the full content plus one
viewport's worth of travel is reachable β max(contentMax, min + containerSize / zoom). The
/ zoom is what keeps panning honest when you're zoomed in or out (a pixel of pane is a
different number of world units at each zoom level). The extent is also kept referentially
stable while its four numbers are unchanged, so React Flow doesn't re-clamp the viewport on
every zoom frame.
You don't have to think about any of this β just wire controller.translateExtent onto
<ReactFlow>. It's documented here because it's the part most hand-rolled scrollbars get wrong.
β Full derivation, the referential-stability memo, and the rest of the design rationale:
Explanation.
A runnable playground (the same fixture the E2E suite drives) lives in
examples/playground:
pnpm install
pnpm example:dev # β http://127.0.0.1:5173- React 18 or 19
- @xyflow/react 12 or later
- The component and hook must run under a
<ReactFlowProvider>(or inside<ReactFlow>, which provides the same context).
MIT Β© Laststance.io