Text editor
Text editor is a rich-text field with a formatting toolbar, powered by TipTap. Use it for lesson notes, briefs and long-form answers.
Beta until design adds an editing area and content styles (C50), item focus and disabled states, and readable text colours (C50). The editor (TipTap, C45), toolbar, link dialog and Field wiring are implemented and tested.
Engine: TipTap
RichTextEditor runs on TipTap 3 (C45, ADR 0006). It stores HTML, renders the Figma toolbar with working commands and adds a link dialog. The shell parts it's built from (TextEditor, TextEditorToolbar, TextEditorToolbarButton…) stay exported for custom editors; see Building your own editor.
For a Notion-like notes page with a slash menu, checklists, tables, code and maths, use Block editor.
Preview
A real editor: select text and use the toolbar or keyboard shortcuts. The HTML it stores updates below. Change the controls to try toolbar subsets and states.
Students see these notes under the video.
const [notes, setNotes] = useState(savedHtml)
<Field label="Lesson notes" hint="Students see these notes under the video.">
<RichTextEditor
value={notes}
onChange={setNotes}
placeholder="Write the key ideas of this lesson…"
/>
</Field><h2>Photosynthesis</h2><p>Plants turn <strong>light</strong>, water and carbon dioxide into glucose and oxygen.</p><ul><li><p>Happens in the chloroplasts</p></li><li><p>Needs chlorophyll</p></li></ul>Installation
Availability
@nuton/ui and @nuton/tokens are private workspace packages. They aren't published to a registry yet (D4: registry deferred; D12: distribution waits on the provenance review). Inside the Nuton monorepo, add them as workspace dependencies. The commands below apply once the packages are published.
1. Add the packages
pnpm add @nuton/ui @nuton/tokens @remixicon/reactnpm install @nuton/ui @nuton/tokens @remixicon/reactyarn add @nuton/ui @nuton/tokens @remixicon/reactPeer dependencies: react and react-dom 18 or later. TipTap (@tiptap/react, @tiptap/starter-kit and three extensions, pinned to 3.31.4) installs with @nuton/ui; you don't add it yourself. Apps that never import RichTextEditor don't bundle it. @remixicon/react supplies the item icons (D1).
2. Load tokens and component styles once
// app/layout.tsx (or your app's entry file)
import '@nuton/tokens/tokens.css' // --nu-* design tokens
import '@nuton/ui/styles.css' // component styles (uses the tokens)No provider is needed. In Next.js, the editor renders on the client only (TipTap's immediatelyRender: false), so server-rendered pages hydrate cleanly.
Usage
import { Field, RichTextEditor } from '@nuton/ui'
export function LessonNotes({ saved }: { saved: string }) {
const [notes, setNotes] = useState(saved)
return (
<Field label="Lesson notes" hint="Students see these notes under the lesson video.">
<RichTextEditor value={notes} onChange={setNotes} placeholder="Write the key ideas of this lesson…" />
</Field>
)
}- Put the editor in a Field. The label, hint, error,
requiredanddisabledreach the content and the toolbar. - The value is HTML. An empty editor reports
''. Passnameto submit it with a form. - Show only the tools the task needs with
tools, for example['marks', 'lists']for feedback. - Sanitize stored HTML on the server before showing it to other people, and render it inside an element with the
nu-rich-textclass so it gets the same styles. - Translate every string with
labelsoutside English.
// Sanitize on the server (for example with DOMPurify), then:
<div className="nu-rich-text" dangerouslySetInnerHTML={{ __html: sanitizedHtml }} />Variants and sizes
There's one size. Figma's toolbar variants are subsets of the same items, and each item's Figma Type follows from its props.
Item types
<TextEditorToolbar>
{/* Text: visible text and a caret, in a menu trigger */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<TextEditorToolbarButton label="Text style">{block}</TextEditorToolbarButton>
</DropdownMenuTrigger>
{/* Paragraph, Heading 1, Heading 2, Quote */}
</DropdownMenu>
{/* Dropdown: icon and caret */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<TextEditorToolbarButton icon={<RiFontSize />} caret label={`Text size, ${size}`} />
</DropdownMenuTrigger>
{/* Small, Medium, Large */}
</DropdownMenu>
<TextEditorToolbarSeparator />
{/* Icon: toggles with pressed */}
<TextEditorToolbarButton icon={<RiBold />} label="Bold" pressed={bold} onClick={toggleBold} />
<TextEditorToolbarButton icon={<RiItalic />} label="Italic" pressed={italic} onClick={toggleItalic} />
<TextEditorToolbarButton icon={<RiUnderline />} label="Underline" pressed={underline} onClick={toggleUnderline} />
<TextEditorToolbarButton icon={<RiStrikethrough />} label="Strikethrough" pressed={strike} onClick={toggleStrike} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<TextEditorToolbarButton icon={<RiAlignLeft />} caret label="Align left" />
</DropdownMenuTrigger>
{/* Align left, center, right */}
</DropdownMenu>
<TextEditorToolbarSeparator />
{/* Color: swatch and caret */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<TextEditorToolbarButton swatch={color} label={`Text colour, ${name(color)}`} />
</DropdownMenuTrigger>
{/* one named item per TEXT_EDITOR_COLORS entry */}
</DropdownMenu>
</TextEditorToolbar>| Figma Type | Props | Content | Start inset |
|---|---|---|---|
| Text | children | Label/Small text + caret (default) | 10 |
| Dropdown | icon + caret | 20px icon + caret | 4 |
| Icon | icon (with pressed for toggles) | 20px icon | 4 |
| Color | swatch | 16px swatch + caret (default) | 8 |
Toolbar variants
Figma's Variant property shows four item sets. They aren't a prop: compose the items you need. The playground's variant control shows each one.
| Figma Variant | Items |
|---|---|
| 01 | The full bar: text style, size, formatting, alignment, colour, comment, link and mention |
| 02 | Text style and size |
| 03 | Bold, italic, underline, strikethrough and alignment |
| 04 | Comment, link and mention |
| Show More | Adds a More (⋮) menu button at the end |
Sizes
| Part | Size | Spacing | Radius |
|---|---|---|---|
| Toolbar | 32px tall | Padding 2, gap 2; wraps onto more rows when narrow | 8 |
| Item | 28 × 28 minimum; icons 20 | Padding 4, inner gap 2 | 6 |
| Separator | 4 × 16 with a 1px line | — | — |
| Swatch | 16 × 16 | — | full |
| Frame | Full width | Padding 12 (inside a 1px border), 8 between toolbar and content | 12 |
| Content (textarea) | 6 rows by default (Paragraph/Small, 24px line) | — | — |
Toolbar and item sizes follow Figma's geometry. Figma has no editing area, so the frame and content reuse Text Area (5:1282) styling (pending a decision). Figma shows a single-row toolbar; code lets it wrap so it reflows at 320px (WCAG 1.4.10).
Colours
TEXT_EDITOR_COLORS lists Figma's ten swatch colours, each the Foundation <hue>/500. The swatch is decoration: always name the colour in the control's label or menu item.
import { TEXT_EDITOR_COLORS, TextEditorSwatch } from '@nuton/ui'
{TEXT_EDITOR_COLORS.map((color) => (
<figure key={color}>
<TextEditorSwatch color={color} />
<figcaption>{color}</figcaption>
</figure>
))}| Colour | Token | vs Bg/White | vs Bg/Weak-50 | 3:1 on white |
|---|---|---|---|---|
gray | --nu-gray-500 | 4.23:1 | 3.95:1 | Passes |
blue | --nu-blue-500 | 5.10:1 | 4.76:1 | Passes |
orange | --nu-orange-500 | 2.79:1 | 2.60:1 | Fails |
red | --nu-red-500 | 3.66:1 | 3.41:1 | Passes |
green | --nu-green-500 | 2.36:1 | 2.20:1 | Fails |
yellow | --nu-yellow-500 | 1.82:1 | 1.70:1 | Fails |
purple | --nu-purple-500 | 4.79:1 | 4.47:1 | Passes |
sky | --nu-sky-500 | 2.01:1 | 1.88:1 | Fails |
pink | --nu-pink-500 | 3.16:1 | 2.95:1 | Passes |
teal | --nu-teal-500 | 1.89:1 | 1.77:1 | Fails |
Orange, green, yellow, sky, pink and teal are below 3:1 against white, as Figma specifies (pending a decision). The swatch is aria-hidden, so the colour name carries the meaning (WCAG 1.4.1).
Text colours and contrast (C50, pending a decision)
In RichTextEditor the same ten colours colour text. Body text needs 4.5:1, and only Blue (5.10:1) and Purple (4.79:1) reach it on white; Gray is 4.23:1 and the rest are lower. Saved HTML stores var(--nu-rich-text-color-<hue>, var(--nu-<hue>-500)), so a darker text shade can be adopted later without rewriting saved content. Until then, use colour for emphasis only, never as the only way to show meaning.
States and interactions
Item states
aria-pressed flips, and the icon turns State/Primary/Base on Weak-50.
aria-expanded drives the Active style, text turns Strong-950 and the caret flips up in primary.
Frame states
Figma has no frame, so these follow Text Area (pending a decision). Disabling the editor disables every toolbar button too.
aria-invalid. Inside a Field, the error prop sets it and links the message.Composition examples
Assignment brief form
A required editor in a form. Submit it empty: the error is linked to the content and focus moves there. The name prop sends the HTML with the form.
const editorRef = useRef<Editor | null>(null)
function submit(e) {
e.preventDefault()
if (editorRef.current?.isEmpty) {
setError('Describe the assignment before publishing.')
editorRef.current.commands.focus()
return
}
publish(new FormData(e.currentTarget).get('brief'))
}
<form onSubmit={submit} noValidate>
<Field label="Assignment brief" required hint="Explain the task, the deadline and how it's marked." error={error}>
<RichTextEditor
name="brief"
tools={['style', 'marks', 'lists', 'link']}
placeholder="What should students do?"
onEditorReady={(editor) => { editorRef.current = editor }}
/>
</Field>
<Button size="sm" type="submit">Publish assignment</Button>
</form>Building your own editor
For a different tool set (mentions, comments) build on the shell parts. Any editable element can replace TextEditorContent. Spread useTextEditorContentProps() onto the engine's root so it gets the Field's label, hint, error and state. A contenteditable can't be the target of <label for>, so the name comes through aria-labelledby. Add role="textbox" and aria-multiline="true" if the engine doesn't. Here a plain contenteditable stands in for the engine.
A plain contenteditable stands in for the engine's editable root.
function EngineRoot() {
const a11y = useTextEditorContentProps()
return (
<div
{...a11y}
role="textbox"
aria-multiline="true"
contentEditable
suppressContentEditableWarning
tabIndex={0}
className="nu-text-editor__content"
>
Cells are the basic unit of life…
</div>
)
}
<Field label="Lesson summary" hint="A plain contenteditable stands in for the engine's editable root.">
<TextEditor toolbar={<FormattingToolbar />}>
<EngineRoot />
</TextEditor>
</Field>With TipTap, read each toggle's pressed from editor.isActive() (via useEditorState) and run the command in onClick, as RichTextEditor does in its source.
Colour menu
A colour item opens a menu with one named option per colour. The item's label names the current colour.
<DropdownMenu>
<DropdownMenuTrigger asChild>
<TextEditorToolbarButton swatch={color} label={`Text colour, ${name(color)}`} />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuRadioGroup value={color} onValueChange={setColor}>
{TEXT_EDITOR_COLORS.map((c) => (
<DropdownMenuRadioItem key={c} value={c} leading={<TextEditorSwatch color={c} />}>
{name(c)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>API reference
RichTextEditor props
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Controlled HTML. When it differs from the editor's HTML the document is replaced; keep it in sync from onChange. |
defaultValue | string | — | Initial HTML for an uncontrolled editor. |
onChange | (html: string) => void | — | Called with the HTML after every change; '' when empty. |
placeholder | string | — | Shown while the editor is empty (Text/Sub-600). |
name | string | — | Adds a hidden input carrying the HTML, so the editor submits with its form. |
tools | RichTextEditorTool[] | every tool | Toolbar groups in order: style, marks, align, lists, color, link. [] hides the toolbar. |
labels | Partial<RichTextEditorLabels> | RICH_TEXT_EDITOR_LABELS (English) | Every visible and accessible string, including colour names and the link dialog. |
invalid | boolean | from Field | Red frame and aria-invalid. |
disabled | boolean | from Field | Read-only content and disabled toolbar. |
required | boolean | from Field | aria-required. Validate with editor.isEmpty. |
aria-label | string | — | Accessible name when there is no Field. |
onEditorReady | (editor: Editor) => void | — | Receives the TipTap editor once it exists, for focus, validation and custom commands. |
contentId | string | from Field | Id of the editable element. |
footer | ReactNode | — | Extra content inside the frame below the text, e.g. a character count. |
ref | Ref<HTMLDivElement> | — | Forwarded to the frame. |
Also exported: RICH_TEXT_EDITOR_LABELS, RICH_TEXT_EDITOR_TOOLS, normalizeLinkHref(input) (the link dialog's check: allows http, https and mailto, completes bare domains and email addresses, rejects everything else) and richTextColorValue(color), the CSS value stored for a text colour.
TextEditor props
| Prop | Type | Default | Description |
|---|---|---|---|
toolbar | ReactNode | — | The formatting toolbar, usually a TextEditorToolbar. Rendered above the content. |
children | ReactNode | <TextEditorContent /> | The editable content: a TextEditorContent or an engine's root wired with useTextEditorContentProps. |
invalid | boolean | from Field, else false | Red frame. Inside a Field it follows error. |
disabled | boolean | from Field, else false | Disables the frame, the default content and every toolbar button. |
ref | Ref<HTMLDivElement> | — | Forwarded to the frame. |
…rest | HTMLAttributes<HTMLDivElement> | — | Any other div attribute (className, id, data-*…). |
TextEditorContent props
| Prop | Type | Default | Description |
|---|---|---|---|
invalid | boolean | from Field, else false | Sets aria-invalid. |
rows | number | 6 | Visible lines. The user can resize vertically. |
id | string | from Field | The Field’s control id, so its label points here. |
disabled | boolean | from TextEditor or Field | Native disabled. |
required | boolean | from Field | Native required. |
aria-describedby | string | — | Merged after the Field’s hint and error ids. |
ref | Ref<HTMLTextAreaElement> | — | Forwarded to the textarea. |
…rest | TextareaHTMLAttributes | — | Any other textarea attribute (value, onChange, placeholder, name, maxLength…). |
TextEditorToolbar props
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label | string | 'Formatting' | The toolbar's name. Pass a translated string in other languages. Dropped when you pass aria-labelledby. |
children | ReactNode | — | TextEditorToolbarButtons and TextEditorToolbarSeparators, directly or inside menus. |
ref | Ref<HTMLDivElement> | — | Forwarded to the toolbar element. |
…rest | HTMLAttributes<HTMLDivElement> | — | Any other div attribute. Your onKeyDown and onFocus run first; call preventDefault() to stop the arrow-key movement. |
TextEditorToolbarButton props
| Prop | Type | Default | Description |
|---|---|---|---|
icon | ReactNode | — | Figma Pick Icon: a 20px Remix icon. Rendered aria-hidden. |
label | string | — | What the item does. With no visible text it's the accessible name (development warning if missing) and the tooltip; with visible text, only the tooltip. |
pressed | boolean | — | Makes the item a toggle with aria-pressed (Figma Active). Leave undefined for actions and menu buttons. |
caret | boolean | true for text and colour items | Shows the dropdown caret. It flips while aria-expanded="true". |
swatch | TextEditorColor | — | Figma Type=Color: shows that colour’s swatch. |
tooltip | ReactNode | false | label | Tooltip content. false turns it off. |
tooltipProps | Pick<TooltipProps, 'side' | 'align' | 'size' | 'tone' | 'delayDuration'> | top, '2xs', 'dark' | Placement and look of the tooltip. |
children | ReactNode | — | Figma Edit Text: visible text, which makes it a Text item. |
disabled | boolean | false (true when the TextEditor is disabled) | Native disabled; skipped by the arrow keys. |
type | 'button' | 'submit' | 'reset' | 'button' | Native type. Never submits a surrounding form by default. |
ref | Ref<HTMLButtonElement> | — | Forwarded to the button, so it works inside DropdownMenuTrigger asChild. |
…rest | ButtonHTMLAttributes | — | Any other button attribute (onClick, aria-label, aria-labelledby…). An explicit aria-label wins over label. |
The Figma Type (text, dropdown, icon, color) is derived from these props and exposed as data-type; its TypeScript type is TextEditorToolbarButtonType.
TextEditorToolbarSeparator and TextEditorSwatch props
| Prop | Type | Default | Description |
|---|---|---|---|
ref | Ref<HTMLDivElement> | — | Forwarded to the element, which has role="separator" and aria-orientation="vertical". |
…rest | HTMLAttributes<HTMLDivElement> | — | Any other div attribute. |
| Prop | Type | Default | Description |
|---|---|---|---|
colorrequired | TextEditorColor | — | Figma Color: one of TEXT_EDITOR_COLORS. |
ref | Ref<HTMLSpanElement> | — | Forwarded to the swatch. Always rendered aria-hidden. |
…rest | HTMLAttributes<HTMLSpanElement> | — | Any other span attribute. |
useTextEditorContentProps and TEXT_EDITOR_COLORS
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | from Field | Id for the editable root. |
invalid | boolean | from Field | Returned as aria-invalid. |
disabled | boolean | from TextEditor or Field | Returned as aria-disabled. Also stop the engine accepting input. |
required | boolean | from Field | Returned as aria-required. |
aria-describedby | string | — | Merged after the Field’s hint and error ids. |
It returns id, aria-labelledby, aria-describedby, aria-invalid, aria-disabled and aria-required to spread onto the root. Call it inside the TextEditor. TEXT_EDITOR_COLORS is the readonly tuple ['gray', 'blue', 'orange', 'red', 'green', 'yellow', 'purple', 'sky', 'pink', 'teal'], and TextEditorColor is its union type. It lives in a plain module, so server code can read it.
Styling hooks
The frame is .nu-text-editor with data-invalid and data-disabled; the default content is .nu-text-editor__content (give an engine's root this class). The toolbar is .nu-text-editor-toolbar, items are .nu-text-editor-toolbar__button with data-type, and swatches are .nu-text-editor-swatch with data-color. Supported override points: --nu-text-editor-toolbar-bg, --nu-text-editor-toolbar-ring, --nu-text-editor-item-size (28px) and --nu-text-editor-divider-width (4px).
Accessibility
Keyboard
| Key | Behaviour |
|---|---|
| Tab | Enters the toolbar on one item (the last one used), then moves on to the content. |
| ← / → | Move between items, wrapping at the ends. Mirrored in right-to-left. Disabled items are skipped. |
| Home / End | Jump to the first or last item. |
| Enter / Space | Activate an item or flip a toggle. On a menu item, open the menu (↓ also opens it). |
| Escape | Closes an open menu, tooltip or the link dialog. |
| ⌘/Ctrl / B | Bold. Also I (italic), U (underline), Shift+S (strikethrough). |
| ⌘/Ctrl / Shift / 8 | Bulleted list (7 for numbered). Tab and Shift+Tab indent and outdent inside a list. |
| ⌘/Ctrl / Z | Undo (Shift+Z or Y to redo). |
Screen readers
The content is a textbox with aria-multiline, named by the Field. A toolbar named “Formatting”. Toggles are read with their pressed state, menu items as having a menu and whether it's expanded. Separators are separators; icons, carets and swatches are hidden. The content gets the Field's label, hint and error. An icon-only item's tooltip repeats its name as the description while open (pending a decision).
Focus
Items keep the browser's focus outline, as Figma has no focus state (C10 precedent). Focus in the content draws the frame's focus ring (C5). After choosing from a menu or closing the link dialog, focus returns to the text; Escape on a menu returns to its item.
Contrast and colour
Item text Sub-600 is 6.69:1; Active primary is 5.22:1 on Weak-50 (code uses local State/Primary/Base, not Figma's remote blue/500 at 4.76:1, C20 precedent). The caret (Soft-400, 2.49:1) and separator (1.19:1) are decorative. Six swatches are below 3:1 (pending a decision), so colours are always named.
Touch and pointer
Items are 28 × 28, above the 24 × 24 minimum (WCAG 2.5.8). The toolbar wraps instead of scrolling. Tooltips don't open on touch, but every item keeps its accessible name.
You must: give every item without visible text a label; name colours in labels and menu items; wire a custom editable root with useTextEditorContentProps, role="textbox" and aria-multiline; pass a translated toolbar aria-label outside English.
Figma mapping
| Figma component · property | Figma values | Code |
|---|---|---|
| Rich Editor [1.1] · Variant | 01 · 02 · 03 · 04 | Compose items inside TextEditorToolbar |
| Rich Editor [1.1] · Show More | Off · On | A More (RiMore2Line) menu button |
| Rich Editor Items [1.1] · Type | Text · Dropdown · Icon · Color | Derived: children · icon + caret · icon · swatch |
| Rich Editor Items [1.1] · State | Default · Hover · Active | Interactive states; Active = pressed or an open menu. Plus disabled (code-only) |
| Rich Editor Items [1.1] · Edit Text · Pick Icon | Text · icon swap | children · icon |
| Rich Editor Colors [1.1] · Color | Gray … Teal (10) | TextEditorSwatch color, TEXT_EDITOR_COLORS |
| Divider | — | TextEditorToolbarSeparator |
| (none) | No editing area or frame | RichTextEditor content (TipTap) or TextEditorContent; Text Area styling and content styles are code-only (C50) |
Figma binds every colour to the remote 01-Tokens, 03-Theme and 06-Foundations libraries; code uses the local twins (C19/C22 principle). Item tooltips (top, 2xs, dark) aren't specified in Figma.
Guidelines and related components
Use Text editor for
- Long-form writing that needs structure: lesson notes, assignment briefs, written feedback.
- Answers where students need headings, lists or emphasis.
- A custom editor built from the shell parts.
Don't use Text editor for
- Short plain text such as a space name or a one-line answer. Use an input.
- Plain multi-line text with no formatting. Use a textarea in a Field.
- Choosing between page views. Use Tabs or a Button group.
Do and don't
Common mistakes
- Leaving
labeloff icon-only items, so the toolbar reads as a row of unnamed buttons. - Hard-coding
pressedinstead of reading it from the engine, so Bold shows on when the cursor is in plain text. - Putting a contenteditable in the content slot without
useTextEditorContentProps, so it loses the Field's label and error. - Adding
tabIndexto items yourself. The toolbar manages one Tab stop; extra tab stops break the pattern.