Notion-Style Block Editor — HTML CSS JS Snippet

Notion-Style Block Editor · Forms · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Seven block types — text, two heading levels, to-do with checkbox, bullet, quote, divider — styled purely via data-type attribute selectors
Per-block contenteditable architecture: clean typed structure, no monolithic rich-text DOM chaos
Slash command menu with live filtering, icon cards, roving keyboard focus, and getBoundingClientRect placement
mousedown + preventDefault on menu items so applying a command never blurs the editable first
Enter creates blocks with list continuation; empty to-do/bullet escapes back to paragraph — Notion's exact rule
Backspace on empty removes the block and restores the caret to the previous block's end via a collapsed Range
Notion-style placeholders: :empty::before + content: attr(data-ph), per-type hint text, zero JS
Hover-revealed drag handles and one-map serialisation of the whole document from the DOM

About this UI Snippet

Notion-Style Block Editor — contenteditable Blocks, Slash Command Menu, Keyboard Block Management & Empty-State Placeholders

Screenshot of the Notion-Style Block Editor snippet rendered live

The block editor — pioneered by Notion and now expected in docs tools, CMSs, note apps, and AI writing products — replaces one big rich-text area with a vertical list of independent blocks: paragraphs, headings, to-dos, quotes, each its own editable unit, created and transformed through a slash-command menu. Full implementations (ProseMirror, Lexical, Tiptap, Editor.js) are heavyweight for good reasons, but the core interaction model fits in a few hundred lines of vanilla JavaScript — and building it teaches exactly why those libraries make the choices they do. This snippet implements the recognisable essentials: seven block types, the "/" menu with filtering and keyboard navigation, Enter/Backspace block management, list continuation, and Notion's floating placeholder text.

One block, one contenteditable

The critical architectural decision: instead of a single contenteditable document (the traditional rich-text approach, and the source of most of its legendary pain), *each block owns its own small contenteditable element*. A block is a flex row — hover-revealed drag dots, an optional checkbox for to-dos, and the .block-content editable div — with its type stored in data-type and all type styling driven by [data-type="…"] attribute selectors. Per-block editables mean the browser can never produce the arbitrary nested markup that plagues monolithic contenteditable (a stray bold span swallowing three paragraphs); the document structure lives in the DOM as a clean list of typed blocks, trivially serialisable by mapping over editor.children. This is, in miniature, the same model Editor.js uses.

The slash menu: trigger, filter, place, apply

Typing "/" as the first character of a block opens the command menu — detected in the input event by checking textContent.startsWith('/'), with everything after the slash used as a live filter over the block-type registry (type "/h" and only headings remain). The menu positions itself under the active block via getBoundingClientRect() math relative to the page wrapper, clamped so it never overflows. Navigation follows the roving-index pattern: ArrowUp/Down move a .focused highlight, Enter applies, Escape dismisses, and mousemove syncs the same index so mouse and keyboard never fight. One subtle but load-bearing detail: menu items listen on mousedown with preventDefault() rather than click — a click would first blur the editable and collapse the selection before the handler runs, the classic bug in every toolbar-over-contenteditable UI. Applying a type calls makeBlock() and replaceWith(), swapping the slash-bearing paragraph for a fresh block of the chosen type.

Keyboard block management

Enter never inserts a newline — it creates the next block (preventDefault, build, block.after(nb), focus). The type of that next block encodes the list-continuation rule: a non-empty to-do or bullet spawns another of the same type, anything else spawns a paragraph — press Enter on an *empty* to-do and you drop back to text, exactly Notion's escape-from-list behaviour. Backspace on an empty block removes it and focuses the previous block with the caret moved to its end, done properly with a collapsed Range (selectNodeContents + collapse(false)) since programmatic focus alone puts the caret at the start. Dividers are the special case: non-editable (contentEditable="false"), rendered as an <hr>, and choosing one auto-inserts a focused paragraph after it so the caret always has somewhere to live.

Placeholders without placeholder attributes

Divs have no placeholder attribute, so the hint text uses the CSS trick every block editor relies on: .block-content:empty::before { content: attr(data-ph) } — each block carries its own hint ("Heading 1", "To-do", "Type '/' for commands") in a data attribute, painted as a pseudo-element only while the element is truly empty, with pointer-events: none so it never intercepts the caret. Type-specific styling covers the rest: attribute selectors size headings, draw the quote's indigo left border, inject bullet dots via ::before on the block, and strike through completed to-dos via a .done class toggled by the checkbox.

Build with AI

Build, Understand, Optimize, and Extend It With AI

The two hardest ideas in this snippet — why structure must never be left to contenteditable, and why menu buttons listen on mousedown — are exactly the kind of thing an AI assistant explains well against concrete code: paste the file into Claude and ask it to demonstrate each by describing what a user would observe if you changed the architecture (one big editable) or the event (click instead of mousedown). Then extend it where your product needs: ask for a callout or code block type end-to-end (registry entry, makeBlock branch, CSS, serialisation), drag-to-reorder using the existing handle dots with the nearest-boundary insertBefore algorithm, or Alt+Arrow keyboard reordering if you want the simpler path. For persistence, have it write the debounced autosave that serialises editor.children to your API and the restore path that rebuilds from JSON. And before you scale this up, ask the assistant the honest architectural question: given your feature list — inline bold? collaboration? undo across blocks? — should you extend this or adopt Lexical/Tiptap, and what is the migration cost of each answer. That conversation is worth more than the code.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build a Notion-style block editor in plain HTML, CSS, and JavaScript — typed content blocks with a slash command menu, no editor libraries.

Requirements:
- Architect the document as a vertical list of blocks where EACH block contains its own small contenteditable content element (never one monolithic editable region), with the block type stored in a data attribute and all type styling driven by [data-type] attribute selectors.
- Support seven types from a single registry array (id, label, description, icon, placeholder): paragraph, heading 1, heading 2, to-do with a real checkbox that strikes through the text via a toggled class, bullet with the dot drawn by a ::before pseudo-element, quote with an accent left border, and a non-editable divider rendered as an hr.
- Typing "/" as the first character of a block opens a command menu positioned under that block via getBoundingClientRect: it lists the registry with icon tiles and descriptions, filters live as the user keeps typing after the slash, supports ArrowUp/ArrowDown roving focus synced with mousemove, applies on Enter or click, and dismisses on Escape or outside click; applying replaces the block with a fresh one of the chosen type (a divider must auto-insert a focused paragraph after itself).
- Menu items must listen on mousedown with preventDefault rather than click, so choosing a command never blurs the editable and collapses the caret first — comment why.
- Enter never inserts a newline: it creates the next block, continuing the same type after a non-empty to-do or bullet but escaping to a paragraph after an empty one; Backspace on an empty block removes it and places the caret at the END of the previous block using a collapsed Range (selectNodeContents + collapse(false)).
- Implement per-type placeholder hints with zero JavaScript via .block-content:empty::before { content: attr(data-ph) }, and reveal drag-handle dots on block hover.
- Seed a demo document (heading, paragraph, sub-heading, two to-dos with one checked, a bullet, a quote), and comment how the whole document serialises to JSON by mapping over the editor's children.

Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.

Step by step

How to Use

  1. 1
    Write with the block modelClick into the seeded document and type. Press Enter to create a new block — inside the to-dos, Enter continues the checklist; on an empty to-do it drops back to a paragraph. Press Backspace on an empty block to remove it, caret landing at the end of the previous block. Tick a to-do checkbox to strike it through. Hover any block to see the drag-handle dots.
  2. 2
    Use the slash menuOn an empty block, type "/" — the command menu opens under the block listing all seven types with icons and descriptions. Keep typing to filter ("/h" shows only headings), navigate with ArrowUp/Down, apply with Enter or a click, dismiss with Escape. Choosing Divider inserts the rule plus a fresh focused paragraph after it, since dividers themselves are not editable.
  3. 3
    Add your own block typesRegister a type in the TYPES array — { id: "callout", label: "Callout", desc: "Highlighted note", icon: "💡", ph: "Callout" } — then add its CSS as a .block[data-type="callout"] .block-content rule (tinted background, padding, radius). For types needing extra DOM (like the to-do checkbox), add a branch in makeBlock(). The slash menu, filtering, and keyboard flow pick the new type up automatically.
  4. 4
    Serialise and restore documentsThe DOM is the document model, so saving is a map: [...editor.children].map(b => ({ type: b.dataset.type, text: b.querySelector(".block-content")?.textContent ?? "", done: b.classList.contains("done") })). Persist the JSON (localStorage or your API), and restore by feeding it through the same seed loop the demo uses. Debounce saves on the editor's input event for autosave — pair with the Form Autosave Indicator.
  5. 5
    Add block reorderingThe drag dots are ready for it: set draggable on the handle, store the dragged block on dragstart, and in dragover on the editor compute the nearest block boundary from event.clientY and insertBefore accordingly — the same algorithm as the Drag Sort List snippet. Alternatively, bind Alt+ArrowUp/Down to swap the focused block with its sibling for keyboard-only reordering, which is simpler and equally Notion-authentic.
  6. 6
    Know when to graduate to a libraryThis pattern handles typed blocks of plain text superbly and stays understandable. The moment you need inline formatting spans (bold mid-sentence), collaborative cursors, or undo history beyond the browser's per-element default, reach for Lexical, Tiptap, or ProseMirror — they exist precisely for those. Click JSX to export the React version of this one; its per-block model maps naturally onto components with a blocks array in state.

Real-world uses

Common Use Cases

Notes, docs, and knowledge-base features inside your product
SaaS products keep growing document surfaces — meeting notes in CRMs, runbooks in ops tools, briefs in project managers — and users now expect the Notion interaction grammar there: slash commands, to-dos, block placeholders. This snippet delivers that grammar at a weight you can actually embed and audit, with serialisation being a single map over children. Start here for typed plain-text blocks; the how-to lists the precise triggers (inline spans, collaboration) for graduating to Lexical or Tiptap later.
Structured content entry for CMSs and page builders
Block editors beat big textareas for CMS content because the output is structured data, not an HTML blob: each block arrives typed (h2, quote, callout), so rendering, validation, and design-system enforcement happen per type. Wire the serialised JSON to your content API and render blocks server-side with your own components. Custom types are one registry entry plus one CSS rule — an image-upload block or CTA block slots into the same slash menu unchanged.
AI writing tools that generate and edit block documents
AI writing products need a canvas the model can populate structurally — generate an outline as h2 blocks, expand each into paragraphs, insert to-dos from action items. Because this editor's document model is a JSON array of typed blocks, LLM output maps onto it directly (ask the model for that exact shape), and streaming generation can append blocks live with makeBlock(). Combine with the AI Streaming Response for the generation feed and the AI Prompt Composer as the instruction bar.
Learning contenteditable's real behaviour and Selection/Range APIs
contenteditable is infamous, and this snippet is a guided tour of taming it: per-block isolation as the structural defence, the mousedown-vs-click blur trap on menu items, caret placement with collapsed Ranges, and placeholders via :empty::before. These four techniques transfer to every editable surface you will ever build — comment boxes, inline rename fields, caption editors. Compare with the Inline Edit Field and Mention Autocomplete snippets, which reuse the same primitives in smaller settings.
Checklist and runbook tools with mixed content
The to-do block with list continuation makes this a natural checklist editor that also carries context: an incident runbook interleaves h2 phase headings, explanatory paragraphs, and actionable to-dos whose done-state serialises with the document. Enter-continues-the-list matches how people brain-dump tasks; the strike-through done styling and the escape-on-empty-Enter rule keep entry fast. Pair with the Onboarding Checklist Widget for the read-only rendering of the same data.
Slash-command UX for any input surface
The menu subsystem stands alone: trigger-character detection in input events, query filtering over a registry, rect-based placement, roving focus, and blur-safe application. Reuse it for "/" commands in chat composers (insert a poll, attach a file), "@" mentions, or ":" emoji pickers — swap the trigger check and the registry. It is the same interaction core as the Command Palette, scoped to a text caret instead of a global overlay, and the Emoji Picker slots in as an alternate payload.

Got questions?

Frequently Asked Questions

A single contenteditable region hands document structure to the browser, and browsers make chaotic editing decisions: pressing Enter might produce a div, a p, or a br depending on engine and context; deleting across a boundary can merge elements into arbitrarily nested spans; pasted content imports foreign markup. Every serious editor spends most of its complexity budget fighting this. Per-block editables sidestep the whole class of problems — the browser only ever edits flat text inside one small div, while all structure (creating, removing, transforming, reordering blocks) goes through your JavaScript, which is exactly the "state-owns-structure" philosophy ProseMirror and Lexical implement with virtual documents. The trade-offs you accept: cross-block text selection doesn't work naturally (Notion itself restricts it, switching to block-level selection), and inline formatting within a block needs either execCommand-style spans or a token model. For typed plain-text blocks — checklists, outlines, structured notes — those trade-offs cost nothing.

Because of focus timing. A click is mousedown + mouseup, and the browser processes focus changes on mousedown: clicking a menu item first blurs the contenteditable block, which collapses the selection and (in a naive implementation) may close the menu via a blur handler — so by the time the click event fires, the context the command needs is gone. Listening on mousedown gets in before any of that, and calling preventDefault() on it suppresses the default focus-transfer entirely, so the editable never blurs and the caret survives. This is the single most common bug in toolbar-over-editor UIs — bold buttons that "lose the selection" — and the fix is always the same pair: mousedown listener, preventDefault, then perform the command against the still-live selection. The demo's applySlash then moves focus deliberately to the newly created block, which is the one intentional focus change in the flow.

Three tiers, in order of effort. Quickest: allow the browser's native shortcuts (Ctrl/Cmd+B and +I work inside contenteditable in all modern browsers, producing <b>/<i> tags) and serialise innerHTML per block instead of textContent — sanitise it on save with an allowlist (b, i, a, code) because contenteditable HTML must never be trusted. Middle: add a floating toolbar that appears on selection (listen for selectionchange, position over getSelection().getRangeAt(0).getBoundingClientRect()) and apply formatting by wrapping the range in elements yourself — reuse the mousedown/preventDefault rule from the slash menu for its buttons. Full: adopt a token-based model where each block stores [{ text, marks: ["bold"] }] segments and renders spans from data — that is the point where you have rebuilt the core of Lexical, and adopting Lexical or Tiptap outright becomes the honest choice. For many products (checklists, outlines, briefs), tier one plus a code-block type covers real usage.

React needs one architectural caution: contenteditable and React's controlled rendering conflict, because re-rendering a block's text from state resets the caret. The working pattern is uncontrolled blocks — keep the blocks array in state for structure (ids, types, order) but let each block's DOM own its text, syncing to state on onInput without echoing state back into the element (suppressContentEditableWarning, and never put the text in JSX). Structural operations (Enter, Backspace, slash-apply) go through setState splices keyed by block id. Angular is analogous: an @for over a signal array of block metadata, [attr.contenteditable] bindings, and text captured on input events without rebinding innerText. Tailwind maps cleanly since all type styling is attribute-driven: data-[type=h1]:text-2xl data-[type=h1]:font-extrabold on the content, data-[type=quote]:border-l-2 data-[type=quote]:border-indigo-500 data-[type=quote]:italic, the placeholder via empty:before:content-[attr(data-ph)] empty:before:text-slate-600, and the menu with the usual popover utilities.