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
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,
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.
npm install @eunomia/elements
<script type="module">
import "@eunomia/elements/textarea.js";
</script>
<eun-textarea label="Description"></eun-textarea>
npm install @eunomia/elements
import "@eunomia/elements/textarea.js";
function DescriptionField() {
return <eun-textarea label="Description" name="description" />;
}
npm install @eunomia/elements
"use client";
import "@eunomia/elements/textarea.js";
export function DescriptionField() {
return <eun-textarea label="Description" name="description" />;
}
npm install @eunomia/elements
<script setup>
import "@eunomia/elements/textarea.js";
</script>
<template>
<eun-textarea label="Description" name="description"></eun-textarea>
</template>
npm install @eunomia/elements
import { CUSTOM_ELEMENTS_SCHEMA, Component } from "@angular/core";
import "@eunomia/elements/textarea.js";
@Component({
selector: "app-description-field",
template: `<eun-textarea
label="Description"
name="description"
></eun-textarea>`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class DescriptionFieldComponent {}
Alternatives
eun-inputGuidance
- Always set
label(or slot one): see the Accessibility tab - Let the field auto-resize (the default, no
rowsset) for content of unpredictable length, and set a fixedrowsonly when the layout genuinely needs a stable height - Only enable
formattingwhere 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-availablefor genuinely inline-editable data (a bio, a description field), not every field in a long form - Set
maxlengthwhenever 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
formattingwith a fixed shortrows: 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
formattingis 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
| Name | Type | Default | Description |
|---|---|---|---|
| readonly-available | boolean | false | Whether the field can switch between a static display and an editable one |
| default-value | string | — | The default value applied when the field connects, and restored on form reset |
| placeholder | string | — | The placeholder shown when the field is empty |
| rows | number | — | The fixed number of visible rows. When omitted, the field automatically grows and shrinks its height to fit its content |
| formatting | boolean | false | Switches 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-label | string | 'Text formatting' | The accessible label of the bold, italic, and underline toggle group |
| format-bold-label | string | 'Bold' | The accessible label of the bold toggle |
| format-italic-label | string | 'Italic' | The accessible label of the italic toggle |
| format-underline-label | string | 'Underline' | The accessible label of the underline toggle |
| format-align-label | string | 'Text alignment' | The accessible label of the alignment toggle group |
| format-align-start-label | string | 'Align left' | The accessible label of the start alignment toggle |
| format-align-center-label | string | 'Center' | The accessible label of the center alignment toggle |
| format-align-end-label | string | 'Align right' | The accessible label of the end alignment toggle |
| format-align-justify-label | string | 'Justify' | The accessible label of the justify alignment toggle |
| format-text-color-label | string | 'Text color' | The accessible label of the text color picker button |
| format-highlight-label | string | 'Highlight' | The accessible label of the highlight color picker button |
| format-color-clear-label | string | 'Default' | The accessible label of each picker's no color swatch |
| autocomplete | string | 'off' | The native autocomplete attribute forwarded to the field |
| minlength | number | — | The native minimum length constraint forwarded to the field |
| maxlength | number | — | The 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-label | string | 'No value set' | Text displayed in place of the value when readonly and empty |
| confirm-label | string | 'Save' | The label of the confirm button in edit mode |
| cancel-label | string | 'Cancel' | The label of the cancel button in edit mode |
Import the exact TypeScript type behind any property above, see
Properties
JS-only — no matching HTML attribute, set these from a script or a template binding.
| Name | Type | Default | Description |
|---|---|---|---|
| colorLabels | Record<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. |
| checkLocalValidators | — | Reads 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 |
| label | string | — | The title label of the field, rendered through the label component |
| instructions | string | — | Instructions displayed below the label |
| name | string | — | The field name |
| value | string | — | The field value, as lightweight formatted markup when formatting is used |
| disabled | boolean | false | Whether the field is disabled |
| readonly | boolean | false | Whether the field is read only |
| required | boolean | false | Whether the field is required |
| hideError | boolean | false | Whether the errors are hidden |
| validators | Array<Validators<string>> | — | The list of validation rules applied to the field value |
| readonlyAvailable | boolean | false | Whether the field can switch between a static display and an editable one |
Events
| Name | Type | Description |
|---|---|---|
| eunchange | ChangeEvent | Fired on every value change, including one applied through the formatting toolbar |
| euncommit | CommitEvent | Fired when an edit is confirmed in readonlyAvailable mode |
Every event above follows the same naming convention, covered in
CSS custom properties
| Name | Description |
|---|---|
| --field-background | Sets the background color of the field |
| --field-border-color | Sets the border color of the field |
| --field-border-color-hover | Sets the border color on hover |
| --field-border-color-focus | Sets the border color on focus |
| --field-border-radius | Sets the corner radius of the field |
| --field-padding | Sets the padding of the field |
| --field-text-color | Sets the text color of the field |
| --field-placeholder-color | Sets the color of the placeholder |
| --field-error-color | Sets the color of the error message |
| --field-hint-color | Sets the color of the hint message |
| --field-focus-outline-color | Sets the color of the focus outline |
| --field-edit-confirm-color | Sets the color of the confirm button in readonly-available edit mode |
| --field-edit-cancel-color | Sets the color of the cancel button in readonly-available edit mode |
| --field-shadow | Sets 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
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 withvalidaria-required: set natively by the underlyingrequiredattributearia-label: set from thelabelproperty directly on the native field, for the same reason described ineun-input's own Accessibility tab (aria-labelledbycan't cross the shadow-root boundary between the field andeun-label's own rendered text). If you only slot custom label content instead of using the property, set your ownaria-labeloneun-textareato match.aria-describedby: references the hint/error#descriptionand/or the character counter (#char-count, whenmaxlengthis 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 whenrequired, mirroring the native attribute's implicit semantics on the plain fieldaria-invalid,aria-label,aria-describedby: same as the plain field, abovecontenteditable="false": wheneverdisabledorreadonly. 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 ownaria-label(formatToolbarLabel). Each button isrole="checkbox"witharia-checkedreflecting whether that format currently wraps the selection (or is pending, see "Pending marks" below). - The alignment toggle group renders
role="radiogroup"(exclusive/singlemode) with its ownaria-label(formatAlignLabel).aria-checkedreflects the field's current whole-field alignment. Its individual buttons are stillrole="checkbox", notrole="radio", a pre-existing characteristic ofeun-toggle-group/eun-toggle-itemshared 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"andaria-expanded. Its open popover isrole="menu"with its ownaria-label(formatTextColorLabel/formatHighlightLabel), and each swatch inside isrole="menuitemradio"witharia-checkedreflecting 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
Enteris 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
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
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