Drag and Drop
Drag and drop primitives help you build pointer-driven reorder UIs with a grip beside your row content.
Atlas integrates with react-grid-layout v2. DragAndDrop forwards gridConfig, dragConfig, resizeConfig, and compactor, and defaults dragConfig.handle to .draggableHandle so dragging starts only from the grip.
Reordering uses pointer dragging inside react-grid-layout (not the HTML draggable attribute). To lock a row at the atlas grip, set disabled on DragAndDrop.Item. Setting draggable={false} on inner markup does not affect reordering here.
Example
Sortable rows with DragAndDrop
Reorder rows inside DragAndDrop, keep order in React state with onLayoutChange, and wrap each row surface in DragAndDrop.Item (inside a keyed wrapper so grid items stay stable).
For gridConfig.cols === 1 with the default vertical compactor, Atlas merges grip-disabled static then runs compact on y beside pinned rows — sort keyed children by y from onLayoutChange (below) beside disabled rows for sane stacking and height. Multi-column stacks or compactor={noCompactor} skip that step automatically.
function DragAndDropReorderDemo() { const [items, setItems] = useState([ { id: 'a', label: 'Alpha' }, { id: 'b', label: 'Beta' }, { id: 'c', label: 'Gamma' }, ]); const handleLayoutChange = (newLayout) => { const yFor = (id) => newLayout.find((l) => String(l.i) === String(id))?.y ?? 0; const sorted = [...items].sort((a, b) => { const dy = yFor(a.id) - yFor(b.id); if (dy !== 0) return dy; /** Stable tie-break when y coords collide momentarily mid-drag */ return String(a.id).localeCompare(String(b.id)); }); if (sorted.some((item, i) => item.id !== items[i].id)) { setItems(sorted); } }; return ( <div className="combo-box-rgl-live"> <div className="combo-box-rgl-demo" style={{ maxWidth: 520 }}> <p style={{ marginTop: 0 }}> Drag via the <strong>grip icon only </strong>(not the gray row)—the combo / field ignores drags elsewhere. </p> <DragAndDrop width={520} onLayoutChange={handleLayoutChange}> {items.map((item, index) => ( <div key={item.id}> <DragAndDrop.Item> <div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 12px', background: '#f3f4f6', borderRadius: 6, border: '1px solid #e5e7eb', }} > <span style={{ color: '#6b7280', minWidth: 28 }}> {index + 1}. </span> <span style={{ flex: 1, fontWeight: 500 }}>{item.label}</span> </div> </DragAndDrop.Item> </div> ))} </DragAndDrop> <p style={{ fontSize: 13, color: '#4b5563', marginBottom: 0 }}> Current order: {items.map((i) => i.label).join(' → ')} </p> </div> </div> ); }
Combo Box beside DragAndDrop.Item
When the atlas grip should sit outside the bordered react-select shell, wrap ComboBox in DragAndDrop.Item. Use one keyed wrapper per grid row (<div key={…}>).
While your sortable host is moving a row, set dragging on that item from your layout lifecycle (Atlas only styles chrome). Use disabled when that row must not reorder—the combo stays editable; only the grip becomes an inert spacer.
This live block puts the disabled combo first so it stays anchored at y=0; the rows below share one grip each—you can reorder those two freely. Putting disabled last instead behaves the same for the draggable pair, but dragging the pinned slot itself (attempting “third row → second” when row 3 is locked) remains impossible by design.
Reorder row keys from onLayoutChange (same y sort as the simple demo) when a disabled row wraps a slot—Atlas applies compact for vertical gaps with the default vertical compactor.
function DragAndDropComboBoxDemo() { const options = [ { value: 'apple', label: 'Apple' }, { value: 'mango', label: 'Mango' }, ]; const comboShared = { blockScroll: false, options, }; /** Pinned grip-disabled row `row-pinned` is first (`static`) — rows below reorder by grip. Keys sort from `onLayoutChange`. */ const rowDefs = [ { key: 'row-pinned', dragging: false, disabled: true, 'aria-label': 'Pinned top — reorder locked here; combo still usable elsewhere', placeholder: 'Grip disabled — row stays fixed', }, { key: 'row-default', dragging: false, disabled: false, 'aria-label': 'Reorder via grip — swap with row below', placeholder: 'Movable combo A', }, { key: 'row-second', dragging: false, disabled: false, 'aria-label': 'Reorder via grip — swap with row above', placeholder: 'Movable combo B', }, ]; const [rowKeys, setRowKeys] = useState([ 'row-pinned', 'row-default', 'row-second', ]); const handleLayoutChange = (newLayout) => { const yFor = (id) => newLayout.find((l) => String(l.i) === String(id))?.y ?? 0; const next = [...rowKeys].sort((a, b) => { const dy = yFor(a) - yFor(b); if (dy !== 0) return dy; return String(a).localeCompare(String(b)); }); if (next.some((key, idx) => key !== rowKeys[idx])) { setRowKeys(next); } }; const defsByKey = new Map(rowDefs.map((cfg) => [cfg.key, cfg])); return ( <div className="combo-box-rgl-live"> <div className="combo-box-rgl-demo" style={{ maxWidth: 520 }}> <p style={{ marginTop: 0, fontSize: 13 }}> Swap <strong>Movable A</strong> and <strong>Movable B</strong> by dragging{' '} <strong>only the grips</strong> (not the fields). The{' '} <strong>pinned</strong> top row cannot be reordered — Atlas marks it{' '} <code>static</code>, so it keeps the top slot (<code>y=0</code>) while the two movable combos swap underneath. </p> <DragAndDrop width={520} gridConfig={{ rowHeight: 40, margin: [0, 10] }} onLayoutChange={handleLayoutChange} > {rowKeys.map((key) => { const cfg = defsByKey.get(key); if (!cfg) { return null; } return ( <div key={cfg.key}> <DragAndDrop.Item dragging={cfg.dragging} disabled={cfg.disabled}> <ComboBox {...comboShared} aria-label={cfg['aria-label']} placeholder={cfg.placeholder} /> </DragAndDrop.Item> </div> ); })} </DragAndDrop> </div> </div> ); }
Multi-column Tile grid with DragAndDrop.Item
Same Tile + react-grid-layout layout as above, but each cell wraps the tile in DragAndDrop.Item so the atlas grip sits beside the tile. Turn off draggable on Tile here—otherwise both the tile grip and the item grip would match .draggableHandle. Keep resizable on Tile so resize still uses the tile chrome. Pass a fixed width, keep layout in state, and set compactor={noCompactor} for dashboard-style placement.
function DragAndDropTileGridWithItemDemo() { const [layout, setLayout] = useState([ { i: 'a', x: 0, y: 0, w: 1, h: 2, minH: 2 }, { i: 'b', x: 1, y: 0, w: 2, h: 2, minH: 2 }, { i: 'c', x: 3, y: 0, w: 1, h: 2, minH: 2 }, ]); return ( <div className="combo-box-rgl-live"> <div className="combo-box-rgl-demo" style={{ maxWidth: 520 }}> <p style={{ marginTop: 0 }}> Drag the atlas grip beside each tile; resize from the tile handle. Layout updates via `onLayoutChange`. </p> <DragAndDrop width={520} layout={layout} onLayoutChange={setLayout} gridConfig={{ cols: 4, rowHeight: 30, margin: [8, 8] }} resizeConfig={{ enabled: true }} compactor={noCompactor} > <div key="a"> <DragAndDrop.Item> <Tile resizable>A</Tile> </DragAndDrop.Item> </div> <div key="b"> <DragAndDrop.Item> <Tile resizable>B</Tile> </DragAndDrop.Item> </div> <div key="c"> <DragAndDrop.Item> <Tile resizable>C</Tile> </DragAndDrop.Item> </div> </DragAndDrop> </div> </div> ); }
Usage Rules
When to use
- You want a single-column stack where only the atlas grip moves the row—compose with Combo Box and
DragAndDroporDragAndDrop.Itembeside fields. - You import
DragAndDropandDragAndDrop.Itemfrom@adjust/components. StandaloneDragHandle,DragAndDropLayout, and DragHandleItem-style splits are not public exports. - Your host relies on the atlas grip selector (
dragConfig.handledefaults to.draggableHandle; overridedragConfigonly when you need a different handle). - You want stable hooks for tests or CSS (
atlas-drag-handle-layout,atlas-drag-handle-item,atlas-sortable-drag-handle).
When not to use
- Rows do not reorder by themselves—you still own item order (
onLayoutChangeor another sortable host). These primitives handle layout and visuals only. - The grip has no explanatory visible label by default; add surrounding context if needed (Atlas sets
aria-label="Drag to reorder"on the active grip).
Dependencies, CSS, and width
Add @adjust/components only — react-grid-layout is installed transitively as a library dependency (not bundled; your app bundler resolves it at build time). You do not need a separate react-grid-layout entry in the MFE package.json, and you do not need to install react-draggable directly.
Importing DragAndDrop from @adjust/components pulls in react-grid-layout/css/styles.css. If you mount raw ReactGridLayout alone, import that stylesheet yourself.
Provide width in pixels—often via ResizeObserver or layout measurement—because the wrapper follows react-grid-layout’s contract.
Grid configuration (react-grid-layout v2)
DragAndDrop accepts the same nested config props as GridLayout. Atlas merges your partial configs with these defaults:
| Prop | Atlas default | Typical override |
|---|---|---|
gridConfig | { cols: 1, rowHeight: 56, margin: [0, 8], containerPadding: [0, 0] } | cols, rowHeight, margin for multi-column tile grids |
dragConfig | { handle: '.draggableHandle' } | Rarely changed |
resizeConfig | { enabled: false } | { enabled: true } when tiles should resize from grid chrome |
compactor | vertical compaction, allowOverlap: false, preventCollision: false | noCompactor for free-form dashboards; getCompactor('vertical', false, true) for strict collision blocking |
Import helpers from react-grid-layout in application code, e.g. getCompactor and noCompactor (live examples below use the same imports via the docs playground scope).
For fields with the grip outside the bordered control, wrap ComboBox in DragAndDrop.Item (Combo Box reorder example). For drag-and-resize tiles, see Tile. Toggle dragging on DragAndDrop.Item from your host while a drag is active.
Controlled layout
Each immediate child under DragAndDrop should use a stable React key, typically <div key={id}>…</div>. onLayoutChange reports y positions keyed by grid item id (i). Omitting layout lets Atlas derive placements from keyed children while onLayoutChange still runs; pass layout for a fully controlled grid (Combo Box reorder example).
Stacks that include disabled grips should reorder keyed children after onLayoutChange so DOM order tracks sorted y.
Setting disabled on DragAndDrop.Item removes .draggableHandle, inserts an inert spacer, and marks that grid item static internally so compaction and sibling drags cannot move that slot (first/middle/last row). compactor.preventCollision defaults to false so movable rows can swap next to static slots; pass compactor={getCompactor('vertical', false, true)} (or your own compactor with preventCollision: true) only if you want react-grid-layout’s stricter overlap blocking (it can prevent adjacent swaps). For gridConfig.cols === 1 with the default vertical compactor, Atlas re-applies static pins then compact so stray y lanes don’t inflate grid height (multi-column / noCompactor skips that). Pass compactor with allowOverlap: true when you need overlapping items. When resizeConfig.enabled is true, locked rows keep isResizable: true on their layout slice so Tiles can still resize from their own chrome.
Props
DragAndDrop
| Name | Type | Default |
|---|---|---|
width * Width of the container in pixels |
| — |
style Additional styles |
| — |
gridConfig Grid measurement configuration.
@see GridConfig |
| — |
dragConfig Drag behavior configuration.
@see DragConfig |
| — |
resizeConfig Resize behavior configuration.
@see ResizeConfig |
| — |
dropConfig External drop configuration.
@see DropConfig |
| — |
positionStrategy CSS positioning strategy.
Use transformStrategy (default), absoluteStrategy, or createScaledStrategy(scale).
@see PositionStrategy |
| — |
compactor Layout compaction strategy.
Use verticalCompactor (default), horizontalCompactor, or noCompactor.
@see Compactor |
| — |
constraints Layout constraints for position and size limiting.
Applied during drag/resize operations.
Default: [gridBounds, minMaxSize]
@see LayoutConstraint |
| — |
droppingItem Item to use when dropping from outside |
| — |
autoSize Whether to auto-size the container height |
| — |
innerRef Ref to the container element |
| — |
onDragStart Called when drag starts |
| — |
onDrag Called during drag |
| — |
onDragStop Called when drag stops |
| — |
onResizeStart Called when resize starts |
| — |
onResize Called during resize |
| — |
onResizeStop Called when resize stops |
| — |
onDrop Called when an item is dropped from outside |
| — |
onDropDragOver Called when dragging over the grid |
| — |
layout |
| — |
onLayoutChange |
| — |
className Class on the outer wrapper with atlas-drag-handle-layout. |
| — |
gridClassName Extra class names on the ReactGridLayout root. |
| — |
data-{foo} Data attributes can be used by testing libraries to retrieve components or assert their existence |
| — |
| * - the prop is required. |
DragAndDrop.Item
DragAndDrop.Item lays out children beside the atlas grip.
| Name | Type | Default |
|---|---|---|
disabled When true, the interactive handle is omitted (inert spacer). Reordering uses pointer
dragging inside ** react-grid-layout**, not the HTML **`draggable`** attribute—inner
DOM **`draggable={false}`** does not lock the row; use **`disabled`** here instead.
The grid keeps that slot fixed in order (**`layout` `static`** for that **`i`**) while other rows move or compact around it when allowed. |
| false |
dragging Styles the row/handle as in an active drag. |
| false |
className |
| — |
contentClassName |
| — |
dragHandleDataTestId Overrides default data-testid on the interactive handle. |
| — |
aria-label ** aria-label** on the draggable grip (**`Drag to reorder`** when omitted).
Use localized copy for non-default locales. |
| — |
data-{foo} Data attributes can be used by testing libraries to retrieve components or assert their existence |
| — |
| * - the prop is required. |
Accessibility
When DragAndDrop.Item is not disabled, the grip is a button with default aria-label="Drag to reorder". When disabled, the spacer is aria-hidden so keyboard users skip it. react-grid-layout is pointer-heavy; add keyboard reordering in your application if you need full parity.