Pages Editor
A simple, Notion-like WYSIWYG editor component for shadcn/ui. Built with TipTap, ProseMirror, and React.
Demo
Documentation
Install
Install this specific component directly from its JSON URL.
npx shadcn@latest add https://editor.pagescms.org/r/editor.jsonUsage
Use controlled state and pass format="markdown" or format="html" depending on how you store content. You can also pass onRequestImage to replace the default image prompt with your own modal or picker, plus onUploadImage for paste/drop uploads.
During uploads, the editor inserts a temporary local preview immediately, then preloads the final uploaded URL before swapping src. If preload fails or times out, it keeps the temporary preview and marks an upload error on that image.
import { useState } from "react"
import { Editor } from "@/components/ui/editor"
export function Example() {
const [value, setValue] = useState("")
return (
<Editor
value={value}
onChange={setValue}
onRequestImage={async () => {
const src = window.prompt("Use URL? Leave empty to simulate file upload")
if (src) return { kind: "url", src, alt: "Optional alt text" }
return { kind: "file", file: new File(["demo"], "demo.png", { type: "image/png" }) }
}}
enableImagePasteDrop
imageFallback="data-url"
onUploadImage={async (file) => ({
src: await fileToDataUrl(file),
alt: file.name,
})}
/>
)
}The default format is "html". Set format="markdown" if your application stores Markdown.
Implement Your Own Source Toggle
Keep one shared value state and switch between Editor and your own source input. This keeps source-mode logic in your app, while the component stays focused on rich-text editing.
import { useState } from "react"
import { Editor } from "@/components/ui/editor"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Textarea } from "@/components/ui/textarea"
type View = "editor" | "source"
export function EditorWithSourceToggle() {
const [view, setView] = useState<View>("editor")
const [value, setValue] = useState("")
return (
<Tabs value={view} onValueChange={(next) => setView(next as View)} className="w-full">
<TabsList>
<TabsTrigger value="editor">Editor</TabsTrigger>
<TabsTrigger value="source">Source</TabsTrigger>
</TabsList>
<TabsContent value="editor">
<Editor value={value} onChange={setValue} format="markdown" />
</TabsContent>
<TabsContent value="source">
<Textarea
value={value}
onChange={(event) => setValue(event.target.value)}
className="min-h-64 font-mono"
/>
</TabsContent>
</Tabs>
)
}Default Content Styles
The editor works without these styles, but you can copy this baseline stylesheet to get the same content typography as the demo.
@layer components {
.cn-editor .tiptap {
@apply border-input placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] md:text-sm;
}
.cn-editor .tiptap > :first-child {
@apply mt-0;
}
.cn-editor .tiptap > :last-child {
@apply mb-0;
}
.cn-editor .tiptap h1 {
@apply mt-8 mb-3 scroll-m-20 text-4xl font-bold tracking-tight text-balance;
}
.cn-editor .tiptap h2 {
@apply mt-8 mb-3 scroll-m-20 text-3xl font-semibold tracking-tight first:mt-0;
}
.cn-editor .tiptap h3 {
@apply mt-6 mb-2 scroll-m-20 text-2xl font-semibold tracking-tight;
}
.cn-editor .tiptap p {
@apply leading-7 [&:not(:first-child)]:mt-4;
}
.cn-editor .tiptap ul {
@apply my-4 ml-6 list-disc;
}
.cn-editor .tiptap ol {
@apply my-4 ml-6 list-decimal;
}
.cn-editor .tiptap blockquote {
@apply my-6 border-l-2 pl-6 italic;
}
.cn-editor .tiptap img {
@apply my-4 transition-shadow;
&.ProseMirror-selectednode {
@apply ring-1;
}
}
.cn-editor .tiptap a {
@apply font-medium text-primary underline decoration-dotted underline-offset-4;
}
.cn-editor .tiptap code {
@apply relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm;
}
.cn-editor .tiptap pre {
@apply my-4 overflow-x-auto rounded-xl bg-background p-4;
}
.cn-editor .tiptap pre code {
@apply bg-transparent p-0;
}
.cn-editor .tiptap table {
@apply my-4 w-full border-collapse;
}
.cn-editor .tiptap th,
.cn-editor .tiptap td {
@apply border px-3 py-2 text-left;
}
.cn-editor .tiptap th {
@apply bg-muted font-medium;
}
.cn-editor .tiptap .selectedCell {
@apply text-foreground;
outline: 1px solid var(--ring);
}
.cn-editor .tiptap .selectedCell::after {
content: none;
}
.cn-editor .tiptap .selectedCell::selection,
.cn-editor .tiptap .selectedCell *::selection {
@apply bg-accent text-foreground!;
}
}Options
Editor is a controlled component. The props below define content format and presentation behavior.
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | "" | Current editor content. |
onChange | (value: string) => void | - | Called whenever content changes. |
format | "markdown" | "html" | "html" | Parsing and output format. |
disabled | boolean | false | Disables editing and toolbar actions. |
enableImages | boolean | true | Enables image-related behaviors (slash command and image actions). |
enableImagePasteDrop | boolean | false | Enables image file paste and drag/drop insertion. |
onRequestImage | (ctx) => { kind: "url", src, alt?, title? } | { kind: "file", file, alt?, title? } | null | Promise<{ kind: "url", src, alt?, title? } | { kind: "file", file, alt?, title? } | null> | - | Called when the user picks the Image slash command. Return image data to insert it; return null to cancel. If omitted, the editor falls back to a native URL prompt. |
onUploadImage | (file, ctx) => { src, alt?, title? } | null | Promise<{ src, alt?, title? } | null> | - | Called for pasted or dropped image files. Return image data to insert. If missing or returning null, fallback behavior is used. |
imageFallback | "data-url" | "prompt-url" | "none" | "prompt-url" | Fallback strategy when no image callback inserts content. For paste/drop, only "data-url" can insert from files. |
maxImageBytes | number | 1000000 | Max file size used by "data-url" fallback. |
onPendingUploadsChange | (count: number) => void | - | Called when pending optimistic uploads change. Use it to gate autosave/save/navigation. |
className | string | - | Extra classes for the root wrapper. |
editorClassName | string | - | Extra classes for the WYSIWYG surface. |
...props | HTMLAttributes<HTMLDivElement> | - | Forwarded to the root container. |
Built by Ronan Berder for Pages CMS.