---
title: Block handles
description: The block handles plugin adds a + button and a drag grip beside each block, with drag and drop, keyboard moves, a drop indicator and an activation hook for block menus.
icon: grip-vertical
---

Block handles are the two buttons left of each block, as in Notion: a `+` that adds a block, then the ⋮⋮ grip. Drag the grip to reorder, nest or outdent a block; focus it and use <kbd>Alt</kbd> with the arrow keys to move it; click it to select the block and open a block menu, such as the bundled [block menu](/docs/plugins/block-menu). `<Edytor>` includes them by default.

## Enabling and configuring

The `blockHandles` prop of `<Edytor>` controls the plugin:

```svelte
<script lang="ts">
	import { Edytor, type BlockHandleActivation } from 'edytor';

	const openMenu = ({ block, anchor }: BlockHandleActivation) => {
		console.log('open a menu for', block.id, 'next to', anchor);
	};
</script>

<!-- Default: handles with drag and drop. -->
<Edytor />

<!-- No handles at all. -->
<Edytor blockHandles={false} />

<!-- Handles and keyboard moves, no pointer drag, and a click callback. -->
<Edytor blockHandles={{ draggable: false, onActivate: openMenu }} />
```

| Prop | Type | Default | Description |
| - | - | - | - |
| `blockHandles?` | `boolean \| BlockHandlesOptions` | `true` | false omits the handles. An object configures them. |
| `draggable?` | `boolean` | `true` | false keeps the handle and its keyboard moves, but disables pointer dragging and drop targets. |
| `onActivate?` | `({ block, anchor }: BlockHandleActivation) => void` | - | Called when a grip is clicked, after the block is selected (a block selection that already holds it stays). anchor is the grip element, to position a menu against. Without it, the click dispatches an edytor-block-activate event instead (see below). |
| `handle?` | `Snippet<[BlockHandleSnippetPayload]>` | - | Replaces the + and the grip with your markup. See Custom handle below. |

The configuration is read when the editor mounts. `blockDnd={false}` is a deprecated alias of `blockHandles={false}`; `blockHandles` wins when both are set.

You can also build the plugin yourself and list it in `plugins`: `blockHandlesPlugin` is the default, `createBlockHandlesPlugin(options)` takes the same options as the prop. The editor recognizes either one and does not add a second set of handles. When `blockHandles` is an object, it replaces any handle plugin in the list.

## Handles

A handle sits in the editor's overlay layer, left of its block, aligned with the block's first line of text. For an island block it aligns with the block's header (a direct child marked `use:block.void`, such as the code block's header), and for a void block with the top of the block. Handles follow layout changes, moves and scrolling.

- A handle exists only for blocks that can move, and is hidden while the editor is readonly.
- Handles mount lazily, for blocks within about one screen of the viewport and for blocks that are hovered, selected, focused or being dragged. Large documents do not pay for a handle per block.
- A handle is transparent until you hover its block. On touch devices (no hover), handles are always visible.
- The buttons are gray (`#ada9a3`) with a 6px-rounded hover background.

### The + button

Clicking `+` opens the [slash menu](/docs/plugins/slash-menu#from-the-block-handle) below it and adds nothing yet: the menu has its own search field. Picking a kind inserts a block of it below the block (above with <kbd>Alt</kbd>) with the caret in it, as one undo step. Typing filters the menu, never the document. The kind lands where it fits, as the slash menu places it: beside a list's item, a heading splits the list after the item and a list's own kind adds an item; a divider or code block follows the block. <kbd>Escape</kbd> closes it with the document and the undo history unchanged and the caret where it was, and so do a press outside the menu (the press then places the caret), focus leaving the menu and the editor turning readonly. A plugin's veto of the insertion adds nothing, and `edytor.dispatcher.last` reads `refused`. When the block is already an empty block of the parent's default kind, the picked kind converts it instead of adding another. Without the slash menu, `+` inserts an empty block of the default kind at once and puts the caret in it. The button does nothing in a readonly editor.

The `+` dispatches `BLOCK_ADD_EVENT` (`'edytor-block-add'`, detail `{ block, anchor, insert }`, a `BlockAddition`) on the editor's root. A menu of your own answers it with `event.preventDefault()`, and calls `insert(then?)` once the user picks: it adds the block, puts the caret in it and runs `then`, as one undo step. When `then` answers `false`, or the command it runs is refused (by the document or an extension's veto) before changing anything, `insert` takes the block back, with its undo step and the selection it moved, and answers `false`.

### Custom handle

A `handle` snippet replaces the `+` and the grip of every handle. The plugin keeps placing it beside the block's first line, showing it on hover and mounting it lazily. The snippet receives a `BlockHandleSnippetPayload`:

| Prop | Type | Default | Description |
| - | - | - | - |
| `block?` | `Block` | - | The block's handle. |
| `grip?` | `action` | - | use:grip makes an element the grip: dragging (when draggable), a click that selects the block and opens its menu, and the Alt+arrow moves while it has focus. |
| `add?` | `(above?: boolean) => void` | - | What + does: the slash menu offers what to add below (above with true); nothing is added until a row is picked. |
| `readonly?` | `boolean` | - | The editor is readonly: hide your buttons. |
| `draggable?` | `boolean` | - | Pointer dragging is on. |

```svelte title="Editor.svelte"
<script lang="ts">
	import { Edytor, type BlockHandleSnippetPayload } from 'edytor';
</script>

{#snippet handle({ block, grip, add, readonly }: BlockHandleSnippetPayload)}
	{#if !readonly}
		<button onmousedown={(event) => event.preventDefault()} onclick={(event) => add(event.altKey)}>+</button>
		<button use:grip aria-label={`Move ${block.type} block`}>⠿</button>
	{/if}
{/snippet}

<Edytor blockHandles={{ handle }} />
```

The same snippet works with `createBlockHandlesPlugin({ handle })` in `plugins`. Your markup renders inside the handle's host, `[data-edytor-block-handle-host]` (a flex row right-aligned to the block's left edge, which carries the hover fade), without the buttons' built-in styles. Keep `preventDefault` on the `+` button's `mousedown`, so the click does not move the caret first.

## Drag and drop

Drag and drop uses Atlassian's Pragmatic drag and drop. While you drag, each block is a drop target split in three bands by the height of its own row (not its children):

| Pointer position            | Drop                                |
| --------------------------- | ----------------------------------- |
| Top quarter                 | Before the block                    |
| Middle                      | Inside it, as its last child        |
| Bottom quarter              | After the block                     |
| Left edge of a nested block (20px) | Beside its parent, one level out |

Only placements the document allows are offered: when "inside" is not allowed, the block splits between before and after. A short gap between blocks keeps the last valid placement, so the indicator does not flicker.

Dragging the handle of a block that is part of a block selection of siblings moves the whole group, in document order. A selected block's selected descendants (a <kbd>Shift</kbd> + <kbd>↓</kbd> selection past a parent holds its children) ride with it rather than counting as siblings. After a drop, the moved blocks are selected. Each drop is one undo step.

### The drop indicator

The indicator is Notion's plain 4px bar, drawn in the overlay rather than on the block, so rounded or padded block styles cannot bend it:

- **Before or after**: the bar spans the blocks at the drop slot. Between two siblings it is centered in the gap and spans both blocks, so hovering the bottom of one block and the top of the next shows the same bar.
- **Inside**: the same bar where the new child will land (after the target's last visible child, else after its own row), indented to the child's column and running to the target's right edge.

Set its color with `--edytor-drop-indicator-color` on the editor or any ancestor of the blocks (the default is Notion's `rgba(35, 131, 226, 0.43)`):

```css
[data-edytor] {
	--edytor-drop-indicator-color: #2eaadc;
}
```

## Keyboard moves

While a handle has focus, <kbd>Alt</kbd> with an arrow key moves its block:

| Keys                           | Move                                                                 |
| ------------------------------ | -------------------------------------------------------------------- |
| <kbd>Alt</kbd> + <kbd>↑</kbd>    | Up: before the previous sibling, or before the parent from a first child |
| <kbd>Alt</kbd> + <kbd>↓</kbd>    | Down: after the next sibling, or after the parent from a last child  |
| <kbd>Alt</kbd> + <kbd>→</kbd>    | In: the last child of the previous sibling                           |
| <kbd>Alt</kbd> + <kbd>←</kbd>    | Out: after the parent (a list's item leaves the list, [as Shift+Tab](/docs/customization/hotkeys#enter-and-backspace-by-role)) |

The handle claims every <kbd>Alt</kbd>+arrow while it has focus, and the moved block stays selected. A move never puts a block directly in a list it is no item of, but a block already there (an image a merge left among the items) moves among them like an item, by key, menu or drag. A move never hides a block you saw: moving a block into a closed toggle, with <kbd>Alt</kbd> + <kbd>→</kbd> or a drop inside it, opens the toggle, as <kbd>Tab</kbd> does. To move the caret's block from the text, or a block selection, see [Arrow move](/docs/plugins/arrow-move).

## Building a block menu

The [block menu plugin](/docs/plugins/block-menu) is a complete Notion-style menu: list `blockMenuPlugin` and leave `onActivate` unset. When no `onActivate` is passed, a grip click dispatches a DOM event on the editor's root, which that plugin (or your own) listens for:

```ts
import { BLOCK_ACTIVATE_EVENT, type BlockActivation, type Plugin } from 'edytor';

export const myMenuPlugin: Plugin = () => ({
	onEdytorAttached: ({ node }) => {
		const open = (event: Event) => {
			const { block, anchor } = (event as CustomEvent<BlockActivation>).detail;
			// Open a menu for block, next to anchor.
		};
		node.addEventListener(BLOCK_ACTIVATE_EVENT, open);
		return () => node.removeEventListener(BLOCK_ACTIVATE_EVENT, open);
	}
});
```

To build your own menu instead, pass `onActivate`. It gives you the block and the grip element; the click has already selected the block. A typical menu converts or moves the block with the editor's commands and then returns the caret to the text:

```ts
import type { BlockHandleActivation } from 'edytor';

const onActivate = async ({ block, anchor }: BlockHandleActivation) => {
	const { edytor } = block;
	const rect = anchor.getBoundingClientRect();
	// Show your menu at rect.right, rect.top, listing edytor.kinds and move actions.
	// For example, "Turn into heading 2": the kind's command converts what the
	// click selected, a selected list's items too (a list itself never converts).
	await edytor.runCommand('block.heading2');
	// Or "Move down":
	if (edytor.canMoveBlocks({ blocks: [block], direction: 'down' })) {
		edytor.moveBlocks({ blocks: [block], direction: 'down' });
	}
};
```

The same `edytor.moveBlocks` and `edytor.canMoveBlocks` calls move blocks from anywhere, with `direction` (`up`, `down`, `in`, `out`) or with a `target` and `position` (`before`, `after`, `inside`). Like the handles, `moveBlocks` opens a closed toggle the blocks land in, or that takes blocks as children, so a moved block is never hidden. See [Commands](/docs/editor/commands).

A click leaves a block selection behind, and a DOM range alone does not replace it. To put the caret back in the block after a menu action, call `setAtTextOffset` once — it replaces the block selection and the projector draws it after the flush — then focus the editor. A kind whose own content is not displayed (a list container) takes the caret in its first child:

```ts
const text = block.firstText ?? block.children[0]?.firstText;
if (text) {
	edytor.selection.setAtTextOffset(text, 0);
	edytor.node?.focus({ preventScroll: true });
}
```
