Eunomia v1.0.0-beta.2
AllAngularReactNext.jsJavaScriptVue
EunomiaSalmonForestVioletOceanGoldFireCustom…
🇬🇧 English🇫🇷 Français

Textarea

Textarea is a multi-line text field. By default it wraps a plain native text area internally, so it gets native constraint validation and keyboard editing for free, while adding auto-resize (grows and shrinks to fit its content by default), an optional click-to-edit static display, and a live character counter when a maximum length is set.

Turning on its rich-text formatting mode switches the field itself to a small editor, with bold, italic, underline, whole-field text alignment, and a text-color and highlight-color picker, and the field becomes an editable surface instead of a plain text area. Unlike raw text, it actually renders bold, italic, underline, color, and alignment live as you type, rather than showing raw formatting delimiters. Its value still carries that same lightweight, Markdown-like markup underneath, so it round-trips correctly for the click-to-edit static display, but neither the toolbar nor the field itself ever expose the raw delimiters to the person editing. See the Accessibility tab for the full keyboard and screen reader details, and its "Formatting toolbar and the readonly display" section for this mode's caveats.

It shares its label, hint, error chrome, and theming with input and select through the same shared field styles, including the error state's border color, which stays red through hover and focus, not just at rest.

Dependencies

eun-label · if it has a label eun-button · for the confirm and cancel actions, in readonly-available edit mode eun-toggle · for the formatting toolbar's bold, italic, underline, and alignment controls, if formatting is set eun-icon · for each toolbar button's glyph, if formatting is set
Overview API Examples Accessibility

When to use

Use textarea for text that can naturally run to several lines, such as a description, a comment, or a message. For a single line of input, such as a name, an email, or a search term, input is the right-sized fit. Only turn on the formatting mode when the resulting text is actually going to be rendered back somewhere, since otherwise the raw formatting delimiters just leak through as plain text.

Install & usage

Pick a framework in the toolbar above and these snippets adapt.

npm install @eunomia/elements
import "@eunomia/elements/textarea.js";
import "@eunomia/elements/label.js";
<eun-textarea label="Description" placeholder="Tell us more…"></eun-textarea>

Importing the file registers <eun-textarea> as a custom element, with no further setup needed. It works with any framework, or none, since it's a standard web component.

Alternatives

You want .. Prefers A single line of input, such as a name, an email, or a search term eun-input

Guidance

  • Always set label (or slot one): see the Accessibility tab
  • Let the field auto-resize (the default, no rows set) for content of unpredictable length, and set a fixed rows only when the layout genuinely needs a stable height
  • Only enable formatting where its output is actually consumed somewhere, rendered back or otherwise parsed: it's meaningless if the raw formatting delimiters just get displayed as plain text downstream
  • Reserve readonly-available for genuinely inline-editable data (a bio, a description field), not every field in a long form
  • Set maxlength whenever there's a real backend/storage limit: the character counter it turns on is cheap, visible feedback the user would otherwise only discover on submit
  • Using the placeholder as a replacement for label: it disappears the moment the user types
  • Mixing formatting with a fixed short rows: the toolbar assumes there's room to compose more than one styled word
  • Overriding colors with inline styles instead of the --field-* CSS variables
  • Relying on the field's exact stored text while formatting is on for anything beyond storage or round-tripping: it's an implementation detail of the toolbar, not a stable text format meant for direct editing outside this component

Live testing

Properties

Textarea <eun-textarea>

Attributes

NameTypeDefaultDescription
readonly-availablebooleanfalseWhether the field can switch between a static display and an editable one
default-valuestringThe default value applied when the field connects, and restored on form reset
placeholderstringThe placeholder shown when the field is empty
rowsnumberThe fixed number of visible rows. When omitted, the field automatically grows and shrinks its height to fit its content
formattingbooleanfalseSwitches the field to a rich text surface with a formatting toolbar above it, and, combined with readonlyAvailable, renders the static display as formatted rich text instead of raw source
format-toolbar-labelstring'Text formatting'The accessible label of the bold, italic, and underline toggle group
format-bold-labelstring'Bold'The accessible label of the bold toggle
format-italic-labelstring'Italic'The accessible label of the italic toggle
format-underline-labelstring'Underline'The accessible label of the underline toggle
format-align-labelstring'Text alignment'The accessible label of the alignment toggle group
format-align-start-labelstring'Align left'The accessible label of the start alignment toggle
format-align-center-labelstring'Center'The accessible label of the center alignment toggle
format-align-end-labelstring'Align right'The accessible label of the end alignment toggle
format-align-justify-labelstring'Justify'The accessible label of the justify alignment toggle
format-text-color-labelstring'Text color'The accessible label of the text color picker button
format-highlight-labelstring'Highlight'The accessible label of the highlight color picker button
format-color-clear-labelstring'Default'The accessible label of each picker's no color swatch
autocompletestring'off'The native autocomplete attribute forwarded to the field
minlengthnumberThe native minimum length constraint forwarded to the field
maxlengthnumberThe field's maximum length. Forwarded natively when not formatting, and enforced manually otherwise. Either way, a live character counter is shown under the field
empty-value-labelstring'No value set'Text displayed in place of the value when readonly and empty
confirm-labelstring'Save'The label of the confirm button in edit mode
cancel-labelstring'Cancel'The label of the cancel button in edit mode

Import the exact TypeScript type behind any property above, see Types.

Properties

JS-only — no matching HTML attribute, set these from a script or a template binding.

NameTypeDefaultDescription
colorLabelsRecord<string, string>Accessible label for each named color swatch in the color/highlight pickers, keyed by RichTextColor's own `name`. Defaults to `RICH_TEXT_COLORS`'s own English `label`s.
checkLocalValidatorsReads native HTML constraint validation from the underlying field element (`required`, `minlength`, `maxlength`) when not `formatting` ; a contenteditable surface has no such native API, so `minlength`/ `maxlength` are checked manually against the plain-text length instead (`required`/empty-value is already covered generically by `ValidatorsMixin`'s own `buildCommonValidators`).
variant'outline' | 'fill' | 'underline''outline'The visual variant to apply to the field
labelstringThe title label of the field, rendered through the label component
instructionsstringInstructions displayed below the label
namestringThe field name
valuestringThe field value, as lightweight formatted markup when formatting is used
disabledbooleanfalseWhether the field is disabled
readonlybooleanfalseWhether the field is read only
requiredbooleanfalseWhether the field is required
hideErrorbooleanfalseWhether the errors are hidden
validatorsArray<Validators<string>>The list of validation rules applied to the field value
readonlyAvailablebooleanfalseWhether the field can switch between a static display and an editable one

Events

NameTypeDescription
eunchangeChangeEventFired on every value change, including one applied through the formatting toolbar
euncommitCommitEventFired when an edit is confirmed in readonlyAvailable mode

Every event above follows the same naming convention, covered in Events.

CSS custom properties

NameDescription
--field-backgroundSets the background color of the field
--field-border-colorSets the border color of the field
--field-border-color-hoverSets the border color on hover
--field-border-color-focusSets the border color on focus
--field-border-radiusSets the corner radius of the field
--field-paddingSets the padding of the field
--field-text-colorSets the text color of the field
--field-placeholder-colorSets the color of the placeholder
--field-error-colorSets the color of the error message
--field-hint-colorSets the color of the hint message
--field-focus-outline-colorSets the color of the focus outline
--field-edit-confirm-colorSets the color of the confirm button in readonly-available edit mode
--field-edit-cancel-colorSets the color of the cancel button in readonly-available edit mode
--field-shadowSets a box shadow on the field, none by default

Basic

<eun-textarea label="Description" placeholder="Tell us more…"></eun-textarea>
document
  .querySelector("eun-textarea")
  .addEventListener("eunchange", (event) => {
    console.log(event.target.value);
  });

Auto-resize vs. fixed rows

<eun-textarea label="Auto-resize (default)"></eun-textarea>
<eun-textarea label="Fixed rows" rows="3"></eun-textarea>

Without rows, the field grows (and shrinks back) to fit its content as you type. Set a fixed rows to cap the height instead, and the field then falls back to native vertical resizing/scrolling, exactly like a plain <textarea rows="3">.

States

Disabled

<eun-textarea
  label="Description"
  disabled
  value="Can't be edited or submitted."
></eun-textarea>

Readonly

<eun-textarea
  label="Description"
  readonly
  value="Focusable and submitted, but not editable."
></eun-textarea>

Error and validation state

<eun-textarea
  label="Description"
  required
  hint="This field can't be empty"
></eun-textarea>
document.querySelector("eun-textarea").validators = [
  {
    isValid: (value?: string) => (value?.trim().length ?? 0) <= 500,
    message: "Keep it under 500 characters",
  },
];

Character limit counter

Setting maxlength shows a live counter under the field, right-aligned, independent of formatting. It measures the raw value length normally, or the plain-text length (Markdown-lite markup stripped out) when formatting is on, so applying Bold/Italic/color never silently eats into the user's remaining budget.

<eun-textarea label="Short bio" maxlength="80"></eun-textarea>
<eun-textarea
  label="Short bio (formatting)"
  formatting
  maxlength="80"
></eun-textarea>

Past maxlength, a plain field simply can't be typed into further (the native constraint). The formatting surface enforces the same limit by blocking further insertion once reached (replacing an existing selection still works even right at the limit), since a contenteditable element has no native maxlength of its own to rely on.

Variants

<eun-textarea label="Outline" variant="outline"></eun-textarea>
<eun-textarea label="Fill" variant="fill"></eun-textarea>
<eun-textarea label="Underline" variant="underline"></eun-textarea>

Part of the shared vocabulary covered in Variants, alongside every other component that reuses it.

Formatting toolbar

Set formatting to switch the field to a small rich-text editor: a toolbar above it (Bold/Italic/Underline, text alignment, text color, highlight color) and a contenteditable surface instead of a plain <textarea>. Select some text and click a button, or use Ctrl+B/I/U (Cmd on macOS), the same shortcuts as any other rich-text editor, to apply it. Unlike a plain text field, the selection actually turns bold/italic/underlined/colored right there as you edit, with no **/*/__ delimiters ever shown. value still carries that same lightweight Markdown-lite markup underneath (so it keeps round-tripping through formatRichText, storage, eunchange, etc. exactly as before), only the editing surface itself changed.

<eun-textarea label="Comment" formatting></eun-textarea>
const comment = document.querySelector("eun-textarea");
comment.addEventListener("eunchange", () => {
  console.log(comment.value); // e.g. "This is **bold** text"
});

Only one mark is ever toggled per click: clicking Bold never touches whatever Italic/Underline/color state the selection already had, so they all combine freely (bold and italic and underlined and colored, all at once, on the same text). With a real selection, a button (or a picked color) applies to that selection immediately. With nothing selected, a plain blinking cursor, every button and picker instead becomes a pending mark for whatever gets typed next, the same "click Bold, then type, and the new text comes out bold" behavior any rich-text editor's toolbar has. It stays pending across multiple characters (and combines with other pending marks) until toggled off again or the cursor moves away untouched.

Text alignment

<eun-textarea label="Announcement" formatting></eun-textarea>
document.querySelector("eun-textarea").value =
  "[align:center]\nCentered by default…";

Unlike Bold/Italic/Underline/color, alignment is a single, whole-field setting (not per-paragraph, and not selection-dependent). Picking one applies to the entire field's content at once, stored as a leading [align:value] directive line in value (omitted entirely for the default start, to keep the common case clutter-free):

document.querySelector("eun-textarea").value;
// "[align:center]\nCentered by default…"

Text color and highlight

<eun-textarea label="Note" formatting></eun-textarea>

Both pickers open a small popover of seven named swatches (plus a "Default" one to clear the mark), not a free color picker, so every choice stays legible and consistent with the rest of the design system. Selecting one wraps the current selection in {color=name}...{/color} (text color) or {highlight=name}...{/highlight} (background highlight), and picking "Default" removes whichever mark that picker manages from the selection. Combine freely with Bold/Italic/Underline: **{color=red}bold and red{/color}** renders and round-trips correctly, nested marks included.

Localizing the toolbar

Every button and picker has its own overridable accessible label.

<eun-textarea
  label="Commentaire"
  formatting
  format-toolbar-label="Mise en forme du texte"
  format-bold-label="Gras"
  format-italic-label="Italique"
  format-underline-label="Souligné"
  format-align-label="Alignement du texte"
  format-align-start-label="Aligner à gauche"
  format-align-center-label="Centrer"
  format-align-end-label="Aligner à droite"
  format-align-justify-label="Justifier"
  format-text-color-label="Couleur du texte"
  format-highlight-label="Surlignage"
  format-color-clear-label="Aucune"
></eun-textarea>

Formatting and readonly-available

Combined with readonly-available, the static display renders the Markdown-lite value as real formatted text, through the same formatRichText utility exported from @eunomia/elements, instead of showing the raw **/*/__ delimiters. Click into it to switch back to the editable field and its toolbar.

<eun-textarea label="Bio" formatting readonly-available></eun-textarea>
document.querySelector("eun-textarea").value =
  `**Product designer** based in *Lyon*.

- 8 years of experience
- Speaks French and English`;
document
  .querySelector("eun-textarea")
  .addEventListener("euncommit", (event) => save(event.target.value));

Bullet lists (- item) and [label](url) links are also recognized by formatRichText when read back. The toolbar itself just doesn't have buttons for those two, so type them directly.

Click to edit

Set readonly-available to let a field switch between a static, clickable display of its current value and a real editable text area, instead of always being one or the other.

<eun-textarea
  readonly-available
  label="Bio"
  value="Product designer based in Lyon."
></eun-textarea>
document
  .querySelector("eun-textarea[readonly-available]")
  .addEventListener("euncommit", (event) => save(event.target.value));

The static display is clickable and keyboard-activatable (Enter/Space) to enter edit mode. Confirming re-runs validation first: an invalid value stays in edit mode instead of committing. See the Accessibility tab for the full keyboard flow.

Theming

eun-textarea shares the same --field-* custom properties as eun-input (and, eventually, eun-select). Override them once for a consistent look across every text-like field in an app, or per-instance for a one-off, including a --field-shadow for a custom box-shadow (none by default).

<eun-textarea
  label="Custom"
  class="custom-textarea"
  value="Themed field"
></eun-textarea>
.custom-textarea {
  --field-border-radius: 16px;
  --field-border-color-focus: #be185d;
  --field-focus-outline-color: #be185d;
  --field-shadow: 0 4px 12px rgba(190, 24, 77, 0.25);
}

Keyboard interactions

When focus is on the plain field (formatting unset):

Key Action
Tab / Shift+Tab Moves focus in/out of the field, natively (single tab stop)
Any character key Types into the field, natively

When focus is on the formatting surface (a contenteditable element, field not in its readonly display):

Key Action
Tab / Shift+Tab Moves focus in/out of the field (single tab stop), same as the plain field
Arrow keys, Home/End Moves the caret, natively (standard contenteditable text navigation)
Any character key Types into the field
Enter Starts a new paragraph (handled explicitly, not left to the browser's own default, see "Formatting toolbar and the readonly display" below)
Backspace at the start of a paragraph Merges it into the previous one, natively
Ctrl+B (Cmd+B on macOS) Toggles Bold on the current selection, same effect as clicking the toolbar button
Ctrl+I (Cmd+I) Toggles Italic on the current selection
Ctrl+U (Cmd+U) Toggles Underline on the current selection

On the toolbar (formatting, field not in its readonly display):

Key Action
Tab / Shift+Tab Moves between every control in turn: the three Bold/Italic/Underline buttons, the four alignment buttons, then the text-color and highlight picker buttons. Each is its own tab stop, same as a group of checkboxes (see eun-toggle-group's own Accessibility tab for the filter-mode/exclusive-mode ARIA patterns behind the first two groups)
Enter / Space (Bold/Italic/Underline) Applies (or removes) that format on the field's current selection
Enter / Space (alignment) Applies that alignment to the whole field
Enter / Space (text-color / highlight trigger) Opens that picker's swatch popover, moving focus to its first swatch
Tab / Shift+Tab (inside an open popover) Moves between its swatches ("Default" first, then the seven named colors)
Enter / Space (on a swatch) Applies (or clears) that color on the selection, closes the popover, and returns focus to the field
Escape (inside an open popover) Closes it without changing anything, returns focus to the field

On the read-only click-to-edit display (readonly-available, not editing):

Key Action
Enter Enters edit mode
Space Enters edit mode

Focus indicator

The field renders a :focus-visible outline (--field-focus-outline-color, falling back to --eun-color-primary-500) that switches to --field-error-color while invalid, so the color itself communicates validity state to sighted keyboard users, not just the border. This is true of both the plain field and the formatting surface, since they share the same #field styling. The underline variant suppresses this outline (the border-color change is kept as the sole focus indicator there) since an outline would visually clash with the bottom-only border. Each color picker's trigger and swatch buttons render their own visible focus outline too.

Aria attributes

Plain field (formatting unset):

  • aria-invalid="true"|"false": kept in sync with valid
  • aria-required: set natively by the underlying required attribute
  • aria-label: set from the label property directly on the native field, for the same reason described in eun-input's own Accessibility tab (aria-labelledby can't cross the shadow-root boundary between the field and eun-label's own rendered text). If you only slot custom label content instead of using the property, set your own aria-label on eun-textarea to match.
  • aria-describedby: references the hint/error #description and/or the character counter (#char-count, when maxlength is set), whichever are actually rendered. See "Character counter" below for why both can be referenced at once.

formatting surface: a contenteditable element has none of the above natively, so every one of them is set explicitly instead, to the same effect:

  • role="textbox" aria-multiline="true": the standard ARIA pattern for a rich-text editing surface (see the Reference links below)
  • aria-required="true": only when required, mirroring the native attribute's implicit semantics on the plain field
  • aria-invalid, aria-label, aria-describedby: same as the plain field, above
  • contenteditable="false": whenever disabled or readonly. The element stays focusable and its content selectable/copyable, just not editable (see "Disabled vs. readonly" below).

Toolbar and pickers (see "Formatting toolbar and the readonly display" and "Color and highlight pickers" below for the full behavioral detail behind each of these):

  • The Bold/Italic/Underline toggle group renders role="group" (filter mode) with its own aria-label (formatToolbarLabel). Each button is role="checkbox" with aria-checked reflecting whether that format currently wraps the selection (or is pending, see "Pending marks" below).
  • The alignment toggle group renders role="radiogroup" (exclusive/single mode) with its own aria-label (formatAlignLabel). aria-checked reflects the field's current whole-field alignment. Its individual buttons are still role="checkbox", not role="radio", a pre-existing characteristic of eun-toggle-group/eun-toggle-item shared by every exclusive-mode toggle group across this library, not specific to this component, see its own Accessibility tab.
  • Each color picker's trigger button has aria-haspopup="true" and aria-expanded. Its open popover is role="menu" with its own aria-label (formatTextColorLabel/formatHighlightLabel), and each swatch inside is role="menuitemradio" with aria-checked reflecting whether it's the color currently active at the selection.

Label accessibility

Always set label (or slot equivalent content). A textarea with no accessible name is exactly as disruptive for screen-reader users as an unlabelled single-line input.

Placeholder accessibility

placeholder is never a substitute for label: it vanishes as soon as a value is typed. On the plain field it's the native placeholder attribute (read by screen readers as placeholder text, same as any input). On the formatting surface, a contenteditable element has no native placeholder at all, so it's a separate, plain aria-hidden="true" overlay element positioned over the field and shown only while value is empty, hidden from assistive technology on purpose, exactly like a native placeholder is (announced once on focus by the browser itself for a native field, not repeated on every keystroke, the overlay approach matches that by not being in the accessibility tree at all, relying on aria-label alone to name the field).

Disabled vs. readonly

disabled removes the field from the tab order (tabindex="-1" on the plain field, the same on the formatting surface, plus contenteditable="false") and excludes its value from form submission entirely. The toolbar and both color pickers, if shown, are disabled along with it (a disabled trigger button can't be clicked open, so a color popover can never be showing while disabled). readonly (native readonly attribute on the plain field, reflected as aria-readonly="true"; contenteditable="false" without a tabindex change on the formatting surface, so it stays a real tab stop) keeps the field focusable and its value selectable and submitted. It communicates "you can't change this right now", not "this doesn't apply", and likewise disables the toolbar and both pickers, since there's nothing to apply a format to. Prefer readonly over disabled whenever the value is still meaningful context for the user, and reserve disabled for fields that are genuinely not part of the current interaction at all.

Error and validation state

Error messages replace the hint text in the same #description region (never shown alongside it) and are linked via aria-describedby as described above. On the plain field, required/minlength/maxlength have native constraint-attribute equivalents (a <textarea> has no pattern, type, or numeric range). The formatting surface has no native constraint validation at all (a contenteditable element isn't a form control), so minlength/maxlength are both checked manually there instead, against the plain-text length with every Markdown-lite marker stripped out. required's empty-value check is unaffected either way, since it never depended on the field's own DOM. Reach for validators for anything past those, same as any other field.

Both the border and the focus outline turn to the same error color while invalid, including through hover and focus, not just at rest, on both the plain field and the formatting surface, since they share the same #field styling. The underline variant keeps only the border-color change (see "Focus indicator" above for why its outline is suppressed entirely).

Character counter

Shown under the field whenever maxlength is set (not in the readonly-available static display, only while actually editing), sharing one row with the hint/error #description, description on one side, counter pinned to the other, a purely presentational grouping. There's no ARIA role on that shared row, and aria-describedby already references both ids independently of how they're laid out visually. The counter is never aria-live. It updates on every keystroke, and announcing it that often would be far more disruptive than helpful. A screen-reader user lands on its exact text ("12/280") by navigating to #char-count directly (it's part of aria-describedby) whenever they choose to check it, same as everyone else.

Readonly-available / click-to-edit flow

The static display exposes role="button" and full keyboard access (Tab to reach it, Enter/Space to activate it), so it's operable without a pointer despite looking like plain text. Entering edit mode moves focus to the field automatically (the native field, or the formatting surface), so a keyboard user never has to Tab a second time after activating it. Confirming an edit re-validates first: an invalid value keeps focus in the field and the error message shown, rather than silently discarding the edit or committing bad data.

Formatting toolbar and the readonly display

The Bold/Italic/Underline toggle's pressed state (and each color picker's own border-color indicator, see "Color and highlight pickers" below) is re-measured on every interaction that can move the selection (typing, clicking, arrowing, refocusing) by walking up the DOM from the selection's anchor to the field itself, a faithful reflection of the current selection, never a stale snapshot from the last click. A collapsed selection (a plain cursor, nothing highlighted) still reports whichever formats surround it, matching the "what would typing here continue" convention word processors use.

Two behaviors are deliberately re-implemented from scratch rather than relying on the browser's own defaults, since a hand-rolled contenteditable surface has to own its own document model to stay serializable back to Markdown-lite:

  • Paragraphs: pressing Enter is intercepted and always creates a new paragraph explicitly, rather than letting each browser's own (inconsistent) default block-splitting behavior decide.
  • Paste: always inserted as plain text, split into one paragraph per line when multi-line. Any rich formatting/HTML on the clipboard is discarded, never inserted as-is. This keeps the field's content limited to the handful of elements the Markdown-lite serializer understands, with no injection surface from arbitrary clipboard content.

formatRichText (used for the formatting and readonly-available static display, and exported from @eunomia/elements for reuse elsewhere, see the Markdown utility page for its full syntax reference) never uses unsafeHTML either. It builds real Lit templates directly from parsed Markdown-lite, so there's no injection surface there either.

One caveat worth knowing: if the value contains a [label](url) link (recognized by formatRichText, though neither the toolbar nor the formatting surface has a way to create one, only pre-existing links in value are recognized when reading it back), the rendered <a> ends up nested inside the static display's own role="button", interactive content inside interactive content, which assistive technology exposes inconsistently. Avoid combining readonly-available with manually-typed links until this is resolved. Plain bold/italic/underline/color/list content has no such issue, since none of those are separately-focusable elements.

Pending marks (formatting with nothing selected)

Toggling a format or picking a color with a real selection applies to that selection immediately, as described above. With nothing selected, a plain blinking cursor, the same controls instead mark themselves pending: an invisible marker is inserted right at the caret, and whatever gets typed next lands inside it, coming out already formatted. Multiple pending marks combine (click Bold, then Italic, then type, and the new text is both). A pending mark stays pending across as many characters as you keep typing, until either toggled off again or the cursor moves away without anything having been typed into it, at which point it's discarded (and the invisible marker cleanly removed, leaving nothing behind).

This has no separate announcement of its own, since there isn't one needed: the exact same aria-checked a screen-reader user already relies on for "is Bold active right where I am" (see "Formatting toolbar and the readonly display" above) is equally accurate whether that's because of a real selection or a pending mark, since both update through the same live DOM-ancestor check. Toggling Bold with nothing selected and having eun-toggle-item[value="bold"] announce as checked is the accessible signal that the next character typed will be bold.

Color and highlight pickers

Each picker follows the Menu Button pattern loosely rather than exactly: role="menu"/role="menuitemradio" communicate "pick one of these mutually-exclusive options" accurately, but unlike a typical application menu, these entries are color swatches with no text label of their own visible on screen. aria-label (each color's own name, e.g. "Red", or formatColorClearLabel for the one that clears the mark) is each swatch's only accessible name, so it always needs to be set (the defaults are already sensible, override them per Localizing the toolbar's example if the rest of the toolbar is localized too).

Each trigger button additionally reflects whichever color (if any) is currently active right where the selection is as its own border color, kept live (see "Formatting toolbar and the readonly display" above), not just computed when the popover happens to be open. This is a sighted-only, supplementary cue, not a replacement for anything accessible. A screen-reader user gets the equivalent information the moment they open the popover, via whichever swatch is announced with aria-checked="true". There's intentionally no separate text alternative for the border color itself, since there's nothing more precise it could announce that opening the popover doesn't already give firsthand.

Opening a picker moves focus to its first swatch ("Default" in visual order). Selecting one (or Escape) closes the popover and returns focus to the field itself, never leaving it stranded on a control that just disappeared.

Fixed rows and scrolling

Same as the plain field's own native rows (native vertical scroll, already fully keyboard-operable, arrow keys/Page Up/Page Down scroll a focused <textarea> natively), the formatting surface's own rows equivalent (a computed fixed height plus overflow-y: auto, since a contenteditable <div> has no native rows) needs no extra ARIA of its own either. It's the same focused, focusable element either way, and moving the caret with arrow keys already scrolls it into view exactly like a native field would.

Reference links

  • WAI-ARIA Authoring Practices: Forms patterns
  • WAI-ARIA Authoring Practices: Menu Button pattern (the color/highlight pickers)
  • WAI Web Accessibility Tutorials: Labeling Controls
  • WHATWG HTML: The textarea element
  • WHATWG HTML: The contenteditable attribute