Skip to the content

ComponentsForm

Editor

A rich-text field with a formatting toolbar: headings, marks, lists, quotes and links, read and written as HTML.

Import

import { Editor } from "@booleanpress/ui/editor"

Also install @tiptap/react, @tiptap/pm, @tiptap/starter-kit: pnpm add @tiptap/react @tiptap/pm @tiptap/starter-kit

Usage

The editor is built on Tiptap 3, whose packages load only with this entry.

import { Editor } from "@booleanpress/ui/editor"

export function TemplateBody({ html, onChange }) {
  return (
    <Editor
      aria-label="Email body"
      value={html}
      onChange={onChange}
      placeholder="Write the emailโ€ฆ"
    />
  )
}

The value is HTML. Pass defaultValue and let the editor keep it, or value and onChange to control it; onChange receives "" once the text is empty, so a required check works on the value directly. onJsonChange receives Tiptap's JSON as well. name adds a hidden field holding the HTML, for a form that posts without JavaScript handling. Clean the HTML on the server before you show it to anyone else: the editor only writes the elements of its toolbar (pasted or given HTML is reduced to them, so scripts, event attributes and javascript: addresses do not survive), but a request can carry anything.

Name the editor with aria-label, or point aria-labelledby at a visible label: a label's htmlFor cannot reach an editable div. aria-invalid and aria-describedby (an error message) go on the text, as on any field. readOnly shows the content without a toolbar, selectable and in the tab order; disabled turns everything off. characterCount writes the number of characters under the text, and maxLength refuses an edit that would pass it. toolbar chooses the controls, one inner list per group ([["bold", "italic"], ["link"]]), or hides the toolbar with false; the default holds heading (a select of paragraph and headings 1 to 3), bold, italic, underline, strike, code, bulletList, orderedList, blockquote, link, undo and redo. size and variant="filled" follow the other fields, and contentClassName sizes the text area (h-80 scrolls inside a fixed height; the default grows from 160 px).

The link button opens a small form for the address. A bare example.com becomes https://example.com and a bare e-mail address a mailto: link; anything else that is not a web, mailto: or tel: address is refused with a message. With no text selected, the address itself is inserted as the link. Markdown-style shortcuts work as you type: # makes a heading, - a list, > a quote.

Examples

Basic

An email template with a heading, marks, a list, a quote and a link, in a 320 px area that scrolls.

import { Editor } from "@booleanpress/ui/editor"

const TEMPLATE = `<h2>Your password reset link</h2>
<p>Hi {{first_name}},</p>
<p>Someone asked to reset the password of your <strong>Northwind</strong> account. The link below works for <em>one hour</em>:</p>
<ul><li>Open the link on the device you sign in with.</li><li>Choose a password you have not used before.</li></ul>
<blockquote>If you did not ask for this, you can ignore this email.</blockquote>
<p>Questions? Read the <a href="https://example.com/help">help centre</a>.</p>`

export default function EditorBasic() {
  return <Editor aria-label="Email template" defaultValue={TEMPLATE} contentClassName="h-80" className="max-w-2xl" />
}

Controlled

value and onChange keep the HTML in state; it is shown under the editor as it changes.

import * as React from "react"
import { Editor } from "@booleanpress/ui/editor"

export default function EditorControlled() {
  const [html, setHtml] = React.useState("<p>Thanks for your patience: the <strong>DKIM records</strong> are now live.</p>")

  return (
    <div className="flex w-full max-w-2xl flex-col gap-3">
      <Editor aria-label="Reply to Maya Chen" value={html} onChange={setHtml} contentClassName="min-h-32" />
      <div className="flex flex-col gap-1">
        <span className="text-xs font-medium text-muted-foreground">HTML sent to the server</span>
        <pre className="overflow-x-auto rounded-md bg-muted px-3 py-2 font-mono text-xs whitespace-pre-wrap text-foreground">
          {html || "(empty)"}
        </pre>
      </div>
    </div>
  )
}

Placeholder

placeholder shows a hint while the editor is empty.

import { Editor } from "@booleanpress/ui/editor"

export default function EditorPlaceholder() {
  return <Editor aria-label="Reply" placeholder="Write a reply to the customerโ€ฆ" contentClassName="min-h-32" className="max-w-2xl" />
}

Read only

readOnly shows release notes without a toolbar; the text can be selected and copied.

import { Editor } from "@booleanpress/ui/editor"

const NOTES = `<h3>Release 4.2</h3>
<ul><li><strong>Templates:</strong> open rate per template in the monthly report.</li><li><strong>Logs:</strong> bounces now show the receiving server's reply.</li></ul>
<p>Read the full notes in the <a href="https://example.com/changelog">changelog</a>.</p>`

export default function EditorReadOnly() {
  return <Editor aria-label="Release notes" readOnly defaultValue={NOTES} contentClassName="min-h-0" className="max-w-2xl" />
}

Minimal toolbar

toolbar keeps bold, italic, underline, a bulleted list and the link.

import { Editor } from "@booleanpress/ui/editor"

export default function EditorMinimalToolbar() {
  return (
    <Editor
      aria-label="Internal note"
      toolbar={[["bold", "italic", "underline"], ["bulletList"], ["link"]]}
      placeholder="Add a note only your team can see"
      contentClassName="min-h-24"
      className="max-w-xl"
    />
  )
}

Character count with limit

characterCount with maxLength={200} counts the characters under the text and refuses more.

import { Label } from "@booleanpress/ui/label"
import { Editor } from "@booleanpress/ui/editor"

export default function EditorCharacterCount() {
  return (
    <div className="flex w-full max-w-xl flex-col gap-2">
      <Label id="summary-label">Ticket summary</Label>
      <Editor
        aria-labelledby="summary-label"
        toolbar={[["bold", "italic"], ["link"]]}
        characterCount
        maxLength={200}
        defaultValue="<p>Password reset emails bounce since the sender moved to a domain without DKIM records.</p>"
        contentClassName="min-h-24"
      />
    </div>
  )
}

Invalid

aria-invalid turns the edge red; aria-describedby points at the error message.

import { Label } from "@booleanpress/ui/label"
import { Editor } from "@booleanpress/ui/editor"

export default function EditorInvalid() {
  return (
    <div className="flex w-full max-w-xl flex-col gap-2">
      <Label id="reply-label">Reply</Label>
      <Editor
        aria-labelledby="reply-label"
        aria-invalid
        aria-describedby="reply-error"
        toolbar={[["bold", "italic", "underline"], ["link"]]}
        contentClassName="min-h-24"
      />
      <p id="reply-error" className="text-xs text-destructive-strong">
        Write a reply before you send it.
      </p>
    </div>
  )
}

Disabled

disabled greys the field and turns off the text and every control.

import { Editor } from "@booleanpress/ui/editor"

export default function EditorDisabled() {
  return (
    <Editor
      aria-label="Signature"
      disabled
      defaultValue="<p><strong>Sam Ortiz</strong><br>Support, Northwind Mail</p>"
      contentClassName="min-h-24"
      className="max-w-2xl"
    />
  )
}

Sizes

size sm, default and lg: 12, 14 and 16 px text, with the toolbar scaled to match.

import { Editor, type EditorTool } from "@booleanpress/ui/editor"

const TOOLS: EditorTool[][] = [["bold", "italic", "link"]]

export default function EditorSizes() {
  return (
    <div className="flex w-full max-w-xl flex-col gap-4">
      <Editor aria-label="Small note" size="sm" toolbar={TOOLS} defaultValue="<p>Small: 12 px text.</p>" contentClassName="min-h-16" />
      <Editor aria-label="Default note" toolbar={TOOLS} defaultValue="<p>Default: 14 px text.</p>" contentClassName="min-h-16" />
      <Editor aria-label="Large note" size="lg" toolbar={TOOLS} defaultValue="<p>Large: 16 px text.</p>" contentClassName="min-h-16" />
    </div>
  )
}

Filled

variant="filled" draws the grey field fill.

import { Editor } from "@booleanpress/ui/editor"

export default function EditorFilled() {
  return (
    <Editor
      aria-label="Auto-reply message"
      variant="filled"
      defaultValue="<p>We have your message and reply within one working day.</p>"
      contentClassName="min-h-32"
      className="max-w-2xl"
    />
  )
}

With a form

name submits the HTML; a disabled editor submits nothing, as a disabled field does; the form's Reset puts defaultValue back.

import * as React from "react"
import { Button } from "@booleanpress/ui/button"
import { Editor } from "@booleanpress/ui/editor"
import { Label } from "@booleanpress/ui/label"
import { Switch } from "@booleanpress/ui/switch"

export default function EditorWithForm() {
  const [disabled, setDisabled] = React.useState(false)
  const [sent, setSent] = React.useState<string | null>(null)

  return (
    <form
      className="flex w-full max-w-2xl flex-col gap-3"
      onSubmit={(event) => {
        event.preventDefault()
        setSent([...new FormData(event.currentTarget)].map(([key, value]) => `${key} = ${value}`).join(", "))
      }}
    >
      <Editor aria-label="Reply" name="reply" defaultValue="<p>Initial text</p>" disabled={disabled} contentClassName="min-h-24" />
      <div className="flex items-center gap-2">
        <Switch id="reply-disabled" checked={disabled} onCheckedChange={setDisabled} />
        <Label htmlFor="reply-disabled">Disable</Label>
      </div>
      <div className="flex gap-2">
        <Button type="submit">Submit</Button>
        <Button type="reset" variant="outline">
          Reset
        </Button>
      </div>
      <pre className="overflow-x-auto rounded-md bg-muted px-3 py-2 font-mono text-xs whitespace-pre-wrap text-foreground">
        {sent === null ? "Press Submit to see what the form sends." : `Sent: ${sent || "(nothing)"}`}
      </pre>
    </form>
  )
}

Accessibility

Semantics
The text is an editable div with role="textbox" and aria-multiline="true", carrying the name, aria-describedby, aria-invalid, aria-readonly, aria-disabled and aria-placeholder. The toolbar is a role="toolbar" that names the text it controls (aria-controls); format buttons are toggle buttons with aria-pressed and their shortcut in aria-keyshortcuts; the text style is a select (role="combobox"). The link form is a dialog with a labelled field; a refused address sets aria-invalid and describes the field with the message.
Labels
Name the editor with aria-label or aria-labelledby. The toolbar is named by editorToolbar, the select by textStyle (its options paragraph and heading, "Heading {level}"), the buttons by bold, italic, underline, strikethrough, inlineCode, bulletList, orderedList, blockquote, link, undo and redo. The link form uses linkUrl, invalidLink, apply and removeLink; the counter characterCount ("{count} of {max} characters") or characters.
Focus
The toolbar is one tab stop: Tab lands on its last-used control, the arrows move between controls, and Tab again enters the text. A toolbar button pressed with the keyboard keeps focus; pressed with the pointer, the caret stays in the text. Choosing a text style with the pointer puts the caret back in the text. The link form takes focus when it opens; applying or removing a link returns focus to the text, and Escape returns it to where the form was opened from. The field's edge turns --ring while the text has focus.
Known limits
  • Tiptap renders in the browser after the page loads; on the server the toolbar and an empty text area are drawn, then the content appears.
  • A paste that would pass maxLength is refused whole, not cut to fit.
  • The counter is read with the field's description when the text is focused; it is not announced on every key.
  • In a list item that can be indented, Tab indents it and Shift+Tab outdents it instead of leaving the editor; Enter on an empty item leaves the list.
  • Read-only mode hides the toolbar. Links in read-only text open with a normal click; in editable text they do not, so a click places the caret.
  • Pressed formats are drawn in the primary colour on a faint plate; the visual target uses the colour alone.

Keyboard

Keyboard
KeyBehaviour
TabMoves into the toolbar (one stop), then into the text; Shift+Tab goes back.
โ†’orโ†In the toolbar, moves to the next or previous control, skipping disabled ones. Reversed in a right-to-left page.
HomeorEndIn the toolbar, moves to the first or last control.
EnterorSpaceOn a format button, turns the format on or off, keeping focus on the button; on the text style, opens its list.
CtrlBBold (โŒ˜B on a Mac).
CtrlIItalic (โŒ˜I on a Mac).
CtrlUUnderline (โŒ˜U on a Mac).
CtrlShiftSStrikethrough (โŒ˜โ‡งS on a Mac).
CtrlEInline code (โŒ˜E on a Mac).
CtrlAlt1โ€“3Heading 1, 2 or 3 (โŒ˜โŒฅ1โ€“3 on a Mac).
CtrlAlt0Back to a paragraph (โŒ˜โŒฅ0 on a Mac).
CtrlShift8Bulleted list (โŒ˜โ‡ง8 on a Mac).
CtrlShift7Numbered list (โŒ˜โ‡ง7 on a Mac).
CtrlShiftBQuote (โŒ˜โ‡งB on a Mac).
CtrlKOpens the link form for the selection (โŒ˜K on a Mac).
CtrlZUndo (โŒ˜Z on a Mac).
CtrlShiftZRedo; Ctrl+Y as well (โŒ˜โ‡งZ on a Mac).
EnterIn the link form, applies the address, or shows why it is refused.
EscapeCloses the link form or the text-style list without a change.

API

Editor

Editor props
PropTypeDefaultDescription
characterCountbooleanfalseShows the number of characters under the text ("12 of 200 characters" with maxLength).
contentClassNamestringClasses for the area that holds the text, such as a height (h-80) or a minimum height.
defaultValuestringThe content to start with, as HTML, uncontrolled.
disabledbooleanfalseStops editing and every toolbar control.
formstringThe id of the form that field belongs to, for an editor placed outside it.
maxLengthnumberThe most characters the text may hold; an edit that would pass it is refused.
namestringAdds a field with this name holding the HTML, for a form that posts natively.
onChange((html: string) => void)Called with the HTML after every change ("" once the editor is empty).
onJsonChange((json: JSONContent) => void)Called with the content as Tiptap JSON after every change.
placeholderstringThe hint shown while the editor is empty.
readOnlybooleanfalseShows the content without a toolbar; it can be selected and copied but not changed.
size"default" | "sm" | "lg"The text size, padding and toolbar: 12, 14 or 16 px text. Defaults to the provider's controlSize.
toolbarfalse | EditorTool[][][ ["heading"], ["bold", "italic", "underline", "strike", "code"], ["bulletList", "orderedList", "blockquote"], ["link"], ["undo", "redo"], ]The toolbar's controls, one inner list per group; false hides it.
valuestringThe content as HTML, controlled. An empty editor's value is "".
variant"default" | "filled"filled draws the grey --field-filled fill. Defaults to the provider's fieldVariant.

Every part takes className, merged with its defaults by cn(), and ref, which reaches the element it renders.

Data attributes: data-slot="editor-heading" (Editor), and data-active, data-tool, data-size, data-variant, data-disabled, data-readonly, data-invalid, data-full.

Provider strings: bold, italic, underline, strikethrough, inlineCode, bulletList, orderedList, blockquote, textStyle, paragraph, heading, link, linkUrl, invalidLink, removeLink, apply, undo, redo, editorToolbar, characterCount, characters (BooleanUIProvider's strings).

Theming

The frame has the field look: --field (--field-filled when filled, --field-disabled when disabled), the --control edge, --control-hover on hover, --ring while the text has focus, --invalid when invalid. The toolbar is ruled off with --border; its icons are --muted-foreground, --foreground on hover and --primary on --accent when pressed. Quotes have a --control bar, inline code and code blocks a --muted fill, links --primary; the placeholder and the counter are --muted-foreground.

The tokens its classes read. Change their values in your theme, and it follows.

Theme tokens
TokenUsed for
--accentbackground
--borderborder
--controlborder
--control-hoverborder
--destructive-strongtext
--fieldbackground
--field-disabledbackground
--field-disabled-foregroundtext
--field-filledbackground
--foregroundtext
--invalidborder
--mutedbackground
--muted-foregroundtext
--primarytext
--ringoutline, border