Always-on live-preview markdown editor — one surface that is both the rendered document and the text you type into
import { Editor } from "@/components/ui/editor";const NOTE = `# Weekly noteClick or touch here to start editing. The editor is **always on** — there is no *edit mode* to enter, and Markdown syntax appears only where you're typing.- [x] Port the live preview off \`@codemirror/lang-markdown\`- [ ] Read the [typography map](https://www.deltacomponents.dev/docs/editor)- [ ] Wire autosave to the databaseTry \`Cmd+B\`, \`Cmd+I\` and \`Cmd+L\` on a selection.`;export default function EditorDemo() {return <Editor defaultValue={NOTE} className="w-full max-w-[560px]" />;}
Installation
npx shadcn@latest add https://www.deltacomponents.dev/r/editor.json
The CLI brings CodeMirror 6 with it — five @codemirror/* packages and
@lezer/common, around 170 kB gzipped — plus lib/live-markdown.ts, which is
where the extensions and the live-preview decorations live. There is no
stylesheet to add: every construct is styled by a class, and the editor's own
chrome reads your tokens.
Usage
1import { Editor } from "@/components/ui/editor";23<Editor4 defaultValue={note.body}5 placeholder="Write something"6 onSave={(body) => saveNote(note.id, body)}7/>
Basic
There is no view/edit toggle, because there is nothing to toggle: the document
you are reading is the document you are typing into. Markdown renders in place
and the syntax marks conceal themselves everywhere the caret isn't — move into
a construct and its **, # or ](url) comes back so you can edit it.
The cost of having no edit mode is that nothing announces there is one, so the demos carry the invitation in their own first line. A document is the only chrome this editor has; a corner badge over it is one more thing to place, and on a phone there is nowhere to put it that isn't on top of the text.
The surface is the core set: headings, bold, italic, strikethrough, inline
code, links, block quotes, horizontal rules, bullet and numbered lists, and
task checkboxes that are real <input>s.
Emphasis styles as soon as you open it, without waiting for the closing
delimiter, because headings do and it reads as inconsistent when inline
constructs don't.
Only the lines on screen are decorated, so what a keystroke costs does not grow with the document: a 60,000-word draft types like a note.
Controls
import { Editor } from "@/components/ui/editor";import { EditorControls } from "@/components/ui/editor-controls";const DOC = `## Release notesSelect a word and press **B**, or put the caret on a line and pick a style.- [x] Toolbar reads the caret- [ ] Undo is the editor's own history---> Every button runs the same command its shortcut does.`;export default function EditorControlsDemo() {return (<Editor defaultValue={DOC} className="w-full max-w-[560px]"><EditorControls /></Editor>);}
EditorControls is the toolbar, and it goes in as a child: the editor renders
it above the text at the text's own width, which is the one place a toolbar
belongs and saves you laying it out. Every button runs the command its
keyboard shortcut runs and reads its pressed state from the caret, so the
toolbar is a second way in, never a second source of truth — Undo is the
editor's own history, and Bold on a bold word unwraps it.
A button never takes focus. The click lands with the caret where you left it, so the selection a button wraps is the one you made, and the Style menu hands focus back to the text when it closes rather than to the button that opened it. It is one row that scrolls sideways on a narrow screen rather than wrapping — three rows of toolbar on a phone is the screen the text needed.
Image writes  and leaves it as text. Rendering a picture inside
the document is the extensions seam, where the embed demo
already does it from a bare URL. Indent and Outdent are there for touch: a
keyboard has Tab, a phone doesn't, and without them a list on a phone is flat.
Typography
import { useState } from "react";import { Button } from "@/components/ui/button";import { Editor } from "@/components/ui/editor";import type { EditorElements } from "@/lib/live-markdown";// Two type sets over one document. The keys are an MDX components map's keys,// and each one takes the classes that construct wears — the elements// themselves (h1, strong, code, a) are emitted either way, so a set only has// to say what it wants to look different.const SETS: Record<string, { root: string; elements: EditorElements }> = {Product: {root: "text-base leading-7",elements: {h1: "text-2xl font-semibold",h2: "text-xl font-semibold",strong: "font-semibold",code: "font-mono text-sm bg-muted rounded px-1",a: "underline underline-offset-2 decoration-muted-foreground",bullet: "text-muted-foreground",},},Editorial: {root: "font-serif text-lg leading-8",elements: {h1: "text-3xl",h2: "text-2xl",strong: "font-semibold",em: "italic",code: "font-mono text-sm text-muted-foreground",a: "underline underline-offset-4 decoration-1",bullet: "text-muted-foreground/60",},},};const DOC = `# On revisionWriting is *rewriting*. The **first** draft only has to exist; the second one has to be read. Keep the \`- [ ]\` list short enough that finishing it is plausible.- [ ] Cut the third paragraph
elements is this editor's answer to an MDX components map. The keys are the
same — h1, strong, code, a — and each one says which element the
construct renders as and what classes it carries, so the editor emits real
<strong>, <code> and <h2> tags around the live text and an existing prose
stylesheet lands on them without being restated.
What it cannot take is React components, and that is not an omission: these
ranges are the characters the caret is moving through. A <strong> here wraps
live text that stays editable, which means it has to be a tag CodeMirror can
mark a range with — a component would have to replace that text with something
rendered, and replaced text is text you can no longer type into. Components
belong to the block-level things you don't type inside (an image, an embed, a
code fence), which is a separate seam from this one.
The box model is the one thing the map doesn't get. Vertical rhythm in a
source editor is its blank lines, so margins and padding on the emitted tags
are pinned at zero — a heading that brought margin: 1rem 0 with it would
push the caret off its own text. Type, weight and colour come through.
Type that belongs to the whole surface — the family, the base size, the
leading — comes from className on the root instead, which the editor
inherits. The two sets above differ in both: one map plus one root class each.
Autosaving
import { useState } from "react";import { Editor } from "@/components/ui/editor";export default function EditorAutosave() {const [saves, setSaves] = useState(0);return (<div className="flex w-full max-w-[560px] flex-col gap-2"><EditordefaultValue={"Type here. The indicator in the corner is the save.\n"}onSave={async () => {// Stands in for the write: `await fetch("/api/note", { method: "PUT", body: doc })`await new Promise((resolve) => setTimeout(resolve, 900));setSaves((n) => n + 1);}}/><span className="text-caption text-muted-foreground">{saves === 0 ? "Nothing written yet" : `${saves} write${saves === 1 ? "" : "s"}`}</span></div>);}
onSave is the persistence hook, and it fires on a debounce (800 ms by
default), on blur, and on unmount, so navigating away mid-sentence still
writes. It never fires with a document it has already stored, which makes an
idle tick on an untouched editor free.
The indicator in the top-right corner is the whole save UI: a dot that appears
the moment the document is dirty, pulses while the write is in flight, and
settles when it lands. It is pinned to the corner rather than trailing the
text because you glance for it, and the document is the wrong length for
something you glance at. Reject from onSave and it says Not saved in
--destructive — that document stays pending, so the next flush sends it
again rather than dropping it. Turn the chip off with showSaveStatus={false}
and drive your own from onSaveState.
That contract is why the prop is defaultValue and not value: the editor
owns the text after mount. Feeding a server value back in while someone is
typing would move their caret and let undo restore state they never wrote, so
the component does not offer it. Undo is the editor's own history — Cmd+Z,
and there is no cancel gesture to go with an autosave.
Extensions
import { StateEffect, StateField, type EditorState, type Range } from "@codemirror/state";import { Decoration, EditorView, WidgetType, type DecorationSet } from "@codemirror/view";import { createRoot, type Root } from "react-dom/client";import { Editor } from "@/components/ui/editor";import { classifyUrl, type EmbedSource } from "@/lib/embed";import { Embed } from "@/components/ui/embed";// Keyed by the mounted DOM node so destroy() can find the React root and the// observer that belong to it.const widgetInstances = new WeakMap<HTMLElement, { root: Root; observer: ResizeObserver }>();class EmbedWidget extends WidgetType {constructor(readonly url: string,readonly source: EmbedSource,) {super();}// Keyed on the URL so CodeMirror keeps the same DOM across unrelated// edits instead of remounting the embed per keystroke.eq(other: EmbedWidget) {return other.url === this.url;}get estimatedHeight() {if (this.source.kind === "spotify") {return this.source.type === "track" || this.source.type === "episode" ? 152 : 352;}if (this.source.kind === "link") return 102;return 315; // 16:9 at a 560px column; an image's real height lands on mount}toDOM(view: EditorView) {const dom = document.createElement("div");dom.className = "py-2";// A press in here lands inside the editor's editable area, so the browser// focuses the editor and a phone raises the keyboard over the picture// being opened. Cancelling the press keeps focus where it was; the click// still goes through. Not on a video, whose timeline is dragged.
The core stays a text surface: no iframes, no network requests, nothing a
consumer didn't ask for. extensions is where the rest goes — plain
CodeMirror extensions, appended after the editor's own. The demo above pairs
the editor with Embed through a small CodeMirror widget: a link
alone on a line keeps its text while the caret is on it, collapses to its
domain once the caret leaves, and mounts an <Embed url> beneath it either
way, in a widget whose toDOM renders it into a React root and whose
ResizeObserver tells CodeMirror when the mounted embed's real height lands.
A picture enlarges on a click, because Embed draws one with the registry's
Image.
Two things to know. The embed only mounts after the caret has left the line for
the first time, because every prefix of a URL being typed is itself a valid
URL — mounting eagerly would load a new iframe on every keystroke. And a plain
link's card is filled in the same way it is anywhere else Embed renders one:
by the component's own preview route, see The preview
route.
Keyboard
Mobile
Touch gets the platform's own behaviour rather than the editor's. CodeMirror's
drawSelection paints its own caret and hides the native one, which on a
desktop is an improvement and on a phone takes away the caret you drag, the
selection handles and the magnifier — so it is left out on a coarse pointer
and iOS and Android keep their own. The input attributes are prose defaults,
not code ones: autocorrect and autocapitalize on, spellcheck on, where
CodeMirror ships all three off.
Two more things a phone needs. The content has a 16px floor there, because iOS
zooms the page when an editable field under 16px takes focus and leaves the
reader zoomed in — a floor, so a surface already larger keeps its size, but a
text-sm editor comes up to 16 on touch. And the on-screen keyboard covers
the window without shrinking the layout viewport, so the editor measures the
obscured height from visualViewport and scrolls the caret clear of it, both
when it scrolls the selection into view and when the keyboard opens or
changes height.
A checkbox's hit area comes up past 24×24 on a coarse pointer — WCAG 2.2's
target size, and a 14px checkbox is a coin toss with a fingertip. The box
itself stays the size you set it: the padding that widens the target is given
straight back as negative margin, so the line keeps its height and the text
keeps its distance rather than ending up jammed against a box that towers over
it. Links still open on
Cmd/Ctrl+click only: on touch a tap places the caret, because this is an
editor first.
API Reference
Editor
EditorControls
Takes the attributes of a div — className for placement, most usefully
sticky top-0 on a long page. Which controls it shows is not a prop: the set
is the markdown the surface renders, and a control that wrote something the
document could not show would be a lie.
EditorElements
Every key is optional and falls back to the default below. A key can be a bare
class string, which keeps that construct's default element:
{ h2: "text-xl font-medium" }. Import the type, or defaultElements to
extend the set rather than replace it, from @/lib/live-markdown.
Accessibility
The editing surface is a contenteditable with its aria-label taken from
placeholder. Headings and emphasis are the real elements, not divs wearing a
size, so a screen reader meets a document rather than a wall of text; task
checkboxes are real inputs labelled "Mark task done", so one can be checked
without touching the markdown; and the save indicator announces through a
polite live region, but only its settled states — narrating every pause in
someone's typing is not help. It is still a step down from a <textarea>,
which is the standing cost of live preview.