Skip to main content

Overview

The Block DnD components provide drag-and-drop functionality for reordering blocks in your editor. Built on top of @dnd-kit, it integrates seamlessly with the renderBlock API to keep DnD logic outside the core editor.
Block Drag and Drop in action

Features

  • @dnd-kit powered — industry-standard drag and drop library
  • Multi-block selection — drag multiple selected blocks at once
  • Visual feedback — drop indicators and drag overlays
  • Accessible — keyboard support built-in
  • Customizable — style drag handles and drop indicators
  • Non-invasive — uses renderBlock API, no core editor changes

Installation

npm install @yoopta/ui
# or
yarn add @yoopta/ui
The @yoopta/ui package includes @dnd-kit as a dependency.

Quick Start

import { useMemo, useCallback } from 'react';
import { createYooptaEditor, YooptaEditor, type RenderBlockProps } from '@yoopta/editor';
import { BlockDndContext, SortableBlock, DragHandle } from '@yoopta/ui/block-dnd';

function MyEditor() {
  const editor = useMemo(
    () => createYooptaEditor({ plugins, marks }),
    [],
  );

  const renderBlock = useCallback(({ children, blockId }: RenderBlockProps) => (
    <SortableBlock id={blockId} useDragHandle>
      <DragHandle blockId={blockId}>
        <GripVertical size={16} />
      </DragHandle>
      {children}
    </SortableBlock>
  ), []);

  return (
    <BlockDndContext editor={editor}>
      <YooptaEditor
        editor={editor}
        onChange={(value) => console.log(value)}
        placeholder="Type / to open menu..."
        renderBlock={renderBlock}
      >
        {/* Other UI components */}
      </YooptaEditor>
    </BlockDndContext>
  );
}

How It Works

The Block DnD system consists of three main parts:
  1. BlockDndContext — Wraps the editor, provides DnD context and handles block reordering
  2. SortableBlock — Wrapper for each block that makes it sortable. When using DragHandle, set useDragHandle={true} to prevent the entire block from being draggable
  3. DragHandle — The draggable trigger element. Must be used with SortableBlock that has useDragHandle={true}
The renderBlock prop on YooptaEditor allows you to wrap each block with DnD components without modifying the core editor. Important: When using DragHandle, you must set useDragHandle={true} on SortableBlock. This ensures that drag listeners are only applied to the handle, not the entire block.

API Reference

BlockDndContext

The root provider that wraps your editor and handles drag events.
<BlockDndContext
  editor={editor}
  onDragStart={(event) => console.log('Drag started', event)}
  onDragEnd={(event) => console.log('Drag ended', event)}
>
  <YooptaEditor ... />
</BlockDndContext>
Props:
PropTypeDescription
editorYooEditorThe Yoopta editor instance
childrenReactNodeEditor and other components
onDragStart(event: DragStartEvent) => voidCalled when drag starts
onDragEnd(event: DragEndEvent) => voidCalled when drag ends

SortableBlock

Wrapper component that makes a block sortable.
<SortableBlock
  id={blockId}
  disabled={isReadOnly}
  className="my-sortable-block"
>
  {children}
</SortableBlock>
Props:
PropTypeDescription
idstringBlock ID (required)
childrenReactNodeBlock content
disabledbooleanDisable sorting for this block
classNamestringCustom CSS classes
useDragHandlebooleanIf true, listeners won’t be applied to the block (use DragHandle instead). Required when using DragHandle

DragHandle

The draggable trigger component. Users drag this to move blocks.
<DragHandle
  blockId={blockId}
  className="my-drag-handle"
>
  <GripVertical size={16} />
</DragHandle>
Props:
PropTypeDescription
blockIdstring | nullBlock ID this handle controls
childrenReactNodeHandle content (usually an icon)
classNamestringCustom CSS classes
onClick(e: MouseEvent) => voidCalled when handle is clicked (not dragged)
asChildbooleanIf true, merges props and event handlers with the child element (useful for integrating with other button components)

useBlockDnd

Hook for accessing DnD state and utilities.
const {
  activeId,
  activeBlock,
  isDragging,
  draggedIds,
  getOrderedBlockIds,
} = useBlockDnd({ editor });
Returns:
PropertyTypeDescription
activeIdstring | nullCurrently dragged block ID
activeBlockYooptaBlockData | nullCurrently dragged block data
isDraggingbooleanWhether a drag is in progress
draggedIdsstring[]All block IDs being dragged (for multi-select)

useBlockDndContext

Hook to access the DnD context value directly.
const { activeId, activeBlock, isDragging, draggedIds, editor } = useBlockDndContext();

getOrderedBlockIds

Utility function to get block IDs sorted by their order.
import { getOrderedBlockIds } from '@yoopta/ui/block-dnd';

const orderedIds = getOrderedBlockIds(editor);
// ['block-1', 'block-2', 'block-3', ...]

Examples

Basic Setup

import { useMemo, useCallback } from 'react';
import { createYooptaEditor, YooptaEditor, type RenderBlockProps } from '@yoopta/editor';
import { BlockDndContext, SortableBlock, DragHandle } from '@yoopta/ui/block-dnd';
import { GripVertical } from 'lucide-react';

function Editor() {
  const editor = useMemo(() => createYooptaEditor({
    plugins: PLUGINS,
    marks: MARKS,
  }), []);

  const renderBlock = useCallback(({ children, blockId }: RenderBlockProps) => (
    <SortableBlock id={blockId} useDragHandle>
      <div className="flex items-start gap-2">
        <DragHandle blockId={blockId} className="opacity-0 group-hover:opacity-100">
          <GripVertical size={16} className="text-gray-400" />
        </DragHandle>
        <div className="flex-1">{children}</div>
      </div>
    </SortableBlock>
  ), []);

  return (
    <BlockDndContext editor={editor}>
      <YooptaEditor
        editor={editor}
        onChange={(value) => console.log(value)}
        placeholder="Type / to open menu..."
        renderBlock={renderBlock}
      />
    </BlockDndContext>
  );
}

With FloatingBlockActions

Integrate DnD with the existing FloatingBlockActions component:
import { FloatingBlockActions } from '@yoopta/ui/floating-block-actions';
import { BlockDndContext, SortableBlock, DragHandle } from '@yoopta/ui/block-dnd';

function Editor() {
  const editor = useMemo(
    () => createYooptaEditor({ plugins, marks }),
    [],
  );

  const renderBlock = useCallback(({ children, blockId }: RenderBlockProps) => (
    <SortableBlock id={blockId} useDragHandle>
      {children}
    </SortableBlock>
  ), []);

  return (
    <BlockDndContext editor={editor}>
      <YooptaEditor editor={editor} onChange={onChange} placeholder="Type / to open menu..." renderBlock={renderBlock}>
        <FloatingBlockActions>
          {({ blockId }) => (
            <>
              <FloatingBlockActions.Button onClick={() => onPlusClick(blockId)}>
                <PlusIcon />
              </FloatingBlockActions.Button>
              {/* Use DragHandle with asChild to integrate with FloatingBlockActions.Button */}
              <DragHandle blockId={blockId} asChild>
                <FloatingBlockActions.Button title="Drag to reorder">
                  <GripVertical size={16} />
                </FloatingBlockActions.Button>
              </DragHandle>
            </>
          )}
        </FloatingBlockActions>
      </YooptaEditor>
    </BlockDndContext>
  );
}

Multi-Block Drag

When blocks are selected (via Shift+Click or SelectionBox), dragging one will move all selected blocks:
function Editor() {
  const editor = useMemo(
    () => createYooptaEditor({ plugins, marks }),
    [],
  );

  const renderBlock = useCallback(({ children, blockId, block }: RenderBlockProps) => {
    // Check if this block is in the multi-selection
    const selectedPaths = editor.path.selected;
    const isSelected = selectedPaths?.includes(block.meta.order);

    return (
      <SortableBlock id={blockId} useDragHandle>
        <div className={isSelected ? 'ring-2 ring-blue-500' : ''}>
          <DragHandle blockId={blockId}>
            <GripVertical size={16} />
          </DragHandle>
          {children}
        </div>
      </SortableBlock>
    );
  }, [editor]);

  return (
    <BlockDndContext editor={editor}>
      <YooptaEditor editor={editor} onChange={onChange} renderBlock={renderBlock}>
        <SelectionBox />
      </YooptaEditor>
    </BlockDndContext>
  );
}

Read-Only Mode

Disable DnD in read-only mode:
function Editor({ readOnly }) {
  const editor = useMemo(
    () => createYooptaEditor({ plugins, marks, readOnly }),
    [readOnly],
  );

  const renderBlock = useCallback(({ children, blockId }: RenderBlockProps) => (
    <SortableBlock id={blockId} disabled={readOnly} useDragHandle={!readOnly}>
      {!readOnly && (
        <DragHandle blockId={blockId}>
          <GripVertical size={16} />
        </DragHandle>
      )}
      {children}
    </SortableBlock>
  ), [readOnly]);

  return (
    <BlockDndContext editor={editor}>
      <YooptaEditor editor={editor} onChange={onChange} renderBlock={renderBlock} />
    </BlockDndContext>
  );
}

Styling

CSS Variables

:root {
  /* Drag handle */
  --yoopta-block-dnd-handle-opacity: 0;
  --yoopta-block-dnd-handle-hover-opacity: 1;
  --yoopta-block-dnd-handle-color: var(--yoopta-ui-muted-foreground);
  --yoopta-block-dnd-focus-ring: #3b82f6;

  /* Drop indicator */
  --yoopta-block-dnd-indicator-color: #3b82f6;
  --yoopta-block-dnd-indicator-height: 2px;

  /* Dragging state */
  --yoopta-block-dnd-dragging-opacity: 0.5;
  --yoopta-block-dnd-dragging-bg: var(--yoopta-ui-accent);

  /* Drag overlay */
  --yoopta-block-dnd-overlay-bg: #ffffff;
  --yoopta-block-dnd-overlay-border: #e5e7eb;
  --yoopta-block-dnd-overlay-text: #374151;
  --yoopta-block-dnd-overlay-text-accent: #1f2937;
  --yoopta-block-dnd-overlay-icon-color: #6b7280;
}

Custom Styles

<SortableBlock id={blockId} className="group relative" useDragHandle>
  <DragHandle
    blockId={blockId}
    className="absolute -left-8 top-1 opacity-0 group-hover:opacity-100 transition-opacity"
  >
    <GripVertical className="w-4 h-4 text-gray-400 hover:text-gray-600" />
  </DragHandle>
  {children}
</SortableBlock>

The renderBlock API

The renderBlock prop on YooptaEditor is the key to integrating DnD without modifying the core editor.
export type RenderBlockProps = {
  /** The block data */
  block: YooptaBlockData;
  /** The rendered block content */
  children: ReactNode;
  /** The block's unique ID */
  blockId: string;
  /** The block's order index */
  index: number;
};

<YooptaEditor
  editor={editor}
  onChange={onChange}
  placeholder="Type / to open menu..."
  renderBlock={({ children, blockId }: RenderBlockProps) => (
    <SortableBlock id={blockId}>
      {children}
    </SortableBlock>
  )}
/>
This pattern allows you to:
  • Add DnD support
  • Add custom block wrappers
  • Implement block-level features without forking the editor

Best Practices

Always wrap renderBlock in useCallback to prevent unnecessary re-renders:
const renderBlock = useCallback(({ children, blockId }: RenderBlockProps) => (
  <SortableBlock id={blockId} useDragHandle>{children}</SortableBlock>
), []);
The context must wrap the entire editor:
// Correct
<BlockDndContext editor={editor}>
  <YooptaEditor editor={editor} renderBlock={renderBlock}>
    {/* UI components */}
  </YooptaEditor>
</BlockDndContext>

// Wrong - context inside editor
<YooptaEditor editor={editor}>
  <BlockDndContext editor={editor}>
    {/* ... */}
  </BlockDndContext>
</YooptaEditor>
Disable drag handles and sorting in read-only mode:
const isReadOnly = useYooptaReadOnly();

<SortableBlock id={blockId} disabled={isReadOnly} useDragHandle={!isReadOnly}>
  {!isReadOnly && <DragHandle blockId={blockId}>...</DragHandle>}
  {children}
</SortableBlock>

TypeScript

Full type definitions are exported:
import type {
  BlockDndContextProps,
  BlockDndContextValue,
  SortableBlockData,
  SortableBlockProps,
  DragHandleProps,
  DropIndicatorProps,
  UseBlockDndOptions,
  UseBlockDndReturn,
} from '@yoopta/ui/block-dnd';