Editable Component

PreviousNext

Editable renders the editable document surface for the nearest Slate provider. App code customizes what content looks like; the runtime owns browser selection, DOM repair, void shells, and editing events.

<Slate editor={editor}>
  <Editable />
</Slate>
<Slate editor={editor}>
  <Editable />
</Slate>

Props

type EditableProps = {
  autoFocus?: boolean
  className?: string
  decorate?: (entry: NodeEntry) => EditableDecoration[]
  decorateDirtiness?: SlateSourceDirtiness
  decorateRuntimeScope?: SlateProjectionRuntimeScope
  disableDefaultStyles?: boolean
  id?: string
  domStrategy?: DOMStrategyOptions | null
  domStrategyLayout?: EditableDOMStrategyLayout | null
  onBeforeInput?: React.FormEventHandler<HTMLDivElement>
  onDOMBeforeInput?: (
    event: InputEvent,
    context: EditableDOMBeforeInputContext
  ) => boolean | EditableRepairRequest | void
  onKeyDown?: EditableKeyDownHandler
  onDOMStrategyMetrics?: (
    metrics: EditableDOMStrategyMetrics
  ) => void
  onPaste?: React.ClipboardEventHandler<HTMLDivElement>
  placeholder?: React.ReactNode
  readOnly?: boolean
  renderElement?: (props: RenderElementProps) => React.ReactNode
  renderLeaf?: (props: RenderLeafProps) => React.ReactNode
  renderPlaceholder?: (props: RenderPlaceholderProps) => React.ReactNode
  renderSegment?: (
    segment: EditableTextSegment,
    children: React.ReactNode
  ) => React.ReactNode
  renderText?: (props: RenderTextProps) => React.ReactNode
  renderVoid?: (props: RenderVoidProps) => React.ReactNode
  root?: RootKey
  scrollSelectionIntoView?: (editor: Editor, domRange: globalThis.Range) => void
  spellCheck?: boolean
  style?: React.CSSProperties
}
type EditableProps = {
  autoFocus?: boolean
  className?: string
  decorate?: (entry: NodeEntry) => EditableDecoration[]
  decorateDirtiness?: SlateSourceDirtiness
  decorateRuntimeScope?: SlateProjectionRuntimeScope
  disableDefaultStyles?: boolean
  id?: string
  domStrategy?: DOMStrategyOptions | null
  domStrategyLayout?: EditableDOMStrategyLayout | null
  onBeforeInput?: React.FormEventHandler<HTMLDivElement>
  onDOMBeforeInput?: (
    event: InputEvent,
    context: EditableDOMBeforeInputContext
  ) => boolean | EditableRepairRequest | void
  onKeyDown?: EditableKeyDownHandler
  onDOMStrategyMetrics?: (
    metrics: EditableDOMStrategyMetrics
  ) => void
  onPaste?: React.ClipboardEventHandler<HTMLDivElement>
  placeholder?: React.ReactNode
  readOnly?: boolean
  renderElement?: (props: RenderElementProps) => React.ReactNode
  renderLeaf?: (props: RenderLeafProps) => React.ReactNode
  renderPlaceholder?: (props: RenderPlaceholderProps) => React.ReactNode
  renderSegment?: (
    segment: EditableTextSegment,
    children: React.ReactNode
  ) => React.ReactNode
  renderText?: (props: RenderTextProps) => React.ReactNode
  renderVoid?: (props: RenderVoidProps) => React.ReactNode
  root?: RootKey
  scrollSelectionIntoView?: (editor: Editor, domRange: globalThis.Range) => void
  spellCheck?: boolean
  style?: React.CSSProperties
}

Editable also accepts safe div attributes such as aria-*, data-*, and event handlers that are not owned by Slate.

DOMStrategyOptions is either 'auto', 'staged', 'full', an object with { type: 'auto' | 'staged' | 'full', textSync? }, or the experimental object form { type: 'virtualized', estimatedBlockSize?, overscan?, threshold?, textSync? }.

scrollSelectionIntoView defaults to defaultScrollSelectionIntoView. Import the helper when custom scroll behavior should wrap Slate's default caret and selection scrolling instead of replacing it completely.

Render Props

Use raw render props for content rendering. Keep renderer functions stable by defining them at module scope or creating them once with the editor.

type RenderElementProps = {
  attributes: {
    'data-slate-inline'?: true
    'data-slate-node': 'element'
    'data-slate-path': string
    'data-slate-runtime-id': RuntimeId
    'data-slate-void'?: true
    ref: React.RefCallback<HTMLElement>
  }
  children: React.ReactNode
  element: Element
  isInline: boolean
  slots: EditableElementSlots
}
type RenderElementProps = {
  attributes: {
    'data-slate-inline'?: true
    'data-slate-node': 'element'
    'data-slate-path': string
    'data-slate-runtime-id': RuntimeId
    'data-slate-void'?: true
    ref: React.RefCallback<HTMLElement>
  }
  children: React.ReactNode
  element: Element
  isInline: boolean
  slots: EditableElementSlots
}

renderElement

Use renderElement for normal elements that render Slate-managed children.

const renderElement = ({ attributes, children, element }) => {
  switch (element.type) {
    case 'code':
      return (
        <pre {...attributes}>
          <code>{children}</code>
        </pre>
      )
    default:
      return <p {...attributes}>{children}</p>
  }
}
const renderElement = ({ attributes, children, element }) => {
  switch (element.type) {
    case 'code':
      return (
        <pre {...attributes}>
          <code>{children}</code>
        </pre>
      )
    default:
      return <p {...attributes}>{children}</p>
  }
}

Always spread attributes on the top-level DOM element and render children.

renderVoid

Use renderVoid for void elements. A void renderer returns visible content only.

const renderVoid = ({ element }) => {
  switch (element.type) {
    case 'image':
      return <ImageElement element={element} />
    default:
      return null
  }
}
const renderVoid = ({ element }) => {
  switch (element.type) {
    case 'image':
      return <ImageElement element={element} />
    default:
      return null
  }
}

Do not render children, hidden text anchors, or shell wrappers in normal void renderers. Slate renders the shell and model anchor for you.

If a void needs selected UI, subscribe from inside the void component.

const ImageElement = ({ element }) => {
  const selected = useElementSelected({ mode: 'collapsed' })
 
  return <img data-selected={selected || undefined} src={element.url} />
}
const ImageElement = ({ element }) => {
  const selected = useElementSelected({ mode: 'collapsed' })
 
  return <img data-selected={selected || undefined} src={element.url} />
}

If an event handler needs the current location of the rendered element, resolve the path inside the handler.

const ImageElement = ({ element }) => {
  const editor = useEditor()
 
  return (
    <button
      onClick={() => {
        const path = editor.api.dom.resolvePath(element)
 
        if (!path) return
 
        editor.update((tx) => {
          tx.nodes.remove({ at: path, voids: true })
        })
      }}
    />
  )
}
const ImageElement = ({ element }) => {
  const editor = useEditor()
 
  return (
    <button
      onClick={() => {
        const path = editor.api.dom.resolvePath(element)
 
        if (!path) return
 
        editor.update((tx) => {
          tx.nodes.remove({ at: path, voids: true })
        })
      }}
    />
  )
}

renderLeaf

Use renderLeaf for text marks.

const renderLeaf = ({ attributes, children, leaf }) => {
  return (
    <span
      {...attributes}
      style={{
        fontWeight: leaf.bold ? 'bold' : 'normal',
        fontStyle: leaf.italic ? 'italic' : 'normal',
      }}
    >
      {children}
    </span>
  )
}
const renderLeaf = ({ attributes, children, leaf }) => {
  return (
    <span
      {...attributes}
      style={{
        fontWeight: leaf.bold ? 'bold' : 'normal',
        fontStyle: leaf.italic ? 'italic' : 'normal',
      }}
    >
      {children}
    </span>
  )
}

renderText

Use renderText when you need to wrap a whole text node, regardless of how decorations split it into leaves.

const renderText = ({ attributes, children, text }) => {
  return (
    <span {...attributes} data-commented={text.commentId || undefined}>
      {children}
    </span>
  )
}
const renderText = ({ attributes, children, text }) => {
  return (
    <span {...attributes} data-commented={text.commentId || undefined}>
      {children}
    </span>
  )
}

decorate

Use Editable.decorate for simple editor-local ranges such as a one-off search match or lightweight syntax highlight.

<Editable
  decorate={([node, path]) => {
    if (!TextApi.isText(node)) return []
 
    const start = node.text.indexOf(query)
 
    return start === -1
      ? []
      : [
          {
            anchor: { path, offset: start },
            data: { search: true },
            focus: { path, offset: start + query.length },
          },
        ]
  }}
  renderSegment={(segment, children) =>
    segment.slices.some((slice) => slice.data?.search) ? (
      <mark>{children}</mark>
    ) : (
      children
    )
  }
/>
<Editable
  decorate={([node, path]) => {
    if (!TextApi.isText(node)) return []
 
    const start = node.text.indexOf(query)
 
    return start === -1
      ? []
      : [
          {
            anchor: { path, offset: start },
            data: { search: true },
            focus: { path, offset: start + query.length },
          },
        ]
  }}
  renderSegment={(segment, children) =>
    segment.slices.some((slice) => slice.data?.search) ? (
      <mark>{children}</mark>
    ) : (
      children
    )
  }
/>

decorate is a convenience adapter over the projection runtime. Use provider-owned decorationSources when the ranges are shared with other UI, come from external state, update frequently, or need source-scoped refreshes.

Use decorateDirtiness and decorateRuntimeScope when a decoration callback depends on external projection state and can name which runtime targets should refresh.

renderSegment

renderSegment renders text after projection sources split it into projected slices. Use it for search results, comments, diagnostics, and other render-time overlays.

<Editable
  renderSegment={(segment, children) =>
    segment.slices.length > 0 ? <mark>{children}</mark> : children
  }
/>
<Editable
  renderSegment={(segment, children) =>
    segment.slices.length > 0 ? <mark>{children}</mark> : children
  }
/>

Normal apps should pass decorationSources and annotationStore to Slate, then render projected text through renderSegment.

placeholder And renderPlaceholder

Use placeholder for the normal empty-editor message.

<Editable placeholder="Start typing..." />
<Editable placeholder="Start typing..." />

Use renderPlaceholder when the placeholder needs custom markup.

<Editable
  placeholder="Start typing..."
  renderPlaceholder={({ attributes, children }) => (
    <span {...attributes}>{children}</span>
  )}
/>
<Editable
  placeholder="Start typing..."
  renderPlaceholder={({ attributes, children }) => (
    <span {...attributes}>{children}</span>
  )}
/>

Keep the provided attributes. They make the placeholder behave like editor chrome instead of document content.

Event Props

Use onKeyDown for UI hotkeys on one Editable instance.

<Editable
  onKeyDown={(event, { editor }) => {
    if (event.key === '`' && event.ctrlKey) {
      editor.update(tx => {
        tx.nodes.set({ type: 'code' })
      })
      return true
    }
  }}
/>
<Editable
  onKeyDown={(event, { editor }) => {
    if (event.key === '`' && event.ctrlKey) {
      editor.update(tx => {
        tx.nodes.set({ type: 'code' })
      })
      return true
    }
  }}
/>

Use extension transforms for model behavior such as deleteBackward, deleteForward, and insertBreak. Those handlers run for keyboard input, native input, programmatic transforms, and tests.

const markdown = defineEditorExtension({
  name: 'markdown',
  transforms: {
    insertText({ next, text, tx }) {
      if (text === ' ') {
        const selection = tx.selection.get()
 
        if (selection) {
          tx.nodes.set({ type: 'code' })
        }
        return true
      }
 
      return next()
    },
  },
})
const markdown = defineEditorExtension({
  name: 'markdown',
  transforms: {
    insertText({ next, text, tx }) {
      if (text === ' ') {
        const selection = tx.selection.get()
 
        if (selection) {
          tx.nodes.set({ type: 'code' })
        }
        return true
      }
 
      return next()
    },
  },
})

onBeforeInput is the React form-event hook on the editable root. Use onDOMBeforeInput only when you need the raw native InputEvent. Returning true or calling event.preventDefault() marks the event handled.

Projection Sources

Put decoration sources and the annotation store on Slate, not Editable. The provider owns editor-level projection sources so the editor surface, toolbar, and overlay UI read the same committed projection.

<Slate
  annotationStore={commentStore}
  decorationSources={[searchSource]}
  editor={editor}
>
  <Editable renderSegment={renderSearchMatch} />
  <CommentsSidebar store={commentStore} />
</Slate>
<Slate
  annotationStore={commentStore}
  decorationSources={[searchSource]}
  editor={editor}
>
  <Editable renderSegment={renderSearchMatch} />
  <CommentsSidebar store={commentStore} />
</Slate>

Inline, void, selectable, and read-only behavior belongs to the editor schema.

editor.extend({
  elements: [
    {
      type: 'mention',
      void: 'markable-inline',
    },
  ],
  name: 'mentions',
})
editor.extend({
  elements: [
    {
      type: 'mention',
      void: 'markable-inline',
    },
  ],
  name: 'mentions',
})

Product input rules belong in higher-level command layers or editor extensions. Keep raw Editable focused on rendering and DOM events.

DOM Strategy

Editable keeps large documents DOM-bounded by default. Use domStrategy="auto" for bounded partial-DOM rendering. Slate keeps coarse groups covered by model-backed boundaries and mounts a small active window inside the opened group; the surrounding range stays selectable and copyable through boundary policy until it materializes. Use domStrategy="staged" when a product needs eventual native DOM coverage for the whole document, or domStrategy="full" to render the full document surface for debugging.

Use onDOMStrategyMetrics to wire production RUM or a Datadog dashboard. The callback runs after commit and reports the current document cohort, requested strategy, effective strategy, degradation mode, mounted/pending counts, DOM coverage boundary counts, visible DOM node count, and editable descendant count.

<Editable
  domStrategy="auto"
  onDOMStrategyMetrics={metrics => {
    datadogRum.addAction('slate.dom_strategy.surface', metrics)
  }}
/>
<Editable
  domStrategy="auto"
  onDOMStrategyMetrics={metrics => {
    datadogRum.addAction('slate.dom_strategy.surface', metrics)
  }}
/>

Track dashboards by interaction name, cohort, document size, requested strategy, effective strategy, degradation mode, native surface completion, boundary count, visible DOM count, editable descendant count, custom renderer flag, browser, mobile/desktop, IME state, and app version. Virtualized and partial-DOM metrics are bounded-surface rows. staged-warmup metrics are staged materialization rows. Do not mix either bucket with complete full-DOM rows.

Pass domStrategyLayout only when the app owns a layout engine that can name virtualized top-level or page items. Normal editor surfaces should leave it unset.

DOM Coverage Boundaries

renderElement receives slots.contentBoundary for model content whose DOM is intentionally not mounted. Use it for closed accordions, inactive tab panels, collapsed sections, or hidden element shells that still exist in the Slate value.

const renderElement = ({ children, element, slots }) => {
  if (element.type === 'section') {
    return (
      <EditableElement>
        {React.Children.toArray(children)[0]}
        {slots.contentBoundary({
          mounted: !element.collapsed,
          onMaterialize: () => openSection(element.id),
          renderPlaceholder: ({ materialize }) => (
            <button onClick={materialize} type="button">
              Show section
            </button>
          ),
          scope: { from: 1, type: 'children' },
          selectionPolicy: 'materialize',
        })}
      </EditableElement>
    )
  }
 
  if (element.type === 'hidden-header') {
    return (
      <EditableElement>
        {slots.contentBoundary({
          boundaryId: 'hidden-header',
          children: <button type="button">Show header</button>,
          copyPolicy: 'exclude',
          mounted: !element.hidden,
          reason: 'app-hidden',
          scope: { type: 'self' },
          selectionPolicy: 'skip',
        })}
      </EditableElement>
    )
  }
 
  return <EditableElement>{children}</EditableElement>
}
const renderElement = ({ children, element, slots }) => {
  if (element.type === 'section') {
    return (
      <EditableElement>
        {React.Children.toArray(children)[0]}
        {slots.contentBoundary({
          mounted: !element.collapsed,
          onMaterialize: () => openSection(element.id),
          renderPlaceholder: ({ materialize }) => (
            <button onClick={materialize} type="button">
              Show section
            </button>
          ),
          scope: { from: 1, type: 'children' },
          selectionPolicy: 'materialize',
        })}
      </EditableElement>
    )
  }
 
  if (element.type === 'hidden-header') {
    return (
      <EditableElement>
        {slots.contentBoundary({
          boundaryId: 'hidden-header',
          children: <button type="button">Show header</button>,
          copyPolicy: 'exclude',
          mounted: !element.hidden,
          reason: 'app-hidden',
          scope: { type: 'self' },
          selectionPolicy: 'skip',
        })}
      </EditableElement>
    )
  }
 
  return <EditableElement>{children}</EditableElement>
}

Boundary content is model-present but DOM-incomplete while mounted is false. Slate maps selection, copy, paste, and DOM point import through the boundary registry instead of resolving missing descendants with raw DOM lookups.

Use the dedicated DOM Coverage Boundaries page for selectionPolicy, copyPolicy, findPolicy, and onMaterialize.

Multiple Roots

Pass root when one editor renders a named document root.

<Slate editor={editor}>
  <Editable aria-label="Header" root="header" />
  <Editable aria-label="Body" />
  <Editable aria-label="Footer" root="footer" />
</Slate>
<Slate editor={editor}>
  <Editable aria-label="Header" root="header" />
  <Editable aria-label="Body" />
  <Editable aria-label="Footer" root="footer" />
</Slate>

Use slots.contentRoot(slot) from renderElement when an element owns an editable child root, such as a synced block body.

const SyncedBlock = ({ attributes, element, slots }) => (
  <section {...attributes}>
    <header>Synced block</header>
    {slots.contentRoot('body', { ariaLabel: 'Synced block body' })}
  </section>
)
const SyncedBlock = ({ attributes, element, slots }) => (
  <section {...attributes}>
    <header>Synced block</header>
    {slots.contentRoot('body', { ariaLabel: 'Synced block body' })}
  </section>
)

See Roots for the value shape, tx.roots, root chrome, and content-root ownership.

Styling

Use style or className for editor styling.

<Editable style={{ minHeight: 200 }} />
<Editable style={{ minHeight: 200 }} />

Pass disableDefaultStyles only when your CSS replaces Slate's default editable-surface styles.