Pages CMS

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.json

Usage

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.

PropTypeDefaultDescription
valuestring""Current editor content.
onChange(value: string) => void-Called whenever content changes.
format"markdown" | "html""html"Parsing and output format.
disabledbooleanfalseDisables editing and toolbar actions.
enableImagesbooleantrueEnables image-related behaviors (slash command and image actions).
enableImagePasteDropbooleanfalseEnables 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.
maxImageBytesnumber1000000Max file size used by "data-url" fallback.
onPendingUploadsChange(count: number) => void-Called when pending optimistic uploads change. Use it to gate autosave/save/navigation.
classNamestring-Extra classes for the root wrapper.
editorClassNamestring-Extra classes for the WYSIWYG surface.
...propsHTMLAttributes<HTMLDivElement>-Forwarded to the root container.

Built by Ronan Berder for Pages CMS.