Editor

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 note
Click 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 database
Try \`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";
2
3<Editor
4 defaultValue={note.body}
5 placeholder="Write something"
6 onSave={(body) => saveNote(note.id, body)}
7/>

Basic

import { Editor } from "@/components/ui/editor";
export default function EditorBasic() {
return (
<Editor
placeholder="Click or touch here to begin editing — # for a heading, - [ ] for a task"
className="w-full max-w-[560px]"
/>
);
}

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 notes
Select 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 ![alt](url) 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 revision
Writing 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

Nothing written yet
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">
<Editor
defaultValue={"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.