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 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.

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 with the default vertical compactor.

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}
          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>
  );
}
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 compactor={noCompactor} 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}
          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>
  );
}
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 relies on the atlas grip selector (dragConfig.handle defaults to .draggableHandle; override dragConfig only 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 (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 @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:

PropAtlas defaultTypical 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
compactorvertical compaction, allowOverlap: false, preventCollision: falsenoCompactor 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).

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). 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

NameTypeDefault
width *
Width of the container in pixels
number
style
Additional styles
CSSProperties
gridConfig
Grid measurement configuration. @see GridConfig
Partial<GridConfig>
dragConfig
Drag behavior configuration. @see DragConfig
Partial<DragConfig>
resizeConfig
Resize behavior configuration. @see ResizeConfig
Partial<ResizeConfig>
dropConfig
External drop configuration. @see DropConfig
Partial<DropConfig>
positionStrategy
CSS positioning strategy. Use transformStrategy (default), absoluteStrategy, or createScaledStrategy(scale). @see PositionStrategy
PositionStrategy
compactor
Layout compaction strategy. Use verticalCompactor (default), horizontalCompactor, or noCompactor. @see Compactor
Compactor
constraints
Layout constraints for position and size limiting. Applied during drag/resize operations. Default: [gridBounds, minMaxSize] @see LayoutConstraint
LayoutConstraint[]
droppingItem
Item to use when dropping from outside
LayoutItem
autoSize
Whether to auto-size the container height
boolean
innerRef
Ref to the container element
Ref<HTMLDivElement>
onDragStart
Called when drag starts
EventCallback
onDrag
Called during drag
EventCallback
onDragStop
Called when drag stops
EventCallback
onResizeStart
Called when resize starts
EventCallback
onResize
Called during resize
EventCallback
onResizeStop
Called when resize stops
EventCallback
onDrop
Called when an item is dropped from outside
((layout: Layout, item: LayoutItem, e: Event) => void)
onDropDragOver
Called when dragging over the grid
((e: DragEvent<Element>) => false | void | { w?: number; h?: number; dragOffsetX?: number | undefined; dragOffsetY?: number | undefined; }) | undefined
layout
Layout
onLayoutChange
((layout: Layout) => void)
className
Class on the outer wrapper with atlas-drag-handle-layout.
string
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. The grid keeps that slot fixed in order (**`layout` `static`** for that **`i`**) while other rows move or compact around it when allowed.
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.