Variable API

Import the extension and value reader from @tiptap-pro/extension-variable:

import { Variable, getVariable } from '@tiptap-pro/extension-variable'

All Variable-specific types below are exported from the same package. Editor and JSONContent come from @tiptap/core.

Variable values

VariableValue

type VariableValue = string | number | boolean | bigint | symbol | null | undefined | object

object includes arrays, Tiptap JSON, and other JavaScript objects. Runtime validation still applies: promises are rejected, and objects must support JSON.stringify() without throwing.

ValueDisplay
stringLiteral text. HTML strings are not parsed.
number, boolean, bigint, symbolString(value). Numbers include NaN and infinities.
null, undefinedEmpty text.
Valid Tiptap JSON node, document, or node arrayRich content using the editor's schema.
Other objects or arraysJSON.stringify(value), or empty text if it returns undefined.

Tiptap JSON uses the JSONContent type from @tiptap/core. Its node and mark types must exist in the editor's schema. Objects that are not valid content for that schema display as JSON text.

import type { JSONContent } from '@tiptap/core'

const label: JSONContent = {
  type: 'text',
  text: 'Ada',
  marks: [{ type: 'bold' }],
}

Rich values remain read-only inside variable references and are not saved with those references. Bound slots save their filled content normally.

VariableResolver and VariableResolverContext

type VariableResolver = (context: VariableResolverContext) => VariableValue

interface VariableResolverContext {
  editor: Editor
  get: (name: string) => VariableValue
  getText: (name: string) => string
}
PropertyTypeBehavior
editorEditorThe current Tiptap editor, including its schema.
get(name: string) => VariableValueReads the raw value and tracks a dependency. An unknown name or circular dependency throws an error.
getText(name: string) => stringReads a variable as text and tracks the same dependency as get. Unknown names and circular dependencies throw an error.

Resolvers return synchronously. Reading another variable with get() or getText() makes the resolver rerun when that variable is updated. Use editor for reads; dispatching transactions inside a resolver can trigger recursive updates. Reads from editor do not register variable dependencies.

getText() extracts text from schema-valid Tiptap JSON nodes, documents, and node arrays. It uses the schema's text serializers, preserves hard breaks, and separates blocks with two newlines. It returns '' for null and undefined, and String(value) for all other values, including ordinary objects and arrays. For example, { name: 'Ada' } becomes '[object Object]'. This conversion differs from how a variable reference displays ordinary objects as JSON.

If a bound source is unreadable, getText() stops the resolver and conceals its value, just like get().

import type { VariableResolver } from '@tiptap-pro/extension-variable'

const greeting: VariableResolver = ({ getText }) => `Hello, ${getText('customer')}!`

A function assigned directly in variables is a resolver. To use a function itself as a value, return it from a resolver or pass it to setVariable; it displays as String(value).

Configuration

VariableOptions

interface VariableOptions {
  HTMLAttributes: Record<string, string>
  variables: Record<string, VariableValue | VariableResolver>
  slots: Record<string, string>
  shortcuts: false | VariableShortcut
  resolveInputRuleVariable: VariableInputRuleResolver
  onUpdate: (options: VariableUpdateOptions) => void
  onError: (options: VariableErrorOptions) => void
}

Every option is optional when calling Variable.configure(). The extension supplies these defaults:

See Styling for styling and custom views.

OptionTypeDefaultMeaning
HTMLAttributesRecord<string, string>{}Attributes for the default NodeView and exported HTML.
variablesRecord<string, VariableValue | VariableResolver>{}Keys are variable names; values are initial values or resolvers.
slotsRecord<string, string>{}Initial slot-ID-to-variable bindings. Requires Slot. Prefer slot attributes or binding commands.
shortcutsfalse | VariableShortcut{ open: '{', close: '}' }Typed delimiters. See typed shortcuts.
resolveInputRuleVariableVariableInputRuleResolverReturns undefinedCustomize or cancel a typed reference.
onUpdate(options: VariableUpdateOptions) => voidNo-op functionCalled after accepted variable-value changes.
onError(options: VariableErrorOptions) => voidNo-op functionCalled when evaluation, rendering, slot synchronization, or an input-rule callback fails.
Variable.configure({
  variables: { customer: 'Ada', greeting },
  slots: { customerField: 'customer', signatureField: 'customer' },
  onUpdate: ({ values }) => console.log(values.customer),
  onError: ({ error }) => console.error(error.message),
})

Slot bindings are stored as variable: 'customer' on slot nodes. variable: null explicitly unbinds a slot. HTML uses data-slot-variable="customer"; an empty attribute means unbound. When the attribute is omitted, the initial slots configuration supplies the binding; the extension writes those initial bindings into the document when the editor is created. Prefer the binding commands for later changes.

VariableUpdateOptions

Both onUpdate and the variablesUpdate event receive this payload:

interface VariableUpdateOptions {
  editor: Editor
  values: Readonly<Record<string, VariableValue>>
}
PropertyTypeMeaning
editorEditorThe editor whose variables changed.
valuesReadonly<Record<string, VariableValue>>All current values, including recalculated dependents. Keys are variable names, not slot IDs.

The values record is frozen. Its object values are not cloned or deeply frozen. Call setVariable after changing an object, or refresh the resolver that supplies it; mutation alone does not trigger an update.

Values are not narrowed by their configured names. Check the type before using a value as a specific object or primitive:

import type { VariableUpdateOptions } from '@tiptap-pro/extension-variable'

const onVariablesUpdate = ({ values }: VariableUpdateOptions): void => {
  if (typeof values.customer === 'string') {
    console.log(values.customer.toUpperCase())
  }
}

VariableErrorOptions

interface VariableErrorOptions {
  editor: Editor
  error: Error
}
PropertyTypeMeaning
editorEditorThe editor where the update failed.
errorErrorThe failure, with a readable message. Thrown non-Error values are converted to Error.

Both callbacks return void; return values are ignored.

Invalid initial configuration or an initial resolver failure throws during editor creation. Later evaluation or slot-synchronization failures in local edits or variable commands preserve the previous runtime values and call onError.

If the default NodeView cannot render a value, it clears the failed content and reports the error through onError. Editing remains available, and setting a valid value restores the display.

Incoming collaborative edits and protection-policy changes still apply if a resolver fails. That resolver's value becomes null, its dependents recalculate, and onError receives the error.

Remote edits that introduce duplicate bound slot IDs remain in the document. While the slot structure is invalid, slot-driven values and their dependents are unavailable and slot synchronization pauses. Repairing the structure restores values from the slots.

Commands

Direct calls through editor.commands return boolean. In a chain, the same commands return the chain; .run() returns boolean.

addVariable

Register a new value or synchronous resolver after editor creation.

interface AddVariableOptions {
  name: string
  value: VariableValue | VariableResolver
}

editor.commands.addVariable({ name: 'company', value: 'Acme' })
editor.commands.addVariable({
  name: 'greeting',
  value: ({ getText }) => `Hello, ${getText('customer')}!`,
})

Both properties are required; explicit undefined is a valid value. A function is treated as a resolver and runs immediately. Its dependencies must already be registered. Use setVariables() to register several variables and their dependencies together.

Returns false for an empty name or a name already registered through configuration, runtime commands, or a slot binding. These checks do not call onError. Use setVariable() to update an existing value or setVariables() to replace a resolver.

Evaluation and synchronization failures return false, call onError, and leave previous definitions and values unchanged. Adding a variable updates existing references to its name; it does not insert a new reference. Chain it with insertVariable() to do both:

editor
  .chain()
  .addVariable({ name: 'company', value: 'Acme' })
  .insertVariable({ name: 'company' })
  .run()

Definitions belong to this editor instance. They are not saved in the document or reverted by document undo.

insertVariable

Insert a non-editable inline reference at the selection.

Argument propertyTypeRequiredMeaning
namestringYesA variable registered through configuration, runtime commands, or a slot binding.

The argument type is { name: string }. There is no separately exported InsertVariableOptions type.

const inserted: boolean = editor.commands.insertVariable({ name: 'greeting' })

Returns false if the name is unknown or the reference cannot be inserted.

setVariable

Set a value and recalculate its dependents.

interface SetVariableOptions {
  name: string
  value: VariableValue
}
Argument propertyTypeRequiredMeaning
namestringYesA registered variable name.
valueVariableValueYesThe new value. Explicit undefined is allowed.
const updated: boolean = editor.commands.setVariable({ name: 'customer', value: 'Grace' })

Returns false when the name is unknown or the update fails. An unknown name does not call onError.

This replaces any resolver with the supplied value. Bound slots take the new value and become read-only.

setVariables

Add variables or replace their values and resolvers at runtime. Names omitted from the record stay unchanged.

interface SetVariablesOptions {
  variables: Record<string, VariableValue | VariableResolver>
}

The variables property is required. Each key is a variable name; each value is a plain value or synchronous resolver.

editor.commands.setVariables({
  variables: {
    customer: 'Ada',
    greeting: ({ getText }) => `Hello, ${getText('customer')}!`,
  },
})

Resolvers run immediately, and dependent values and bound slots update. You can register a resolver and its dependencies in the same call.

Replace a resolver with a value to stop recalculating it. To keep its current value:

editor.commands.setVariables({
  variables: { greeting: getVariable({ editor, name: 'greeting' }) },
})

Returns false if evaluation or slot synchronization fails, and calls onError. The whole update is rejected, leaving previous definitions and values unchanged.

Definitions belong to this editor instance. They are not saved in the document or reverted by document undo.

removeVariable

Remove one runtime definition using the same rules as removeVariables():

interface RemoveVariableOptions {
  name: string
}

editor.commands.removeVariable({ name: 'greeting' })

The name property is required. Returns false for an unknown name or when removing an unbound variable would leave a remaining resolver without its dependency. These checks do not call onError. Remove dependent resolvers first or use removeVariables() to remove the group together.

For a bound variable, this removes its explicit value or resolver and returns it to editable slot content. References to a removed, unbound variable stay in the document and display an empty value. Adding its name again restores their display.

Evaluation and synchronization failures return false, call onError, and leave the previous runtime state unchanged.

removeVariables

Remove variables and their resolvers together.

interface RemoveVariablesOptions {
  names: string[]
}

The names property is required. An empty array does nothing.

editor.commands.removeVariables({ names: ['greeting'] })

For a bound variable, this removes its explicit value or resolver. Its slots become editable and supply the value instead.

For an unbound variable, this removes the variable. Returns false if a name is unknown or removing it would leave a remaining resolver without its dependency. These checks do not call onError.

Existing document references stay in place. References to removed, unbound variables display an empty value.

bindSlotVariable

Bind a slot to a variable, or replace its current binding. The binding is saved in the slot's variable attribute.

interface BindSlotVariableOptions {
  id: string
  name: string
}

editor.commands.bindSlotVariable({ id: 'customerField', name: 'customer' })

Both properties are required. id must identify exactly one slot; name must be nonempty. An unknown name creates a variable whose value comes from the slot.

An explicit value or resolver takes precedence and makes the slot read-only. Otherwise, the first bound slot supplies the value and all bound slots stay editable. Adding another slot copies the variable's current value into it.

Bindings inside another bound slot are saved but ignored while nested. Only the outer binding takes effect. Removing the outer binding or moving the inner slot outside it activates the inner binding.

Returns false and calls onError if the slot is missing or ambiguous, the name is empty, or synchronization or Content Protection rejects the change. The whole change is rejected.

unbindSlotVariable

Remove a binding and keep the slot's current content.

interface UnbindSlotVariableOptions {
  id: string
}

editor.commands.unbindSlotVariable({ id: 'customerField' })

The id property is required and must identify exactly one slot. The command saves variable: null on that slot. It returns false and calls onError when the slot cannot be found uniquely or the change is rejected.

When a slot-driven variable loses its last bound slot, its value becomes undefined. Explicit values and resolvers are unaffected. Binding and unbinding support document undo and redo.

Both binding commands support editor.can() without evaluating resolvers or changing bindings. A dry run checks the slot, name, and permission to change the binding; it cannot predict synchronization failures.

refreshVariables

Refresh resolvers and their dependent values.

interface RefreshVariablesOptions {
  names?: string[]
}

The options object and its names property are both optional.

namesBehavior
Omitted or undefinedRefresh all registered resolvers, including those added at runtime.
string[]Refresh the named variables and their dependents. Names must be registered.
[]Run no resolvers; synchronize bound slots with current values.
const refreshed: boolean = editor.commands.refreshVariables({ names: ['customer'] })
editor.commands.refreshVariables()

Returns false for an unknown name or a failed update. An unknown name does not call onError. Refreshing a plain value leaves it unchanged but recalculates its dependents.

editor.can().setVariable({ name, value }) and editor.can().refreshVariables() check variable names without executing resolvers. Their boolean result cannot predict evaluation or slot-write failures.

editor.can().setVariables({ variables }) also skips evaluation. editor.can().removeVariables({ names }) checks existing names and whether remaining resolvers would lose a dependency. Neither changes the runtime definitions.

editor.can().addVariable({ name, value }) checks that the name is nonempty and unregistered without running the resolver. editor.can().removeVariable({ name }) checks the same rules as bulk removal. Neither dry run changes definitions or predicts evaluation or slot-write failures.

Read a value

getVariable and GetVariableOptions

interface GetVariableOptions {
  editor: Editor
  name: string
}
Argument propertyTypeRequiredMeaning
editorEditorYesThe editor instance to read from.
namestringYesThe variable name to read.

Return type: VariableValue | undefined. VariableValue itself also includes undefined.

const customer = getVariable({ editor, name: 'customer' })
if (typeof customer === 'string') console.log(customer.toUpperCase())

This returns the value itself, including object values and Tiptap JSON. It does not run the resolver.

ResultMeaning
undefinedThe configured value is undefined, the name is unknown, or the extension is not installed.
nullThe configured value is null, a bound source is concealed, or a resolver failed during a remote edit or policy change.
Any other VariableValueThe current runtime value.

Concealing a bound slot also conceals its dependent values. They are recalculated when access is restored.

Typed shortcuts

Typing {foo} creates an inline reference named foo, even when foo is not defined in variables. Unknown names display an empty value. This creates a document reference, not a new runtime value or resolver.

The rule is enabled by default. Disable it with Variable.configure({ shortcuts: false }), or change the delimiters:

Variable.configure({
  shortcuts: { open: '[[', close: ']]' },
})
interface VariableShortcut {
  open: string
  close: string
}

shortcuts has type false | VariableShortcut and defaults to { open: '{', close: '}' }. Both delimiters must be nonempty, distinct strings. The captured name is case-sensitive and is not trimmed. Empty matches, nested opening delimiters, and code blocks are skipped. Pasting or loading content does not trigger conversion. undoInputRule() restores the typed text.

resolveInputRuleVariable

Customize or cancel a reference before it is inserted. The default callback returns undefined, keeping the captured name.

Variable.configure({
  resolveInputRuleVariable({ text }) {
    if (text === 'private') return false
    if (text === 'name') return { name: 'customer' }
    return undefined
  },
})
interface VariableInputRuleContext {
  editor: Editor
  text: string
  delimiters: Readonly<VariableShortcut>
  range: Readonly<{ from: number; to: number }>
  defaults: Readonly<{ name: string }>
}

interface VariableInputRuleResult {
  name?: string
}

type VariableInputRuleResolver = (
  context: VariableInputRuleContext,
) => VariableInputRuleResult | false | undefined
Context propertyMeaning
editorThe editor receiving the input.
textThe text between the delimiters, without either delimiter.
delimitersThe configured opening and closing strings.
rangeDocument positions to replace, before the pending input is inserted.
defaultsThe default reference attributes: { name: text }.

Return false to leave the input as text. Return { name: 'customer' } to override the name, or {} / undefined to use the default. An override must be a nonempty string; it does not have to name a configured variable. The callback is synchronous. Thrown errors and invalid results cancel conversion and call onError.

renderVariableValue

Render a value outside the document using the editor's schema and custom NodeViews.

interface RenderVariableValueOptions {
  editor: Editor
  value: VariableValue
}

interface RenderedVariableValue {
  dom: DocumentFragment
  destroy: () => void
}

function renderVariableValue(options: RenderVariableValueOptions): RenderedVariableValue

Both option properties are required. editor supplies the schema and NodeViews; value is the current value to display. These interfaces and the function are exported from @tiptap-pro/extension-variable.

dom contains the rendered snapshot. Tiptap JSON preserves formatting and custom NodeViews; other objects display as JSON text. Call destroy() before replacing or removing the result. It releases nested NodeViews and is safe to call more than once.

The helper does not subscribe to updates. Nested NodeViews have no document position, so their getPos() returns undefined. Rendering errors are thrown after cleaning up any NodeViews already created.

Listen for changes

The event name is variablesUpdate. Its listener type is (options: VariableUpdateOptions) => void. onVariablesUpdate is just the handler name used in these examples.

Subscribe and unsubscribe using the same function:

editor.on('variablesUpdate', onVariablesUpdate)
editor.off('variablesUpdate', onVariablesUpdate)

Both editor.on() and editor.off() return the editor instance. The listener receives the same { editor, values } payload as the extension's onUpdate callback.

Commands, refreshed resolvers, and slot edits can emit this event. Adding or removing a variable also emits it, even when its value is undefined. Removed names are absent from values. Replacing a resolver without changing its value does not emit it. Rejected updates and unchanged values do not emit it. Registering a listener does not send an initial value; use getVariable to read it.

Document reference

Variable references store only a name:

{ "type": "variable", "attrs": { "name": "customer" } }
PropertyTypeMeaning
type'variable'The extension's node name.
attrs.namestring | nullA variable name. The schema default is null; insertVariable requires a registered name.

A missing or unknown name displays an empty value. The runtime value is absent from document JSON, exported HTML, and plain text.