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
    • Tabbable
    • Toolbar
    • Yjs
    • Multi SelectEditor
    • CSV
    • DOCX
    • HTML
    • Markdown

Details

PreviousNext

Add semantic disclosure blocks with editable summaries.

Details ElementDetails Toolbar Button
Loading…
FootnoteMarks

On This Page

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

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

Kit usage

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';
 
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();
 
  return (
    <EditorElement {...props} className="relative my-1 pl-6">
      <Button
        aria-controls={bodyId}
        aria-expanded={open}
        aria-label={open ? 'Collapse details' : 'Expand details'}
        className="absolute top-0 -left-0.5 size-6 rounded-md p-0 text-muted-foreground"
        contentEditable={false}
        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>
 
      {slots.children({ from: 0, to: 0 })}
 
      <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>
  );
}
 
export function DetailsSummaryElement(
  props: EditorElementProps<typeof DetailsSummaryPlugin>
) {
  return (
    <EditorElement {...props} className="min-h-6 font-medium">
      {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';
 
export function DetailsElement(













































































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. The Summary becomes the editor's default text block 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 a selection inside its body to the end of its Summary. Removing the node prunes its transient key.

Serialization

HTML uses fixed <details> and <summary> tags. Markdown uses equivalent MDX elements, including for nested Details. Decoding ignores open and name, and encoding never writes them.

Toolbar

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

Plugins

BaseDetailsPlugin

Headless schema, codecs, corrections, transient state, transforms, and Enter, Backspace, and Delete behavior for details nodes.

BaseDetailsSummaryPlugin

Headless text-block schema and codecs 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';
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();
return (
<EditorElement {...props} className="relative my-1 pl-6">
<Button
aria-controls={bodyId}
aria-expanded={open}
aria-label={open ? 'Collapse details' : 'Expand details'}
className="absolute top-0 -left-0.5 size-6 rounded-md p-0 text-muted-foreground"
contentEditable={false}
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>
{slots.children({ from: 0, to: 0 })}
<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>
);
}
export function DetailsSummaryElement(
props: EditorElementProps<typeof DetailsSummaryPlugin>
) {
return (
<EditorElement {...props} className="min-h-6 font-medium">
{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 }),
  ],
});