A Notion-style block editor — slash menu, drag handles, and media blocks — built on Editor.js.
npx shadcn@latest add https://sdk-components.thesqd.com/r/notion-editor.jsonimport { NotionEditor } from "@/components/blocks/notion-editor";import { NotionEditor } from "@/components/blocks/notion-editor";
export function Example() {
return (
<NotionEditor
defaultTitle="Untitled"
defaultValue={"Type **/** for commands."}
onChange={(data, markdown) => console.log(data, markdown)}
/>
);
}NotionEditor (props)
├── value? / defaultValue?: OutputData | string // Editor.js JSON or markdown
├── onChange?: (data, markdown) => void
├── showTitle? / title? / defaultTitle? / onTitleChange? / titlePlaceholder?
├── placeholder?: string
├── hiddenBlocks?: Array<"text" | "h1" | "h2" | "h3" | "bulletList" | "orderedList" | "checklist"
│ | "quote" | "code" | "divider" | "table" | "columns" | "steps" | "toggle"
│ | "callout" | "button" | "gallery" | "audio" | "diagram" | "chart"
│ | "image" | "video" | "link" | "file">
├── actions?: Array<"bold" | "italic" | "underline" | "strike" | "highlight" | "color" | "code" | "link">
├── inlineToolbar? / addButton? / slashMenu? / blockTunes? / dragDrop? / undo? / embeds?: boolean
├── hiddenTunes?: Array<"moveUp" | "moveDown" | "delete" | "convertTo">
├── onUpload?: (file: File) => Promise<string> // images, video, files
├── linkEndpoint?: string // enables "Link preview"
├── readOnly? / autofocus? / minHeight? / className? / aria-label?
Anatomy
└── NotionEditor // Editor.js engine, Squad skin
├── InvisibleInput // page title (Enter jumps into the body)
├── Gutter // "+" block menu, ⋮⋮ tunes + drag handle
├── Block menu // "/" or "+" — overlay-surface, dropdown rows, search
├── Inline toolbar // on text selection
└── Blocks // text, headings, lists, to-dos, quote, code, divider,
│ // table, callout, image, video, link preview, file, embeds
├── Image / Video // empty: Input (url) + Embed Button; FilePicker (image) or
│ // outline Button (video) to upload; then the media + caption.
│ // Edge handles resize; right-click: align, size, replace,
│ // copy link, open, download, delete
├── Callout // Alert v2 — title + body editable, tone picked in ⋮⋮
├── Code // the SDK CodeEditor (CodeMirror): syntax colours for ~20
│ // languages, Auto-detect (highlight.js), Prettier Format, Copy
├── 2 columns // a nested editor per column, every block but columns/steps
├── Steps // numbered rail; a title + nested editor per step, Add step
├── Toggle // Collapsible: caret + editable title, nested editor body
├── Button // SDK Button + label / URL fields; Primary / Outline, align in ⋮⋮
├── Gallery // SDK ImageCarousel; FilePicker (multiple) or a link to add
├── Audio // SDK AudioPlayer; same picker, resize and right-click as video
├── Diagram // Mermaid source in the CodeEditor, rendered SVG below
└── Chart // SDK chart (Recharts); rows edited under it, kind in ⋮⋮
Inline toolbar: bold, italic, underline, strikethrough, highlight, Color (text +
background), inline code, link. Tunes: text alignment on paragraphs / headings /
quotes. Plugins: drag-drop, undo / redo.Edit the page — its markdown appears here.
// POST {ai} — what a URL passed to `ai` receives and returns
// Request (JSON):
{ "prompt": "Draft an outline for this page", "context": "## Getting started\n\n…page as markdown…", "model": "anthropic/claude-sonnet-5.5" }
// Response: a text stream of markdown (any streaming text/plain body works).
// app/api/ai/notion-editor/route.ts — this site's demo, on Vercel AI Gateway
import { createGateway, streamText } from "ai";
const gateway = createGateway({ apiKey: process.env.AI_GATEWAY_API_KEY });
export async function POST(req: Request) {
const { prompt, context } = await req.json();
const result = streamText({
model: gateway("anthropic/claude-sonnet-5.5"),
instructions: "Write markdown for a block editor. Content only, no preamble.",
prompt: context ? `The page so far:\n\n${context}\n\nRequest: ${prompt}` : prompt,
maxOutputTokens: 1200,
abortSignal: req.signal,
});
return result.toTextStreamResponse();
}
// Or skip the endpoint and pass a function:
<NotionEditor ai={({ prompt, context, signal }) => fetch("/my/ai", { method: "POST", body: JSON.stringify({ prompt, context }), signal })} />// GET {linkEndpoint}?url=https://example.com
{
"success": 1,
"link": "https://example.com",
"meta": {
"title": "Example Domain",
"description": "…",
"image": { "url": "https://example.com/og.png" }
}
}| Prop | Type | Default | Description |
|---|---|---|---|
value | OutputData | string | — | Editor.js JSON or markdown. Re-renders only when it differs from what `onChange` last emitted. |
defaultValue | OutputData | string | — | Initial Editor.js JSON or markdown. |
onChange | (data: OutputData, markdown: string) => void | — | Fires after every edit with the Editor.js JSON and its markdown (RichMarkdown / MarkdownArea format). |
showTitle | boolean | true | Renders the `InvisibleInput` page title above the body. |
title / defaultTitle | string | — | Controlled / uncontrolled title (with `onTitleChange`). |
titlePlaceholder | string | "Untitled" | Placeholder for the title. |
placeholder | string | "Type '/' for commands…" | Placeholder on an empty first line. |
hiddenBlocks | NotionBlockId[] | [] | Blocks left out of the `+` / `/` menu: `ai` (only offered when `ai` is set), `text`, `h1`, `h2`, `h3`, `bulletList`, `orderedList`, `checklist`, `quote`, `code`, `divider`, `table`, `columns`, `steps`, `toggle`, `callout`, `button`, `image`, `gallery`, `video`, `audio`, `link`, `file`, `diagram`, `chart`. Existing blocks of that kind still render. |
actions | Array<"bold" | "italic" | "underline" | "strike" | "highlight" | "color" | "code" | "link"> | all eight | Inline toolbar tools, in order. `color` opens Notion's text + background colours; the rest are the ids MarkdownArea uses. |
inlineToolbar | boolean | true | Show the inline toolbar on text selection. |
addButton | boolean | true | Show the `+` button beside the hovered block. |
slashMenu | boolean | true | Typing `/` on an empty line opens the block menu. |
blockTunes | boolean | true | Show the ⋮⋮ block tunes button. |
hiddenTunes | Array<"moveUp" | "moveDown" | "delete" | "convertTo"> | [] | Tunes left out of the ⋮⋮ menu. |
dragDrop | boolean | true | Drag blocks by their ⋮⋮ handle to reorder. |
ai | string | ({ prompt, context, signal }) => string | Response | ReadableStream | AsyncIterable<string> | — | Turns on **Ask AI** in the `/` menu. A URL is POSTed `{ prompt, context }` (context = the page as markdown) and streams markdown back; or pass a function and wire any model. The answer streams in word by word (transitions.dev streaming text) and Insert turns it into blocks. |
aiModel | string | — | Which model **Ask AI** uses — sent as `model` with each request (or handed to your function). Your endpoint decides what it accepts; this site's demo route allows Claude Sonnet 5.5 (default), Haiku 4.5, Opus 5.5, GPT-6 Luna / Sol and Gemini 3.8 Flash on AI Gateway. Live. |
undo | boolean | true | ⌘Z / ⌘⇧Z undo and redo — the whole page, nested columns, steps and toggles included. |
onUpload | (file: File) => Promise<string> | — | Store an image, video or file and resolve its public URL. Defaults to an inline data URL. |
linkEndpoint | string | — | GET endpoint for link previews (`?url=`). Without it the Link preview block is hidden. |
embeds | boolean | true | Auto-embed supported links (YouTube, Vimeo, Loom, X, CodePen, Miro, Instagram, GitHub gist, …) pasted on an empty line. |
readOnly | boolean | false | Render the page without editing chrome. Live. |
autofocus | boolean | false | Focus the first block on mount. |
minHeight | number | 240 | Minimum body height in px. |
aria-label | string | "Page editor" | Accessibility label for the body. |