Text Edit
A Tiptap-based rich-text editor with a compact, token-styled toolbar and markdown-ish input — controlled HTML in, HTML out.
- Status
- 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-editThe 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:
| Capability | Why it's out of the base | Where it lives |
|---|---|---|
| Image upload / paste-to-upload | Needs app storage (R2/CDN) | App resolves URLs (same split as Image) |
| @mentions | Needs the app's user/entity data | App-supplied data source + query |
| Markdown import/export, emoji, task lists, code-block language menus | Composed Tiptap extensions | Future composed addons |
| Real-time collaboration (multi-cursor, awareness, Yjs CRDT) | Needs @tiptap/extension-collaboration + yjs + a sync backend | Separate 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 theringtoken on:focus-withinand to destructive when it wraps anaria-invalidcontrol (also settingdata-invalid). Carriesdata-editablewhen editable. - Toolbar (
data-slot="text-edit-toolbar",role="toolbar") — a row ofTogglebuttons (bold, italic, strike, heading, bullet list, ordered list, blockquote, inline code). Each button's pressed state is derived fromeditor.isActive(...)via Tiptap'suseEditorState, so it stays in sync with the caret. Hidden wheneditable={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/typographydependency.
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
| Prop | Type | Default | Description |
|---|---|---|---|
aria-describedby | string | — | ID(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" | boolean | — | Marks 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-label | string | — | Accessible label for the editable region (applied to the contenteditable surface). Provide one when there is no associated visible label. |
aria-labelledby | string | — | References 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. |
className | string | — | Additional class names on the editor container. |
defaultValue | string | '' | Uncontrolled initial HTML content, used only on first render. Ignored when
value is provided. |
editable | boolean | true | Whether the content is editable. When false, renders read-only rich text
and disables the toolbar. |
id | string | — | id 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. |
maxHeight | string | number | — | Maximum 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. |
minHeight | string | number | a 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. |
placeholder | string | — | Placeholder shown (overlaid) while the document is empty. |
ref | React.Ref<HTMLDivElement> | — | Ref forwarded to the editor's root container <div>. |
value | string | — | Controlled 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
| Attribute | Values |
|---|---|
data-editable | "" |
data-invalid | "" |
data-slot | "text-edit" | "text-edit-content" |
Accessibility
- The editable surface is a
role="textbox"witharia-multiline="true". Give it anaria-label(or associate a visible label) so the region is announced. - Use
aria-invalidandaria-describedbyto connect validation state and helper/error text to the contenteditable textbox. - The toolbar is a Base UI
Toolbarlabelled “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 threeToolbar.Groupclusters. Each button is an icon-onlyTogglewith anaria-labelandaria-pressedreflecting the active mark or node. - Editing keymaps come from Tiptap
StarterKit— see the table below. Focus re-colors the container border with theringtoken (focus-within:border-ring/(--alpha-tint-border)); never styled withoutline: none. - An invalid control (
aria-invalid) re-colors the container border to destructive (has-aria-invalid:border-destructive-border/(--alpha-tint-border)) and setsdata-invalidfor downstream styling. Focus outranks invalid: while anything inside the editor holds focus the container carries theringtint 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.
| Key | Action |
|---|---|
| Cmd/Ctrl + B | Toggle bold. |
| Cmd/Ctrl + I | Toggle italic. |
| Cmd/Ctrl + Z | Undo. |
| Cmd/Ctrl + Shift + Z | Redo. |
| Enter | New paragraph (or new list item inside a list). |
| Cmd/Ctrl + Enter | Fire onSubmit with the current HTML (when set). The host decides what submitting means; plain Enter still inserts a newline. |
| Tab | Move 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. |
| Contract | States tested |
|---|---|
| Behaviour | default, active, disabled, empty, error, invalid, pressed, read-only |
| Accessibility | described, disabled, invalid, labeled, semantic-html |
| Visual | default, hover, invalid, error, empty |