Skip to content
This page is available as Markdown at /editor/guides/drag-and-drop.md. For the full documentation index, see /llms.txt, or the complete corpus at /llms-full.txt.

Drag and drop blocks

Users can move blocks around the document by dragging them. The work splits in two:

  • The editor moves the content. On drop, it takes the dragged blocks out of their old place and inserts them where the user let go. Drops respect the destination’s schema: blocks the destination doesn’t accept are left out.
  • Your render owns everything the user sees during the drag: the element they grab (the drag source) and the line that shows where the blocks will land (the drop indicator).

The editor draws no drag handles and no drop indicators of its own. This guide adds both. It assumes you have a text block registration, as set up in the getting started guide.

@portabletext/plugin-dnd tracks where a drop would land, so your render can draw the indicator.

Terminal window
npm i @portabletext/plugin-dnd

Wrap PortableTextEditable in DndProvider, inside the EditorProvider:

import {DndProvider} from '@portabletext/plugin-dnd'
function MyEditor() {
return (
<EditorProvider initialConfig={{schemaDefinition}}>
<NodePlugin nodes={nodes} />
<DndProvider>
<PortableTextEditable />
</DndProvider>
</EditorProvider>
)
}

Text blocks have no drag source until you give them one. A drag handle is any element inside the block’s outer element with contentEditable={false}, draggable={!props.readOnly}, and some visible content to grab. The editor works out which block to move from the block around the handle, so the handle needs no other attributes.

import {defineTextBlock, type TextBlockRenderProps} from '@portabletext/editor'
const textBlock = defineTextBlock({
type: 'block',
render: (props) => <TextBlock {...props} />,
})
function TextBlock(props: TextBlockRenderProps) {
return (
<div {...props.attributes} style={{position: 'relative'}}>
<span
contentEditable={false}
draggable={!props.readOnly}
style={{
position: 'absolute',
left: '-1.5em',
cursor: 'grab',
userSelect: 'none',
}}
>
⠿
</span>
{props.children}
<DropIndicator path={props.path} />
</div>
)
}

DropIndicator is the component from the next section. The negative left puts the handle in the margin next to the text, so give the editor some padding on the left.

Keep draggable on the handle. Don’t put it on the block’s outer element: Firefox never starts a drag from it, and Chromium only starts one from empty space next to the text.

If the user has selected text across several blocks and grabs a handle inside that selection, the drag moves all the selected blocks, in full.

Block objects and inline objects have no editable text, so the visible content itself can be the drag source. The custom blocks guide covers the setup: block objects wrap their visible content in an element with contentEditable={false} and draggable={!props.readOnly}, and inline objects put draggable={!props.readOnly} on an inner wrapper.

Images are draggable in the browser by default. The block still moves when the user drags the image, but the drag also carries the image’s URL, and in Firefox the image file, so dropping it outside the editor hands over the image. Set draggable={false} on an <img> inside a block object so the drag carries only the block. The contentEditable={false} wrapper matters here too: without it, Firefox drags the image even with draggable={false}. This image block also renders the DropIndicator from the next section, so users can drop blocks next to it:

import {defineBlockObject} from '@portabletext/editor'
const imageBlock = defineBlockObject({
type: 'image',
render: (props) =>
typeof props.node.src === 'string' ? (
<div {...props.attributes} style={{position: 'relative'}}>
{props.children}
<div contentEditable={false} draggable={!props.readOnly}>
<img src={props.node.src} alt="" draggable={false} />
</div>
<DropIndicator path={props.path} />
</div>
) : (
props.renderDefault(props)
),
})

useDropPosition returns where a drop would land relative to the block at a path: 'start' (before the block), 'end' (after it), or undefined when it would land somewhere else. It is a React hook, and the editor calls render as a plain function, so call it from a component your render returns. A small component that reads the hook and draws a line works in every block render:

import type {Path} from '@portabletext/editor'
import {useDropPosition} from '@portabletext/plugin-dnd'
function DropIndicator(props: {path: Path}) {
const dropPosition = useDropPosition(props.path)
return dropPosition === undefined ? null : (
<div
contentEditable={false}
style={{
position: 'absolute',
left: 0,
right: 0,
top: dropPosition === 'start' ? 0 : undefined,
bottom: dropPosition === 'end' ? 0 : undefined,
borderTop: '2px solid dodgerblue',
pointerEvents: 'none',
}}
/>
)
}

Render it inside the block’s outer element and give that element position: relative, as TextBlock above does. position: absolute keeps the line out of the text flow, so the block doesn’t shift when it appears. pointerEvents: 'none' lets the drag keep reaching the block underneath. Add the same <DropIndicator path={props.path} /> to every block render that should show one, as the image block above does.

The position is only set while the user drags whole blocks, and it stays undefined:

  • for a drag of part of a block’s text,
  • over the blocks being dragged,
  • over the middle of a non-empty text block. A drop there splits the block, and the browser’s own caret shows where.

While an indicator shows, the plugin hides the browser’s caret, so the user sees one drop target at a time. A component that reads useDropPosition re-renders only when the position at its own path changes, so moving the drag from one block to the next re-renders just those two blocks.

useDropPosition throws when it is called outside a DndProvider.

A handle in a container’s own chrome drags the whole container. Put it in the container render, next to children:

import {defineContainer} from '@portabletext/editor'
const callout = defineContainer({
type: 'callout',
arrayField: 'content',
render: (props) => (
<aside {...props.attributes} style={{position: 'relative'}}>
<span contentEditable={false} draggable={!props.readOnly}>
⠿
</span>
{props.children}
<DropIndicator path={props.path} />
</aside>
),
})

Text blocks inside the container render through your text block registration, handle included. Their handle drags that one block, which lets users move it within the container or out of it. Drop positions work at every depth: dragging over a block inside a container puts the indicator on that block, not on the container.

Every drag reaches the Behavior API as drag.* events: drag.dragstart, drag.drag, drag.dragend, drag.dragenter, drag.dragover, drag.dragleave, and drag.drop. Listen to one with its exact name, or to all of them with on: 'drag.*'. A behavior that matches an event takes it over. Return forward(event) from its actions to let the editor carry on as usual, or leave it out to stop the event.

drag.drop and drag.dragover events carry the drop position and the browser’s dataTransfer. A drag that started in this editor also carries dragOrigin, the selection being dragged. This behavior uses that to ignore anything dropped from outside the editor, such as text from another page or files from the desktop:

import {defineBehavior} from '@portabletext/editor/behaviors'
import {BehaviorPlugin} from '@portabletext/editor/plugins'
const ignoreOutsideDrops = defineBehavior({
on: 'drag.drop',
guard: ({event}) => event.dragOrigin === undefined,
actions: [],
})
function MyEditor() {
return (
<EditorProvider initialConfig={{schemaDefinition}}>
<BehaviorPlugin behaviors={[ignoreOutsideDrops]} />
<PortableTextEditable />
</EditorProvider>
)
}

Blocks dragged within the editor still move, because their drop events carry a dragOrigin and the guard lets them through. The Behavior API reference lists the actions you can return instead, for example to insert the dropped content somewhere else.

The editor ignores drag starts while it is read-only. Use draggable={!props.readOnly} on every drag source, as the examples on this page do, so the browser doesn’t offer a drag the editor won’t act on.