dev-examples/shadow-dom-web-component/README.md
A framework-free Vite app that packages a Lexical
rich-text editor as a custom element (<lexical-editor>) whose toolbar,
styles, and contentEditable all live inside an open ShadowRoot —
the scenario from
facebook/lexical#2119,
#6709, and
#8125.
Where the sibling shadow-dom example demonstrates a React
app with the editor in a shadow root and the toolbar outside it, this one
demonstrates the inverse packaging: a fully self-contained web component.
The demo page mounts four instances — three light-DOM <lexical-editor>
hosts inside the form (a required notes editor, a themable summary editor,
and a pre-rendered editor that hydrates from <template shadowrootmode="open">)
plus a fourth instance inside a wrapper <div> that opens its own shadow root,
so the editor's contentEditable sits two shadow boundaries below the document
and exercises the multi-level walk through getDOMShadowRoots. Everything
runs on platform APIs only:
Element.attachShadow({mode: 'open'}) in connectedCallback, with the
editor built by @lexical/extension's buildEditorFromExtensions and torn
down (editor.dispose()) in disconnectedCallback.ElementInternals
API — each editor submits its serialized editor state with the surrounding
<form>, no hidden <input> required.required validation through
ElementInternals.setValidity,
driven by the editor's plain text content. A <lexical-editor required>
participates in form.checkValidity() and the browser's native invalid-form
UI just like a <textarea required>.disabled and readonly attributes that flip Lexical's editable
state. disabled also drops the editor out of FormData and skips
validation, matching <input disabled>; the
formDisabledCallback
picks up an ancestor <fieldset disabled> automatically.--lexical-bg,
--lexical-fg, and a small palette of toolbar variables on its host
element. The page redefines them to recolour the editor; inherited
custom properties cross the shadow boundary on their own, so the
internal layout stays private.<button slot="toolbar-extra"> projects a
light-DOM button into the editor's toolbar row. The button stays in
the page (its click never crosses the boundary), but the page can drive
the editor through the host's public API.lexical-selection-rect CustomEvent carrying the live viewport
rect of the selection inside its shadow root, computed through
Lexical's getDOMSelectionRangeAndPoints. A page-level
popover
positions itself from those coordinates and drives bold / italic /
underline back through the host's editor.input event that crosses the shadow boundary so the page can
observe edits.Selection.getComposedRanges / Selection.direction, and focus via
ShadowRoot.activeElement.From the repository root:
pnpm install
pnpm -C dev-examples/shadow-dom-web-component dev
Then open the printed URL. Try:
Alt/Ctrl + Shift + arrow keys, then using the
in-shadow toolbar buttons — they reflect the selection's formats.Alt/Ctrl + Backspace/Delete.required) is empty: the
browser blocks the submit and surfaces its native validation tooltip on
the editor.toolbar-extra
slot, then drives the editor through the host's public API.readonly attribute on the summary editor, then trying to type
inside it: the contentEditable refuses input, but the form still
submits the value.--lexical-bg, --lexical-fg, etc. in the page CSS) to
see how page-side CSS variables retheme each editor without touching
its shadow root.Playwright tests in tests/ cover the
editors rendering in independent shadow roots (including the nested
editor inside the wrapper shadow root), typing and formatting, editor
independence, word deletion, ElementInternals form association, the
composed input event crossing the shadow boundary, and the floating
popover anchoring to a selection inside the nested shadow root. They
start the dev server automatically:
pnpm -C dev-examples/shadow-dom-web-component exec playwright install chromium
pnpm -C dev-examples/shadow-dom-web-component test
This example aims to be a full reference a production user can copy out. The Playwright suite covers each of the surfaces above plus a second round of audit items:
disconnectedCallback caches the
serialized state, connectedCallback restores it)delegatesFocus: true on the shadow root + tabindex="0" on the
contentEditablehost.setCustomValidity() + the standard validity / willValidate
/ checkValidity / reportValidity surfaceformAssociatedCallback + host.form, formResetCallback,
formDisabledCallback, formStateRestoreCallback (bfcache / autocomplete)inert attribute, aria-label / aria-invalid /
role="textbox" mirroringlexical-validity-change event for a visible error
message@media (prefers-color-scheme: dark) / (prefers-reduced-motion) /
(forced-colors: active) inside the shadow stylesheet<template shadowrootmode>) — the third
editor on the demo page pre-renders its shadow content and our
connectedCallback reuses the existing .content element instead
of creating a fresh contentEditablecustomElements.define of the
same tag throws NotSupportedError (the shipped helper guards
against this); a host that fails to build doesn't crash the
surrounding pageThe playground e2e suite (packages/lexical-playground/__tests__/e2e/ShadowDOM.spec.mjs)
covers the corresponding playground-side surfaces: markdown shortcuts
(# heading, - list) and @lexical/list inside the shadow root,
@lexical/history undo/redo, the tree-view mirror, pointer events
(composedPath recovery for touch / pen / mouse), HTML paste
sanitization (a <script> tag is stripped), a large keyboard input
that keeps the reconciler responsive, image insert + paste,
NodeSelection on image click, blur + re-focus through the shadow
boundary, Korean and Chinese IME composition cycles, and yjs
convergence between two clients each rendered inside its own open
shadow root.
lexical-devtools descends through open shadow roots using the same
helpers this PR adds (getDOMShadowRoots / getActiveElementDeep /
getEditorPropertyFromDOMNode), so it resolves the shadow-mounted
editor without dev-example-side glue. Closed-mode shadow roots remain
opaque to a page-level integration by spec — the concept page documents
the limitation and the browser-unit suite verifies the helpers behave
correctly when a host attaches a closed root.
A handful of newer platform APIs are not exercised by this single
example because they slot in at the page layer rather than the editor
itself: the
View Transitions API
animates between shadow-mounted instances on document.startViewTransition
without any editor-side glue, and the
CSS Custom Highlight API
(Highlight + ::highlight()) styles ranges that span the shadow
boundary as long as the page hands them un-retargeted boundary points —
the same shape getDOMSelectionRangeAndPoints already returns.