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, numbers1.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
ida plugin sets on a block element oropenon 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.