Skip to content
Edytor
Esc
↑↓navigate↵open⌘Jpreview
On this page

Styling

The Notion theme, the DOM the editor renders, the stable data attributes to style it with, the overlay layer for chrome, and how to lay out nested blocks.

The core ships almost no visual styles: you style the document with CSS, through stable data attributes on the elements it renders. For a finished look, import the optional Notion theme. This page covers the theme, those attributes, the overlay layer that holds handles and menus, and the rules to follow so styling never fights the editor.

Notion theme

edytor/themes/notion.css styles every rich text, image and code kind like Notion’s light theme. Import it once and put the edytor-notion class on an element around <Edytor> (or on the editor itself, with its class prop):

<script lang="ts">
	import { Edytor, richTextPlaceholder } from 'edytor';
	import 'edytor/themes/notion.css';
</script>

<div class="edytor-notion">
	<Edytor placeholder={richTextPlaceholder} />
</div>

What it sets:

  • 16px text at a 1.5 line height in Notion’s text color, and blocks as 3px/2px-padded rows 1px apart;
  • headings at 1.875em, 1.5em and 1.25em, weight 600. A leading top-level Heading 1 is the page title: 40px, weight 700;
  • a 24px marker column for lists and to-dos: bullets • ◦ ▪ by depth, numbers 1. a. i. by depth, with a run of numbered items sharing one counter;
  • Notion’s blue to-do checkbox, with done text struck through and muted;
  • a toggle caret that rotates as the toggle opens, a 3px quote bar, a 10px-rounded callout panel, a thin divider and the code panel;
  • images with rounded corners and a muted caption, and the empty image’s gray “Add an image” panel with its link field and blue buttons;
  • inline code in red on a light tint, links underlined at 0.7 opacity, and blue text and block selections.

It styles the document only. The block handles, menus and toolbar carry their own Notion look, unless you replace their markup (see Menus and handles). Pair it with richTextPlaceholder for Notion’s placeholders.

Adapt it by overriding its custom properties on .edytor-notion:

Property Default Used for
--notion-text #2c2c2b Text, caret, list markers, the quote bar, the to-do box
--notion-text-secondary #7d7a75 Done to-do text, image captions and the empty image panel
--notion-placeholder rgba(44, 44, 43, 0.4) Placeholders
--notion-border rgba(28, 19, 1, 0.11) The divider
--notion-hover rgba(33, 27, 23, 0.05) The checkbox hover
--notion-panel #f9f8f7 The callout background
--notion-code-panel #f7f6f3 The code block background
--notion-inline-code #cf5148 Inline code text
--notion-blue #2783de The checked to-do box, the image panel’s buttons
--notion-text-selection rgba(35, 131, 226, 0.28) Text selection
--notion-block-selection rgba(35, 131, 226, 0.14) Selected blocks
--notion-font Notion’s system sans stack Body text
--notion-mono 'SFMono-Regular', Menlo, … Inline code

The theme also sets --edytor-drop-indicator-color to Notion’s rgba(35, 131, 226, 0.43). Its rules are scoped under .edytor-notion: to override one, use a more specific selector, or the same selector in a stylesheet loaded after it.

The rendered DOM

For a paragraph with bold text and a mention, followed by the overlay:

<div class="my-editor" data-edytor contenteditable="true" role="textbox" aria-multiline="true">
	<div data-edytor-block="true" data-edytor-id="b1" data-edytor-type="paragraph">
		<p>
			<span data-edytor-text="true" data-edytor-id="t:b1:0" data-edytor-text-empty="false">
				Hello <strong data-edytor-mark="bold">world</strong>
			</span>
			<span data-edytor-inline-block="mention" data-edytor-id="i1" contenteditable="false">…</span>
			<span data-edytor-text="true" data-edytor-id="t:b1:1" data-edytor-text-empty="true">​</span>
		</p>
	</div>
</div>
<div data-edytor-overlay style="position: absolute; width: 0; height: 0">…</div>

The block element (div here, the default) comes from the kind’s element; the markup inside it (p) from the kind’s snippet. The class prop of <Edytor> goes on the root.

Attributes

Attribute On Meaning
data-edytor the root The editable root. contenteditable is false when readonly.
data-edytor-block="true" every block element A block.
data-edytor-id blocks, texts, atoms The block’s, text segment’s or atom’s id.
data-edytor-type blocks The block’s kind, such as heading or todo-item.
data-edytor-void="true" void blocks and chrome Non-editable: a void kind, or an element marked use:block.void.
data-edytor-selected="true" blocks The block is part of a block selection.
data-edytor-focused="true" blocks The caret or a text selection is in the block.
data-edytor-text="true" text segments A run of editable text between inline atoms.
data-edytor-text-empty text segments "true" when the segment has no characters.
data-placeholder empty text segments The placeholder text, see Placeholder.
data-edytor-mark="<name>" mark elements A mark: the mark’s tag, or a span around a snippet mark.
data-edytor-inline-block="<type>" inline atoms An atom, non-editable.
data-edytor-todo-checkbox the to-do checkbox The rich text to-do’s input, a direct child of the block.
data-edytor-callout-icon the callout icon The rich text callout’s icon span.
data-edytor-code-header the code block header The void header holding the language and the Copy button.
data-edytor-code-language the code block language The language label inside the header.
data-edytor-image, data-edytor-image-empty the image block The wrapper of the img, or of the empty state; see Image.

Block attributes are rendered with the element, so they are also present in server-rendered HTML. A kind’s own attributes (from element) sit beside them, and the rich text heading renders as <h2 data-edytor-block … data-edytor-type="heading">.

Some useful selectors:

/* A kind */
[data-edytor-type='quote'] { border-left: 3px solid currentColor; padding-left: 14px; }
/* A kind's data, when its element exposes it */
h2[data-edytor-type='heading'] { font-size: 1.6rem; }
/* A mark */
a[data-edytor-mark='link'] { color: #2383e2; }
/* Selected blocks */
[data-edytor-selected] { background: #e8e6df; border-radius: 3px; }
/* The block holding the caret */
[data-edytor-focused] { background: transparent; }

A block selection is exactly its members: a selected parent does not select its children. Nested blocks render inside their parent’s element, so a background on a selected parent paints under its unselected children too. The demo paints them back:

[data-edytor-selected] [data-edytor-block]:not([data-edytor-selected]) {
	background: #fff;
}

Nested blocks

Children render inside the parent’s element, wherever the kind’s snippet renders children(). Keep a block’s own content and its children in separate elements, then lay them out in a grid so a nested block starts on a new row, in the text column:

[data-edytor-type='bulleted-list-item'] {
	list-style: none;
	display: grid;
	grid-template-columns: 16px minmax(0, 1fr);
	column-gap: 10px;
}
[data-edytor-type='bulleted-list-item']::before {
	content: '•';
	text-align: center;
}
[data-edytor-type='bulleted-list-item'] > div {
	grid-column: 2;
	min-width: 0;
}

The rich text list items, to-dos and callouts render their content and their children in two direct div children, which this rule puts in column 2. A flex row would instead place a nested block beside its parent’s text.

For numbered items, count with CSS counters on the editor root:

[data-edytor] { counter-reset: numbered; }
[data-edytor-type='numbered-list-item'] { counter-increment: numbered; }
[data-edytor-type='numbered-list-item']::before { content: counter(numbered) '.'; }

The overlay

Chrome never lives inside the editable root. The editor adds a sibling right after it, [data-edytor-overlay], an absolutely positioned, zero-size layer. Block handles, the drop indicator, the slash menu, the toolbar, the block menu and collaborators’ carets are rendered there and positioned against the blocks once per frame, following scrolls, resizes and edits.

Element Selector
Block handle host [data-edytor-block-handle-host], with data-block-id and data-visible
Block handle buttons button.edytor-block-add (the +) and button.edytor-block-handle (the grip)
Drop indicator [data-edytor-drop-indicator], with data-position before, after or inside
Slash menu host [data-edytor-slash-menu-host]
Toolbar host [data-edytor-toolbar-host]
Block menu host [data-edytor-block-menu-host]

The layer sits at its natural place right after the root and positions chrome relative to itself, so chrome follows the editor through page and container scrolls. Since it is a sibling of the root, scope overlay styles with the overlay selector, not the root’s. To override the handles’ built-in styles, use a selector at least as specific as [data-edytor-overlay] button.edytor-block-handle.

CSS variables

Variable Default Effect
--edytor-drop-indicator-color rgba(35, 131, 226, 0.43) The drop indicator’s bar. Read from the target block, so set it on the root or any ancestor.
[data-edytor] {
	--edytor-drop-indicator-color: #2eaadc;
}

Style with CSS, not by mutating the DOM

The editor compares the DOM it rendered with the document and restores what it owns. If a script changes the editor’s DOM:

  • a node added inside a text segment is removed;
  • a node added beside the segments, inside a block’s markup, is left alone;
  • text added inside a block’s content is read as typed text and becomes part of the document;
  • attributes the core does not own, such as an id a plugin sets on a block element or open on a toggle, are never reverted.

So style with CSS selectors on the attributes above, add attributes from a plugin’s onBlockAttached if you need them, and render extra markup from a kind’s snippet (marked use:block.void) or in the overlay. Do not insert elements into text or rewrite text nodes to decorate them; use a kind’s transformText or a mark instead.

Was this page helpful?