Diagrams
A React Flow canvas in the page's theme, with no colour written anywhere: one container declares the variables xyflow reads, from the palette, in both themes.
React Flow, in the page's theme
Box Kite is not a diagram library — the canvas stays React Flow's. What it adds is the part every app writes by hand: a dark mode, one selection colour, one edge grey, and a visible keyboard focus on the nodes you draw yourself. React Flow already reads about thirty CSS variables for its background, edges, handles, minimap and controls;
FlowContainer is a Box that declares them from palette tokens, so the canvas below names no colour and switches with the theme in the header. Import React Flow's own stylesheet once, as its docs say: the container sets only the variables that stylesheet reads.A React Flow canvas themed by the page
JSX
import { Background, Controls, MiniMap, ReactFlow } from '@xyflow/react';
import { FlowContainer, flowColorMode } from '@box-kite/react/components/flow';
function Canvas() {
const [theme] = Box.useTheme();
return (
<FlowContainer height={90}>
<ReactFlow defaultNodes={nodes} defaultEdges={edges} nodeTypes={nodeTypes} colorMode={flowColorMode(theme)} fitView>
<Background />
<Controls />
<MiniMap />
</ReactFlow>
</FlowContainer>
);
}Eight roles, not thirty variables
Every
--xy-* variable the container declares reads one of eight roles: surface (the canvas), panel (nodes, minimap, controls), text, border, grid (the background pattern), edge, selection (a selected edge, the connection line, the selection box, the resizer and your nodes' focus) and handle. The defaults are drawn in base and accent, so Box.palette() recolours a canvas with the rest of the page. tokens repaints a role — one token for both themes, or a pair — and a single variable is plain vars, because the container is a Box. A prop on <MiniMap> or <Background> still wins: that is the app speaking.Repaint a role — a custom node reads the same roles
JSX
<FlowContainer tokens={{ selection: 'emerald-500', surface: { light: 'base-50', dark: 'base-950' } }} p={4} borderRadius={3} css={{ backgroundColor: 'var(--flow-surface)' }}>
<Svg viewBox="0 0 100 24" width="100%" height="4rem" label="Two steps joined by an edge, the second selected">
<Path d="M34 12H66" stroke="var(--flow-edge)" strokeWidth={1} />
<Rect x={4} y={4} width={30} height={16} rx={2} fill="var(--flow-panel)" stroke="var(--flow-border)" />
<Rect x={66} y={4} width={30} height={16} rx={2} fill="var(--flow-panel)" stroke="var(--flow-selection)" />
</Svg>
</FlowContainer>colorMode follows the page
React Flow's
colorMode="dark" puts a dark class on its root, and Box Kite's themes treat that class as a theme root — so the two have to agree. Pass colorMode={flowColorMode(theme)} with the theme from Box.useTheme() and they always do. A canvas in a different theme from the page sits in <Box.Theme use="local">, which marks a boundary with data-theme. Never set colorMode="light" under a dark page: light is no boundary, so the page's dark rules still reach into the canvas and the two themes mix.A custom node with a visible focus and a selected state
JSX
import { Handle, NodeProps, Position } from '@xyflow/react';
import { flowNodeSelectedStyles, flowNodeStyles } from '@box-kite/react/components/flow';
function StepNode({ data, selected }: NodeProps) {
return (
<Box
{...flowNodeStyles}
{...(selected ? flowNodeSelectedStyles : {})}
px={4}
py={3}
borderRadius={2}
b={1}
css={{ backgroundColor: 'var(--flow-panel)', borderColor: 'var(--flow-border)', color: 'var(--flow-text)' }}
>
<Handle type="target" position={Position.Left} />
{String(data.label)}
<Handle type="source" position={Position.Right} />
</Box>
);
}Focus and selection on your own nodes
xyflow removes the focus outline from every node and edge and gives a replacement only to its four built-in node types, so a custom node is invisible to a keyboard user.
flowNodeStyles puts an outline on your node while xyflow's wrapper has :focus-visible — a group keyed on react-flow__node — and flowNodeSelectedStyles is the selected state, which xyflow marks with a class and hands your node as the selected prop. Both are outlines rather than shadows, so they survive forced colours, and their colour is the container's selection role. A custom edge built on BaseEdge is painted by xyflow's own selected rule; flowEdgeStyles widens it under Tab.A custom edge, wider under Tab
JSX
import { BaseEdge, EdgeProps, getSmoothStepPath } from '@xyflow/react';
import { useClassNames } from '@box-kite/react';
import { flowEdgeStyles } from '@box-kite/react/components/flow';
function StepEdge(props: EdgeProps) {
const [path] = getSmoothStepPath(props);
const { className, styles } = useClassNames(flowEdgeStyles, { svg: true, base: false });
return (
<>
{styles}
<BaseEdge path={path} markerEnd={props.markerEnd} className={className} />
</>
);
}Menus and popovers inside a node
A menu opened from a node is in the top layer, which changes where it paints and not where it sits in the tree: its presses, wheels and keys still bubble to the node, so Backspace on a menu item deletes the node and an arrow key moves it. Wrap the canvas in one
<LayerScope> from @box-kite/react/a11y and every layer inside carries React Flow's opt-out classes. isolate={['key']} also keeps the layer's keys from reaching the page, which is what stops Safari treating a Backspace on a menu item as Back. The trigger itself sits in the node, so it carries nodrag of its own. Try it on the demo's three task nodes.One opt-out for every layer in the canvas
JSX
import { Edge, Node, ReactFlow } from '@xyflow/react';
import { LayerScope } from '@box-kite/react/a11y';
function Canvas({ nodes, edges }: { nodes: Node[]; edges: Edge[] }) {
return (
<LayerScope className="nodrag nowheel nokey" isolate={['key']}>
<ReactFlow nodes={nodes} edges={edges} />
</LayerScope>
);
}Classing React Flow's own elements
useClassNames puts Box props on an element Box does not render, and by default its class carries Box's reset: margin: 0, display: block and the base transition. On <ReactFlow>, <MiniMap> or a BaseEdge that undoes React Flow's own panel margin and transitions, so pass { base: false } and only your props are added.A canvas that moves every frame
Every Box transitions over a quarter of a second, which is right for a button and wrong for a node dragged under the pointer: a Box given a new position or size every frame trails behind it. Name the transition on what moves (
transition="colors"), or give the container motion="static", which zeroes every Box transition inside the canvas — layers' entrances included, which is why it is opt-in.FlowContainer props
Everything below is this component’s own. All 238 of Box’s style props work on it too, and those are on /box rather than repeated here.
| Prop | Type | Default | What it does |
|---|---|---|---|
tokens | FlowTokens | — | The canvas's roles — surface, panel, text, border, grid, edge, selection, handle — each a token for both themes ({ selection: 'sky-500' }) or one per theme ({ surface: { light: 'base-50', dark: 'base-950' } }). One --xy-* variable is plain vars, because the container is a Box. |
motion | 'static' | — | 'static' zeroes every Box transition inside the canvas, layers' entrances included — for a canvas whose nodes are Boxes moved or resized every frame, which the default transition: all would make trail the pointer. |
FlowContainer accessibility
- A
<div>with variables on it: no role, no name. React Flow owns the canvas's accessibility; a custom node or edge gets a visible focus fromflowNodeStyles/flowEdgeStyles.
Swept with axe on every release, in this state:
FlowContainer. No violations, with contrast and landmark rules left to a human. Screen-reader results are not published yet.FlowContainer style tree
Every part the component draws is a node with a name, so a default can be restyled with
Box.components() instead of a selector — and a variant is a name too.flowContainer