A longer document
The full toolbar adds headings, lists, code blocks, and history.
<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 (built on ProseMirror); Tiptap itself stays a peer dependency you install from npm, exactly like the chart adapter. WireKit ships only the lightweight Alpine glue.
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):
# 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
// 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…' }),
],
});
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:
{{-- # 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) 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 for the full bundle matrix.
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.
config contractWireKit 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.
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.
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) 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.
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.
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.
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.
Set :editable="false" to render a document as a non-editable preview (no toolbar, no hidden input) — useful for moderation queues and comment threads.
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.
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:
@name)@tiptap/extension-mention turns @ into a typeahead. You own the suggestion source and the popup; WireKit just hosts the editor.
// 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.
},
});
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.
// 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();
},
}
Real-time collaboration uses @tiptap/extension-collaboration on top of a Yjs document synced by a provider you run.
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.
// 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.
| 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. |
| 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. |
The factory dispatches input and change on the hidden field after every (debounced) change, so wire:model.live and legacy listeners both fire.
role="textbox" with aria-multiline="true"; the label prop names it (or a humanized name fallback).role="toolbar" labeled "Editor commands"; toggle buttons expose aria-pressed reflecting the current mark/node.mousedown.prevent so clicking a command never steals selection from the editor.disabled + dimmed) when there is nothing to undo or redo, so the toolbar never offers a dead command.aria-invalid="true" + aria-describedby to the error text — same as every other WireKit input.| 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 |
| 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 |
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.
mews/purifier — server-side HTML sanitizer for Laravel