Details adds semantic disclosure blocks with one editable Summary and any number of direct body blocks. Open state belongs to the editor session, so HTML, Markdown, saved values, collaboration, and undo keep only document content.
A Details node always starts with exactly one Summary. Every following child is a direct body block:
const value = [
{
type: 'details',
children: [
{ type: 'summary', children: [{ text: 'Shipping details' }] },
{ type: 'paragraph', children: [{ text: 'Ships in 2–3 days.' }] },
],
},
];const value = [
{
type: 'details',
children: [
{ type: 'summary', children: [{ text: 'Shipping details' }] },
{ type: 'paragraph', children: [{ text: 'Ships in 2–3 days.' }] },
],
},
];open and name are not document properties. Use the plugin state when the application needs to expand or collapse a Details node.
Set level from 1 to 6 on a Summary to render it as a heading. Omit level for a plain title. The Summary stays the first child; formatting never moves its text into the body.
With HeadingPlugin installed, choose a heading in the formatting or slash menu, use the heading keyboard shortcuts, or type a Markdown heading prefix in the title. Text in the same menus removes the level. The headless equivalent is:
import { BaseHeadingPlugin } from 'platejs';
editor.plugin(BaseHeadingPlugin).update.setLevel(2);
editor.update.blocks.reset({ unset: ['level'] });import { BaseHeadingPlugin } from 'platejs';
editor.plugin(BaseHeadingPlugin).update.setLevel(2);
editor.update.blocks.reset({ unset: ['level'] });setLevel gives every selected block the level: a Summary keeps its type and other blocks become headings. It returns false and changes nothing when the schema refuses any of them. reset with unset: ['level'] turns each selected block into its parent's default, so a title stays a title without its level. toggle({ level }) removes the level when a selected block already has it and sets it otherwise. Use these operations instead of changing a Summary's type directly.
Install the copied DetailsKit, React elements, and toolbar button with the Plate CLI:
'use client';
import { ChevronRightIcon } from 'lucide-react';
import { BaseDetailsPlugin } from 'platejs/details';
import { DetailsPlugin, DetailsSummaryPlugin } from 'platejs/details/react';
import {
type EditorElementProps,
EditorElement,
useEditor,
useEditorSelector,
usePluginStore,
} from 'platejs/react';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { headingVariants } from '@/components/editor/heading';
export function DetailsElement(
props: EditorElementProps<typeof DetailsPlugin>
) {
const { element, slots } = props;
const editor = useEditor();
const detailsKey = editor.key(element);
const openKeys = usePluginStore(BaseDetailsPlugin, 'openKeys');
const { api } = useEditor().plugin(BaseDetailsPlugin);
const open = detailsKey !== undefined && openKeys.has(detailsKey);
const bodyId = React.useId();
const titleLevel = useEditorSelector(
(current) => {
const path =
detailsKey === undefined
? undefined
: current.read.nodes.get(detailsKey)?.[1];
return path
? current.read.nodes.get([...path, 0], {
type: DetailsSummaryPlugin,
})?.[0].level
: undefined;
},
{
shouldUpdate: (change) =>
!change ||
detailsKey === undefined ||
change.changed.hasNodeKey(detailsKey, 'node'),
}
);
return (
<EditorElement {...props} className="relative my-1 pl-6">
<div className="relative -ml-6 pl-6" data-node-selection-target>
<DetailsToggleLine level={titleLevel}>
<Button
aria-controls={bodyId}
aria-expanded={open}
aria-label={open ? 'Collapse details' : 'Expand details'}
className="size-6 rounded-md p-0 text-muted-foreground"
size="icon"
type="button"
variant="ghost"
onClick={(event) => {
event.preventDefault();
if (detailsKey !== undefined) api.setOpen(detailsKey, !open);
}}
onMouseDown={(event) => {
event.preventDefault();
}}
>
<ChevronRightIcon
className={cn(
'transition-transform duration-75',
open && 'rotate-90'
)}
data-icon
/>
</Button>
</DetailsToggleLine>
{slots.children({ from: 0, to: 0 })}
</div>
<div id={bodyId}>
{element.children.length > 1
? slots.contentBoundary({
copyPolicy: 'model',
mounted: open,
onMaterialize: () => {
if (detailsKey !== undefined) api.setOpen(detailsKey, true);
},
reason: 'app-collapse',
renderPlaceholder: () => null,
scope: {
from: 1,
to: element.children.length - 1,
type: 'children',
},
selectionPolicy: 'skip',
})
: null}
</div>
</EditorElement>
);
}
function DetailsToggleLine({
children,
level,
}: {
children: React.ReactNode;
level: EditorElementProps<typeof DetailsSummaryPlugin>['element']['level'];
}) {
return (
<span
className={cn(
level && headingVariants({ level }),
'absolute top-0 -left-0.5 m-0 flex h-[1lh] items-center p-0'
)}
contentEditable={false}
>
{children}
</span>
);
}
export function DetailsSummaryElement(
props: EditorElementProps<typeof DetailsSummaryPlugin>
) {
const { level } = props.element;
return (
<EditorElement
{...props}
as={level ? `h${level}` : 'div'}
className={cn(
'min-h-6 font-medium',
level && headingVariants({ level }),
'm-0'
)}
>
{props.children}
</EditorElement>
);
}
export const DetailsKit = [
DetailsSummaryPlugin.configure({ component: DetailsSummaryElement }),
DetailsPlugin.configure({ component: DetailsElement }),
] as const;'use client';
import { ChevronRightIcon } from 'lucide-react';
import { BaseDetailsPlugin } from 'platejs/details';
import { DetailsPlugin, DetailsSummaryPlugin } from 'platejs/details/react';
import {
type EditorElementProps,
EditorElement,
useEditor,
useEditorSelector,
usePluginStore,
} from 'platejs/react';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { headingVariants } from '@/components/editor/heading'
The kit contains DetailsPlugin, DetailsSummaryPlugin, DetailsElement, and DetailsSummaryElement.
Add DetailsKit to the editor plugins:
import { createEditor } from 'platejs/react';
import { DetailsKit } from '@/components/editor/details';
const editor = createEditor({
plugins: [
// ...otherPlugins,
...DetailsKit,
],
});import { createEditor } from 'platejs/react';
import { DetailsKit } from '@/components/editor/details';
Configure both node components. DetailsPlugin owns the container behavior; DetailsSummaryPlugin owns the editable first child.
import {
DetailsPlugin,
DetailsSummaryPlugin,
} from 'platejs/details/react';
import { createEditor } from 'platejs/react';
import {
DetailsElement,
DetailsSummaryElement,
} from '@/components/editor/details';
const editor = createEditor({
plugins: [
// ...otherPlugins,
DetailsSummaryPlugin.configure({ component: DetailsSummaryElement }),
DetailsPlugin.configure({ component: DetailsElement }),
],
});Server transforms and serialization only need BaseDetailsPlugin:
import { createEditor } from 'platejs';
import { BaseDetailsPlugin } from 'platejs/details';
const editor = createEditor({
plugins: [BaseDetailsPlugin],
});import { createEditor } from 'platejs';
import { BaseDetailsPlugin } from 'platejs/details';
const editor = createEditor({
plugins: [BaseDetailsPlugin],
});Insert an open Details node with an empty Summary and body paragraph after the
current block. Pass select: true to place the selection in the Summary and
replaceEmpty: true to replace an empty source in the same history step.
An explicit at keeps node-insertion placement; after identifies the source block.
editor.plugin(BaseDetailsPlugin).update.insert({}, { select: true });editor.plugin(BaseDetailsPlugin).update.insert({}, { select: true });Wrap selected sibling blocks. The first text block becomes the Summary; structural first blocks get an empty Summary before them.
editor.plugin(BaseDetailsPlugin).update.wrap();editor.plugin(BaseDetailsPlugin).update.wrap();Unwrap the selected Details nodes. A heading Summary becomes a Heading of the same level when the Heading plugin is installed. Other summaries become Paragraphs before the body blocks.
editor.plugin(BaseDetailsPlugin).update.unwrap();editor.plugin(BaseDetailsPlugin).update.unwrap();Read and update disclosure state through BaseDetailsPlugin. The state uses NodeKey, not a serialized element property.
const details = editor.plugin(BaseDetailsPlugin);
const key = editor.key(detailsElement);
if (key !== undefined) {
details.api.setOpen(key, true);
const open = details.store.get('isOpen', key);
}const details = editor.plugin(BaseDetailsPlugin);
const key = editor.key(detailsElement);
if (key !== undefined) {
details.api.setOpen(key, true);
const open = details.store.get('isOpen', key);
}Closing a Details node moves the closing editor's selection out of its body to the end of its Summary. A read-only view moves the owning editor's selection instead. Undo restores a removed Details node with its open state.
HTML uses fixed <details> and <summary> tags. Markdown uses the same registered tags, including for nested Details:
<details>
<summary>Title</summary>
Body text.
</details><details>
<summary>Title</summary>
Body text.
</details>Decoding ignores open and name, and encoding never writes them.
A heading title uses <summary><h2>Title</h2></summary> in HTML and <summary level="2">Title</summary> in Markdown. Both preserve inline formatting. HTML reads the level only when the heading is the summary's only child.
Use DetailsToolbarButton to wrap the current blocks or unwrap the active Details node.
Headless schema, format mappings, corrections, transient state, transforms, and Enter, Backspace, and Delete behavior for details nodes.
Headless text-block schema and format mappings for the persisted summary node type.
React adapter for Details rendering.
React adapter for Summary rendering.
import type {
DetailsElement,
DetailsSummaryElement,
} from 'platejs/details';import type {
DetailsElement,
DetailsSummaryElement,
} from 'platejs/details';import {
DetailsPlugin,
DetailsSummaryPlugin,
} from 'platejs/details/react';
import { createEditor } from 'platejs/react';
import {
DetailsElement,
DetailsSummaryElement,
} from '@/components/editor/details';
const editor = createEditor({
plugins: [
// ...otherPlugins,
DetailsSummaryPlugin.configure({ component: DetailsSummaryElement }),
DetailsPlugin.configure({ component: DetailsElement }),
],
});