Notion editor

A Notion-style block editor — slash menu, drag handles, and media blocks — built on Editor.js.

Installation
$Terminal
npx shadcn@latest add https://sdk-components.thesqd.com/r/notion-editor.json
Usage
One import, every setting a prop.
TSImport
import { NotionEditor } from "@/components/blocks/notion-editor";
TSExample
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)}
    />
  );
}
Composition
Anatomy of the NotionEditor block.
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.
Default
Type `/` on an empty line or click `+` beside a block to open the block menu. **Preview read-only** flips the same editor to the published view (`readOnly` switches live). ⋮⋮ opens block tunes (move, delete, convert) and drags the block. Select text for the inline toolbar.
Media
Images (upload, paste, drop or URL), video (upload, an .mp4 link, or YouTube / Vimeo / Loom), link previews via `linkEndpoint`, file attachments and callouts. Paste a supported link on an empty line to embed it. Pass `onUpload` to store files.
Layout & data blocks
Steps, 2 columns and toggles each hold a full editor of their own — any block, nested. Charts (bar / line / area / pie in ⋮⋮) edit their rows under the chart; diagrams are Mermaid source in the SDK CodeEditor with the render below; buttons are SDK Buttons with a link. Text alignment lives in ⋮⋮ on paragraphs, headings and quotes.
Restricted menus
`hiddenBlocks` trims the `/` menu, `hiddenTunes` trims ⋮⋮, `actions` trims the inline toolbar, and `dragDrop`, `addButton`, `blockTunes`, `slashMenu`, `inlineToolbar` and `embeds` switch whole features off.
Markdown in and out
`defaultValue` takes markdown as well as Editor.js JSON, and `onChange` hands back both — the markdown is what MarkdownArea writes and RichMarkdown reads.
Edit the page — its markdown appears here.
Read-only
`readOnly` renders the page with no gutter, menus or placeholders. It toggles live.
AI endpoint
What `ai` sends and expects when it's a URL, and this site's Vercel AI Gateway route. Pass a function instead to wire any model directly.
// 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 })} />
Link preview endpoint
What `linkEndpoint` must return. This site serves one at /api/link-preview.
// GET {linkEndpoint}?url=https://example.com
{
  "success": 1,
  "link": "https://example.com",
  "meta": {
    "title": "Example Domain",
    "description": "…",
    "image": { "url": "https://example.com/og.png" }
  }
}
API Reference
Props for NotionEditor.
PropTypeDefaultDescription
valueOutputData | string—Editor.js JSON or markdown. Re-renders only when it differs from what `onChange` last emitted.
defaultValueOutputData | 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).
showTitlebooleantrueRenders the `InvisibleInput` page title above the body.
title / defaultTitlestring—Controlled / uncontrolled title (with `onTitleChange`).
titlePlaceholderstring"Untitled"Placeholder for the title.
placeholderstring"Type '/' for commands…"Placeholder on an empty first line.
hiddenBlocksNotionBlockId[][]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.
actionsArray<"bold" | "italic" | "underline" | "strike" | "highlight" | "color" | "code" | "link">all eightInline toolbar tools, in order. `color` opens Notion's text + background colours; the rest are the ids MarkdownArea uses.
inlineToolbarbooleantrueShow the inline toolbar on text selection.
addButtonbooleantrueShow the `+` button beside the hovered block.
slashMenubooleantrueTyping `/` on an empty line opens the block menu.
blockTunesbooleantrueShow the ⋮⋮ block tunes button.
hiddenTunesArray<"moveUp" | "moveDown" | "delete" | "convertTo">[]Tunes left out of the ⋮⋮ menu.
dragDropbooleantrueDrag blocks by their ⋮⋮ handle to reorder.
aistring | ({ 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.
aiModelstring—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.
undobooleantrue⌘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.
linkEndpointstring—GET endpoint for link previews (`?url=`). Without it the Link preview block is hidden.
embedsbooleantrueAuto-embed supported links (YouTube, Vimeo, Loom, X, CodePen, Miro, Instagram, GitHub gist, …) pasted on an empty line.
readOnlybooleanfalseRender the page without editing chrome. Live.
autofocusbooleanfalseFocus the first block on mount.
minHeightnumber240Minimum body height in px.
aria-labelstring"Page editor"Accessibility label for the body.