Sign up (with export icon)

Running CKEditor 5 inside a shadow DOM

Contribute to this guideShow the table of contents

CKEditor 5 runs inside an open shadow root. Selection, focus, positioning, scrolling, drag and drop, and the floating user interface all work there, and so do the premium features.

Two things do not happen by themselves, because a shadow root is a separate DOM tree with a styling boundary of its own. The editor style sheets have to reach every tree that holds the editor user interface, and CSS variables have to be overridden on the shadow host rather than on :root.

The integration guides show how to do both in each framework:

Note

Writing an editor feature that has to keep working inside a shadow root is a different job. The Deep dive into shadow DOM support guide covers it.

Demo

Copy link

The two editors below have the same configuration. The one on the left runs in the regular page DOM, and the one on the right runs inside a shadow root. Use the button to add global rules that restyle every paragraph, heading, and list item on this page, and see how each editor reacts.


Light DOM
Shadow DOM

A shadow root isolates the editor from the styles of the page that hosts it. Changes to the global styles of your application do not alter the edited content or the editor user interface, so both keep a consistent look wherever you embed the editor.

In the demo, the rules reach the content of the light-DOM editor because it is part of the page, like every other paragraph, heading, and list around it. This is expected behavior, and you can handle it with additional CSS in your application. The editor on the right needs no such adjustments. Its floating user interface is isolated as well, because it renders in a separate shadow root at the end of the page. Open the developer tools of your browser and inspect the right editor to find it inside a #shadow-root.

The demo uses the setup described in the sections below.

Loading the editor styles

Copy link

Attaching a shadow root creates a separate DOM tree, and style sheets are scoped to the tree they belong to. The isolation works both ways: selectors in a page style sheet do not match elements inside a shadow tree, and styles defined inside a shadow tree do not affect the rest of the page.

Load the editor style sheets into the tree the editor is attached to:

Inside a shadow root, the most practical way is to adopt a constructed style sheet. The same sheet can be adopted by any number of roots, so the editor root and the overlay container described in the next section share a single copy.

When you install from npm, import the style sheets as strings and create the sheets from them:

// The `?inline` query makes the bundler return the style sheet as a string.
import editorStyles from 'ckeditor5/ckeditor5.css?inline';
// If you use premium features.
import premiumStyles from 'ckeditor5-premium-features/ckeditor5-premium-features.css?inline';

const editorStyleSheet = new CSSStyleSheet();
const premiumStyleSheet = new CSSStyleSheet();

editorStyleSheet.replaceSync( editorStyles );
premiumStyleSheet.replaceSync( premiumStyles );

const host = document.querySelector( '#editor-host' );
const editorShadowRoot = host.attachShadow( { mode: 'open' } );
const editorElement = editorShadowRoot.appendChild( document.createElement( 'div' ) );

editorShadowRoot.adoptedStyleSheets = [ editorStyleSheet, premiumStyleSheet ];
Copy code

The ?inline query is supported by Vite. In webpack 5, import the style sheets with a ?raw query loaded with the asset/source type, as the webpack documentation shows. Exclude that query from your CSS rule, or the regular CSS loader handles the import and the shadow root gets an empty style sheet.

When you load the editor from CDN, fetch the style sheet text instead:

async function createStyleSheet( url ) {
	const response = await fetch( url );
	const styleSheet = new CSSStyleSheet();

	await styleSheet.replace( await response.text() );

	return styleSheet;
}

const host = document.querySelector( '#editor-host' );
const editorShadowRoot = host.attachShadow( { mode: 'open' } );
const editorElement = editorShadowRoot.appendChild( document.createElement( 'div' ) );

editorShadowRoot.adoptedStyleSheets = await Promise.all( [
	createStyleSheet( 'https://cdn.ckeditor.com/ckeditor5/49.0.0/ckeditor5.css' ),
	// If you use premium features.
	createStyleSheet( 'https://cdn.ckeditor.com/ckeditor5-premium-features/49.0.0/ckeditor5-premium-features.css' )
] );
Copy code

A <link rel="stylesheet"> element placed inside the shadow root works as well. Each root then loads a style sheet of its own, and its content is unstyled until the style sheet loads. The CDN loader of the framework integrations uses this approach when you set its injectedStylesheetsLocation option, as the React, Vue, and Angular CDN guides show.

The editor renders its floating user interface outside the editing area, so that tree needs the same style sheets. Which tree? That depends on where the overlay layer mounts, described in the next section.

Where the floating user interface mounts

Copy link

Balloons, dialogs, and tooltips are rendered outside the editor’s own DOM structure, so they stack above the editor and are not clipped by a scrollable or overflow: hidden ancestor. That is the overlay layer.

When you do not configure anything, the editor mounts the overlay layer in the tree its editing root lives in. It is the shadow root when the editor is inside one. Treat that as a fallback rather than a plan: the shadow host or one of its ancestors may clip or misplace the overlay layer, for example when it has overflow: hidden or position: relative. That is why an editor in a shadow root without a configured overlay container logs the ui-overlay-container-not-configured warning.

Point config.ui.overlayContainer at a shadow root of your own instead, attached to a host element at the end of document.body. As a direct child of the body, that host has no ancestor that can clip the overlay layer, and its own shadow root keeps it a self-contained styling boundary:

const overlayHost = document.body.appendChild( document.createElement( 'div' ) );
const overlayContainer = overlayHost.attachShadow( { mode: 'open' } );

// A shadow root does not inherit the page styles. Reuse the constructed style sheets
// adopted by the editor root, as shown in the "Loading the editor styles" section.
overlayContainer.adoptedStyleSheets = editorShadowRoot.adoptedStyleSheets;

ClassicEditor
	.create( {
		attachTo: editorElement,
		ui: {
			overlayContainer
		}
	} )
	.then( /* ... */ )
	.catch( /* ... */ );
Copy code

Only constructed style sheets can be adopted this way, meaning ones created with the CSSStyleSheet constructor rather than the sheets a <style> or <link> element brings in. The assignment copies the list of sheets, not the sheets themselves, so a sheet you adopt into the editor root later has to be adopted into the overlay container as well. If the editor root loads its styles through <link> elements instead, there is nothing to copy, so add the same <link> elements to the overlay container.

Each mount target gets an overlay layer of its own, wrapped in a .ck-body-wrapper element of its own, so an editor in a shadow root and an editor in the light DOM do not share one. A single document.querySelector( '.ck-body-wrapper' ) therefore no longer finds every editor’s layer, and finds none at all before the editor attaches. Read editor.ui.view.body.bodyCollectionContainer instead, which exists from the moment the editor is created.

The Fullscreen feature follows the same chain. With no config.fullscreen.container set, it mounts in the first of these that applies:

  1. The config.ui.overlayContainer, when you set one.
  2. The shadow root the editor lives in.
  3. The <body> element.

Setting the container yourself keeps the page scrollable, because the fullscreen mode then fills an element of your layout instead of covering the viewport.

Overriding CSS variables

Copy link

The editor declares its CSS variables on both :root and :host, so one style sheet resolves them in the main document and inside a shadow root alike. This is necessary because :root matches nothing inside a shadow tree, and :host matches nothing outside one.

Override the variable on the shadow host element instead, or anywhere inside the shadow root. A rule in the main document that matches the host still wins, so the override does not have to live inside the root.

Put the same class on every shadow host that holds the editor user interface, the overlay container included. One rule then covers them all:

/* A class you put on every shadow host that holds editor UI. */
.my-editor-shadow-host {
	--ck-radius-base: 16px;
}
Copy code

Rules that apply to the page

Copy link

A few editor rules have to match <html> or <body>, which no shadow root can contain. The scroll lock that fullscreen mode and modal dialogs put on the page is one, through the ck-fullscreen-scroll-locked and ck-dialog-scroll-locked classes.

Those rules are no longer shipped in the theme style sheets, because a sheet adopted into a shadow root cannot reach the page. The editor adopts them into the document at runtime instead, once per document however many editors ask for them. They land later in the cascade than a theme sheet would, so overriding one of them may need higher specificity than before.

Slotting the editor into your own component

Copy link

You can place the editor in a <slot> of a component of your own. This is where the open-root requirement bites hardest: the editor finds the slot through Element#assignedSlot, which a closed root does not report and for which the DOM standard exposes no alternative. Without it, the editor cannot work out which element really scrolls and clips it. That is why balloons are positioned against the wrong ancestor and scrolling the selection into view scrolls the window rather than your container.

An open root is what component frameworks attach by default, so this usually needs nothing from you.

Known limitations

Copy link
  • Selection direction. Safari and Chromium do not report the direction of a selection made with the mouse inside a shadow root, so a backward selection looks forward. User interface anchored to the selection, the balloon toolbar for one, then appears at the end of the selection rather than at its start. There is no workaround.