Plate
PlateEditorsTemplates
GitHub16kGitHub
DiscordDiscord
    • Stream
    • Copilot
  • Comments
  • Discussion
  • Suggestions
    • Basic Blocks
      • Blockquote
      • Heading
      • Horizontal Rule
    • Callout
    • Code Block
    • Column
    • Date
    • Equation
    • Link
    • Media
    • MentionElement
    • Table
    • Table of Contents
    • Footnote
    • Details
  • Marks
    • Bold
    • Italic
    • Underline
    • Code
    • Highlight
    • Keyboard Input
    • Strikethrough
    • Subscript
    • Superscript
      • Font
      • Line Height
      • Text Align
    • Indent
    • List
      • Exit Break
      • Single Block
      • Trailing Block
    • Autoformat
    • Block Menu
    • Block Placeholder
    • Combobox
      • Emoji
      • MentionElement
      • Slash Command
    • Drag & Drop
    • Navigation Feedback
    • Linting
    • Tabbable
    • Toolbar
    • Yjs
    • Multi SelectEditor
    • DOCX
    • HTML
    • Markdown

Details

PreviousNext

Add semantic disclosure blocks with editable summaries.

Details ElementDetails Toolbar Button
Loading…
FootnoteMarks

On This Page

FeaturesDocument modelHeading titlesKit usageConvert a title to another blockInstallationAdd kitManual usageInstallationAdd pluginsAdd headless supportTransformseditor.plugin(BaseDetailsPlugin).update.inserteditor.plugin(BaseDetailsPlugin).update.wrapeditor.plugin(BaseDetailsPlugin).update.unwrapOpen stateSerializationToolbarPluginsBaseDetailsPluginBaseDetailsSummaryPluginDetailsPluginDetailsSummaryPluginTypes
Build your editor
Production-ready AI template and reusable components.
Get all-access

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.

Features

  • Native details and summary HTML serialization.
  • Nested Details with direct block children.
  • Transient open state keyed by node identity.
  • Wrap, unwrap, and insert transforms.
  • Keyboard behavior for entering, exiting, and unwrapping Details.
Report an issue

Document model

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.' }] },
    ],
  },
];

The Details schema enforces this shape. Turning a title into Quote, Callout or Columns, or typing three dashes in it, changes nothing. Code and List still convert a title and remove its Details. When Enter, paste or Duplicate creates a block after the title, that block is a body paragraph. A value with a Summary out of place, or with two, fails to load.

open and name are not document properties. Use the plugin state when the application needs to expand or collapse a Details node.

Heading titles

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; the heading commands below never move 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.

Kit usage

Convert a title to another block

Heading and Text choices keep the Details structure. List choices turn the selected title into a list item and put its body one level deeper under it. Tab, Shift+Tab, drag and Cut move that item together with its body. The Code Block command turns the title into a code block and leaves the body as the blocks after it. Both conversions remove only the selected Details layer and keep an enclosing Details.

With the Code Block kit installed, use mod+alt+8 for the code conversion. Turn into disables Code when the selection holds the title and more blocks. Undo restores the Details and its disclosure state. A list conversion changes nothing when a body block cannot take an indent. Other selected blocks stay unchanged too. IndentKit lists the block types that can, so a custom setup adds its body block types to Indent's targetPlugins.

A block inserted from a closed Details title lands after the whole Details, whether it comes from the slash menu, an insert command, a pasted file or AI. From an open title it lands at the start of the body. Heading and Text slash choices format the existing title instead.

Installation

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,
  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 { titleRef, toggleRef } = useToggleOnFirstLine();
 
  return (
    <EditorElement {...props} className="relative my-1 pl-6">
      <div
        ref={titleRef}
        className="relative -ml-6 pl-6"
        data-node-selection-target
      >
        <span
          ref={toggleRef}
          className="absolute top-3 -left-0.5 flex h-6 -translate-y-1/2 items-center"
          contentEditable={false}
        >
          <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>
        </span>
 
        {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>
  );
}
 
/**
 * Measures the title's text boxes because a heading level or a font-size mark
 * sets font size on inner elements, which no CSS on the title can read. Writes
 * `style.top` directly because a state update from a ResizeObserver callback
 * commits after the frame paints, leaving the toggle off-center for one frame.
 */
function useToggleOnFirstLine() {
  const titleRef = React.useRef<HTMLDivElement>(null);
  const toggleRef = React.useRef<HTMLSpanElement>(null);
 
  React.useLayoutEffect(() => {
    const title = titleRef.current;
    const toggle = toggleRef.current;
 
    if (!title || !toggle) return undefined;
 
    const place = () => {
      const line = firstLineExtent(title);
 
      toggle.style.top = line
        ? `${(line.top + line.bottom) / 2 - title.getBoundingClientRect().top}px`
        : '';
    };
 
    place();
 
    const observer = new ResizeObserver(place);
 
    observer.observe(title);
 
    return () => observer.disconnect();
  }, []);
 
  return { titleRef, toggleRef };
}
 
function firstLineExtent(container: HTMLElement) {
  const range = container.ownerDocument.createRange();
  const walker = container.ownerDocument.createTreeWalker(
    container,
    NodeFilter.SHOW_TEXT
  );
  let line: { bottom: number; top: number } | undefined;
 
  for (let node = walker.nextNode(); node; node = walker.nextNode()) {
    const chrome = node.parentElement?.closest('[contenteditable="false"]');
 
    if (chrome && container.contains(chrome)) continue;
 
    range.selectNodeContents(node);
 
    for (const rect of range.getClientRects()) {
      if (rect.height === 0) continue;
      if (!line) {
        line = { bottom: rect.bottom, top: rect.top };
        continue;
      }
      // Under a tight line height, the text boxes of adjacent lines can
      // overlap by a pixel or two.
      if (
        Math.min(line.bottom, rect.bottom) - Math.max(line.top, rect.top) <=
        2
      ) {
        return line;
      }
 
      line.top = Math.min(line.top, rect.top);
      line.bottom = Math.max(line.bottom, rect.bottom);
    }
  }
 
  return line;
}
 
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,
  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 kit

Add DetailsKit to the editor plugins:

editor.tsx
import { createEditor } from 'platejs/react';
import { DetailsKit } from '@/components/editor/details';
 
const editor = createEditor({
  plugins: [
    // ...otherPlugins,
    ...DetailsKit,
  ],
});
editor.tsx
import { createEditor } from 'platejs/react';
import { DetailsKit } from '@/components/editor/details';
 





Manual usage

Installation

pnpm add platejs
pnpm add platejs

Add plugins

Configure both node components. DetailsPlugin owns the container behavior; DetailsSummaryPlugin owns the editable first child.

editor.tsx
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 }),
  ],
});

Add headless support

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],
});

Transforms

editor.plugin(BaseDetailsPlugin).update.insert

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 });

editor.plugin(BaseDetailsPlugin).update.wrap

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();

editor.plugin(BaseDetailsPlugin).update.unwrap

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();

Open state

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.

Serialization

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 both HTML and Markdown, the form GitHub renders, and keeps its inline formatting. Both read the level only when the heading is the summary's only content. Markdown also reads a level attribute, as in <summary level="2">Title</summary>.

HTML follows the browser's rule for the title. The first <summary> child is the title wherever it sits, and each later <summary> becomes a body block that keeps its formatting: a paragraph, or the heading it holds. Markdown is stricter. A <details> whose <summary> is not first, or that holds two, fails to parse.

Toolbar

Use DetailsToolbarButton to wrap the current blocks or unwrap the active Details node.

Plugins

BaseDetailsPlugin

Headless schema, format mappings, transient state, transforms, and Enter, Backspace, and Delete behavior for details nodes.

BaseDetailsSummaryPlugin

Headless text-block schema and format mappings for the persisted summary node type.

DetailsPlugin

React adapter for Details rendering.

DetailsSummaryPlugin

React adapter for Summary rendering.

Types

import type {
  DetailsElement,
  DetailsSummaryElement,
} from 'platejs/details';
import type {
  DetailsElement,
  DetailsSummaryElement,
} from 'platejs/details';
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 { titleRef, toggleRef } = useToggleOnFirstLine();
return (
<EditorElement {...props} className="relative my-1 pl-6">
<div
ref={titleRef}
className="relative -ml-6 pl-6"
data-node-selection-target
>
<span
ref={toggleRef}
className="absolute top-3 -left-0.5 flex h-6 -translate-y-1/2 items-center"
contentEditable={false}
>
<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>
</span>
{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>
);
}
/**
* Measures the title's text boxes because a heading level or a font-size mark
* sets font size on inner elements, which no CSS on the title can read. Writes
* `style.top` directly because a state update from a ResizeObserver callback
* commits after the frame paints, leaving the toggle off-center for one frame.
*/
function useToggleOnFirstLine() {
const titleRef = React.useRef<HTMLDivElement>(null);
const toggleRef = React.useRef<HTMLSpanElement>(null);
React.useLayoutEffect(() => {
const title = titleRef.current;
const toggle = toggleRef.current;
if (!title || !toggle) return undefined;
const place = () => {
const line = firstLineExtent(title);
toggle.style.top = line
? `${(line.top + line.bottom) / 2 - title.getBoundingClientRect().top}px`
: '';
};
place();
const observer = new ResizeObserver(place);
observer.observe(title);
return () => observer.disconnect();
}, []);
return { titleRef, toggleRef };
}
function firstLineExtent(container: HTMLElement) {
const range = container.ownerDocument.createRange();
const walker = container.ownerDocument.createTreeWalker(
container,
NodeFilter.SHOW_TEXT
);
let line: { bottom: number; top: number } | undefined;
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const chrome = node.parentElement?.closest('[contenteditable="false"]');
if (chrome && container.contains(chrome)) continue;
range.selectNodeContents(node);
for (const rect of range.getClientRects()) {
if (rect.height === 0) continue;
if (!line) {
line = { bottom: rect.bottom, top: rect.top };
continue;
}
// Under a tight line height, the text boxes of adjacent lines can
// overlap by a pixel or two.
if (
Math.min(line.bottom, rect.bottom) - Math.max(line.top, rect.top) <=
2
) {
return line;
}
line.top = Math.min(line.top, rect.top);
line.bottom = Math.max(line.bottom, rect.bottom);
}
}
return line;
}
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;
const editor = createEditor({
plugins: [
// ...otherPlugins,
...DetailsKit,
],
});
editor.tsx
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 }),
  ],
});