Skip to content
Component installs need the registry setup— the Base UI shadcn project, the @vegastack namespace and the Cloudflare Access service token.
VegaStack Design

Text Edit

A Tiptap-based rich-text editor with a compact, token-styled toolbar and markdown-ish input — controlled HTML in, HTML out.

Status
stable
Since
0.1.0
Accessibility pattern
multiline textbox + APG toolbar

Last updated

Install

Add Text Edit from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.

pnpm dlx shadcn@latest add @vegastack/text-edit

The same command installs the registry items it composes: @vegastack/toggle.

It also adds the sanctioned engines to your package.json: @tiptap/react, @tiptap/starter-kit, @tiptap/pm.

Usage

import { TextEdit } from "@/components/ui/text-edit";

const [html, setHtml] = useState("<p>Hello</p>");

<TextEdit
  value={html}
  onValueChange={setHtml}
  placeholder="Write something…"
  aria-label="Body"
  aria-describedby="body-help"
/>;

TextEdit is controlled with value / onValueChange (HTML strings), or uncontrolled with defaultValue. It is built on Tiptap v3 StarterKit, which bundles undo/redo history, the bold / italic / strike / code marks, headings, bullet & ordered lists, blockquote, and markdown-ish input rules (type **bold**, # Heading, - for a list, > for a quote, `code`).

// Uncontrolled — seed initial content and read changes via onValueChange.
<TextEdit
  defaultValue="<p>Draft…</p>"
  onValueChange={(html) => save(html)}
  aria-label="Notes"
/>

Scope

TextEdit is the presentational base editor — a controlled HTML value, the StarterKit formatting set, the styled toolbar, the placeholder, read-only mode, a Cmd/Ctrl+Enter onSubmit affordance, and minHeight/maxHeight sizing. Heavier, app-coupled capabilities are intentionally out of this core (each needs app infrastructure or ships as a separate composed extension), mirroring the design system's G7 presentational/app-coupled rule:

CapabilityWhy it's out of the baseWhere it lives
Image upload / paste-to-uploadNeeds app storage (R2/CDN)App resolves URLs (same split as Image)
@mentionsNeeds the app's user/entity dataApp-supplied data source + query
Markdown import/export, emoji, task lists, code-block language menusComposed Tiptap extensionsFuture composed addons
Real-time collaboration (multi-cursor, awareness, Yjs CRDT)Needs @tiptap/extension-collaboration + yjs + a sync backendSeparate text-edit-collab component

This base ships no @tiptap/extension-collaboration and no yjs dependency — it is a self-contained single-user editor. For richer needs, compose the relevant extension on top or use the dedicated follow-up component.

Prose styling is not this component's to own: the editor surface wears the shared prose recipe from @vegastack/design, the same string MarkdownView puts on its root, so what a person types here and what the app renders from markdown are one typography.

Examples

Anatomy

TextEdit is a single self-contained component, but it composes three layered pieces internally:

<TextEdit>
  {" "}
  {/* data-slot="text-edit" — bordered container, focus-within border tint (no ring) */}
  <Toolbar />{" "}
  {/* data-slot="text-edit-toolbar" — role="toolbar", Toggle buttons */}
  <EditorContent />{" "}
  {/* Tiptap contenteditable, role="textbox" aria-multiline */}
</TextEdit>
  • Container (data-slot="text-edit") — the bordered surface. Re-colors its border with the ring token on :focus-within and to destructive when it wraps an aria-invalid control (also setting data-invalid). Carries data-editable when editable.
  • Toolbar (data-slot="text-edit-toolbar", role="toolbar") — a row of Toggle buttons (bold, italic, strike, heading, bullet list, ordered list, blockquote, inline code). Each button's pressed state is derived from editor.isActive(...) via Tiptap's useEditorState, so it stays in sync with the caret. Hidden when editable={false}.
  • Editor surface (role="textbox", aria-multiline) — the Tiptap/ProseMirror contenteditable. Prose is styled entirely with semantic tokens (headings, lists, code, blockquote), so it tracks the active theme with no @tailwindcss/typography dependency.

Editing patterns

An empty editor with a placeholder, and a read-only editor (toolbar hidden) for rendering stored content.

Invalid

Pass aria-invalid to mark the editor invalid: it forwards to the contenteditable textbox and the container border turns destructive (has-aria-invalid:border-destructive/70, plus data-invalid). Connect the error text with aria-describedby so it is announced with the region. aria-invalid also accepts "grammar" and "spelling".

A comment is required before you can post.

Submit on Cmd/Ctrl+Enter

Set onSubmit to get a keyboard submit affordance — pressing Cmd/Ctrl+Enter fires it with the current HTML. It's presentational only: the host decides what submitting means (save, send, …), and plain Enter still inserts a newline. Omit onSubmit to disable it.

Sized content area

minHeight sets a starting height and maxHeight caps it — content past the cap scrolls (overflow-y-auto) while the toolbar stays pinned. A number is treated as px; a string (e.g. '8rem') is used verbatim. Both are fed to the --te-min-h / --te-max-h CSS variables.

API Reference

PropTypeDefaultDescription
aria-describedbystringID(s) of helper or error text describing the editor. Space-separate multiple ids, same as the native ARIA attribute.
aria-invalid"false" | "grammar" | "spelling" | "true" | booleanMarks the contenteditable textbox invalid for form integrations. When true (or "grammar" / "spelling"), the container also receives the destructive invalid styling hook via the child textbox.
aria-labelstringAccessible label for the editable region (applied to the contenteditable surface). Provide one when there is no associated visible label.
aria-labelledbystringReferences the id(s) of the element(s) that label the editable region (space-separated, same as the native ARIA attribute), applied to the contenteditable surface alongside aria-label/id. Prefer this over aria-label when a visible label element already exists.
classNamestringAdditional class names on the editor container.
defaultValuestring''Uncontrolled initial HTML content, used only on first render. Ignored when value is provided.
editablebooleantrueWhether the content is editable. When false, renders read-only rich text and disables the toolbar.
idstringid applied to the contenteditable surface — the same host element that receives aria-label. Use it to target the editor with a <label htmlFor> or to reference it from another element's aria-labelledby/aria-controls.
maxHeightstring | numberMaximum height of the editable content area. A number is treated as px; a string is used verbatim. When set, the content area scrolls past it. Fed from this runtime value into the --te-max-h CSS variable.
minHeightstring | numbera built-in minimum (`min-h-24`)Minimum height of the editable content area. A number is treated as px; a string is used verbatim (e.g. '8rem'). Fed from this runtime value into the --te-min-h CSS variable (not a token) so it reflects the consumer's prop while keeping the inline style variable-only.
onSubmit((html: string) => void)Fire when the user presses Cmd/Ctrl+Enter inside the editor, with the current serialized HTML. A presentational keyboard affordance only — the *host* decides what submitting does (save, send, …); plain Enter still inserts a newline. Omit to disable the shortcut.
onValueChange((html: string) => void)Called with the serialized HTML whenever the document changes.
placeholderstringPlaceholder shown (overlaid) while the document is empty.
refReact.Ref<HTMLDivElement>Ref forwarded to the editor's root container <div>.
valuestringControlled HTML value. When provided, the editor is synced to this string whenever it changes externally (and the editor isn't focused). Pair with onValueChange to drive it from React state.

Data attributes and CSS variables on TextEdit

AttributeValues
data-editable""
data-invalid""
data-slot"text-edit" | "text-edit-content"

Accessibility

  • The editable surface is a role="textbox" with aria-multiline="true". Give it an aria-label (or associate a visible label) so the region is announced.
  • Use aria-invalid and aria-describedby to connect validation state and helper/error text to the contenteditable textbox.
  • The toolbar is a Base UI Toolbar labelled “Formatting” — a real APG toolbar, so the whole row is one tab stop and the arrow keys move between controls (wrapping at the ends) across the three Toolbar.Group clusters. Each button is an icon-only Toggle with an aria-label and aria-pressed reflecting the active mark or node.
  • Editing keymaps come from Tiptap StarterKit — see the table below. Focus re-colors the container border with the ring token (focus-within:border-ring/(--alpha-tint-border)); never styled with outline: none.
  • An invalid control (aria-invalid) re-colors the container border to destructive (has-aria-invalid:border-destructive-border/(--alpha-tint-border)) and sets data-invalid for downstream styling. Focus outranks invalid: while anything inside the editor holds focus the container carries the ring tint instead, and the destructive tint returns on blur — the contenteditable has no outline, so the container border is its only focus cue.
  • editable={false} removes the toolbar and marks the surface read-only.
KeyAction
Cmd/Ctrl + BToggle bold.
Cmd/Ctrl + IToggle italic.
Cmd/Ctrl + ZUndo.
Cmd/Ctrl + Shift + ZRedo.
EnterNew paragraph (or new list item inside a list).
Cmd/Ctrl + EnterFire onSubmit with the current HTML (when set). The host decides what submitting means; plain Enter still inserts a newline.
TabMove focus out of the editor to the next control.
/ (in the toolbar)Move between formatting controls; focus wraps at either end.
Shift + Tab (in the toolbar)Leave the toolbar in one press, rather than stepping back through every button.
ContractStates tested
Behaviourdefault, active, disabled, empty, error, invalid, pressed, read-only
Accessibilitydescribed, disabled, invalid, labeled, semantic-html
Visualdefault, hover, invalid, error, empty

Do / Don't

Do
Use TextEdit for short-to-medium rich text — comments, descriptions, release notes — and drive it with controlled value/onValueChange HTML.
Don't
Use it for plain single-line input (use Input) or for collaborative real-time docs (use the deferred text-edit-collab).

On this page