NEW in 0.2: Shortcuts, proofreader & a native Angular component — plus version history, sepia and RTL

Documentation

Penna is a headless-when-you-want, batteries-included-when-you-don't editor built on ProseMirror. Six packages, all MIT:

@abhinavakhil/penna-coreThe editor, its UI, commands, Markdown/HTML/JSON I/O, renderToHTML, and penna.css.
@abhinavakhil/penna-element<penna-editor> web component. Use it from Svelte, Solid, Lit, HTMX, plain HTML.
@abhinavakhil/penna-react<Penna>, usePenna(), usePennaState(), <PennaContent>.
@abhinavakhil/penna-vueSame for Vue 3, with v-model.
@abhinavakhil/penna-angular<ngx-penna> with ngModel and reactive forms, providePenna(), <ngx-penna-content>. SSR-safe.
@abhinavakhil/penna-aiAI extension: free providers, streaming, accept/discard UI.

Install

npm i @abhinavakhil/penna-core            # always
npm i @abhinavakhil/penna-react           # or -vue, -angular, or -element
npm i @abhinavakhil/penna-ai              # optional

Import the stylesheet once, anywhere: import '@abhinavakhil/penna-core/penna.css'. It styles the editor and rendered content.

Plain HTML / any framework

The web component needs no build step and works everywhere custom elements do (every modern browser, every framework).

<link rel="stylesheet" href="https://unpkg.com/@abhinavakhil/penna-core/dist/penna.css">
<script type="module" src="https://unpkg.com/@abhinavakhil/penna-element"></script>

<penna-editor id="ed" placeholder="Write…" theme="auto" autosave-key="draft">
  <p>Initial <strong>HTML</strong> can go right here.</p>
</penna-editor>

<script>
  const ed = document.getElementById('ed')
  ed.addEventListener('change', (e) => console.log(e.detail.html, e.detail.json, e.detail.markdown))
  ed.value = '<p>set content</p>'     // get/set in the element's format (html | markdown | json)
  ed.editor                          // the full PennaEditor instance
  ed.options = { onUpload, extensions: [ai({...})] }   // set before it's attached
</script>

Attributes: value, placeholder, readonly, theme, format (html | markdown | json), toolbar="false", autosave-key. Events: change, ready, editor-focus, editor-blur.

React

import { Penna, usePenna, usePennaState, PennaContent } from '@abhinavakhil/penna-react'
import { ai } from '@abhinavakhil/penna-ai'
import '@abhinavakhil/penna-core/penna.css'

function Editor() {
  const [doc, setDoc] = useState(null)          // store JSON (recommended) or html/markdown
  const ref = useRef(null)                        // PennaEditor instance
  return (
    <Penna
      ref={ref}
      defaultValue={doc}
      placeholder="Write something…"
      onChange={({ json, html, markdown }) => setDoc(json)}
      onUpload={async (file) => (await upload(file)).url}
      extensions={[ai({ provider: 'groq', apiKey: KEY })]}
    />
  )
}

// A custom toolbar rendered inside <Penna> children can use the context:
function WordCount() {
  const editor = usePenna()
  const count = usePennaState(editor, (e) => e.wordCount())
  return <span>{count?.words} words</span>
}

// Display saved content without loading the editor (works in server components too):
<PennaContent json={doc} />

value makes it controlled (external changes reset the doc; your own edits don't). defaultValue is uncontrolled. Both accept HTML, Markdown (with contentFormat="markdown") or JSON.

Vue

<script setup>
import { Penna, PennaContent } from '@abhinavakhil/penna-vue'
import { ai } from '@abhinavakhil/penna-ai'
import '@abhinavakhil/penna-core/penna.css'
const doc = ref(null)
</script>

<template>
  <Penna v-model="doc" format="json" placeholder="Write…" :options="{ extensions: [ai({ provider: 'groq', apiKey })] }" />
  <PennaContent :json="doc" />
</template>

Angular

Use @abhinavakhil/penna-angular: a standalone component that works with ngModel, formControl and formControlName. A disabled control makes the editor read-only. Add node_modules/@abhinavakhil/penna-core/dist/penna.css to styles in angular.json.

import { PennaEditorComponent, PennaContentComponent, providePenna } from '@abhinavakhil/penna-angular'

@Component({
  imports: [PennaEditorComponent, PennaContentComponent, ReactiveFormsModule],
  template: `
    <ngx-penna [formControl]="story" format="markdown" placeholder="Tell your story…" theme="auto" />
    <ngx-penna-content [json]="saved" />
  `,
})
export class StoryForm { story = new FormControl('', Validators.required) }

// app-wide defaults, e.g. extensions every editor should have
bootstrapApplication(App, { providers: [providePenna({ extensions: [shortcuts()] })] })

Inputs mirror the options below (placeholder, readonly, theme, dir, toolbar, extensions…, plus options for everything else). Outputs: ready, valueChange, contentChange, focused, blurred. It runs outside the Angular zone and does nothing on the server.

Svelte, Solid, Lit…

Use @abhinavakhil/penna-element. Import it once, then use the tag.

// Svelte
<penna-editor value={html} on:change={(e) => (html = e.detail.html)} />

// Solid
<penna-editor prop:value={html()} on:change={(e) => setHtml(e.detail.html)} />

Vanilla JS API

import { createEditor } from '@abhinavakhil/penna-core'

const editor = createEditor('#editor', { placeholder: 'Write…' })

editor.getJSON() / editor.getHTML() / editor.getMarkdown() / editor.getText()
editor.setJSON(json) / editor.setHTML(html) / editor.setMarkdown(md)
editor.insertContent('<p>hi</p>')            // at the cursor
editor.readonly = true                          // toggle read-only
editor.theme = 'dark'                           // 'light' | 'dark' | 'sepia' | 'auto'
editor.dir = 'rtl'                              // 'ltr' | 'rtl' | 'auto'
editor.focus() / editor.isEmpty() / editor.wordCount()
editor.exec(editor.commands.heading(2))         // run any command
editor.exec(editor.commands.image('https://…'))
editor.exec(editor.commands.table(3, 3))
editor.on('update', (e) => save(e.getJSON()))  // 'update' | 'selection' | 'focus' | 'blur'
editor.toggleFind() / editor.openLinkEditor()
editor.destroy()

Every command in editor.commands is a plain ProseMirror command, so anything from the ProseMirror ecosystem works with editor.view and editor.state.

Options

contentInitial content: HTML string, Markdown string (set contentFormat: 'markdown') or JSON doc.
placeholderEmpty-document hint. Empty lines show "Type '/' for commands".
readonlyStart read-only. Toggle later with editor.readonly.
theme'auto' (default: follows a data-theme or .dark ancestor, then the system), 'light', 'dark', 'sepia'.
dir'ltr', 'rtl' or 'auto'. RTL mirrors lists, quotes, menus and the drag handle.
toolbarfalse to hide the fixed toolbar, or an array of items to customise it.
bubbleMenu / slashMenufalse to disable, or an array of items.
dragHandle / statusBarfalse to disable.
borderlessNo border or focus ring. Nice inside your own card.
typographyConfigure heading/body defaults and the font family, size, and line-height choices shown in the toolbar.
onUpload(file)Return a URL for a dropped/pasted/picked file. Default: inline data URL.
autosave{ key, storage?, debounceMs? }. Restores on mount. editor.clearAutosave() to reset.
extensionsArray of extensions such as ai().
onUpdate, onSelectionChange, onFocus, onBlurCallbacks with the editor instance.

Content: HTML, Markdown, JSON

JSON is canonical and lossless. HTML is lossless for everything Penna can produce. Markdown covers everything but colors/alignment (rendered as plain text). Recommended: store JSON, render with renderToHTML.

import { renderToHTML, toMarkdown, fromMarkdown } from '@abhinavakhil/penna-core'
renderToHTML(json)            // string, no DOM needed
toMarkdown(fromMarkdown(md))  // parser/serializer are exported too

Rendering content anywhere

Three ways to show saved content:

  1. Server / no editor: renderToHTML(json) and wrap it in <div class="penna-content"> with penna.css loaded. Pixel-identical to the editor. Works in Node, Workers, Deno, Bun.
  2. React / Vue: <PennaContent json={doc} />.
  3. Read-only editor: createEditor(el, { content, readonly: true, toolbar: false }). Checkbox and embed interactions still work.

Sanitise on the server if content comes from untrusted users. The schema already rejects script, inline handlers and unknown tags, but defence in depth is cheap.

Images & uploads

Drop, paste, pick from the toolbar, or /image. Images get resize handles, alignment and alt text when selected. Videos too. By default files are stored inline as data URLs so it works with zero setup; for real apps pass onUpload:

createEditor('#editor', {
  onUpload: async (file) => {
    const { url } = await supabase.storage.from('images').upload(`${crypto.randomUUID()}-${file.name}`, file)
    return url   // any string URL — S3, Cloudinary, your API…
  },
})

Pasting a bare image URL inserts an image. Pasting a YouTube / Vimeo / Loom / Figma / CodePen link on an empty line inserts an embed. Pasting a URL over selected text makes a link.

AI

import { ai } from '@abhinavakhil/penna-ai'

createEditor('#editor', {
  extensions: [
    ai({
      provider: 'groq',          // 'groq' | 'gemini' | 'openrouter' | 'together' | 'ollama'
      apiKey: KEY,               //  | 'openai' | 'mistral' | 'openai-compatible'
      // model: 'llama-3.3-70b-versatile',   // optional, each provider has a good default
      // baseUrl: 'https://your-proxy/v1',   // required for 'openai-compatible'
      // fetch: myFetch,                     // route through your server
      // actions: [...defaultActions, myAction],
    }),
  ],
})

Free by default. Groq, Gemini, OpenRouter and Together all have free tiers; Ollama is free and local. Paid models work through the OpenAI, Mistral presets or any OpenAI-compatible URL (Anthropic via a shim, Azure, Vercel AI Gateway, LiteLLM, your own proxy).

Triggers: Ctrl+J, the ✦ button in the bubble menu and toolbar, or /ai and /continue. Results stream into a preview; nothing changes the document until you accept.

Custom actions: an action is { id, title, prompt(ctx) → messages, mode?: "replace" | "insert", askInput? }. Pass actions to replace the list, or spread defaultActions to extend it.

Production note: a key in the browser is visible to your users. Point baseUrl at a tiny proxy that adds the key server-side, or pass a custom fetch. Hosted Penna AI (no key, usage-billed) is planned; provider: 'penna' is reserved for it.

Themes, dark mode & RTL

On auto, Penna follows data-theme="dark" (or Tailwind's .dark) on any ancestor, then prefers-color-scheme. Force a theme with theme: 'light' | 'dark' | 'sepia'. For Urdu, Arabic or Hebrew set dir: 'rtl'; the stylesheet uses logical properties, so everything mirrors. Every color is a CSS variable on .penna and .penna-content:

.penna, .penna-content {
  --pn-font: Inter, sans-serif;
  --pn-accent: #e11d48;        /* links, active buttons, selection */
  --pn-bg: #fff;  --pn-fg: #111;  --pn-muted: #666;  --pn-border: #eee;
  --pn-surface: #fff;          /* menus, toolbar */
  --pn-radius: 12px;
  --pn-highlight: #fef08a;  --pn-code-bg: #f3f4f6;  --pn-pre-bg: #0f172a;
}

Class names are stable and prefixed with pn- if you want to go further.

Typography choices are stored on paragraph and heading nodes, so JSON and HTML preserve them. Penna never downloads fonts in your users' applications; load the families you expose through your own CSS or font host:

createEditor('#editor', {
  typography: {
    bodyFontFamily: 'Inter',
    headingFontFamily: 'Plus Jakarta Sans',
    fontFamilies: [
      { label: 'Brand Sans', value: 'Brand Sans' },
      { label: 'Editorial Serif', value: 'Lora' },
    ],
    fontSizes: ['14px', '16px', '18px', '24px', '32px'],
    lineHeights: ['1', '1.25', '1.5', '1.75', '2'],
  },
})

Built-in extensions

All opt-in and exported from @abhinavakhil/penna-core. Add only the ones you need:

import { shortcuts, fundraisingShortcuts, proofread, mentions, mergeFields, comments, versionHistory, focusMode } from '@abhinavakhil/penna-core'

createEditor('#editor', { extensions: [
  shortcuts({ items: fundraisingShortcuts }),        // /budget, /impact, /milestones · Ctrl+Alt+B/I/M
  proofread({ onChange: (s) => showScore(s.score) }), // typos, wordy phrases, long sentences
  mentions({ items: (q) => api.people(q) }),        // @Name
  mergeFields({ fields: [{ key: 'first_name', label: 'first name' }] }), // {{first_name}}
  comments({ onAdd: (c) => api.createThread(c), onSelect: openThread }),
  versionHistory({ key: 'campaign-42', author: user.name }),
  focusMode({ level: 'sentence' }),
] })
shortcuts()Blocks you can drop in from the slash menu or with a hotkey. Pass items as HTML or Markdown; people can select text and press + in the bubble to save their own. Saved shortcuts live in localStorage (storageKey), and onChange lets you sync them. API: list(), insert(id), create(title), remove(id).
proofread()Underlines by category (correctness, clarity, engagement, delivery, long sentences) with one-click fixes. Decorations only, so the saved document never changes by itself. Bring your own rules; API: summary(), fixAll(), setCategory().
mentions()@ opens a list from items(query) (sync or async). Saved as <span data-mention>, @Name in Markdown.
mergeFields(){{ opens your fields. Saved as {{key}} in Markdown and text, ready for your email sender. setPreview({ first_name: 'Aisha' }) shows real values.
comments()Anchors a thread id to the selected words; the thread itself lives in your database. API: add(), resolve(id), list(), setActive(id).
versionHistory()Autosaves after 2s idle, named versions, a word diff against now, and restore (which saves first, so it can be undone). Adds a clock button to the toolbar.
focusMode()Dims everything except the current sentence or paragraph, keeps the caret line mid-screen, and fades the toolbar while you type.

Helpers for your own features: readability(text) (grade and reading age), diffWords / diffHTML, commands.insertStarter('My name is ___'), and suggestPlugin() to build your own trigger-character menu.

Writing your own extension

An extension is a plain object. This is how @abhinavakhil/penna-ai is built, and it's enough for most needs:

import { commands, type PennaExtension } from '@abhinavakhil/penna-core'

const signature: PennaExtension = {
  name: 'signature',
  slashItems: [{ id: 'sig', title: 'Signature', icon: '✍️', group: 'Custom', run: (view) => {
    view.dispatch(view.state.tr.insertText('— Abhinav'))
  }}],
  bubbleItems: [{ id: 'shout', icon: 'highlight', label: 'Shout', run: (view) => commands.highlight('#fca5a5')(view.state, view.dispatch) }],
  toolbarItems: ['|', { id: 'sig', icon: 'text', label: 'Signature', run: (view) => view.dispatch(view.state.tr.insertText('— Abhinav')) }],
  plugins: (editor) => [ /* any ProseMirror plugins */ ],
}
createEditor('#editor', { extensions: [signature] })

To fully replace a menu, pass your own array to toolbar, bubbleMenu or slashMenu. defaultToolbarItems, defaultBubbleItems and defaultSlashItems are exported so you can filter or reorder them. The schema, keymap, input rules and every plugin are exported from @abhinavakhil/penna-core if you want to build something entirely different on top.

Keyboard shortcuts

/Insert block menu
Ctrl+B / I / U / EBold / italic / underline / code
Ctrl+Shift+S / HStrikethrough / highlight
Ctrl+KLink
Ctrl+JAsk AI
Ctrl+FFind & replace
Ctrl+Alt+0-3Paragraph / heading 1-3
Ctrl+Shift+7 / 8 / 9Numbered / bullet / task list
Ctrl+Shift+B, Ctrl+Alt+CQuote, code block
Ctrl+Alt+TTable
Ctrl+Alt+B / I / MBudget / impact tiers / milestones (with shortcuts({ items: fundraisingShortcuts }))
Ctrl+Shift+L / E / RAlign left / center / right
Tab / Shift+TabIndent / outdent list, next / previous table cell
Ctrl+EnterExit code block · Shift+Enter line break
Ctrl+\Clear formatting
Markdown# - 1. [ ] > ``` --- **bold** _italic_ ~~strike~~ ==mark== `code`