---
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 `+` inserts a new block of the parent's default kind below the block (above with <kbd>Alt</kbd>), puts the caret in it and types `/`, so the [slash menu](/docs/plugins/slash-menu) opens there. When the block is already an empty block of that default kind, it takes the `/` itself instead of adding another. The button does nothing in a readonly editor.

### 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: a new block below (above with true), opened on the slash menu. |
| `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                                                |

The handle claims every <kbd>Alt</kbd>+arrow while it has focus, and the moved block stays selected. 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 { convertToKind, type BlockHandleActivation } from 'edytor';

const onActivate = ({ 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":
	// `true` puts the caret at the start of the converted block.
	const row = edytor.kinds.find((kind) => kind.id === 'block.heading2');
	if (row) convertToKind(edytor, block, row, true);
	// 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`). 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 });
}
```
