---
title: Editor
description: Rich-text editor — a Tiptap adapter with a toolbar and form binding
visibility: guest
draft: false
---

# Editor

`<x-wirekit::editor>` is a rich-text editing surface that drops into a Livewire form the same way `<x-wirekit::textarea>` does — a Blade tag, a hidden form field, `wire:model` support, theme-token styling, full keyboard access. It is a thin adapter around [Tiptap](https://tiptap.dev) (built on ProseMirror); Tiptap itself stays a **peer dependency** you install from npm, exactly like the [chart adapter](/components/chart). WireKit ships only the lightweight Alpine glue.

## Installation

Tiptap is not bundled. Install the core plus whatever extensions you need, then expose a `window.wirekitEditor(config)` factory that returns a Tiptap `Editor`. The factory name is engine-neutral — any ProseMirror-based `Editor` works — and the legacy `window.tiptapEditor` name is still accepted as a deprecated alias (renaming is a one-line change):

```bash
# 1. Install the Tiptap core + starter kit (the 80% case).
npm install @tiptap/core @tiptap/starter-kit

# 2. Optional extensions, per form.
npm install @tiptap/extension-link @tiptap/extension-placeholder \
            @tiptap/extension-text-align @tiptap/extension-character-count
```

```js
// 3. app.js — expose the factory WireKit calls at Alpine init().
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';

window.wirekitEditor = (config) => new Editor({
    // 4. WireKit hands you element / content / editable / callbacks / editorProps.
    element: config.element,
    content: config.content,
    editable: config.editable,
    editorProps: config.editorProps,
    onCreate: config.onCreate,
    onUpdate: config.onUpdate,
    onSelectionUpdate: config.onSelectionUpdate,
    onTransaction: config.onTransaction,
    // 5. YOU own the extension set. `config.extensions` is an OPTIONAL list of
    //    string name-hints (empty by default) — map them to real extensions only
    //    if your factory reads them; never spread the raw strings into Tiptap
    //    (they are not Extension objects). Restrict link protocols to block
    //    javascript: URLs.
    extensions: [
        StarterKit,
        Link.configure({ protocols: ['http', 'https', 'mailto'], openOnClick: false }),
        Placeholder.configure({ placeholder: config.placeholder ?? 'Write something…' }),
    ],
});
```

### Which WireKit bundle registers the editor

The `wirekitEditor` Alpine factory that `<x-wirekit::editor>` wires into `x-data` ships in the **full** bundle (`wirekit.js`) and the **self-contained** bundle (`wirekit-alpine.js`) — if you load either, the editor works with no extra script. If you load the **core** bundle (`wirekit.core.js`, chart-only) to keep your footprint small, add the standalone editor adapter alongside it:

```blade
{{-- # 1. Core bundle (no overlays) keeps the base footprint small --}}
@wirekitScripts(bundle: 'core')
{{-- # 2. Editor adapter glue — registers `wirekitEditor` next to core --}}
<script defer src="{{ asset('vendor/wirekit/wirekit-tiptap.js') }}"></script>
```

The `wirekit-tiptap.js` adapter is tiny (size in [Dependencies](/dependencies)) and contains only the editor glue — no Tiptap code (that stays your peer dependency). Loading it next to `wirekit.js` or `wirekit-alpine.js` is redundant: those already include the editor. See [Choosing a JS bundle](/getting-started/integration#choosing-a-js-bundle) for the full bundle matrix.

::: warning
Your factory **must forward `config.editorProps`** (as the example above does). It carries the editable surface's WAI-ARIA `role="textbox"` + `aria-multiline` **and** the `wk-editor-content` class that applies WireKit's typography, the flex-fill that turns the whole box into the work area, and the inner focus-outline suppression. A factory that drops `editorProps` mounts an editor with **no accessible role and no styling** — it still renders, but screen readers announce no textbox and none of the theme rules apply. Forward `config.onCreate` and `config.onTransaction` too, so the character counter and toolbar active-state stay in sync.
:::

### Factory `config` contract

WireKit calls `window.wirekitEditor(config)` with exactly these keys — your factory receives them and wires each into Tiptap:

| `config` key | What WireKit passes | Your factory should |
|---|---|---|
| `element` | The DOM node to mount the editor into | pass as Tiptap `element` |
| `content` | The initial HTML (the `wire:model` value) | pass as Tiptap `content` |
| `editable` | `false` when the `editable` prop is `false`, otherwise `true` | pass as Tiptap `editable` |
| `extensions` | An **optional** array of extension *name hints* (default `[]`) — never real extension objects | own the real extension set; read the hints only if your factory maps them |
| `editorProps` | ProseMirror `editorProps` carrying `role="textbox"`, `aria-multiline`, any `aria-label` / `aria-describedby` / `aria-invalid`, and the `wk-editor-content` class | forward **verbatim** (required for a11y + styling) |
| `onCreate` · `onUpdate` · `onSelectionUpdate` · `onTransaction` | Lifecycle callbacks WireKit uses to sync `wire:model`, the character counter, and toolbar active-state | forward **each** verbatim |

WireKit treats this as a stable input contract: existing keys won't change or disappear in a minor or patch release (new keys, if any, arrive as optional additions). Writing your factory straight from this table — rather than guessing from the Alpine source — is what avoids the classic `extensions: ['StarterKit']` mistake of spreading name hints into Tiptap as if they were real extension objects.

::: info
If no factory is defined (neither `window.wirekitEditor` nor the legacy `window.tiptapEditor` alias), the editor **gracefully falls back** to a plain `<textarea>` and logs a console error, so the form stays usable either way.
:::

Your factory can return the `Editor` as-is — WireKit marks it non-reactive
(Vue's `markRaw` flag) before storing it on Alpine state, so Alpine never
deep-proxies it. A reactivity-proxied ProseMirror editor would reject every
command with a "mismatched transaction" error; the glue rules that whole class
out for you.

::: warning
**Sanitize on store.** Tiptap's schema strips `<script>`, inline event handlers, and disallowed nodes when it *parses*, but that is not a complete XSS defense for the round-trip. Always run a server-side sanitizer (e.g. [`mews/purifier`](https://github.com/mewebstudio/Purifier)) on **every** write path before saving the HTML, and restrict the link extension's `protocols` to `['http', 'https', 'mailto']`. Never render saved HTML on a page without these protections.
:::

## Basic Usage

The `toolbar="basic"` preset (the default) gives you bold / italic / strike / link / lists. The document is written to a hidden input as HTML on every change, so `wire:model` and native form submission just work.

:::preview{title="Basic editor"}
<x-wirekit::editor
    name="post_body"
    label="Post body"
    value="<p>Edit me — <strong>bold</strong>, <em>italic</em>, and a <a href='#'>link</a> all round-trip through the hidden input.</p>"
/>
:::

## Toolbar Presets

`toolbar="full"` exposes headings, quotes, code blocks, and undo/redo; `toolbar="false"` hides it (keyboard shortcuts still work); `toolbar="custom"` lets you compose `<x-wirekit::editor.toolbar :commands="[...]">` in the `toolbar` slot.

::: info
A few commands live in Tiptap extensions that **StarterKit does not bundle**: `underline` (in the `full` preset) needs `@tiptap/extension-underline`, and the optional `align-*` / `task-list` commands need `@tiptap/extension-text-align` / `@tiptap/extension-task-list`. Register each one in your `window.wirekitEditor` factory before enabling its button. If a command's extension is missing, the editor logs a console hint and keeps working — the click is a no-op rather than a hard error.
:::

:::preview{title="Full toolbar"}
<x-wirekit::editor
    name="article_body"
    toolbar="full"
    size="lg"
    value="<h2>A longer document</h2><p>The full toolbar adds headings, lists, code blocks, and history.</p>"
/>
:::

## Read-only

Set `:editable="false"` to render a document as a non-editable preview (no toolbar, no hidden input) — useful for moderation queues and comment threads.

:::preview{title="Read-only document"}
<x-wirekit::editor
    :editable="false"
    :toolbar="false"
    value="<p>This document is read-only. There's no toolbar and no form field — just the rendered content.</p>"
/>
:::

## Character limit

Set `maxLength` to render a live counter in the bottom bar. It's a **soft** limit — the count turns red past the limit but typing isn't blocked (for a hard stop, pass a `limit` to `CharacterCount` in your factory). The visible count is `aria-hidden`; a debounced screen-reader announcement speaks the remaining count when the user pauses, so it never narrates every keystroke.

:::preview{title="Character counter"}
<x-wirekit::editor
    name="tweet"
    label="What's happening?"
    :max-length="280"
    value="<p>The counter sits in the bottom bar and turns red once you pass the limit.</p>"
/>
:::

## Advanced: mentions, slash commands & collaboration

The `extensions` array in **your** `window.wirekitEditor` factory is the extension point for everything Tiptap can do beyond the toolbar presets. WireKit doesn't bundle these — you add the Tiptap peer extensions you need and wire their config, exactly where you already add `StarterKit`. Three common ones:

### Mentions (`@name`)

`@tiptap/extension-mention` turns `@` into a typeahead. You own the suggestion source and the popup; WireKit just hosts the editor.

```js
// 1. npm install @tiptap/extension-mention
import Mention from '@tiptap/extension-mention';

// 2. Add it to the factory's extension set (alongside StarterKit, Link, …).
//    The suggestion.items callback is YOUR data source.
Mention.configure({
    HTMLAttributes: { class: 'wk-mention' },
    suggestion: {
        items: ({ query }) =>
            people.filter((p) => p.toLowerCase().startsWith(query.toLowerCase())).slice(0, 5),
        // 3. render() mounts the suggestion popup — see Tiptap's suggestion utility.
    },
});
```

### Slash commands & AI

A slash menu is the same suggestion mechanism keyed on `/` instead of `@`. Build it on `@tiptap/suggestion`: each item runs an editor command when picked. An "AI" item is just a command that calls **your** endpoint and inserts the response.

```js
// 1. A slash item that asks YOUR backend to continue the text, then inserts it.
{
    title: 'Continue with AI',
    command: async ({ editor, range }) => {
        // 2. The request, model, auth, and rate-limiting are all yours —
        //    WireKit never calls an AI service.
        const text = await fetch('/ai/continue', { method: 'POST' }).then((r) => r.text());
        editor.chain().focus().deleteRange(range).insertContent(text).run();
    },
}
```

### Collaboration (Yjs)

Real-time collaboration uses `@tiptap/extension-collaboration` on top of a [Yjs](https://yjs.dev) document synced by a provider you run.

::: warning
Collaboration needs a **sync backend you provide** — a `y-websocket` server, Hocuspocus, or similar. WireKit ships no server, and a component library cannot: the shared document state lives in your infrastructure. When you wire Collaboration you must let **Yjs own the content** — do **not** also pass `value` or treat `wire:model` as the source of truth, or the two fight over the document. Use the hidden input only if you still want a snapshot on form submit.
:::

```js
// 1. npm install @tiptap/extension-collaboration yjs y-websocket
import Collaboration from '@tiptap/extension-collaboration';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';

// 2. One shared doc + a provider pointed at YOUR server.
const ydoc = new Y.Doc();
new WebsocketProvider('wss://collab.example.com', roomName, ydoc);

// 3. Add Collaboration and DON'T set `content` — Yjs hydrates the document.
//    Collaboration manages undo/redo, so disable StarterKit's own history.
Collaboration.configure({ document: ydoc });
```

For presence cursors, add `@tiptap/extension-collaboration-cursor` with each user's name and color.

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `name` | string\|null | `null` | Form-field name (and the hidden input's name). Required to submit. |
| `value` | string\|array\|null | `null` | Initial document — an HTML string (`format="html"`) or a JSON document (`format="json"`). |
| `format` | string | `'html'` | `html` or `json` — what's written to the hidden input on every change. |
| `extensions` | array\|null | config | Editor extension **name** hints passed to your `window.wirekitEditor` (empty by default; the factory owns the real extension set). |
| `toolbar` | bool\|string | `'basic'` | `false`, `'basic'`, `'full'`, or `'custom'` (use the `toolbar` slot). |
| `placeholder` | string\|null | `null` | Empty-document placeholder (needs `@tiptap/extension-placeholder`). |
| `maxLength` | int\|null | `null` | Renders a live character counter in the bottom bar. **Soft** limit — it shows an over-limit state but does not block typing; for a hard stop, configure `CharacterCount` with a `limit` in your factory. The counter reads `@tiptap/extension-character-count` when installed, else plain-text length. |
| `editable` | bool | `true` | `false` renders a read-only preview with no toolbar / hidden input. |
| `label` | string\|null | `null` | Renders above via `<x-wirekit::label>`. |
| `hint` | string\|null | `null` | Helper text below. |
| `error` | string\|null | `null` | Error message; sets `aria-invalid` on the content region. |
| `size` | string | `'md'` | `sm` / `md` / `lg` — minimum content height. |
| `maxHeight` | string\|null | `null` | Cap the editable area's height (any CSS length, e.g. `'20rem'` / `'50vh'`). The content already scrolls; this gives it a ceiling so a long document scrolls **inside** the field instead of growing the page downward. Width is set the normal way — the editor is full-width, so constrain it with a class or `style` on the component (e.g. `style="max-width: 40rem"`). |
| `autofocus` | bool | `false` | Focus the editor on first paint. |
| `scope` | string\|null | `null` | Scoped personalization name. |

## Slots

| Slot | Description |
| --- | --- |
| `toolbar` | Custom toolbar (with `toolbar="custom"`) — usually `<x-wirekit::editor.toolbar :commands="[...]">`. |
| `bottomBar` | Optional bottom-edge slot for a character counter or save-status indicator. |

## Events

The factory dispatches `input` and `change` on the hidden field after every (debounced) change, so `wire:model.live` and legacy listeners both fire.

## Accessibility

- The content region is a `role="textbox"` with `aria-multiline="true"`; the `label` prop names it (or a humanized `name` fallback).
- The toolbar is a `role="toolbar"` labeled "Editor commands"; toggle buttons expose `aria-pressed` reflecting the current mark/node.
- Toolbar buttons use `mousedown.prevent` so clicking a command never steals selection from the editor.
- The **undo** and **redo** buttons disable themselves (`disabled` + dimmed) when there is nothing to undo or redo, so the toolbar never offers a dead command.
- Error state sets `aria-invalid="true"` + `aria-describedby` to the error text — same as every other WireKit input.
- Default Tiptap keyboard shortcuts work (see below).

## Keyboard Interaction

| Key | Action |
| --- | --- |
| `Cmd/Ctrl + B` | Bold |
| `Cmd/Ctrl + I` | Italic |
| `Cmd/Ctrl + Shift + X` | Strikethrough |
| `Cmd/Ctrl + E` | Inline code |
| `Cmd/Ctrl + Z` / `Cmd/Ctrl + Shift + Z` | Undo / redo |
| `Tab` / `Shift + Tab` | Indent / outdent inside lists |

## Design Tokens

| Token | Used for |
| --- | --- |
| `--color-wk-bg-input` | Editor surface |
| `--color-wk-border-strong` / `--color-wk-border-error` | Editor border |
| `--color-wk-bg-subtle` | Toolbar background |
| `--color-wk-ring` | Focus-within ring |
| `--color-wk-accent` | Links inside the document |
| `--radius-wk-md` | Editor corner radius |

## Usage & Conventions

::: info
**Tiptap licensing.** Tiptap's core (`@tiptap/core`, `@tiptap/starter-kit`, and the `@tiptap/extension-*` packages) is **MIT** and free. Optional **Pro** extensions (`@tiptap-pro/*` — AI, comments, version history) are commercial and namespaced separately. WireKit's adapter targets the MIT core; you can pass any Pro extension through if you've licensed it, while WireKit itself ships only the MIT glue.
:::

## Further Reading

- [Tiptap documentation](https://tiptap.dev/docs/editor/getting-started/overview)
- [Tiptap pricing / license model](https://tiptap.dev/pricing)
- [ProseMirror](https://prosemirror.net/) — the engine Tiptap wraps
- [`mews/purifier` — server-side HTML sanitizer for Laravel](https://github.com/mewebstudio/Purifier)
