The fastest way to add drag and drop functionality is with the DndKit, which includes the pre-configured DndPlugin along with React DnD setup and the BlockDraggable UI component.
'use client';
import { GripVertical } from 'lucide-react';
import { PathApi, type Element, type Path } from 'platejs';
import {
DndPlugin,
type DropLineDirection,
useDraggable,
useDndPlugin,
useDropLine,
} from 'platejs/dnd/react';
import { BaseColumnItemPlugin } from 'platejs/layout';
import { PlaceholderPlugin } from 'platejs/media/react';
import {
type Editor,
type RenderNodeWrapperDescriptor,
type RenderNodeWrapperProps,
useEditor,
useEditorSelector,
useElement,
usePluginStore,
} from 'platejs/react';
import {
BaseTableCellPlugin,
BaseTablePlugin,
BaseTableRowPlugin,
} from 'platejs/table';
import * as React from 'react';
import { DndContext, DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
const UNDRAGGABLE_PLUGINS = [
BaseColumnItemPlugin,
BaseTableRowPlugin,
BaseTableCellPlugin,
];
const DndInteractionContext = React.createContext({
activate: () => {},
active: false,
hasTableCellSelection: false,
});
const isBlockDraggable: NonNullable<
RenderNodeWrapperDescriptor<typeof DndPlugin>['match']
> = ({ editor, element, renderPath }) => {
if (editor.read.view.isReadOnly()) return false;
const isUndraggable = UNDRAGGABLE_PLUGINS.some((plugin) => {
const portal = editor.plugin(plugin);
return portal.installed && portal.schema.type === element.type;
});
const container = !isUndraggable
? getDraggableContainer(editor, renderPath)
: null;
return !!container;
};
const BlockDraggableComponent = (props: RenderNodeWrapperProps) => {
const container = getDraggableContainer(props.editor, props.renderPath);
const interaction = React.useContext(DndInteractionContext);
if (!container) return <>{props.children}</>;
return (
<Draggable
{...props}
activate={interaction.activate}
active={interaction.active}
container={container}
hasTableCellSelection={interaction.hasTableCellSelection}
/>
);
};
export const BlockDraggable: RenderNodeWrapperDescriptor<typeof DndPlugin> = {
component: BlockDraggableComponent,
match: isBlockDraggable,
};
type DraggableContainer = 'column' | 'root' | 'table';
function Draggable({
activate,
active,
container,
hasTableCellSelection,
...props
}: RenderNodeWrapperProps & {
activate: () => void;
active: boolean;
container: DraggableContainer;
hasTableCellSelection: boolean;
}) {
const { children, editor, element } = props;
const [dragButtonTop, setDragButtonTop] = React.useState(0);
const [dropLine, setDropLine] = React.useState<DropLineDirection>('');
const [isPointerActive, setIsPointerActive] = React.useState(false);
const [isThisDragging, setIsThisDragging] = React.useState(false);
const nodeRef = React.useRef<HTMLDivElement>(null);
const previewRef = React.useRef<HTMLDivElement>(null);
const dragButtonRef = React.useRef<HTMLButtonElement>(null);
const [previewTop, setPreviewTop] = React.useState(0);
const resetPreview = React.useCallback(() => {
if (previewRef.current) {
previewRef.current.replaceChildren();
previewRef.current.classList.add('hidden');
}
}, []);
const isInColumn = container === 'column';
const isInTable = container === 'table';
const isActive = active || isPointerActive;
return (
<div
className={cn(
'relative hover:[&>.editor-gutterLeft]:opacity-100',
isThisDragging && 'opacity-50'
)}
onMouseEnter={() => {
if (isThisDragging) return;
setDragButtonTop(calcDragButtonTop(editor, element));
}}
>
{isActive && (
<DraggableRuntime
dragButtonRef={dragButtonRef}
element={element}
nodeRef={nodeRef}
onDraggingChange={setIsThisDragging}
onDropLineChange={setDropLine}
previewRef={previewRef}
resetPreview={resetPreview}
/>
)}
{!isInTable && !hasTableCellSelection && (
<Gutter active={isPointerActive}>
<button
ref={dragButtonRef}
aria-label="Drag block"
className={cn(
'pointer-events-auto absolute -left-0 h-6 w-4.5 cursor-grab p-0 text-muted-foreground',
isInColumn && 'w-4'
)}
style={{ top: `${dragButtonTop + 3}px` }}
type="button"
data-editor-prevent-deselect
data-editor-selectable
onFocus={() => {
activate();
setIsPointerActive(true);
}}
onMouseEnter={() => {
activate();
setIsPointerActive(true);
}}
onPointerOver={() => {
activate();
setIsPointerActive(true);
}}
>
{isPointerActive ? (
<DragHandle
isDragging={isThisDragging}
previewRef={previewRef}
resetPreview={resetPreview}
setPreviewTop={setPreviewTop}
/>
) : (
'⠿'
)}
</button>
</Gutter>
)}
<div
ref={previewRef}
className={cn('-left-0 absolute hidden w-full')}
style={{ top: `${-previewTop}px` }}
contentEditable={false}
/>
<DropLine dropLine={dropLine} />
<div ref={nodeRef} className="editor-blockWrapper flow-root">
{children}
</div>
</div>
);
}
const getDraggableContainer = (
editor: Editor,
path: Path
): DraggableContainer | null => {
if (path.length === 1) return 'root';
if (path.length === 3) {
const column = editor.plugin(BaseColumnItemPlugin);
if (
column.installed &&
editor.read.nodes.some({ at: path, type: BaseColumnItemPlugin })
) {
return 'column';
}
}
if (path.length === 4) {
const table = editor.plugin(BaseTablePlugin);
if (
table.installed &&
editor.read.nodes.some({ at: path, type: BaseTablePlugin })
) {
return 'table';
}
}
return null;
};
function DraggableRuntime({
dragButtonRef,
element,
nodeRef,
onDraggingChange,
onDropLineChange,
previewRef,
resetPreview,
}: {
dragButtonRef: React.RefObject<HTMLButtonElement | null>;
element: Element;
nodeRef: React.RefObject<HTMLDivElement | null>;
onDraggingChange: (dragging: boolean) => void;
onDropLineChange: (dropLine: DropLineDirection) => void;
previewRef: React.RefObject<HTMLDivElement | null>;
resetPreview: () => void;
}) {
const { handleRef, isAboutToDrag, isDragging } = useDraggable({
element,
multiplePreviewRef: previewRef,
nodeRef,
onDropHandler: () => {
resetPreview();
},
});
const { dropLine } = useDropLine();
React.useEffect(() => {
handleRef(dragButtonRef.current);
return () => {
handleRef(null);
};
}, [dragButtonRef, handleRef]);
React.useEffect(() => {
onDraggingChange(isDragging);
return () => {
onDraggingChange(false);
};
}, [isDragging, onDraggingChange]);
React.useEffect(() => {
onDropLineChange(dropLine ?? '');
return () => {
onDropLineChange('');
};
}, [dropLine, onDropLineChange]);
React.useEffect(() => {
if (!isDragging) resetPreview();
}, [isDragging, resetPreview]);
React.useEffect(() => {
if (isAboutToDrag) {
previewRef.current?.classList.remove('opacity-0');
}
}, [isAboutToDrag, previewRef]);
return null;
}
function Gutter({
active,
children,
className,
...props
}: React.ComponentProps<'div'> & {
active: boolean;
}) {
return (
<div
{...props}
className={cn(
'editor-gutterLeft',
'-translate-x-full absolute top-0 z-50 flex h-full w-[22px] cursor-text select-none hover:opacity-100 sm:opacity-0',
'focus-within:opacity-100',
active && 'opacity-100',
className
)}
contentEditable={false}
data-editor-selectable
>
{children}
</div>
);
}
function DragHandle({
isDragging,
previewRef,
resetPreview,
setPreviewTop,
}: {
isDragging: boolean;
previewRef: React.RefObject<HTMLDivElement | null>;
resetPreview: () => void;
setPreviewTop: (top: number) => void;
}) {
const editor = useEditor();
const element = useElement();
const dnd = editor.plugin(DndPlugin);
const selectElement = () => {
const path = editor.read.nodes.path(element);
if (!path) return;
if (
!editor.read.selection
.nodes()
.some(([, selectedPath]) => PathApi.equals(selectedPath, path))
) {
editor.update.selection.setNodes([element]);
}
editor.api.dom.focus();
};
return (
<Tooltip>
<TooltipTrigger asChild>
<div
className="flex size-full items-center justify-center"
onClick={(e) => {
e.preventDefault();
selectElement();
}}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
selectElement();
}}
onMouseDown={(e) => {
resetPreview();
if ((e.button !== 0 && e.button !== 2) || e.shiftKey) return;
const elements = styleDragPreviewElements(
dnd.api.prepareDrag(element)
);
previewRef.current?.append(...elements);
previewRef.current?.classList.remove('hidden');
previewRef.current?.classList.add('opacity-0');
editor
.plugin(DndPlugin)
.store.set({ multiplePreviewRef: previewRef });
}}
onMouseEnter={() => {
if (isDragging) return;
const processedBlocks = dnd.read.dragEntries(element);
const elementNodeKey = editor.key(element);
const keys = processedBlocks.map(([block]) => editor.key(block));
if (keys.length > 1 && keys.includes(elementNodeKey)) {
const previewTop = calculatePreviewTop(editor, {
blocks: processedBlocks.map((block) => block[0]),
element,
});
setPreviewTop(previewTop);
} else {
setPreviewTop(0);
}
}}
onMouseUp={() => {
resetPreview();
}}
data-editor-prevent-deselect
role="button"
tabIndex={0}
>
<GripVertical className="text-muted-foreground" />
</div>
</TooltipTrigger>
<TooltipContent>Drag to move</TooltipContent>
</Tooltip>
);
}
function DropLine({
className,
dropLine,
...props
}: React.ComponentProps<'div'> & { dropLine: DropLineDirection }) {
return (
<div
{...props}
aria-hidden
className={cn(
'editor-dropLine',
'pointer-events-none absolute inset-x-0 h-0.5 transition-opacity',
'bg-brand/50',
dropLine ? 'opacity-100' : 'opacity-0',
dropLine === 'top' && '-top-px',
dropLine === 'bottom' && '-bottom-px',
className
)}
contentEditable={false}
/>
);
}
const styleDragPreviewElements = (
previews: Array<{ domNode: HTMLElement; preview: HTMLElement }>
): HTMLElement[] =>
previews.map(({ domNode, preview }, index) => {
const document = domNode.ownerDocument;
const { scrollLeft } = domNode;
if (scrollLeft > 0) {
const scrollWrapper = document.createElement('div');
scrollWrapper.style.overflow = 'hidden';
scrollWrapper.style.width = `${domNode.clientWidth}px`;
const innerContainer = document.createElement('div');
innerContainer.style.transform = `translateX(-${scrollLeft}px)`;
innerContainer.style.width = `${domNode.scrollWidth}px`;
while (preview.firstChild) innerContainer.append(preview.firstChild);
preview.style.padding = '0';
innerContainer.style.padding =
document.defaultView?.getComputedStyle(domNode).padding ?? '';
scrollWrapper.append(innerContainer);
preview.append(scrollWrapper);
}
const wrapper = document.createElement('div');
wrapper.append(preview);
wrapper.style.display = 'flow-root';
const previous = previews[index - 1]?.domNode.parentElement;
const current = domNode.parentElement;
if (previous && current) {
const distance =
current.getBoundingClientRect().top -
previous.getBoundingClientRect().bottom;
if (distance > 15) wrapper.style.marginTop = `${distance}px`;
}
return wrapper;
});
const calculatePreviewTop = (
editor: Editor,
{
blocks,
element,
}: {
blocks: readonly Element[];
element: Element;
}
): number => {
const child = editor.api.dom.resolveDOMNode(element);
if (!child) return 0;
const editable = editor.api.dom.resolveDOMNode(editor);
const window = child.ownerDocument.defaultView;
if (!editable || !window) return 0;
let firstDomNode: HTMLElement | null = null;
for (const block of blocks) {
firstDomNode = editor.api.dom.resolveDOMNode(block);
if (firstDomNode) break;
}
if (!firstDomNode) return 0;
// Get editor's top padding
const editorPaddingTop = Number(
window.getComputedStyle(editable).paddingTop.replace('px', '')
);
// Calculate distance from first selected node to editor top
const firstNodeToEditorDistance =
firstDomNode.getBoundingClientRect().top -
editable.getBoundingClientRect().top -
editorPaddingTop;
// Get margin top of first selected node
const firstMarginTopString = window.getComputedStyle(firstDomNode).marginTop;
const marginTop = Number(firstMarginTopString.replace('px', ''));
// Calculate distance from current node to editor top
const currentToEditorDistance =
child.getBoundingClientRect().top -
editable.getBoundingClientRect().top -
editorPaddingTop;
const currentMarginTopString = window.getComputedStyle(child).marginTop;
const currentMarginTop = Number(currentMarginTopString.replace('px', ''));
const previewElementsTopDistance =
currentToEditorDistance -
firstNodeToEditorDistance +
marginTop -
currentMarginTop;
return previewElementsTopDistance;
};
const calcDragButtonTop = (editor: Editor, element: Element): number => {
const child = editor.api.dom.resolveDOMNode(element);
const window = child?.ownerDocument.defaultView;
if (!child || !window) return 0;
const currentMarginTopString = window.getComputedStyle(child).marginTop;
const currentMarginTop = Number(currentMarginTopString.replace('px', ''));
return currentMarginTop;
};
const DndIntegration = ({
children,
editableRef,
}: {
children: React.ReactNode;
editableRef: React.RefObject<HTMLDivElement | null>;
}) => {
const [editableElement, setEditableElement] =
React.useState<HTMLDivElement | null>(null);
// oxlint-disable-next-line react-hooks/exhaustive-deps -- The stable ref can receive a different element during any commit.
React.useLayoutEffect(() => {
if (editableElement !== editableRef.current) {
setEditableElement(editableRef.current);
}
});
const { dragDropManager } = React.useContext(DndContext);
useDndPlugin(editableElement);
const hasTableCellSelection = useEditorSelector((innerEditor) => {
const table = innerEditor.plugin(BaseTablePlugin);
return table.installed && (table.read.selection()?.cells.length ?? 0) > 1;
});
const isDragging = usePluginStore(DndPlugin, 'isDragging');
const [active, setActive] = React.useState(false);
const activate = React.useCallback(() => {
setActive(true);
}, []);
React.useEffect(() => {
if (!editableElement) return undefined;
const document = editableElement.ownerDocument;
const deactivate = () => {
setActive(false);
};
document.addEventListener('dragend', deactivate);
document.addEventListener('drop', deactivate);
document.addEventListener('mouseup', deactivate);
return () => {
document.removeEventListener('dragend', deactivate);
document.removeEventListener('drop', deactivate);
document.removeEventListener('mouseup', deactivate);
};
}, [editableElement]);
const interaction = React.useMemo(
() => ({
activate,
active: active || isDragging,
hasTableCellSelection,
}),
[activate, active, hasTableCellSelection, isDragging]
);
return (
<TooltipProvider>
<DndInteractionContext value={interaction}>
{dragDropManager ? (
children
) : (
<DndProvider backend={HTML5Backend}>{children}</DndProvider>
)}
</DndInteractionContext>
</TooltipProvider>
);
};
export const DndKit = [
DndPlugin.configure({
initialState: {
enableScroller: true,
onDropFiles: ({ dragItem, editor, target }) => {
editor
.plugin(PlaceholderPlugin)
.update.insertMedia(dragItem.files, { at: target });
},
},
slots: {
wrapNode: BlockDraggable,
wrapRoot: DndIntegration,
},
}),
];'use client';
import { GripVertical } from 'lucide-react';
import { PathApi, type Element, type Path } from 'platejs';
import {
DndPlugin,
type DropLineDirection,
useDraggable,
useDndPlugin,
useDropLine,
} from 'platejs/dnd/react';
import { BaseColumnItemPlugin } from 'platejs/layout';
import { PlaceholderPlugin } from 'platejs/media/react';
import {
type Editor,
type RenderNodeWrapperDescriptor,
type RenderNodeWrapperProps,
useEditor,
useEditorSelector,
BlockDraggable: Renders drag handles and drop indicators for blocks.import { createEditor } from 'platejs/react';
import { DndKit } from '@/components/editor/dnd';
const editor = createEditor({
plugins: [
// ...otherPlugins,
...DndKit,
],
});import { createEditor } from 'platejs/react';
import { DndKit } from '@/components/editor/dnd';
const editor = createEditor({
DndKit provides the HTML5 backend and binds cleanup to each mounted Editable, including when automatic scrolling is disabled. Render the editor inside EditorRoot:
import { EditorRoot, useCreateEditor } from 'platejs/react';
import { DndKit } from '@/components/editor/dnd';
import { Editor, EditorContainer } from '@/components/editor/editor';
export function DraggableEditor() {
const editor = useCreateEditor({ plugins: DndKit });
return (
<EditorRoot editor={editor}>
<EditorContainer>
<Editor />
</EditorContainer>
</EditorRoot>
);
}Install DndKit and configure behavior through DndPlugin:
import { DndPlugin } from 'platejs/dnd/react';
import { createEditor } from 'platejs/react';
import { DndKit } from '@/components/editor/dnd';
const editor = createEditor({
plugins: [
...DndKit,
DndPlugin.configure({
initialState: { enableScroller: false },
}),
],
});import
To supply a shared or custom manager, place your DndProvider above the editors. Each DndKit inherits that manager; otherwise, the kit supplies the default HTML5 backend.
Add automatic scrolling and file drop handling:
import { DndPlugin } from 'platejs/dnd/react';
import { PlaceholderPlugin } from 'platejs/media/react';
import { BlockDraggable } from '@/components/editor/dnd';
DndPlugin.configure({
initialState: {
enableScroller: true,
onDropFiles: ({ dragItem, editor, target }) => {
editor
.plugin(PlaceholderPlugin)
.update.insertMedia(dragItem.files, { at: target });
},
},
slots: {
wrapNode: BlockDraggable,
},
});enableScroller: Enables automatic scrolling when dragging blocks near the viewport edges.onDropFiles: Handles file drops by inserting them as media blocks at the target location.Plugin for drag and drop functionality within the editor.
Use the plugin to prepare a custom drag handle:
const dnd = editor.plugin(DndPlugin);
const entries = dnd.read.dragEntries(element);
const previews = dnd.api.prepareDrag(element);
for (const { domNode, preview } of previews) {
preview.style.width = `${domNode.clientWidth}px`;
previewContainer.append(preview);
}const dnd = editor.plugin(DndPlugin);
const entries = dnd.read.dragEntries(element);
const previews = dnd.api.prepareDrag(element);
for (const { domNode, preview } of previews) {
preview.style.width = `${domNode.clientWidth}px`;
previewContainer.append(preview);
}read.dragEntries(element) returns the selected blocks when the element is in
the selection, or that element alone otherwise. Nested list items follow their
selected parent. The read does not change selection.
api.prepareDrag(element) selects that payload, records its drag keys and
returns { node, domNode, preview } pairs for mounted blocks. Clones are inert,
hidden from accessibility and contain no Plate DOM bindings. Selected blocks
without mounted views stay in the payload. Read-only or removed elements do not
start preparation. The copied component owns preview placement and styling.
useDndPlugin(editableElement: HTMLElement | null): void attaches drag cleanup to one mounted view's ownerDocument. Pass its exact Editable element, or null before attachment and after detach. The hook releases the old document's listeners when the element changes or the component unmounts.
DndKit calls this hook automatically. Use it directly only for custom integration that owns its own provider and view lifetime.
A custom hook that enables draggable behavior for a given element. It provides a preview for the element, which can be customized or disabled.
The element to make draggable.
Orientation of drag and drop.
'vertical'Type of drag item.
'block'Handler called after the landing passes the drop guards. Return true to handle the drop; otherwise the package performs the move.
Checks every dragged element before a hover indicator or drop callback. Receives dragEntry, dropEntry, dragItem, the target editor, and sourceEditor. Return false to reject the entire payload.
Returns the current drop line state for an element.
import { EditorRoot, useCreateEditor } from 'platejs/react';
import { DndKit } from '@/components/editor/dnd';
import { Editor, EditorContainer } from '@/components/editor/editor';
export function DraggableEditor() {
const editor = useCreateEditor({ plugins: DndKit });
return (
<EditorRoot editor={editor}>
<EditorContainer>
<Editor />
</EditorContainer>
</EditorRoot>
);
}import { DndPlugin } from 'platejs/dnd/react';
import { PlaceholderPlugin } from 'platejs/media/react';
import { BlockDraggable } from '@/components/editor/dnd';
DndPlugin.configure({
initialState: {
enableScroller: true,
onDropFiles: ({ dragItem, editor, target }) => {
editor
.plugin(PlaceholderPlugin)
.update.insertMedia(dragItem.files, { at: target });
},
},
slots: {
wrapNode: BlockDraggable,
},
});