Value slots
Value slots let you store any JSON value inside a slot, beyond Tiptap content. Display these values as form fields such as text inputs and checkboxes, or build your own custom input.
Insert a value slot
When creating a slot, pass mode: 'value' to create a value slot:
editor.commands.insertSlot({
mode: 'value',
kind: 'inline',
id: 'priority',
value: null,
config: {
label: 'Priority',
placeholder: 'Choose a priority',
input: {
type: 'select',
options: [
{ value: 'normal', label: 'Normal' },
{ value: 'urgent', label: 'Urgent' },
],
},
},
})Use kind: 'block' to put the control in its own block. Without mode: 'value', insertion creates an ordinary content slot.
Values accept any finite JSON: null, strings, booleans, numbers, arrays, or objects. Use validation to enforce your application's requirements.
Built-in inputs
The Slot extension comes with a set of built-in input types. You can also create your own custom inputs.
Set config.input.type to select a control. Omit input for a text input.
| Type | Control | Stored value | Options type (config.input.options) |
|---|---|---|---|
text | Native text input | string | Not used |
textarea | Multiline text input | string | Not used |
number | Native number input | string, including '' | Not used |
date | Native date input | string, such as '2026-10-14' | Not used |
checkbox | Native checkbox | boolean | Not used |
select | Single select | string option value | { value: string; label: string }[] |
radio | List of radio buttons | string option value | { value: string; label: string }[] |
SlotInput.options is optional. Only select and radio controls use it; they store the selected option's value, not its label.
For a radio group, set config.input to:
{
type: 'radio',
options: [
{ value: 'standard', label: 'Standard' },
{ value: 'express', label: 'Express' },
],
}Each slot is an independent radio group. For multi-select, use a custom NodeView like the demo's chip picker.
Number inputs store values as strings because browsers may return '' for an incomplete number.
Read and update values
import { getSlot } from '@tiptap-pro/extension-slot'
editor.commands.setSlotValue({ id: 'priority', value: 'urgent' })
// Clearing a slot sets its value to null.
editor.commands.clearSlot({ id: 'priority' })
// Update multiple slot values in an atomic batch.
editor.commands.setSlotValues({
values: [
{ id: 'priority', value: 'urgent' },
{ id: 'name', value: 'Alex' },
],
})
const slot = getSlot({ snapshot: editor.storage.slot.snapshot, id: 'priority' })
if (slot?.mode === 'value') console.log(slot.value)Slots are compatible with collaboration. When collaboration is enabled, updates to slots sync in real time with other users of the collaborative document.
Mix custom and default NodeViews
Follow a complete tutorial to build a custom toggle button, including its UI:
Choose a renderer inside addNodeView() and keep built-in controls as the fallback:
import { Slot, InlineValueSlot, createDefaultSlotValueView } from '@tiptap-pro/extension-slot'
const CustomValueSlot = InlineValueSlot.extend({
addNodeView() {
return (props) => {
if (props.node.attrs.config?.input?.type === 'rating') {
return createRatingView(props)
}
return createDefaultSlotValueView(props)
}
},
})
Slot.configure({ inlineValueSlot: CustomValueSlot })createRatingView is application code. The demo implements it with native buttons. Use BlockValueSlot and the blockValueSlot option for block views. Preserve the original node name and leaf schema. Do not provide a contentDOM or NodeViewContent.
Inside a custom view, createSlotValueController({ editor, getPos }) provides:
getValue()andsetValue(value)to read and write the answer.getState()for a stable snapshot ofvalue,config,editable, andavailable.subscribe(listener)to update the UI after document and permission changes.focus()to select the slot when its input receives focus.destroy()to release subscriptions when a vanilla view is removed.
Custom views should:
- Disable controls when
editableis false. - Render the current state after
setValuereturnsfalse(a refused write). - Keep input events inside the control and handle IME composition for text inputs.
- Release subscriptions when the view is removed.
If changing configuration selects a different renderer, return false from your NodeView's update method so Tiptap runs addNodeView's factory again. The default view does this when the input type changes.
Create a custom input with vanilla JavaScript
Build a button that switches between On and Off and stores a boolean. Native buttons provide keyboard support with Enter and Space.
1. Create the UI
Create toggle-slot.js. Use the value controller for reads, guarded writes, and permission changes:
import {
BlockValueSlot,
createDefaultSlotValueView,
createSlotValueController,
} from '@tiptap-pro/extension-slot'
function createToggleView({ editor, getPos, node }) {
const slot = createSlotValueController({ editor, getPos })
const dom = document.createElement('div')
dom.contentEditable = 'false'
const button = document.createElement('button')
button.type = 'button'
dom.append(button)
function render() {
const { value, editable, available } = slot.getState()
const on = value === true
button.textContent = `${slot.getState().config?.label ?? 'Notifications'}: ${on ? 'On' : 'Off'}`
button.setAttribute('aria-pressed', String(on))
button.disabled = !available || !editable
}
button.addEventListener('focus', () => slot.focus())
button.addEventListener('click', () => {
slot.setValue(slot.getValue() !== true)
render() // Also restore the current state if the write was refused.
})
const unsubscribe = slot.subscribe(render)
render()
return {
dom,
update(nextNode) {
return nextNode.type === node.type && nextNode.attrs.config?.input?.type === 'toggle'
},
stopEvent: () => true,
ignoreMutation: () => true,
destroy() {
unsubscribe()
slot.destroy()
},
}
}subscribe refreshes the button when values or permissions change. It does not call render immediately, so call it once yourself. focus() selects the slot; the browser keeps focus on the button.
2. Register the custom renderer
Add this to the same file. Handle toggle inputs and keep the default renderer for built-in inputs:
export const ToggleSlot = BlockValueSlot.extend({
addNodeView() {
return (props) =>
props.node.attrs.config?.input?.type === 'toggle'
? createToggleView(props)
: createDefaultSlotValueView(props)
},
})The view's update returns false when the input type changes so Tiptap can choose another renderer. Keep the leaf schema: do not add contentDOM.
3. Configure the editor and insert a slot
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { Slot } from '@tiptap-pro/extension-slot'
import { ToggleSlot } from './toggle-slot.js'
const editor = new Editor({
element: document.querySelector('#editor'),
extensions: [StarterKit, Slot.configure({ blockValueSlot: ToggleSlot })],
content: '<p>Notification preferences</p>',
})
editor.commands.insertSlot({
mode: 'value',
kind: 'block',
id: 'notifications',
value: false,
config: { label: 'Notifications', input: { type: 'toggle' } },
})Mount the editor in <div id="editor"></div>. Click the button or focus it and press Space: attrs.value switches between true and false. Undo restores the previous answer. With Content Protection, a locked slot disables the button automatically.
toggle is your application's input type, not a built-in. The stored value remains ordinary JSON, and exported HTML renders a static answer.
Create a custom input with React
Build the same boolean toggle as the JavaScript tutorial, using a React component. The button supports Enter and Space without custom keyboard handlers.
Use ReactNodeViewRenderer from @tiptap/react to render the component. The slot package itself has no React or Vue dependency.
1. Create the component
Create ToggleSlot.jsx. Connect the controller with useSyncExternalStore so document and permission changes refresh the UI:
import { useMemo, useSyncExternalStore } from 'react'
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
import {
BlockValueSlot,
createDefaultSlotValueView,
createSlotValueController,
} from '@tiptap-pro/extension-slot'
function ToggleView({ editor, getPos }) {
const slot = useMemo(() => createSlotValueController({ editor, getPos }), [editor, getPos])
const { value, config, editable, available } = useSyncExternalStore(
slot.subscribe,
slot.getState,
slot.getState,
)
const on = value === true
return (
<NodeViewWrapper contentEditable={false}>
<button
type="button"
aria-pressed={on}
disabled={!available || !editable}
onFocus={() => slot.focus()}
onClick={() => slot.setValue(slot.getValue() !== true)}
>
{config?.label ?? 'Notifications'}: {on ? 'On' : 'Off'}
</button>
</NodeViewWrapper>
)
}setValueuses the same guarded commands as built-in controls.- The button renders controller state, so refused writes leave its answer unchanged.
- React unsubscribes on unmount. The controller releases its editor listener when its last subscriber leaves, including during React Strict Mode cleanup.
Do not add NodeViewContent: a value slot has no editable children.
2. Register the React renderer
Add this to ToggleSlot.jsx. Use React only for your toggle input and preserve built-in controls:
export const ToggleSlot = BlockValueSlot.extend({
addNodeView() {
const renderToggle = ReactNodeViewRenderer(ToggleView, {
update: ({ newNode, updateProps }) => {
if (newNode.attrs.config?.input?.type !== 'toggle') return false
updateProps()
return true
},
})
return (props) =>
props.node.attrs.config?.input?.type === 'toggle'
? renderToggle(props)
: createDefaultSlotValueView(props)
},
})Returning false when the input type changes lets Tiptap choose another renderer.
3. Mount the editor and insert a slot
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { Slot } from '@tiptap-pro/extension-slot'
import { ToggleSlot } from './ToggleSlot.jsx'
export function NotificationEditor() {
const editor = useEditor({
extensions: [StarterKit, Slot.configure({ blockValueSlot: ToggleSlot })],
content: '<p>Notification preferences</p>',
immediatelyRender: false,
})
return (
<>
<button
type="button"
disabled={!editor}
onClick={() =>
editor.commands.insertSlot({
mode: 'value',
kind: 'block',
id: 'notifications',
value: false,
config: { label: 'Notifications', input: { type: 'toggle' } },
})
}
>
Add notifications toggle
</button>
<EditorContent editor={editor} />
</>
)
}Click Add notifications toggle once, then activate the toggle with the mouse, Enter, or Space. Its attrs.value switches between true and false; Undo restores the previous answer. Use a unique slot ID when inserting additional toggles.
Add Content Protection to restrict editing. The component follows the controller's editable state automatically.
Using Vue? Use VueNodeViewRenderer instead. Create the controller in setup, copy getState() into a shallowRef, update that ref from subscribe, and unsubscribe and destroy the controller in onBeforeUnmount. Render a NodeViewWrapper without NodeViewContent.
Validation
Validation checks answers without preventing users from drafting invalid values. For example, require a name and check it before submission:
import { isSlotValidationCurrent, validateEditorSlots } from '@tiptap-pro/extension-slot'
editor.commands.insertSlot({
mode: 'value',
kind: 'block',
id: 'name',
value: null,
config: { label: 'Your name', required: true, input: { type: 'text' } },
})
// Run in your application's submit handler.
const result = await validateEditorSlots({ editor })
if (isSlotValidationCurrent({ editor, result })) {
if (result.valid) {
save(editor.getJSON()) // Your application's persistence function.
} else {
console.log(result.issues) // Display these beside the affected inputs.
}
}- An empty or whitespace-only name fails validation, but users can still draft it.
requiredtreatsnull, blank strings, and empty arrays as empty.falseand0count as answers; use a custom validator for a checkbox that must be checked.- Select and radio answers must match an option value. Content settings such as
allowedBlocksanddefaultContentdo not apply to value slots.
See Validate before submission for custom validators and cancelled runs.
Content protection
Use Content Protection to let users fill in answers while protecting the document's structure and slot configuration. After inserting the slots, allow editing only the name slot:
import { createSlotFillingPolicy } from '@tiptap-pro/extension-slot'
// Requires ContentProtection in the editor's extensions.
editor.commands.setContentProtectionPolicy({
policy: createSlotFillingPolicy({ editableSlotIds: ['name'] }),
})Users can change the slot's value, but cannot delete it or change its configuration. Built-in and custom inputs follow the same permissions when using the value controller.
See Fill-only editing for protection setup.