Skip to content
This page is available as Markdown at /editor/concepts/containers.md. For the full documentation index, see /llms.txt, or the complete corpus at /llms-full.txt.

Containers

A container is a block object that holds editable block content: one of its fields is an array whose members include text blocks (or further containers). Containers are how callouts carry editable paragraphs, code blocks carry editable lines, and tables carry editable cells, without leaving Portable Text.

There is no editor-specific data format. A container in the value is an ordinary object block whose field happens to hold more blocks:

{
"_type": "callout",
"_key": "a1b2c3",
"tone": "note",
"content": [
{
"_type": "block",
"_key": "d4e5f6",
"children": [
{
"_type": "span",
"_key": "g7h8i9",
"text": "Editable text inside the callout.",
"marks": []
}
],
"markDefs": [],
"style": "normal"
}
]
}

Serializers and queries see nested Portable Text, nothing more. What makes it a container is that the editor knows to render the content array as an editable region instead of treating callout as an opaque block object.

A container starts in the schema: a block object with an array field whose of includes a {type: 'block'} member.

import {defineSchema} from '@portabletext/editor'
const schemaDefinition = defineSchema({
decorators: [{name: 'strong'}, {name: 'em'}],
blockObjects: [
{
name: 'callout',
fields: [
{name: 'tone', type: 'string'},
{name: 'content', type: 'array', of: [{type: 'block'}]},
],
},
],
})

The nested {type: 'block'} member declares the sub-schema for text inside the container. Each of styles, decorators, annotations, lists, and inlineObjects resolves independently:

  • Declared on the nested block, it overrides for that property.
  • Declared empty (decorators: []), it forbids that property inside the container.
  • Absent, it inherits from the nearest enclosing container that declares one, falling back to the root schema.

This is how a code block restricts its lines to a code style with no decorators while the rest of the document keeps its full schema. The complete resolution rules live in the @portabletext/schema README.

The schema declares what a container allows; a registration tells the editor to render it as one. Create the registration with defineContainer and mount it with NodePlugin:

import {
defineContainer,
EditorProvider,
PortableTextEditable,
} from '@portabletext/editor'
import {NodePlugin} from '@portabletext/editor/plugins'
// Module scope: a new array identity re-registers the nodes on every render.
const nodes = [
defineContainer({
type: 'callout',
arrayField: 'content',
render: ({attributes, children, selected}) => (
<aside {...attributes} data-selected={selected ? '' : undefined}>
<span contentEditable={false}>💡</span>
{children}
</aside>
),
}),
]
function App() {
return (
<EditorProvider initialConfig={{schemaDefinition}}>
<NodePlugin nodes={nodes} />
<PortableTextEditable />
</EditorProvider>
)
}

The render contract:

  • Spread attributes onto your outer element so the engine can track the node.
  • Render children where the editable content goes; the engine fills it with the container’s blocks.
  • Mark any chrome that is not editable content (icons, buttons, drag handles) with contentEditable={false}.
  • selected is true when the container is the current selection; use it for selection styling, as data-selected above does.
  • readOnly is true when the editor is read-only; check it before making chrome interactive, for example draggable={!readOnly} on a drag handle.
  • renderDefault renders the engine’s minimal wrapper; call it to fall back or wrap the default.

Omitting render falls through to the engine default, so a registration can exist purely to mark the field as editable.

Registering a container also normalizes the value: existing blocks of the registered type are seeded down to a cursor-ready structure, so a bare callout block gains a content array holding an empty text block.

A container registration that doesn’t match the schema is skipped with a console.warn naming the actual mismatch: an unknown type, a missing field, a field that isn’t an array, or an array of primitives only. The editor keeps working; the type renders as an ordinary block object until the registration and schema agree.

Containers only exist as registrations, there’s no legacy render prop to migrate from. If the rest of your editor still renders through the deprecated props, the migration guide covers moving those to registrations too.

Inside a container, the editor resolves the schema at the caret, not the top-level schema. A code block whose sub-schema declares no decorators won’t accept bold, whether from the keyboard, the toolbar, or a paste.

Schema-aware plugins get the same gating for free: MarkdownShortcutsPlugin passes each callback a context object carrying the schema at the caret as context.schema. Take a schema whose code block forbids decorators, and a Markdown shortcut configured by schema lookup:

import {MarkdownShortcutsPlugin} from '@portabletext/plugin-markdown-shortcuts'
const schemaDefinition = defineSchema({
decorators: [{name: 'strong'}, {name: 'em'}],
blockObjects: [
{
name: 'code-block',
fields: [
{
name: 'lines',
type: 'array',
// The code line allows a `code` style and no decorators.
of: [{type: 'block', styles: [{name: 'code'}], decorators: []}],
},
],
},
],
})
<MarkdownShortcutsPlugin
boldDecorator={({context}) =>
context.schema.decorators.find((decorator) => decorator.name === 'strong')
?.name
}
/>

The callback runs against the schema at the caret. In a regular paragraph the lookup finds strong, so typing **bold** applies the decorator. Inside a code-block line the sub-schema declares decorators: [], the lookup returns undefined, and the shortcut skips itself. Nothing was configured per container: the schema is the feature flag, and the lookup is what reads it.

Your own code can resolve the schema at any position with getPathSubSchema from the traversal API.

A registration’s markup ownership, and how the engine dispatches between global and positional renders, is one model shared by every kind of registration. Rendering covers that model; this page covers the part specific to containers: an of array scopes registrations to positions inside one.

A container’s of entry beats the global registration for the same type, one level down, in its immediate children. A code block can render its lines without paragraph spacing, or a callout can render its paragraphs tighter than the rest of the document:

import {defineContainer, defineTextBlock} from '@portabletext/editor'
const nodes = [
// Paragraphs everywhere: `block` is the text block type at every
// nesting level.
defineTextBlock({
type: 'block',
render: ({attributes, children, node}) => (
<p {...attributes} data-style={node.style}>
{children}
</p>
),
}),
defineContainer({
type: 'callout',
arrayField: 'content',
render: ({attributes, children}) => (
<aside {...attributes}>{children}</aside>
),
// Inside the callout, the same `block` type renders tighter.
of: [
defineTextBlock({
type: 'block',
render: ({attributes, children}) => (
<p {...attributes} style={{margin: 0, fontSize: '0.875em'}}>
{children}
</p>
),
}),
],
}),
]

Paragraphs render through the first defineTextBlock everywhere else and through the tighter one inside the callout.

Any text block’s of can scope marks one level deeper, into defineDecorator and defineAnnotation entries, whether or not that text block sits inside a container; it lives on this page because of scoping is this page’s subject. Register the same decorator globally and positionally to see the scoping:

defineDecorator({
type: 'strong',
render: ({children}) => <strong>{children}</strong>,
})
defineTextBlock({
type: 'block',
render: ({attributes, children}) => (
<p {...attributes} style={{margin: 0, fontSize: '0.875em'}}>
{children}
</p>
),
of: [
defineDecorator({
type: 'strong',
render: ({children}) => <mark>{children}</mark>,
}),
],
})

With both registered, strong renders as <mark> inside these text blocks and as <strong> everywhere else. A global defineDecorator or defineAnnotation renders inside every text block; a positional entry, registered through a text block’s of, only renders inside that scope, everywhere else the global registration still applies. Remove the positional entry’s render and strong renders as <strong> everywhere: an of entry without its own render defers to the global registration, or, absent one, whatever would have rendered anyway. That is a different fall-through than a top-level registration: a top-level registration without render falls to the engine default, while an of entry without render falls to the global registration first. See Dispatch precedence for how positional, global, and the '*' catch-all rank against each other.

Containers nest through the of array, which scopes registrations to positions inside the parent. A table is three containers deep:

defineContainer({
type: 'table',
arrayField: 'rows',
render: ({attributes, children}) => <table {...attributes}>{children}</table>,
of: [
defineContainer({
type: 'row',
arrayField: 'cells',
render: ({attributes, children}) => <tr {...attributes}>{children}</tr>,
of: [
defineContainer({
type: 'cell',
arrayField: 'content',
render: ({attributes, children}) => (
<td {...attributes}>{children}</td>
),
}),
],
}),
],
})

Nesting isn’t limited to containers: of accepts any block-level registration, defineContainer, defineTextBlock, and defineBlockObject, so anything can render differently inside a container than in the rest of the document. A code block can render each line without paragraph spacing, and a callout can render images compactly. Inline kinds scope the same way one level down, through a text block’s own of, which takes defineSpan and defineInlineObject registrations (a code-span inside a code block, for example).

Scope is one level deep: an of override applies to the container’s immediate children only, and anything nested deeper falls through to the global registrations. An image inside a table cell sees the cell’s of, not the table’s.

Everything above composes in one nodes array. This editor renders text blocks and images everywhere, and a callout that renders the same image type compactly inside itself:

import {
defineBlockObject,
defineContainer,
defineSchema,
defineTextBlock,
EditorProvider,
PortableTextEditable,
} from '@portabletext/editor'
import {NodePlugin} from '@portabletext/editor/plugins'
const schemaDefinition = defineSchema({
decorators: [{name: 'strong'}, {name: 'em'}],
styles: [{name: 'normal'}, {name: 'h2'}],
blockObjects: [
{name: 'image', fields: [{name: 'src', type: 'string'}]},
{
name: 'callout',
fields: [
{name: 'tone', type: 'string'},
{
name: 'content',
type: 'array',
// The callout holds text blocks and images.
of: [{type: 'block'}, {type: 'image'}],
},
],
},
],
})
const nodes = [
// Text blocks, at the root and inside the callout alike.
defineTextBlock({
type: 'block',
render: ({attributes, children, node}) =>
node.style === 'h2' ? (
<h2 {...attributes}>{children}</h2>
) : (
<p {...attributes}>{children}</p>
),
}),
// Images at the root: full width.
defineBlockObject({
type: 'image',
render: (props) =>
typeof props.node.src === 'string' ? (
<figure {...props.attributes}>
<div contentEditable={false} draggable={!props.readOnly}>
<img src={props.node.src} alt="" style={{width: '100%'}} />
</div>
{props.children}
</figure>
) : (
props.renderDefault(props)
),
}),
// The callout, with a positional override: the same `image` type
// renders compactly inside it.
defineContainer({
type: 'callout',
arrayField: 'content',
render: ({attributes, children}) => (
<aside {...attributes}>
<span contentEditable={false}>💡</span>
{children}
</aside>
),
of: [
defineBlockObject({
type: 'image',
render: (props) =>
typeof props.node.src === 'string' ? (
<span {...props.attributes}>
<span contentEditable={false} draggable={!props.readOnly}>
<img src={props.node.src} alt="" style={{height: '4rem'}} />
</span>
{props.children}
</span>
) : (
props.renderDefault(props)
),
}),
],
}),
]
function App() {
return (
<EditorProvider initialConfig={{schemaDefinition}}>
<NodePlugin nodes={nodes} />
<PortableTextEditable />
</EditorProvider>
)
}

An image block at the document root renders through the global registration, full width. The same _type: 'image' inside the callout’s content hits the positional override first and renders compact. The text block registration needs no positional counterpart: type: 'block' already covers both scopes.

Both registrations call renderDefault when node.src isn’t a string; see renderDefault on the Rendering page for what that falls back to.

Decorators and annotations scope the same way, one level deeper: a defineDecorator or defineAnnotation entry inside the callout’s text block of (as shown under Narrow rendering with of) makes an annotation like link render differently only inside the callout, and the text block entry needs no render of its own to carry it.

snapshot.context.containers exposes what’s currently registered, keyed by the container’s bare block-object _type (callout, table, and so on). Each entry carries type and the field that holds the container’s editable children: the field’s name, plus the schema’s of list of allowed child types. If the registration declared a nested of, the entry also carries that array, holding further container entries or positional entries (span, block object, inline object overrides). Only top-level registrations appear as flat map entries: a type registered solely inside a parent’s of shows up nested there, not as its own entry.

Read it with a selector like any other part of the snapshot:

import type {EditorSelector} from '@portabletext/editor'
const isCalloutRegistered: EditorSelector<boolean> = (snapshot) =>
snapshot.context.containers.has('callout')

@portabletext/plugin-table is built entirely on this API: three nested containers plus behaviors and UI. It’s both a ready-made table editor and the reference for what containers can carry. The Portable Text Playground ships container examples you can try, callouts, code blocks, fact boxes, and tables.