Skip to main content
Version: 4.3

Selection

Selection in JointJS+ has two layers. The state, which cells are selected, lives in a collection owned by the <Diagram> root: it exists the moment you use <Diagram>, with or without any selection UI, and reading or changing it is all you need for selection logic. The <Selection /> component, mounted inside <Paper>, adds the visual layer on top: frames around the selected cells, resize/rotate handles, and region selection (rubber-band across blank space to select multiple cells at once).

<Diagram> wires the standard selection gestures for you, on by default: click to select, Cmd/Ctrl+click to toggle, Shift+drag for a region, blank-click to clear, plus keyboard shortcuts for deleting, clearing, and selecting all. The demo below relies on these Built-in interactions and only configures the region kind. When you need gestures the defaults don't cover, wire them yourself with <Paper> event props (onCellPointerClick, onBlankPointerDown) and the programmatic helpers below.

Requires the Diagram root

This feature reads the selection collection owned by the <Diagram> root. Wrap your diagram in <Diagram> (not just <GraphProvider>) for it to work.

Selecting cells

The editor below tracks a selection across a small workflow. Click a card to select just that card, or Shift-drag across blank space to rubber-band a region (a plain blank-drag pans the <PaperScroller> instead). The toolbar switches the region between a rectangle, a free-form polygon, and a horizontal range, and the Links toggle controls whether a region also grabs links. Select all grabs every card at once, Clear empties the selection, and the live counter reports how many elements and links are currently selected.

The gestures come from <Diagram>'s built-in interactions: click selects, Cmd/Ctrl+click toggles, and Shift+drag draws the region. Because the demo wraps its <Paper> in a <PaperScroller>, blank-drag pans the canvas and the wheel pans too (Ctrl/Cmd + wheel or pinch to zoom), all from the same default interactions. The demo configures only the region itself, through interactions.selectionRegion (its kind follows the toolbar; see Selection region types). The toolbar's Select all and Clear call the programmatic helpers from useSelection(), and because that handle never re-renders, the live counter subscribes to the collection separately with useCells, so only the counter updates as the selection changes.

The region kind and whether it pulls in links are both configured on interactions.selectionRegion. The toolbar drives them through regionType and selectLinks state, which the diagram passes as interactions={{ selectionRegion: { type: regionType, selectLinks } }}. Click-to-select adds whatever cell was clicked, link or element.

Selected cards ring in indigo and selected links recolor to match: the cards read their own state with useIsCellSelected(), while the links rely on the jj-is-selected class that <Selection> adds automatically. These are the two styling layers detailed in Per-cell selection styling and Frames and the selected-cell class.

Wiring gestures yourself

Prefer manual control? Turn the built-ins off (interactions={{ selection: false }}, or just selectionRegion: false for the region alone) and wire <Paper> event props to the programmatic helpers: onCellPointerClick to selectCells([id]), onBlankPointerDown to startSelectionRegion().

Keyboard shortcuts

With the diagram's built-in interactions on (the default) and a <Selection> present, Delete or Backspace removes the selected cells, Escape clears the selection, and Ctrl/Cmd+A selects every element. They belong to the selection interaction, so interactions={{ selection: false }} switches them off along with the rest of selection, while interactions={{ keyboard: false }} switches off every built-in shortcut.

For every interaction flag, see Built-in interactions. To bind your own shortcuts, see Custom Keyboard Shortcuts.

Reading the current selection

useSelection() exposes the identity-stable collection that holds the selected cells. To display the selection, read it reactively with useCells: pass the collection plus a selector, and the component re-renders only when the slice you derive changes (for instance useCells(collection, (cells) => cells.length) for a live count). This is what drives selection counters, conditional toolbars, and filtered lists.

function SelectionCount() {
const { collection } = useSelection();
const count = useCells(collection, (cells) => cells.length);
return <span>{count} selected</span>;
}

For per-cell state, useIsCellSelected() lets a cell's own renderElement / renderLink read whether it is selected without subscribing to the whole collection. It is covered in Per-cell selection styling.

Targeting a specific paper

useSelection() resolves the single default paper automatically. Pass a paper id or ref (useSelection(paper)) to target a specific paper when your app renders more than one.

Programmatic control

useSelection() also carries the imperative helpers that change what's selected. The handle never re-renders, so reach for it from event handlers and buttons rather than to display state.

selectCells replaces the selection with an array of cell ids (or cells), takes an updater that receives the currently-selected cells (handy for appending, such as a Cmd/Ctrl+click that adds a cell instead of replacing), or clears it with an empty array:

const { selectCells } = useSelection();

selectCells([id]); // replace
selectCells((selected) => [...selected, id]); // append
selectCells([]); // clear

selectAllElements selects every element in the graph:

function SelectAllButton() {
const { selectAllElements } = useSelection();
return <button onClick={selectAllElements}>Select all</button>;
}

startSelectionRegion(options?) begins an interactive region from code, say a toolbar button that drops the user straight into polygon-draw. It takes the same options as interactions.selectionRegion and resolves to the resulting selection (or null if the gesture is cancelled):

const { startSelectionRegion } = useSelection();

async function drawPolygon() {
const cells = await startSelectionRegion({ type: 'polygon' });
// cells: the selected cells, or null if cancelled
}

Per-cell selection styling

There are two ways to style a selected cell. The automatic jj-is-selected class, toggled by <Selection> on every selected cell's root, is layer 2: drop CSS rules and you're done (see Frames and the selected-cell class). The useIsCellSelected() hook is layer 1: it reads the <Diagram> collection directly, works without <Selection> mounted, and lets each renderElement / renderLink function drive per-cell rendering in React.

Inside renderElement, each cell reads its own selection state with useIsCellSelected(). Only the cells whose selection state changes re-render.

import { selectElementSize, useCell, useIsCellSelected } from '@joint/react-plus';

function RenderElement() {
const { width, height } = useCell(selectElementSize);
const isSelected = useIsCellSelected();
return (
<rect
width={width}
height={height}
fill="transparent"
stroke={isSelected ? '#3b82f6' : '#94a3b8'}
strokeWidth={isSelected ? 3 : 1.5}
/>
);
}

Use it for visual feedback that lives on the cell itself: a highlight stroke, a selected-state background, or a class toggle that drives CSS. It pairs naturally with <Selection>: the wrapper shows the bounding box, the hook drives the per-cell styling.

By default a region selects only elements. Pass selectLinks to also include links. It is a per-gesture option on startSelectionRegion():

startSelectionRegion({ selectLinks: true });

For the Built-in interactions Shift+drag region, set it once on the diagram instead:

<Diagram interactions={{ selectionRegion: { selectLinks: true } }}>...</Diagram>

Click-to-select adds whatever cell was clicked regardless of type, element or link.

Restricting what can be selected

Use filter to restrict which cells the region gesture can pick up. It runs against every cell the region touches. It does not gate direct writes: selectCells([...]) and collection.reset([...]) accept any cell.

filter is a per-gesture option on startSelectionRegion(). It receives a SelectionFilter context ({ id, model, paper, graph }) and returns a boolean:

startSelectionRegion({
filter: ({ model }) =>
model.isElement() && model.get('data')?.kind === 'task',
});

Because it is per-gesture, you can vary it from the triggering event, for example "select only red cells while Shift is held":

const onBlankPointerDown = useCallback(
({ event }) =>
startSelectionRegion({
filter: ({ model }) =>
event.shiftKey ? model.get('data')?.color === 'red' : true,
}),
[startSelectionRegion],
);

<Paper onBlankPointerDown={onBlankPointerDown} />;

To filter the Built-in Interactions region instead, set it on the diagram: <Diagram interactions={{ selectionRegion: { filter } }}>.

Pair filter with strict to control how containment is judged. By default any overlap selects; pass strict: true to require full geometric containment within the dragged region.

Selection region types

A region can be drawn in three kinds, chosen with the type option:

  • 'rectangular' (default): drag a rectangle around the cells to select.
  • 'polygon': click vertices, double-click to close the polygon.
  • 'range': drag a one-dimensional band across the paper.

type is a per-gesture option on startSelectionRegion(), so you can drive it from state. The demo above keeps a regionType in state, switches it from the toolbar, and forwards it on each gesture:

startSelectionRegion({ type: regionType });

To set the kind for the Built-in Interactions Shift+drag region instead, configure it on the diagram, statically or per gesture:

// always a polygon
<Diagram interactions={{ selectionRegion: { type: 'polygon' } }}>...</Diagram>

// decide from the event (Shift for polygon, otherwise rectangle)
<Diagram
interactions={{
selectionRegion: ({ event }) => ({
type: event.shiftKey ? 'polygon' : 'rectangular',
}),
}}
>...</Diagram>

Each kind takes its own shape options as top-level props alongside type. For the range region these are vertical, displayMode, domain, and constraints:

startSelectionRegion({
type: 'range',
vertical: true,
});

vertical switches the band between horizontal and vertical, and displayMode controls whether it renders as a filled rectangle or a line. domain sets the band's cross-axis extent and constraints limits how far it can be dragged. See range.

Pass live: true to update the collection during the drag instead of only on pointer release, useful when downstream UI needs to react to the region in progress:

startSelectionRegion({ live: true });

To pick the kind per gesture from a blank-pointer handler, decide it from the event:

const onBlankPointerDown = useCallback(
({ event }) =>
startSelectionRegion({
type: event.shiftKey ? 'polygon' : 'rectangular',
}),
[startSelectionRegion],
);

<Paper onBlankPointerDown={onBlankPointerDown} />;

Customising the selection handles

<Selection> renders the default JointJS handles around the selection bounding box: remove, rotate, resize, and clone. Handles belong to the wrapper (the bounding box element that hosts them), so they are configured via the wrapper prop. Pass handles inside a wrapper options object as either an array (full override) or a function (defaultHandles) => SelectionHandle[] (transform the defaults):

import { getSelectionDefaultHandle, Selection } from '@joint/react-plus';

// Full override: exactly two handles.
<Selection
wrapper={{
handles: [
getSelectionDefaultHandle('remove'),
getSelectionDefaultHandle('rotate'),
],
}}
/>;

// Function form: drop the resize handle, keep everything else.
<Selection
wrapper={{
handles: (defaults) => defaults.filter((h) => h.name !== 'resize'),
}}
/>;

The function form is the lighter touch: append new handles, swap one out, or hide a single button without re-listing the rest.

A SelectionHandle is a plain config object: content takes a React node (rendered to static markup) or an HTML string, group places it, and name is optional, so <Selection> assigns a unique one when you omit it or spread the same default twice. getSelectionDefaultHandle is exported from @joint/react-plus directly.

The demo below appends a custom handle that arranges the selected elements into a grid with GridLayout from @joint/plus. Its pointerdown callback receives { event, x, y, collection, paper, graph }, so it can act on the live selection. Shift-drag a region to select a few cards, then click the blue grid handle.

The same wrapper object also takes a visibility predicate. The demo passes (collection) => collection.length > 1, so the bounding box and its handles appear only for a multi-cell selection; a single selected card is left to its own ring.

Resizing needs model geometry

The resize handle changes an element's size on the model. But an element rendered with a plain <HTMLHost> (or <HTMLBox>) measures its own content and writes that size back, immediately undoing the resize, so the element snaps back to fit its content. To let the selection resize an element, render it with useModelGeometry (<HTMLHost useModelGeometry> or <HTMLBox useModelGeometry>) and give it an explicit size: the host then draws at the model's geometry instead of measuring, so resizing the model resizes the element. The demo above sizes its cards to their content, so it drops the resize handle with the function form and keeps just remove and rotate; the colored-frames demo below keeps the resize handle and renders with useModelGeometry instead.

Transforming the selection

A few props control how the wrapper transforms the cells inside it: moving, rotating, and resizing.

<Selection
allowTranslate={false} // freeze positions (drag-to-move is on by default)
translateConnectedLinks="subgraph" // 'none' | 'subgraph' | 'all' (default: 'all')
wrapper={{
rotateAngleGrid: 45, // snap rotation to 45° steps (default: 15°)
preserveAspectRatio: true, // lock the width/height ratio while resizing
}}
/>

allowTranslate is on by default: dragging any selected cell moves the entire selection together, which is what makes a multi-cell selection worth having. Set it to false to freeze positions and treat the selection as a read-only grouping. Links wired to the moved elements already follow along (translateConnectedLinks defaults to 'all'); dial it down to 'subgraph' to move only links between selected elements, or 'none' to leave links in place.

rotateAngleGrid and preserveAspectRatio live on the wrapper, since they only matter when the wrapper renders the rotate and resize handles. rotateAngleGrid is the rotation snap step in degrees and defaults to 15, so rotation already snaps to 15° increments; raise it for coarser steps, or lower it (e.g. 1) for near-continuous rotation. preserveAspectRatio holds an element's width-to-height ratio steady as the resize handle is dragged.

Frames and the selected-cell class

<Selection> always toggles the jj-is-selected CSS class on every selected cell's root element via an internal highlighter. That class is available whether or not you pass frames, making CSS the lightest path to per-cell selection styling:

.jj-is-selected .jj-box { outline: 2px solid var(--accent-color); }
.jj-is-selected .jj-link-line { stroke: var(--accent-color); }

The frames prop controls whether HTML overlay boxes are also drawn over each selected cell:

<Selection />                          {/* default: jj-is-selected class only, no boxes */}
<Selection frames /> {/* class + an HTML overlay box on every selected cell */}
<Selection frames={{ shouldRender: (model) => model.isElement() }} /> {/* boxes on elements only */}

When you pass an options object to frames, it is forwarded to the internal frame list. The available fields are:

  • shouldRender: a (model: dia.Cell) => boolean predicate that controls which cells receive an HTML overlay box. The jj-is-selected class is applied regardless of this predicate.
  • style: inline styles for the overlay box (HTML frames).
  • className: extra CSS class names added to the overlay box (static string or per-cell function).
  • cellClassName: CSS class applied to each selected cell's root via the highlighter. Defaults to jj-is-selected. Pass '' to opt out of the class entirely.

Because style is a per-cell callback, a frame can borrow something from the cell it wraps. In the demo below each element stores a color, and the frame paints its border to match (cellClassName: '' drops the default class so only the colored box shows). Click a card to select it, or Shift-drag a region. This demo keeps the default wrapper, resize handle included, so its cards render with useModelGeometry (otherwise a resize would snap back, as noted above).

const frames: SelectionFramesOptions = {
cellClassName: '',
style: (model) => ({
borderStyle: 'solid',
borderWidth: '2px',
borderColor: model.get('data')?.color ?? '#64748b',
borderRadius: '12px',
}),
};

<Selection frames={frames} />;

For full control over the box markup, pass a ui.SelectionFrameList instance directly to frames. That is the escape hatch for custom HTML structures or fully bespoke overlay rendering.