---
title: "EditorToolbar"
description: "A customizable toolbar for editor actions that can be displayed as fixed, bubble, or floating menu."
canonical_url: "https://vinicunca.dev/pohon/components/editor-toolbar"
---
# EditorToolbar

> A customizable toolbar for editor actions that can be displayed as fixed, bubble, or floating menu.

## Usage

The EditorToolbar component displays a toolbar of formatting buttons that automatically sync their active state with the editor content. It supports three layout modes using the `@tiptap/vue-3/menus` package:

- `fixed` (always visible)
- `bubble` (appears on text selection)
- `floating` (appears on empty lines)

> [!CAUTION]
> 
> It must be used inside an [Editor](https://vinicunca.dev/pohon/components/editor) component's default slot to have access to the editor instance.

```vue [EditorToolbarExample.vue]
<script setup lang="ts">
import type { EditorToolbarItem } from 'pohon-ui';

const value = ref(`# Toolbar

Select some text to see the formatting toolbar appear above your selection.`);

const items: Array<Array<EditorToolbarItem>> = [[{
  icon: 'i-lucide-heading',
  tooltip: { text: 'Headings' },
  content: {
    align: 'start',
  },
  items: [{
    kind: 'heading',
    level: 1,
    icon: 'i-lucide-heading-1',
    label: 'Heading 1',
  }, {
    kind: 'heading',
    level: 2,
    icon: 'i-lucide-heading-2',
    label: 'Heading 2',
  }, {
    kind: 'heading',
    level: 3,
    icon: 'i-lucide-heading-3',
    label: 'Heading 3',
  }, {
    kind: 'heading',
    level: 4,
    icon: 'i-lucide-heading-4',
    label: 'Heading 4',
  }],
}], [{
  kind: 'mark',
  mark: 'bold',
  icon: 'i-lucide-bold',
  tooltip: { text: 'Bold' },
}, {
  kind: 'mark',
  mark: 'italic',
  icon: 'i-lucide-italic',
  tooltip: { text: 'Italic' },
}, {
  kind: 'mark',
  mark: 'underline',
  icon: 'i-lucide-underline',
  tooltip: { text: 'Underline' },
}, {
  kind: 'mark',
  mark: 'strike',
  icon: 'i-lucide-strikethrough',
  tooltip: { text: 'Strikethrough' },
}, {
  kind: 'mark',
  mark: 'code',
  icon: 'i-lucide-code',
  tooltip: { text: 'Code' },
}]];
</script>

<template>
  <PEditor
    v-slot="{ editor }"
    v-model="value"
    content-type="markdown"
    class="min-h-21 w-full"
  >
    <PEditorToolbar
      :editor="editor"
      :items="items"
      layout="bubble"
    />
  </PEditor>
</template>
```

> [!NOTE]
> 
> The bubble and floating layouts use TipTap's [BubbleMenu](https://tiptap.dev/docs/editor/extensions/functionality/bubble-menu) and [FloatingMenu](https://tiptap.dev/docs/editor/extensions/functionality/floatingmenu) extensions.

### Items

Use the `items` prop as an array of objects with the following properties:

- `label?: string`
- `icon?: string`
- `color?: "error" | "primary" | "secondary" | "success" | "info" | "warning" | "neutral"`
- `activeColor?: "error" | "primary" | "secondary" | "success" | "info" | "warning" | "neutral"`
- `variant?: "solid" | "outline" | "soft" | "ghost" | "link" | "subtle"`
- `activeVariant?: "solid" | "outline" | "soft" | "ghost" | "link" | "subtle"`
- `size?: "xs" | "sm" | "md" | "lg" | "xl"`
- [`kind?: "mark" | "textAlign" | "heading" | "link" | "image" | "blockquote" | "bulletList" | "orderedList" | "taskList" | "codeBlock" | "horizontalRule" | "paragraph" | "undo" | "redo" | "clearFormatting" | "duplicate" | "delete" | "moveUp" | "moveDown" | "suggestion" | "mention" | "emoji"`](https://vinicunca.dev/pohon/components/editor#handlers)
- `disabled?: boolean`
- `loading?: boolean`
- `active?: boolean`
- `tooltip?: TooltipProps`
- [`slot?: string`](#with-link-popover)
- `onClick?: (e: MouseEvent) => void`
- `items?: EditorToolbarItem[] | EditorToolbarItem[][]`
- `class?: any`

You can pass any property from the [Button](https://vinicunca.dev/pohon/components/button#props) component such as `color`, `variant`, `size`, etc.

```vue [EditorToolbarItemsExample.vue]
<script setup lang="ts">
import type { EditorToolbarItem } from 'pohon-ui';
import { TextAlign } from '@tiptap/extension-text-align';

const value = ref(`This toolbar showcases **all available formatting options** using built-in handlers. Try the different controls to see them in action!

You can apply **bold**, *italic*, <u>underline</u>, ~~strikethrough~~, and \`inline code\` formatting to your text.
`);

const items: Array<Array<EditorToolbarItem>> = [
  // History controls
  [{
    kind: 'undo',
    icon: 'i-lucide-undo',
    tooltip: { text: 'Undo' },
  }, {
    kind: 'redo',
    icon: 'i-lucide-redo',
    tooltip: { text: 'Redo' },
  }],
  // Block types
  [{
    icon: 'i-lucide-heading',
    tooltip: { text: 'Headings' },
    content: {
      align: 'start',
    },
    items: [{
      kind: 'heading',
      level: 1,
      icon: 'i-lucide-heading-1',
      label: 'Heading 1',
    }, {
      kind: 'heading',
      level: 2,
      icon: 'i-lucide-heading-2',
      label: 'Heading 2',
    }, {
      kind: 'heading',
      level: 3,
      icon: 'i-lucide-heading-3',
      label: 'Heading 3',
    }, {
      kind: 'heading',
      level: 4,
      icon: 'i-lucide-heading-4',
      label: 'Heading 4',
    }],
  }, {
    icon: 'i-lucide-list',
    tooltip: { text: 'Lists' },
    content: {
      align: 'start',
    },
    items: [{
      kind: 'bulletList',
      icon: 'i-lucide-list',
      label: 'Bullet List',
    }, {
      kind: 'orderedList',
      icon: 'i-lucide-list-ordered',
      label: 'Ordered List',
    }],
  }, {
    kind: 'blockquote',
    icon: 'i-lucide-text-quote',
    tooltip: { text: 'Blockquote' },
  }, {
    kind: 'codeBlock',
    icon: 'i-lucide-square-code',
    tooltip: { text: 'Code Block' },
  }, {
    kind: 'horizontalRule',
    icon: 'i-lucide-separator-horizontal',
    tooltip: { text: 'Horizontal Rule' },
  }],
  // Text formatting
  [{
    kind: 'mark',
    mark: 'bold',
    icon: 'i-lucide-bold',
    tooltip: { text: 'Bold' },
  }, {
    kind: 'mark',
    mark: 'italic',
    icon: 'i-lucide-italic',
    tooltip: { text: 'Italic' },
  }, {
    kind: 'mark',
    mark: 'underline',
    icon: 'i-lucide-underline',
    tooltip: { text: 'Underline' },
  }, {
    kind: 'mark',
    mark: 'strike',
    icon: 'i-lucide-strikethrough',
    tooltip: { text: 'Strikethrough' },
  }, {
    kind: 'mark',
    mark: 'code',
    icon: 'i-lucide-code',
    tooltip: { text: 'Code' },
  }],
  // Link
  [{
    kind: 'link',
    icon: 'i-lucide-link',
    tooltip: { text: 'Link' },
  }],
  // Text alignment
  [{
    icon: 'i-lucide-align-justify',
    tooltip: { text: 'Text Align' },
    content: {
      align: 'end',
    },
    items: [{
      kind: 'textAlign',
      align: 'left',
      icon: 'i-lucide-align-left',
      label: 'Align Left',
    }, {
      kind: 'textAlign',
      align: 'center',
      icon: 'i-lucide-align-center',
      label: 'Align Center',
    }, {
      kind: 'textAlign',
      align: 'right',
      icon: 'i-lucide-align-right',
      label: 'Align Right',
    }, {
      kind: 'textAlign',
      align: 'justify',
      icon: 'i-lucide-align-justify',
      label: 'Align Justify',
    }],
  }],
];
</script>

<template>
  <PEditor
    v-slot="{ editor }"
    v-model="value"
    content-type="markdown"
    :extensions="[TextAlign.configure({ types: ['heading', 'paragraph'] })]"
    class="flex flex-col gap-4 min-h-37 w-full"
  >
    <PEditorToolbar
      :editor="editor"
      :items="items"
      class="overflow-x-auto sm:px-8"
    />
  </PEditor>
</template>
```

> [!NOTE]
> 
> You can also pass an array of arrays to the `items` prop to create separated groups of items.

> [!TIP]
> 
> Each item can take an `items` array of objects with the same properties as the `items` prop to create a [DropdownMenu](https://vinicunca.dev/pohon/components/dropdown-menu).

### Layout

Use the `layout` prop to change how the toolbar is displayed. Defaults to `fixed`.

```vue [EditorToolbarLayoutExample.vue]
<script setup lang="ts">
import type { EditorToolbarItem } from 'pohon-ui';

defineProps<{
  layout: 'fixed' | 'bubble' | 'floating';
}>();

const value = ref(`Switch between layouts to see the different toolbar modes.

The **fixed** layout displays the toolbar above the editor. The **bubble** layout shows the toolbar when you select text. The **floating** layout appears on empty lines.`);

const items: Array<Array<EditorToolbarItem>> = [[{
  kind: 'mark',
  mark: 'bold',
  icon: 'i-lucide-bold',
}, {
  kind: 'mark',
  mark: 'italic',
  icon: 'i-lucide-italic',
}, {
  kind: 'mark',
  mark: 'code',
  icon: 'i-lucide-code',
}]];
</script>

<template>
  <PEditor
    v-slot="{ editor }"
    v-model="value"
    content-type="markdown"
    class="flex flex-col gap-4 min-h-26 w-full"
  >
    <PEditorToolbar
      :key="layout"
      :editor="editor"
      :items="items"
      :layout="layout"
      :data-layout="layout"
      class="data-[layout=fixed]:sm:px-8"
    />
  </PEditor>
</template>
```

### Options

When using `bubble` or `floating` layouts, use the `options` prop to customize the positioning behavior using [Floating UI options](https://floating-ui.com/docs/computeposition#options).

```vue
<template>
  <PEditor v-slot="{ editor }">
    <PEditorToolbar
      :editor="editor"
      :items="items"
      layout="bubble"
      :options="{
        placement: 'top',
        offset: 8,
        flip: { padding: 8 },
        shift: { padding: 8 },
      }"
    />
  </PEditor>
</template>
```

### Should Show

When using `bubble` or `floating` layouts, use the `should-show` prop to control when the toolbar appears. This function receives context about the editor state and returns a boolean.

```vue
<template>
  <PEditor v-slot="{ editor }">
    <PEditorToolbar
      :editor="editor"
      :items="items"
      layout="bubble"
      :should-show="({ view, state }) => {
        const { selection } = state
        const { from, to } = selection
        const text = state.doc.textBetween(from, to)
        return view.hasFocus() && !selection.empty && text.length > 10
      }"
    />
  </PEditor>
</template>
```

## Examples

### With image toolbar

Use the `should-show` prop to create context-specific toolbars that appear only for certain node types. This example shows a `bubble` toolbar with download and delete actions that only appears when an image is selected.

```vue [EditorToolbarImageExample.vue]
<script setup lang="ts">
import type { Editor } from '@tiptap/vue-3';
import type { EditorToolbarItem } from 'pohon-ui';

const value = ref(`Click on the image below to see the image-specific toolbar:

![Image Placeholder](/placeholder.jpeg)`);

function items(editor: Editor): Array<Array<EditorToolbarItem>> {
  const node = editor.state.doc.nodeAt(editor.state.selection.from);

  return [[{
    icon: 'i-lucide-download',
    to: node?.attrs?.src,
    download: true,
    tooltip: { text: 'Download' },
  }], [{
    icon: 'i-lucide-trash',
    tooltip: { text: 'Delete' },
    onClick: () => {
      const { state } = editor;
      const { selection } = state;

      const pos = selection.from;
      const node = state.doc.nodeAt(pos);

      if (node && node.type.name === 'image') {
        editor.chain().focus().deleteRange({ from: pos, to: pos + node.nodeSize }).run();
      }
    },
  }]];
}
</script>

<template>
  <PEditor
    v-slot="{ editor }"
    v-model="value"
    content-type="markdown"
    class="min-h-113 w-full"
  >
    <PEditorToolbar
      :editor="editor"
      :items="items(editor)"
      layout="bubble"
      :should-show="({ editor, view }) => {
        return editor.isActive('image') && view.hasFocus()
      }"
    />
  </PEditor>
</template>
```

### With link popover

This example demonstrates how to create a custom link popover using the `slot` property on toolbar items and the [Popover](https://vinicunca.dev/pohon/components/popover) component.

1. Create a Vue component that wraps a [Popover](https://vinicunca.dev/pohon/components/popover) with link editing functionality:

```vue [EditorLinkPopover.vue]
<script setup lang="ts">
import type { Editor } from '@tiptap/vue-3';

const props = defineProps<{
  editor: Editor;
  autoOpen?: boolean;
}>();

const open = ref(false);
const url = ref('');

const active = computed(() => props.editor.isActive('link'));
const disabled = computed(() => {
  if (!props.editor.isEditable) {
    return true;
  }
  const { selection } = props.editor.state;
  return selection.empty && !props.editor.isActive('link');
});

watch(() => props.editor, (editor, _, onCleanup) => {
  if (!editor) {
    return;
  }

  const updateUrl = () => {
    const { href } = editor.getAttributes('link');
    url.value = href || '';
  };

  updateUrl();
  editor.on('selectionUpdate', updateUrl);

  onCleanup(() => {
    editor.off('selectionUpdate', updateUrl);
  });
}, { immediate: true });

watch(active, (isActive) => {
  if (isActive && props.autoOpen) {
    open.value = true;
  }
});

function setLink() {
  if (!url.value) {
    return;
  }

  const { selection } = props.editor.state;
  const isEmpty = selection.empty;
  const hasCode = props.editor.isActive('code');

  let chain = props.editor.chain().focus();

  // When linking code, extend the code mark range first to select the full code
  if (hasCode && !isEmpty) {
    chain = chain.extendMarkRange('code').setLink({ href: url.value });
  } else {
    chain = chain.extendMarkRange('link').setLink({ href: url.value });

    if (isEmpty) {
      chain = chain.insertContent({ type: 'text', text: url.value });
    }
  }

  chain.run();
  open.value = false;
}

function removeLink() {
  props.editor
    .chain()
    .focus()
    .extendMarkRange('link')
    .unsetLink()
    .setMeta('preventAutolink', true)
    .run();

  url.value = '';
  open.value = false;
}

function openLink() {
  if (!url.value) {
    return;
  }
  window.open(url.value, '_blank', 'noopener,noreferrer');
}

function handleKeyDown(event: KeyboardEvent) {
  if (event.key === 'Enter') {
    event.preventDefault();
    setLink();
  }
}
</script>

<template>
  <PPopover
    v-model:open="open"
    :ui="{ content: 'p-0.5' }"
  >
    <PTooltip text="Link">
      <PButton
        icon="i-lucide-link"
        color="neutral"
        active-color="primary"
        variant="ghost"
        active-variant="soft"
        size="sm"
        :active="active"
        :disabled="disabled"
      />
    </PTooltip>

    <template #content>
      <PInput
        v-model="url"
        autofocus
        name="url"
        type="url"
        variant="none"
        placeholder="Paste a link..."
        @keydown="handleKeyDown"
      >
        <div class="mr-0.5 flex items-center">
          <PButton
            icon="i-lucide-corner-down-left"
            variant="ghost"
            size="sm"
            :disabled="!url && !active"
            title="Apply link"
            @click="setLink"
          />

          <PSeparator
            orientation="vertical"
            class="mx-1 h-6"
          />

          <PButton
            icon="i-lucide-external-link"
            color="neutral"
            variant="ghost"
            size="sm"
            :disabled="!url && !active"
            title="Open in new window"
            @click="openLink"
          />

          <PButton
            icon="i-lucide-trash"
            color="neutral"
            variant="ghost"
            size="sm"
            :disabled="!url && !active"
            title="Remove link"
            @click="removeLink"
          />
        </div>
      </PInput>
    </template>
  </PPopover>
</template>
```

1. Use the custom component in the toolbar with a named slot:

```vue [EditorToolbarCustomSlotExample.vue]
<script setup lang="ts">
import type { EditorToolbarItem } from 'pohon-ui';
import EditorLinkPopover from './EditorLinkPopover.vue';

const value = ref(`Select text and click the link button to add a link with the custom popover.

You can also edit existing links like [this one](https://pohon.vinicunca.dev).`);

const toolbarItems = [[{
  kind: 'mark',
  mark: 'bold',
  icon: 'i-lucide-bold',
}, {
  kind: 'mark',
  mark: 'italic',
  icon: 'i-lucide-italic',
}, {
  slot: 'link' as const,
}]] satisfies Array<Array<EditorToolbarItem>>;
</script>

<template>
  <PEditor
    v-slot="{ editor }"
    v-model="value"
    content-type="markdown"
    class="flex flex-col gap-4 min-h-30 w-full"
  >
    <PEditorToolbar
      :editor="editor"
      :items="toolbarItems"
      class="sm:px-8"
    >
      <template #link>
        <EditorLinkPopover
          :editor="editor"
          auto-open
        />
      </template>
    </PEditorToolbar>
  </PEditor>
</template>
```

## API

### Props

```ts
/**
 * Props for the EditorToolbar component
 */
interface EditorToolbarProps {
}
```

### Slots

```ts
/**
 * Slots for the EditorToolbar component
 */
interface EditorToolbarSlots {
}
```

## Theme

```ts [app.config.ts]
export default defineAppConfig({
  ui: {
    editorToolbar: {
      slots: {
        root: 'focus:outline-none',
        base: 'flex items-stretch gap-1.5',
        group: 'flex items-center gap-0.5',
        separator: 'w-px self-stretch bg-border'
      },
      variants: {
        layout: {
          bubble: {
            base: 'bg-background border border-border rounded-lg p-1'
          },
          floating: {
            base: 'bg-background border border-border rounded-lg p-1'
          },
          fixed: {
            base: ''
          }
        }
      }
    }
  }
})
```

## Changelog

See commit history for [component](https://github.com/vinicunca/pohon/commits/main/src/runtime/components/EditorToolbar.vue) and [theme](https://github.com/vinicunca/pohon/commits/main/src/theme/editor-toolbar.ts).

---

- [GitHub](https://github.com/vinicunca/pohon/blob/main/src/runtime/components/EditorToolbar.vue)


## Sitemap

See the full [sitemap](https://vinicunca.dev/sitemap.md) for all pages.
