Skip to main content
The DragHandle extension adds a draggable handle to blocks in your editor, allowing users to reorder content by dragging. It uses Floating UI for intelligent positioning and supports nested content like list items and blockquotes.

Installation

npm install @tiptap/extension-drag-handle

Basic Usage

import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { DragHandle } from '@tiptap/extension-drag-handle'

const editor = new Editor({
  extensions: [
    StarterKit,
    DragHandle.configure({
      render() {
        const element = document.createElement('div')
        element.classList.add('drag-handle')
        element.innerHTML = '⋮⋮'
        return element
      },
    }),
  ],
})

Styling the Drag Handle

.drag-handle {
  position: absolute;
  cursor: grab;
  padding: 4px;
  color: #999;
  background: white;
  border: 1px solid #ddd;
  border-radius: 4px;
  user-select: none;
}

.drag-handle:hover {
  color: #333;
  background: #f5f5f5;
}

.drag-handle:active {
  cursor: grabbing;
}

Configuration Options

render
() => HTMLElement
required
Function that renders and returns the drag handle element.
DragHandle.configure({
  render() {
    const element = document.createElement('div')
    element.classList.add('drag-handle')
    element.innerHTML = `
      <svg width="10" height="10">
        <circle cx="5" cy="5" r="1" fill="currentColor"/>
        <circle cx="5" cy="2" r="1" fill="currentColor"/>
        <circle cx="5" cy="8" r="1" fill="currentColor"/>
      </svg>
    `
    return element
  },
})
computePositionConfig
ComputePositionConfig
Configuration for position computation using the Floating UI library. See Floating UI documentation for details.Default:
{
  placement: 'left-start',
  strategy: 'absolute',
}
Example:
DragHandle.configure({
  render() { /* ... */ },
  computePositionConfig: {
    placement: 'right-start',
    strategy: 'fixed',
  },
})
getReferencedVirtualElement
() => VirtualElement | null
Function that returns a virtual element for positioning. Useful when the drag handle needs to be positioned relative to a specific DOM element.
DragHandle.configure({
  render() { /* ... */ },
  getReferencedVirtualElement: () => ({
    getBoundingClientRect: () => customElement.getBoundingClientRect(),
  }),
})
locked
boolean
Whether the drag handle is locked in place and visibility.Default: false
DragHandle.configure({
  render() { /* ... */ },
  locked: true, // Handle stays visible and in position
})
onNodeChange
function
Callback function called when a node is hovered over or unhovered.Parameters:
  • node: The hovered node (or null if unhovered)
  • editor: The editor instance
DragHandle.configure({
  render() { /* ... */ },
  onNodeChange: ({ node, editor }) => {
    if (node) {
      console.log('Hovering over:', node.type.name)
    } else {
      console.log('No longer hovering')
    }
  },
})
onElementDragStart
(e: DragEvent) => void
Callback fired when dragging starts.
DragHandle.configure({
  render() { /* ... */ },
  onElementDragStart: (e) => {
    console.log('Drag started')
    document.body.classList.add('is-dragging')
  },
})
onElementDragEnd
(e: DragEvent) => void
Callback fired when dragging ends.
DragHandle.configure({
  render() { /* ... */ },
  onElementDragEnd: (e) => {
    console.log('Drag ended')
    document.body.classList.remove('is-dragging')
  },
})
nested
boolean | NestedOptions
Enable drag handles for nested content like list items and blockquotes.Default: falseSimple enable:
DragHandle.configure({
  render() { /* ... */ },
  nested: true,
})
With configuration:
DragHandle.configure({
  render() { /* ... */ },
  nested: {
    allowedContainers: ['bulletList', 'orderedList'],
    edgeDetection: 'left',
    defaultRules: true,
  },
})
Nested Options:
nested.rules
DragHandleRule[]
Custom rules to determine which nodes are draggable. These run after the default rules.
nested: {
  rules: [{
    id: 'excludeCodeBlocks',
    evaluate: ({ node }) => {
      return node.type.name === 'codeBlock' ? 1000 : 0
    },
  }],
}
nested.defaultRules
boolean
Whether to include default rules for common cases like list items.Default: true
nested.allowedContainers
string[]
Restrict nested dragging to specific container types.
nested: {
  allowedContainers: ['bulletList', 'orderedList', 'blockquote'],
}
nested.edgeDetection
'left' | 'right' | 'both' | 'none'
Control when to prefer parent over nested node based on cursor position.
  • 'left' (default): Prefer parent near left/top edges
  • 'right': Prefer parent near right/top edges (for RTL)
  • 'both': Prefer parent near any horizontal edge
  • 'none': Disable edge detection
Or pass a config object:
nested: {
  edgeDetection: {
    edges: ['left', 'top'],
    threshold: 12,  // pixels from edge
    strength: 500,  // scoring strength
  },
}

Commands

lockDragHandle
command
Lock the drag handle in place and visibility.
editor.commands.lockDragHandle()
unlockDragHandle
command
Unlock the drag handle.
editor.commands.unlockDragHandle()
toggleDragHandle
command
Toggle the drag handle lock state.
editor.commands.toggleDragHandle()

Advanced Examples

Custom Drag Handle with Icon

DragHandle.configure({
  render() {
    const element = document.createElement('div')
    element.classList.add('custom-drag-handle')
    element.innerHTML = `
      <svg width="20" height="20" viewBox="0 0 20 20">
        <path d="M7 2a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 2zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 8zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 14zm6-8a2 2 0 1 0-.001-4.001A2 2 0 0 0 13 6zm0 2a2 2 0 1 0 .001 4.001A2 2 0 0 0 13 8zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 13 14z" fill="currentColor"/>
      </svg>
    `
    return element
  },
})

Track Drag Events

DragHandle.configure({
  render() {
    const element = document.createElement('div')
    element.classList.add('drag-handle')
    element.innerHTML = '⋮⋮'
    return element
  },
  onElementDragStart: (e) => {
    console.log('Started dragging')
    // Add visual feedback
    document.body.classList.add('is-dragging')
  },
  onElementDragEnd: (e) => {
    console.log('Finished dragging')
    // Remove visual feedback
    document.body.classList.remove('is-dragging')
    // Track analytics
    analytics.track('block_reordered')
  },
  onNodeChange: ({ node }) => {
    if (node) {
      console.log('Hovering node:', node.type.name)
    }
  },
})

Enable Nested Dragging for Lists

DragHandle.configure({
  render() {
    const element = document.createElement('div')
    element.classList.add('drag-handle')
    element.innerHTML = '⋮⋮'
    return element
  },
  nested: {
    allowedContainers: ['bulletList', 'orderedList'],
    edgeDetection: 'left',
  },
})

Custom Rules for Nested Dragging

DragHandle.configure({
  render() { /* ... */ },
  nested: {
    rules: [
      {
        id: 'preferListItems',
        evaluate: ({ node }) => {
          // Prefer list items over lists
          if (node.type.name === 'listItem') return -100
          if (node.type.name === 'bulletList') return 100
          return 0
        },
      },
      {
        id: 'excludeCodeBlocks',
        evaluate: ({ node }) => {
          // Make code blocks undraggable
          if (node.type.name === 'codeBlock') return 1000
          return 0
        },
      },
    ],
    defaultRules: true, // Keep default rules
  },
})

Right-Side Placement (RTL)

DragHandle.configure({
  render() { /* ... */ },
  computePositionConfig: {
    placement: 'right-start',
    strategy: 'absolute',
  },
  nested: {
    edgeDetection: 'right', // Prefer parent near right edge
  },
})

Lock/Unlock Programmatically

const editor = new Editor({
  extensions: [
    StarterKit,
    DragHandle.configure({
      render() { /* ... */ },
    }),
  ],
})

// Lock handle during certain operations
function performSensitiveOperation() {
  editor.commands.lockDragHandle()
  
  // Do operation...
  
  editor.commands.unlockDragHandle()
}

// Toggle lock with button
document.querySelector('#toggle-drag').addEventListener('click', () => {
  editor.commands.toggleDragHandle()
})

React Integration

import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { DragHandle } from '@tiptap/extension-drag-handle'
import { useRef } from 'react'

function Editor() {
  const dragHandleRef = useRef<HTMLDivElement>(null)
  
  const editor = useEditor({
    extensions: [
      StarterKit,
      DragHandle.configure({
        render() {
          const element = document.createElement('div')
          element.className = 'drag-handle'
          element.innerHTML = '⋮⋮'
          return element
        },
        onNodeChange: ({ node }) => {
          if (node) {
            console.log('Hovering:', node.type.name)
          }
        },
      }),
    ],
  })
  
  return (
    <div>
      <button onClick={() => editor?.commands.toggleDragHandle()}>
        Toggle Drag Handle Lock
      </button>
      <EditorContent editor={editor} />
    </div>
  )
}

Styling Examples

Animated Drag Handle

.drag-handle {
  position: absolute;
  cursor: grab;
  padding: 4px 6px;
  color: #999;
  background: white;
  border: 1px solid #e0e0e0;
  border-radius: 4px;
  transition: all 0.15s ease;
  opacity: 0;
}

/* Show on hover */
*:hover > .drag-handle {
  opacity: 1;
}

.drag-handle:hover {
  color: #333;
  background: #f5f5f5;
  transform: scale(1.1);
}

.drag-handle:active {
  cursor: grabbing;
  transform: scale(1);
}

/* When dragging */
body.is-dragging .drag-handle {
  opacity: 0.5;
}

Nested Content Styling

/* Different styles for nested items */
.ProseMirror li > .drag-handle {
  left: -30px;
}

.ProseMirror blockquote > .drag-handle {
  left: -35px;
  color: #666;
}

Source Code

View the source code on GitHub: