0046. A format lists styles per block type, and a block carries one as classes on its outer element¶
Status: Accepted
Date: 2026-11-03
Context¶
Sites moving from CKEditor 5 give authors a Style dropdown. Core's Style plugin takes one style per line as element.classA.classB|Label, such as h1.fancy.title|Fancy title, puts the classes on the element, and lets one element carry several styles (core/modules/ckeditor5/src/Plugin/CKEditor5Plugin/Style.php). It ships no CSS for them: the editor shows a style's effect only when the default theme names stylesheets under ckeditor5-stylesheets in its .info.yml, which Ckeditor5Hooks::themeCss() loads into the editor.
A document here carries no class. Every class on the page is the module's own, prefixed blocknote- (0004), and docs/rendering.md section 3 calls the vocabulary closed. The converter reports a class no mapping names as class_not_mapped (docs/converting.md section 7), so a styled paragraph converts to a plain one. On 2026-11-02 the maintainer decided that a text format lists styles per block type, as CKEditor 5 lists them per element, and that a block type with styles gets a Style dropdown.
In BlockNote's vocabulary a style is an inline mark, such as bold. The fixed list a format selects from (0031) is that vocabulary, and 0005 keeps custom marks out of scope. A block style is not a mark: it is a choice about one block.
A block holds data only in its props, and a BlockNote prop is a boolean, a number or a string (PropSpec in @blocknote/core types/src/schema/propTypes.d.ts). A prop declared { default: undefined, type: 'string' } has no default, so a block that does not set it has no key at all. A spike on 2026-11-03 against BlockNote 0.57.0, through the module's own createEditor() and against the server's validator, measured what adding such a prop to a block's specification does:
- For
paragraph,heading,bulletListItem,numberedListItem,checkListItem,quoteandcodeBlock, adding it to the specification'spropSchemais enough. Load, update, undo and redo, BlockNote's full HTML export and parse, and copy and paste inside the editor all keep it, and BlockNote writes it on the block's element asdata-block-style. tableis built on a node of its own, which wins over the specification, so the prop is dropped on load and on update. An attribute added to that node keeps it on load, but the element's attribute goes stale after an update, and the full HTML round trip and paste lose it, because the table's parse rule reads thetableand not its wrapper.- A block rendered through React, such as
drupalMediaor an extension's block, keeps the prop in the document through load, update, undo and paste, but never shows it in the editor's DOM, because a React block specification keeps the prop schema it was created with; BlockNote's full HTML export leaves it out too. The module calls that export nowhere. - A ProseMirror node decoration puts classes on every block alike: on
.bn-block-contentfor BlockNote's own blocks, the table included, and on the.react-rendererwrapper, one level further out, for a React block. The editor'sdomAttributesoption applies to every block at once and cannot carry one block's classes. - Changing a paragraph into a heading carries the prop over, whatever the format permits on headings, and HTML pasted from outside the editor with a
data-block-styleattribute arrives with its value unchecked. - A block holding the prop, opened in a format whose schema does not declare it, loses it with no notice, since ProseMirror ignores an attribute its node does not declare; the next save stores the document without it.
- On the server,
DocumentValidatorrefuses the prop asunknown_propthrough each plugin's prop schema, and nothing strips an unknown prop.
The editor already keeps block props to the format as the author edits. codeBlockPolicyExtensions() in js/src/policy.ts keeps a code block's language to the format's list; stripHeadingProps() removes a heading's isToggleable on load when the format permits no collapsible headings and names it in the stripped-content notice, and Heading::validate() refuses it on save (0017). DocumentValidator checks the shared color and alignment props against the format itself, in validateBlockColors() and validateBlockAlignment(), beside each plugin's own validation.
Every block renders as one outer element, except a paragraph in a single-paragraph format whose wrapper is none (0030). BlockNoteBlockBase::blockAttributes() builds that element's classes for every block the module ships, drupalMedia included, from the props the plugin declares.
Decision¶
The format lists its styles one per line, in CKEditor 5's syntax with a block type in place of the element: paragraph.indented|Indented, heading.smaller|Smaller heading, paragraph.lead.large|Lead. A line may give several classes, each made of letters, digits, - and _. A style's identity is its block type and its classes; its label is only what the author reads, and may change freely. The form refuses a line naming a block type the format does not enable, and a line naming table. The list is a filter setting of its own, apart from styles, which lists the permitted marks, because the save and the page both read it (0016).
The form shows the block types a line may name. Below the list, a collapsed <details>, Block types in this format, lists each block type the format enables by its label, with its machine name beside it in monospace and a copy button per row. The button shows on hover and on keyboard focus, never on hover alone, and announces Copied through Drupal.announce(). js/blocknote.format-form.js keeps the list in step with the block checkboxes as they change.
A block carries one style at most. Choosing a style replaces the one before. A site that wants a combination defines it as one style with several classes.
A block stores its style as blockStyle, a string prop with no default, { default: undefined, type: 'string' }, holding the style's classes as written, separated by spaces: lead large. A block with no style has no blockStyle key, so no stored document changes. The value is a list of tokens in the format of an HTML class attribute, and docs/rendering.md defines it so, which lets any consumer of the document, such as an app that renders it natively, split it on whitespace. The client declares the prop only on the block types the format names in a style.
No block plugin declares it. DocumentValidator checks blockStyle once, beside colors and alignment, against the styles the format lists for the block's type, and takes it out of the block before the plugin's own prop schema and validation run. BlockNoteBlockBase::blockAttributes() renders it. A block another module provides therefore takes styles with no code of its own, as long as it builds its outer element through the base class.
Any block type the format enables may have styles, except table, and a paragraph in a single-paragraph format whose wrapper is none. A table is left out until it keeps the prop through an update, BlockNote's HTML round trip and paste; the paragraph has no element to carry a class. The form says so for each.
On the page the classes go on the block's outer element, after the module's own classes: <p class="blocknote-block blocknote-block--paragraph indented">. A list item's go on its li and a media block's on its figure. Nothing else is added: no wrapper and no data attribute.
A style the format does not list is kept off the page and out of the document. The page renders the block without it. In the editor a guard keeps blockStyle to the format's list, as the code block's language is kept: a block whose type changes keeps its style only where the new type lists the same classes, and a style pasted in from outside is checked the same way. A document opened in a format that no longer lists a block's style has it removed on load and named in the stripped-content notice, as a heading's isToggleable is. A save that carries one anyway, from a tab opened before the format changed or a write through the API, is refused with a message worded by 0023's rule.
The author picks a style from a Style select in the formatting toolbar, right after the block type select, shown only when the selected block's type has styles. Its trigger is Remix Icon's brush-line, held in js/icons/remix/ as 0033 has it, beside the current value: the style's label, such as Indented, or Default style when the block has none. The menu's first item, Default style, clears the prop. The trigger's accessible name is Style: Indented or Style: Default style, which contains its visible text (WCAG 2.5.3). Choosing keeps focus in the editor. Several selected blocks of different types are offered only the styles they share. The Alt+F10 block menu offers the same choices in a Style group (0011), as it offers a media block's view modes.
The editor shows the classes through a node decoration, not through BlockNote's data-block-style. The decoration puts them on the block's element, .bn-block-content, and for a block rendered through React, such as a media block, on its wrapper one level further out. The module ships no CSS for any style. It reads a blocknote-stylesheets key from the default theme's .info.yml and attaches the stylesheets it lists wherever the editor loads, as core reads ckeditor5-stylesheets in Ckeditor5Hooks::themeCss(). Their selectors target the classes on the block's element, or on the wrapper of a React block. Without such a stylesheet the select's value is the only sign of a style in the editor.
The converter applies a style by itself. When an element becomes a block whose type the target format gives a style with exactly the element's classes, in any order, the block takes that style, with no mapping of the site's own. A new kind of class mapping, ClassMapping::style(), maps a different class to a style. A class used either way is no longer reported as class_not_mapped.
Amended 2026-11-03. A style is its set of classes on every path, as it already was for the converter and the guard. The save accepts a blockStyle holding a listed style's classes in another order, large lead for paragraph.lead.large, and the page renders it, where the save had refused it and the page had left it off while a change of type and a conversion kept it. The page carries the classes in the order the format lists them, the editor's load writes them in that order with no notice, and DocumentNormalizer stores them in that order on the updater's and the converter's path.
Amended 2026-11-03. A class may not start with bn-, blocknote- or ProseMirror, which BlockNote, this module and ProseMirror put on the same elements: the form refuses such a line, a stored list drops it, and the editor neither offers nor draws it. The configuration schema holds a list written another way, such as by a recipe, to the form's rules through the BlockNoteBlockStyles constraint: it refuses a table, such a class, a class named twice, a second style of one block type with the same classes, and a style with no label.
Amended 2026-11-03. The form also refuses a line a single-paragraph format would leave unused: one naming any block type but the paragraph, and, while the paragraph renders as no element, one naming the paragraph. The list of block types under the field shows the same, as the single-paragraph settings change.
Amended 2026-11-03. The classes come last on the outer element even where a plugin adds a class of its own once the element is built, as a checked checklist item, a collapsible heading and a media block do: the renderer moves them after the others.
Amended 2026-11-03. With several blocks selected, the Style select reads a style only when every one of them carries it, Default style when none carries one, and Multiple styles, the words CKEditor 5's Style dropdown shows when a selection carries more than one style, when they differ, one of them carrying none included, with no item checked; a block's style counts even where only its own type lists it. The select's items, like those of the media block's and the gallery's menus, are radio items, so assistive technology reads which one is checked, where Mantine's menu item role had made aria-checked invalid. A label is put into the trigger's accessible name and the stripped-content notice as the site wrote it, where Drupal.t() had escaped it into character references the reader saw.
Amended 2026-11-03. Saving another default theme invalidates the cached library definitions, as core's CKEditor 5 module does for ckeditor5-stylesheets while it is installed, so the editor takes the new theme's stylesheets with no cache clear.
Alternatives considered¶
Several styles on one block, as CKEditor 5 allows. Rejected. One choice keeps the control a plain select and matches how an author thinks of a style, and a line with several classes covers a combination.
Storing the label, or a machine name of the style's own. Rejected. A label changes, and the classes are what the page renders and what a theme and the converter read, so storing them makes the stored value and the rendered class the same thing.
An array of classes. Not possible: a BlockNote prop holds a boolean, a number or a string. A JSON-encoded list inside the string was rejected: it is harder to read and to validate than a list of tokens, and gains nothing.
A default of ''. Rejected after the spike. Every block of a styled type, new ones included, would then store "blockStyle": "", so every stored document would change on its next save.
Declaring the prop in every plugin's prop schema, through the base class. Rejected. Every schema would carry it, a plugin that does not extend the base class would refuse it, and the format's list would be checked in as many places as there are plugins.
Styles on tables now, through an attribute added to BlockNote's table node. Not taken. It keeps the prop on load only; the element's attribute goes stale after an update, and the HTML round trip and paste lose it, unless the module patches the table block or upstream changes it.
Showing the classes through BlockNote's data-block-style. Rejected. A React block never writes it, and a decoration reaches every block the same way.
Removing a dropped style on load with no notice. Rejected. A heading's isToggleable is named when it is removed, and an author should learn that a choice they made is gone before saving.
A wrapper element or a data attribute on the page. Rejected. A theme written for CKEditor 5's styles expects the class on the element itself.
Shipping CSS for styles. Rejected. The module cannot know what a site's classes mean.
A stylesheet the format names. Not taken. Core's CKEditor 5 takes the editor's stylesheets from the default theme's .info.yml, so a theme that already styles its classes for CKEditor 5 adds one key beside the one it has, and the CSS stays with the theme that defines the classes.
Applying styles only through mappings a site writes. Rejected. A site whose CKEditor 5 format and BlockNote format list the same styles would write one mapping per style for nothing.
Consequences¶
The page carries classes the module did not choose. docs/rendering.md section 3 keeps its closed vocabulary for the module's own classes, and a style's classes are the one open addition to it, on a block's outer element only, written by the site in its format; section 11 treats them as it treats a single-paragraph format's wrapper, a change the site builder makes by choice. 0004's rule that presentation is a vocabulary of blocknote- classes still holds for everything the module emits. A style's class may collide with a class the theme uses for something else, which is the site's to avoid, as it is under CKEditor 5.
A CKEditor 5 style line copies over with its element replaced by a block type, but not every line has a counterpart. A style on ul or ol has none, since a list item carries its classes on its li, and the converter does not apply one, since a ul does not become a block. A style on table waits for tables.
A block that builds its outer element without BlockNoteBlockBase::blockAttributes() emits no style until it does. A theme that overrode a block's template keeps the classes only where it prints attributes, as the shipped templates do.
In the editor a React block's classes sit one element further out than a BlockNote block's, so a theme's blocknote-stylesheets rule for a media block's style targets that wrapper. The key is a new part of the module's contract with themes.
Removing a style from the format changes the page of every block that carries it at once, and the next save from the editor removes the prop, which the stripped-content notice says first.
Blocks of different types selected together may share no style, and the select is then not shown.
References¶
- Drupal core:
core/modules/ckeditor5/src/Plugin/CKEditor5Plugin/Style.php,core/modules/ckeditor5/src/Hook/Ckeditor5Hooks.php(themeCss()). - BlockNote 0.57.0:
@blocknote/coretypes/src/schema/propTypes.d.ts,src/schema/blocks/createSpec.ts,src/schema/blocks/internal.ts,src/blocks/Table/block.ts,src/api/blockManipulation/commands/updateBlock/updateBlock.ts;@blocknote/reactsrc/schema/ReactBlockSpec.tsx. - This module:
js/src/schema.tsx,js/src/policy.ts(codeBlockPolicyExtensions(),stripHeadingProps()),js/src/stripped.tsx,js/src/toolbar.tsx,js/src/blockmenu.tsx,js/blocknote.format-form.js,js/icons/remix/,src/BlockNoteBlockBase.php(blockAttributes()),src/Document/DocumentValidator.php(validateBlockColors(),validateBlockAlignment()),src/Plugin/BlockNoteBlock/Heading.php,src/Plugin/Editor/BlockNote.php,src/Plugin/Filter/BlockNoteFilter.php,src/Convert/ClassMapping.php,src/Convert/HtmlWalker.php. - docs/rendering.md sections 2, 3 and 11, docs/converting.md section 7, docs/extending.md section 14.
- 0004, 0005, 0010, 0011, 0016, 0017, 0023, 0024, 0030, 0031, 0033.