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
  • Accessibility
  • Figma Text Editor 4457:59770
  • packages/ui/src/components/RichTextEditor

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.

tsx
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>
html
<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

bash
pnpm add @nuton/ui @nuton/tokens @remixicon/react

Peer 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

tsx
// 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

tsx
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, required and disabled reach the content and the toolbar.
  • The value is HTML. An empty editor reports ''. Pass name to 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-text class so it gets the same styles.
  • Translate every string with labels outside English.
tsx
// 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

Figma TypePropsContentStart inset
TextchildrenLabel/Small text + caret (default)10
Dropdownicon + caret20px icon + caret4
Iconicon (with pressed for toggles)20px icon4
Colorswatch16px 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 VariantItems
01The full bar: text style, size, formatting, alignment, colour, comment, link and mention
02Text style and size
03Bold, italic, underline, strikethrough and alignment
04Comment, link and mention
Show MoreAdds a More (⋮) menu button at the end

Sizes

PartSizeSpacingRadius
Toolbar32px tallPadding 2, gap 2; wraps onto more rows when narrow8
Item28 × 28 minimum; icons 20Padding 4, inner gap 26
Separator4 × 16 with a 1px line——
Swatch16 × 16—full
FrameFull widthPadding 12 (inside a 1px border), 8 between toolbar and content12
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.

Gray
Blue
Orange
Red
Green
Yellow
Purple
Sky
Pink
Teal
ColourTokenvs Bg/Whitevs Bg/Weak-503:1 on white
gray--nu-gray-5004.23:13.95:1Passes
blue--nu-blue-5005.10:14.76:1Passes
orange--nu-orange-5002.79:12.60:1Fails
red--nu-red-5003.66:13.41:1Passes
green--nu-green-5002.36:12.20:1Fails
yellow--nu-yellow-5001.82:11.70:1Fails
purple--nu-purple-5004.79:14.47:1Passes
sky--nu-sky-5002.01:11.88:1Fails
pink--nu-pink-5003.16:12.95:1Passes
teal--nu-teal-5001.89:11.77:1Fails

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

Default and hoverText and icons Sub-600, caret Soft-400. Hover a button for the Bg/Weak-50 fill; it applies only on hover-capable pointers.

Active (pressed)Bold starts pressed. Click a toggle: aria-pressed flips, and the icon turns State/Primary/Base on Weak-50.

Active (menu open)Open a menu: aria-expanded drives the Active style, text turns Strong-950 and the caret flips up in primary.

FocusTab into the toolbar. Figma defines no item focus state, so the browser's focus outline is kept (C10 precedent; a designed state is pending a decision).
DisabledNot in Figma. Code uses Text/Disabled-500 and Icon/Disabled-300 and fades the swatch (pending a decision). Disabled items are skipped by the arrow keys.

Frame states

Figma has no frame, so these follow Text Area (pending a decision). Disabling the editor disables every toolbar button too.

FocusFocusing the content gives the frame a Strong-950 border and the important-focus effect (below 3:1, C5). Focusing the toolbar doesn't.
InvalidState/Error/Base border and aria-invalid. Inside a Field, the error prop sets it and links the message.
DisabledWeak-50 fill, no border, Disabled-500 text (4.23:1, C8). The textarea and every toolbar button are disabled.

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.

Explain the task, the deadline and how it's marked.

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.

Cells are the basic unit of life. Plant cells have a cell wall and chloroplasts; animal cells don't.

A plain contenteditable stands in for the engine's editable root.

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.

API reference

RichTextEditor props

RichTextEditor props
PropTypeDefaultDescription
valuestring—Controlled HTML. When it differs from the editor's HTML the document is replaced; keep it in sync from onChange.
defaultValuestring—Initial HTML for an uncontrolled editor.
onChange(html: string) => void—Called with the HTML after every change; '' when empty.
placeholderstring—Shown while the editor is empty (Text/Sub-600).
namestring—Adds a hidden input carrying the HTML, so the editor submits with its form.
toolsRichTextEditorTool[]every toolToolbar groups in order: style, marks, align, lists, color, link. [] hides the toolbar.
labelsPartial<RichTextEditorLabels>RICH_TEXT_EDITOR_LABELS (English)Every visible and accessible string, including colour names and the link dialog.
invalidbooleanfrom FieldRed frame and aria-invalid.
disabledbooleanfrom FieldRead-only content and disabled toolbar.
requiredbooleanfrom Fieldaria-required. Validate with editor.isEmpty.
aria-labelstring—Accessible name when there is no Field.
onEditorReady(editor: Editor) => void—Receives the TipTap editor once it exists, for focus, validation and custom commands.
contentIdstringfrom FieldId of the editable element.
footerReactNode—Extra content inside the frame below the text, e.g. a character count.
refRef<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

TextEditor props
PropTypeDefaultDescription
toolbarReactNode—The formatting toolbar, usually a TextEditorToolbar. Rendered above the content.
childrenReactNode<TextEditorContent />The editable content: a TextEditorContent or an engine's root wired with useTextEditorContentProps.
invalidbooleanfrom Field, else falseRed frame. Inside a Field it follows error.
disabledbooleanfrom Field, else falseDisables the frame, the default content and every toolbar button.
refRef<HTMLDivElement>—Forwarded to the frame.
…restHTMLAttributes<HTMLDivElement>—Any other div attribute (className, id, data-*…).

TextEditorContent props

TextEditorContent props
PropTypeDefaultDescription
invalidbooleanfrom Field, else falseSets aria-invalid.
rowsnumber6Visible lines. The user can resize vertically.
idstringfrom FieldThe Field’s control id, so its label points here.
disabledbooleanfrom TextEditor or FieldNative disabled.
requiredbooleanfrom FieldNative required.
aria-describedbystring—Merged after the Field’s hint and error ids.
refRef<HTMLTextAreaElement>—Forwarded to the textarea.
…restTextareaHTMLAttributes—Any other textarea attribute (value, onChange, placeholder, name, maxLength…).

TextEditorToolbar props

TextEditorToolbar props
PropTypeDefaultDescription
aria-labelstring'Formatting'The toolbar's name. Pass a translated string in other languages. Dropped when you pass aria-labelledby.
childrenReactNode—TextEditorToolbarButtons and TextEditorToolbarSeparators, directly or inside menus.
refRef<HTMLDivElement>—Forwarded to the toolbar element.
…restHTMLAttributes<HTMLDivElement>—Any other div attribute. Your onKeyDown and onFocus run first; call preventDefault() to stop the arrow-key movement.

TextEditorToolbarButton props

TextEditorToolbarButton props
PropTypeDefaultDescription
iconReactNode—Figma Pick Icon: a 20px Remix icon. Rendered aria-hidden.
labelstring—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.
pressedboolean—Makes the item a toggle with aria-pressed (Figma Active). Leave undefined for actions and menu buttons.
caretbooleantrue for text and colour itemsShows the dropdown caret. It flips while aria-expanded="true".
swatchTextEditorColor—Figma Type=Color: shows that colour’s swatch.
tooltipReactNode | falselabelTooltip content. false turns it off.
tooltipPropsPick<TooltipProps, 'side' | 'align' | 'size' | 'tone' | 'delayDuration'>top, '2xs', 'dark'Placement and look of the tooltip.
childrenReactNode—Figma Edit Text: visible text, which makes it a Text item.
disabledbooleanfalse (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.
refRef<HTMLButtonElement>—Forwarded to the button, so it works inside DropdownMenuTrigger asChild.
…restButtonHTMLAttributes—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

TextEditorToolbarSeparator props
PropTypeDefaultDescription
refRef<HTMLDivElement>—Forwarded to the element, which has role="separator" and aria-orientation="vertical".
…restHTMLAttributes<HTMLDivElement>—Any other div attribute.
TextEditorSwatch props
PropTypeDefaultDescription
colorrequiredTextEditorColor—Figma Color: one of TEXT_EDITOR_COLORS.
refRef<HTMLSpanElement>—Forwarded to the swatch. Always rendered aria-hidden.
…restHTMLAttributes<HTMLSpanElement>—Any other span attribute.

useTextEditorContentProps and TEXT_EDITOR_COLORS

useTextEditorContentProps options
PropTypeDefaultDescription
idstringfrom FieldId for the editable root.
invalidbooleanfrom FieldReturned as aria-invalid.
disabledbooleanfrom TextEditor or FieldReturned as aria-disabled. Also stop the engine accepting input.
requiredbooleanfrom FieldReturned as aria-required.
aria-describedbystring—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

KeyBehaviour
TabEnters 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 / EndJump to the first or last item.
Enter / SpaceActivate an item or flip a toggle. On a menu item, open the menu (↓ also opens it).
EscapeCloses an open menu, tooltip or the link dialog.
⌘/Ctrl / BBold. Also I (italic), U (underline), Shift+S (strikethrough).
⌘/Ctrl / Shift / 8Bulleted list (7 for numbered). Tab and Shift+Tab indent and outdent inside a list.
⌘/Ctrl / ZUndo (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 · propertyFigma valuesCode
Rich Editor [1.1] · Variant01 · 02 · 03 · 04Compose items inside TextEditorToolbar
Rich Editor [1.1] · Show MoreOff · OnA More (RiMore2Line) menu button
Rich Editor Items [1.1] · TypeText · Dropdown · Icon · ColorDerived: children · icon + caret · icon · swatch
Rich Editor Items [1.1] · StateDefault · Hover · ActiveInteractive states; Active = pressed or an open menu. Plus disabled (code-only)
Rich Editor Items [1.1] · Edit Text · Pick IconText · icon swapchildren · icon
Rich Editor Colors [1.1] · ColorGray … Teal (10)TextEditorSwatch color, TEXT_EDITOR_COLORS
Divider—TextEditorToolbarSeparator
(none)No editing area or frameRichTextEditor 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

DoShow only the tools the task needs. Feedback needs emphasis, not colours and mentions. Fewer items are faster to scan and to arrow through.
Don'tDon't add every item everywhere. A full bar on a short answer field is noise, and wraps onto extra rows on small screens.
DoName colours in words. The swatch is hidden from screen readers, and six of the ten colours are hard to see on white.
Don'tDon't rely on the swatch alone. A pale dot with no name tells people nothing (WCAG 1.4.1).
DoUse pressed for toggles only. Bold is on or off, so it's a toggle. Link opens something, so it's an action.
Don'tDon't put pressed on an action. A Link button announced as “pressed” tells people a state that doesn't exist.

Common mistakes

  • Leaving label off icon-only items, so the toolbar reads as a row of unnamed buttons.
  • Hard-coding pressed instead 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 tabIndex to items yourself. The toolbar manages one Tab stop; extra tab stops break the pattern.