Skip to main content

Drag and Drop

Drag and drop primitives help you build pointer-driven reorder UIs with a grip beside your row content.

Atlas integrates with stacks such as react-grid-layout: the handle exposes the .draggableHandle class so dragging starts only from the grip, not from the rest of the row.

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 cols === 1 with compactType='vertical', Atlas merges grip-disabled static then runs compact on y beside pinned rows (utils.compact) — sort keyed children by y from onLayoutChange (below) beside disabled rows for sane stacking and height. Multi-column stacks or compactType={null} skip that step automatically.

Live Editor
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>
  );
}
Result
Loading...

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 when compactType='vertical'.

Live Editor
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}
          rowHeight={64}
          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>
  );
}
Result
Loading...

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 compactType to null for dashboard-style placement.

Live Editor
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}
          cols={4}
          rowHeight={30}
          compactType={null}
          isResizable
          margin={[8, 8]}
        >
          <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>
  );
}
Result
Loading...

Usage Rules

When to use

  • You want a single-column stack where only the atlas grip moves the row—compose with Combo Box and DragAndDrop or DragAndDrop.Item beside fields.
  • You import DragAndDrop and DragAndDrop.Item from @adjust/components. Standalone DragHandle, DragAndDropLayout, and DragHandleItem-style splits are not public exports.
  • Your host expects a draggable-handle selector (draggableHandle=".draggableHandle" is fixed on the Atlas wrapper).
  • 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 (onLayoutChange or 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 react-grid-layout alongside @adjust/components when you use the grid root. react-grid-layout itself depends on react-draggable (and react-resizable, resize-observer-polyfill); you do not need to install react-draggable directly—installing react-grid-layout brings it in.

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.

tip

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). preventCollision is forwarded from your props (react-grid-layout); when undefined, Atlas passes false so movable rows can swap next to static slots. Pass preventCollision={true} only if you want RGL’s stricter overlap blocking (it can prevent adjacent swaps). For cols === 1 with compactType='vertical', Atlas re-applies static pins then compact so stray y lanes don’t inflate grid height (multi-column / compactType={null} skips that). allowOverlap is forwarded untouched. When isResizable is enabled on DragAndDrop, locked rows keep isResizable: true on their layout slice so Tiles can still resize from their own chrome.

Props

DragAndDrop

NameTypeDefault
style
Inline-style object to pass to the root element.
CSSProperties
className
The classname to add to the root element. Class on the outer wrapper with atlas-drag-handle-layout.
string
cols
Number of columns in this layout.
number
margin
Margin between items [x, y] in px.
[number, number]
containerPadding
Padding inside the container [x, y] in px.
[number, number]
layout
Layout is an array of object with the format: {x: number, y: number, w: number, h: number} The index into the layout must match the key used on each item component. If you choose to use custom keys, you can specify that key in the layout array objects like so: `{i: string, x: number, y: number, w: number, h: number}` If not provided, use data-grid props on children.
Layout[]
onLayoutChange
Callback so you can save the layout. Calls back with (currentLayout) after every drag or resize stop.
((layout: Layout[]) => void)
width
This allows setting the initial width on the server side. This is required unless using the HOC <WidthProvider> or similar.
number
autoSize
If true, the container height swells and contracts to fit contents.
boolean
draggableCancel
A CSS selector for tags that will not be draggable. For example: draggableCancel: '.MyNonDraggableAreaClassName' If you forget the leading. it will not work. "".react-resizable-handle" is always prepended to this value.
string
compactType
Compaction type.
"vertical" | "horizontal" | null
rowHeight
Rows have a static height, but you can change this based on breakpoints if you like.
number
droppingItem
Configuration of a dropping element. Dropping element is a "virtual" element which appears when you drag over some element from outside.
{ i: string; w: number; h: number; }
verticalCompact
If true, the layout will compact vertically.
boolean
maxRows
Default Infinity, but you can specify a max here if you like. Note that this isn't fully fleshed out and won't error if you specify a layout that extends beyond the row capacity. It will, however, not allow users to drag/resize an item past the barrier. They can push items beyond the barrier, though. Intentionally not documented for this reason.
number
isDraggable
If set to false it will disable dragging on all children.
boolean
isResizable
If set to false it will disable resizing on all children.
boolean
isBounded
If true and draggable, all items will be moved only within grid.
boolean
useCSSTransforms
Uses CSS3 translate() instead of position top/left. This makes about 6x faster paint performance.
boolean
transformScale
If parent DOM node of ResponsiveReactGridLayout or ReactGridLayout has "transform: scale(n)" css property, we should set scale coefficient to avoid render artefacts while dragging.
number
allowOverlap
If true, grid can be placed one over the other.
boolean
preventCollision
If true, grid items won't change position when being dragged over.
boolean
isDroppable
If true, droppable elements (with draggable={true} attribute) can be dropped on the grid. It triggers "onDrop" callback with position and event object as parameters. It can be useful for dropping an element in a specific position NOTE: In case of using Firefox you should add `onDragStart={e => e.dataTransfer.setData('text/plain', '')}` attribute along with Link
boolean
resizeHandles
Defines which resize handles should be rendered Allows for any combination of: 's' - South handle (bottom-center) 'w' - West handle (left-center) 'e' - East handle (right-center) 'n' - North handle (top-center) 'sw' - Southwest handle (bottom-left) 'nw' - Northwest handle (top-left) 'se' - Southeast handle (bottom-right) 'ne' - Northeast handle (top-right)
ResizeHandle[]
resizeHandle
Defines custom component for resize handle
ReactNode | ((resizeHandle: ResizeHandle) => ReactNode)
onDragStart
Calls when drag starts.
ItemCallback
onDrag
Calls on each drag movement.
ItemCallback
onDragStop
Calls when drag is complete.
ItemCallback
onResizeStart
Calls when resize starts.
ItemCallback
onResize
Calls when resize movement happens.
ItemCallback
onResizeStop
Calls when resize is complete.
ItemCallback
onDrop
Calls when some element has been dropped
((layout: Layout[], item: Layout, e: Event) => void)
onDropDragOver
Calls when an element is being dragged over the grid from outside as above. This callback should return an object to dynamically change the droppingItem size Return false to short-circuit the dragover
((e: DragOverEvent) => false | { w?: number; h?: number; } | undefined) | undefined
innerRef
Ref for getting a reference for the grid's wrapping div. You can use this instead of a regular ref and the deprecated ReactDOM.findDOMNode()` function.
Ref<HTMLDivElement>
gridClassName
Extra class names on the ReactGridLayout root.
string
data-{foo}
Data attributes can be used by testing libraries to retrieve components or assert their existence
string
* - the prop is required.

DragAndDrop.Item

DragAndDrop.Item lays out children beside the atlas grip.

NameTypeDefault
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.
boolean
false
dragging
Styles the row/handle as in an active drag.
boolean
false
className
string
contentClassName
string
dragHandleDataTestId
Overrides default data-testid on the interactive handle.
string
aria-label
**aria-label** on the draggable grip (**`Drag to reorder`** when omitted). Use localized copy for non-default locales.
string
data-{foo}
Data attributes can be used by testing libraries to retrieve components or assert their existence
string
* - 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.