Skip to main content

rc-textarea

Textarea wrapper with line decorations, gutter rendering, inline widgets, and plugin hooks.

Package
@rcarls/rc-textarea
Element
<rc-textarea>
Native dependency
Requires a native textarea child
State model
Native textarea value
Main events
rc-textarea-focusrc-textarea-blurrc-textarea-changerc-textarea-select

Installation

npm install @rcarls/rc-textarea
import '@rcarls/rc-textarea/define';

Demos

Plain textarea with auto-grow and no gutter — the minimal starting point.

<rc-textarea auto-grow>
<textarea rows="4" aria-label="Text editor"></textarea>
</rc-textarea>

Options

Attributes

AttributePropertyDescription
line-numberslineNumbersShow sequential line numbers in the gutter.
guttergutterShow an empty gutter that plugins can populate with LineDecoration.gutterContent.
word-wrapwordWrapWrap long lines instead of scrolling horizontally.
auto-growautoGrowLet the field grow vertically with content.
read-onlyreadOnlyRender selectable, non-editable content.

list-numbers and label still exist for compatibility but are deprecated. Prefer a plugin with LineDecoration.gutterContent for sparse numbering, and put accessible names on the slotted textarea with aria-label or a real <label for="...">.

JavaScript properties

PropertyTypeDescription
valuestringCurrent plain-text value. Host writes are silent.
defaultValuestring | undefinedInitial uncontrolled value.
pluginRCTextareaPlugin | nullDeclarative plugin hook for reactive frameworks.
decorationsDecorationInput[]External decoration layer merged with plugin and pattern decorations.
selectionStart / selectionEndnumberCurrent plain-text selection offsets (read-only).

Events

All events bubble and are composed.

EventDetailFires when
rc-textarea-change{ value: string }User editing changes the plain-text value.
rc-textarea-focusnoneThe editor receives focus.
rc-textarea-blurnoneThe editor loses focus.
rc-textarea-select{ selectionStart: number, selectionEnd: number }The selection changes while the editor is focused.
editor.addEventListener('rc-textarea-change', (e) => {
console.log(e.detail.value);
});

Keyboard Behavior

KeyBehavior
TabInserts \t.
Ctrl/Cmd+ZUndo.
Ctrl/Cmd+Y / Ctrl/Cmd+Shift+ZRedo.
PasteInserts plain text only; normalizes line endings.
EnterInserts a line break.

Theming

rc-textarea is design-system neutral and uses CSS system colors by default.

Component tokens

TokenDefaultUse
--rc-textarea-font-familymonospaceEditor and gutter font family.
--rc-textarea-font-size1emEditor and gutter font size.
--rc-textarea-line-height1.5Editor and gutter line height.
--rc-textarea-padding0.5emEditor and gutter padding.
--rc-textarea-backgroundFieldField background.
--rc-textarea-colorvar(--rc-text, FieldText)Field text color.
--rc-textarea-caret-colorvar(--rc-textarea-color, FieldText)Caret color.
--rc-textarea-border1px solid ButtonBorderField border.
--rc-textarea-border-radius2pxField corner radius.
--rc-textarea-focus-outline2px solid HighlightFocus ring.
--rc-textarea-active-line-bgtransparentActive line background.
--rc-textarea-gutter-bgCanvasGutter background.
--rc-textarea-gutter-colorGrayTextGutter text color.
--rc-textarea-gutter-border1px solid ButtonBorderGutter separator.
--rc-textarea-gutter-padding-inline-end0.75emSpace between gutter labels and text.
rc-textarea {
color-scheme: dark;
--rc-textarea-font-family: 'Fira Code', monospace;
--rc-textarea-font-size: 13px;
--rc-textarea-background: #1e1e2e;
--rc-textarea-color: #cdd6f4;
--rc-textarea-active-line-bg: rgb(255 255 255 / 0.06);
}

CSS parts

The exposed CSS parts are root, gutter, gutter-cells, editor-area, and editor.

rc-textarea::part(editor) {
tab-size: 2;
}

Decoration styles

Decoration elements (.mark, .line, .widget) live inside the shadow root. Use api.adoptStyleSheet() from a plugin to reach them — document stylesheets cannot pierce the shadow boundary.

Pattern Highlights

Use addPattern() for lightweight regex decoration without writing a plugin.

const todoId = editor.addPattern({
pattern: /\bTODO\b/g,
bold: true,
color: 'var(--editor-todo-color)',
});

editor.removePattern(todoId);
editor.clearPatterns();

Patterns can also style named capture groups and add line diagnostics:

editor.addPattern({
pattern: /^(?<key>\w[\w-]*):\s*(?<value>.+)$/gm,
captureGroups: {
key: { bold: true, color: 'var(--editor-key-color)' },
value: { color: 'var(--editor-value-color)' },
},
createLineDecoration: () => ({ className: 'config-line' }),
});

Plugin API

Mental model

rc-textarea stores one plain-text value. Plugins add visual decorations over that value. Decorations never change the submitted textarea value. Offsets are always absolute character indices into the current plain-text value unless a helper explicitly says otherwise.

Only one plugin is active at a time. usePlugin() or assigning the plugin property replaces the previous plugin and calls its destroy() hook.

Quick start

import type { RCTextareaPlugin, RCTextareaPluginAPI } from '@rcarls/rc-textarea';

const editor = document.querySelector('rc-textarea');

let api: RCTextareaPluginAPI | undefined;

const plugin: RCTextareaPlugin = {
mount(pluginApi) {
api = pluginApi;
},

update(value, pluginApi) {
pluginApi.setDecorations(parseDiagnostics(value));
},

destroy() {
api = undefined;
},
};

editor.usePlugin(plugin);

Plugin interface

interface RCTextareaPlugin {
mount?(api: RCTextareaPluginAPI): void;
destroy?(): void;

/** Display-layer transform — read-only mode only. */
transform?(value: string, api: RCTextareaPluginAPI): string | null | void;

/** Imperative decoration hook. May be async. */
update?(value: string, api: RCTextareaPluginAPI): void | Promise<void>;

/**
* HTML compat hook — return HTML token spans from hljs/prism.
* The returned string is parsed via api.parseDecorationsFromHtml().
*/
highlight?(
value: string,
api: RCTextareaPluginAPI,
): string | null | void | Promise<string | null | void>;
}

At least one of update or highlight must be provided. Async hooks are allowed — if a newer render starts while a hook is pending, stale results are discarded.

transform() runs only in read-only mode and substitutes the display-layer text without changing editor.value or the slotted textarea value.

External updates

Call scheduleUpdate() when external state changes but the text value does not:

let removeThemeListener: (() => void) | undefined;

editor.usePlugin({
mount(api) {
const onThemeChange = () => api.scheduleUpdate();
window.addEventListener('theme-change', onThemeChange);
removeThemeListener = () => window.removeEventListener('theme-change', onThemeChange);
},
update(value, api) {
api.setDecorations(decorate(value, currentTheme));
},
destroy() {
removeThemeListener?.();
},
});

Architecture

Core invariants

  • A consumer-provided native <textarea> remains connected as the form control.
  • The public value is plain text. Marks, line diagnostics, widgets, gutter labels, and syntax colors are visual decorations only.
  • Host writes to value are silent. User editing dispatches rc-textarea-change.
  • Selection is stored as plain-text offsets so it survives full DOM rebuilds.
  • Plugin, pattern, and external decorations are merged for rendering but keep separate sources.

DOM structure

rc-textarea (LitElement shadow host, delegatesFocus=true)
└── #root part="root"
├── #gutter part="gutter" aria-hidden="true"
│ └── #gutter-cells part="gutter-cells"
│ └── .gutter-cell spans
└── #editor-area part="editor-area"
├── #editor part="editor" contenteditable role="textbox"
│ └── .line divs
│ ├── text nodes
│ ├── .mark spans
│ ├── .widget spans contenteditable="false"
│ └── data-message / data-message-class line metadata
└── <slot>
└── consumer <textarea> in light DOM

The shadow editor is the interactive surface after upgrade. The slotted textarea remains the submission and validation surface and is visually hidden after slot resolution.

Editing loop

  1. Browser editing, paste, or plugin text mutation changes the contenteditable surface or the value model.
  2. _onInput() extracts plain text with getText(), saves selection offsets, remaps existing plugin decorations through the edit, syncs the slotted textarea, pushes undo state, and dispatches rc-textarea-change.
  3. _scheduleRender() batches work into one animation frame.
  4. _performRender() resolves the display value, runs patterns, runs the active plugin, merges all decoration sources, rebuilds the Parchment document, restores selection, reapplies active line state, and syncs gutter labels/heights.

The editor DOM is rebuilt on each render frame because decoration changes can alter the Parchment tree shape.

Parchment integration

Parchment provides the DOM shape and blot registry; rc-textarea owns the render loop.

  • The scroll/root blot wraps #editor.
  • Block blots render .line elements, one per logical line.
  • Inline blots render .mark spans.
  • Widget blots render .widget spans with contenteditable=false.
  • RCDocument.build(text, decorations) creates the new tree from scratch on each render.

Parchment mutation observation is suppressed so browser DOM mutations do not fight the component's scheduled render pass.

Troubleshooting

Decorations disappear after large edits. Plugin decorations are remapped through edits, but very large text changes clear mapped plugin decorations to avoid stale offsets. Recompute plugin decorations in update() for durable results.

Cursor position looks different after decorations change. Selection is restored through plain-text offsets. Widgets have no text width in the value, and new decorations can change the visual shape around the same offset. Use getCursorRect() for positioning UI.

Styles do not apply to marks or lines. Decoration elements are in the shadow root. Use api.adoptStyleSheet() rather than a document stylesheet.

HTML highlighting duplicates decorations. Returning a string from highlight() adds parsed marks to existing plugin decorations. If you need exact replacement, do all work inside update() and call api.setDecorations().

API

Properties

PropertyMarkupTypeDefaultDescription
lineNumbersline-numbersbooleanfalseShow sequential line numbers in the gutter. Enables the gutter implicitly.
listNumberslist-numbersbooleanNot specifiedNo description provided.
guttergutterbooleanfalseEnable the gutter column without any built-in content. Plugins can populate individual cells via `LineDecoration.gutterContent`. For sequential line numbers use `lineNumbers` / `line-numbers` instead.
wordWrapword-wrapbooleanfalseWrap long lines within the field to prevent horizontal overflow.
autoGrowauto-growbooleanfalseAllow the field to grow vertically with content to prevent vertical overflow.
readOnlyread-onlybooleanfalseDisable editing. The field renders as a styled read-only display.
labellabelstring | nullNot specifiedNo description provided.
pluginJS property onlyRCTextareaPlugin | nullNot specifiedDeclarative plugin hook for framework integrations.
decorationsJS property onlyDecorationInput[]Not specifiedImperatively set an external layer of decorations. Ideal for reactive frameworks. ```tsx // Solid <rc-textarea decorations={decorations()} /> // React 19+ <rc-textarea decorations={decorations} /> ``` Merges with plugin decorations and pattern decorations on every render. Setting this property triggers a new render; setting it to `undefined` or `[]` clears any previously set external decorations.
valuevaluestringNot specifiedThe current field value. Setting this property puts the component into controlled mode (host owns the value, changes are reflected immediately, no change events dispatched)
defaultValuedefaultValuestring | undefinedNot specifiedInitial uncontrolled value.

Methods

MethodDescription
wrapSelection(prefix: string, suffix: string)Wrap the current selection with `prefix` and `suffix`. No-op when the selection is collapsed (no text selected). Uses the model path directly (not `execCommand`) because callers such as toolbar buttons move focus away from the editor before this fires, which clears the DOM selection. `_savedSelection` retains the last model-level anchor/focus offsets.
replaceSelection(text: string)Replace the current selection with `text`. When the selection is collapsed this is equivalent to `insertText`.
clearHistory()Reset the undo/redo history. Call when the field receives entirely new content.
usePlugin(plugin: RCTextareaPlugin)Register and mount a plugin imperatively. Replaces any currently active plugin. Prefer the `plugin` property for reactive-framework integrations.
removePlugin()Unmount the active plugin, clear its decorations, and release its stylesheets.
addPattern(pattern: Omit<TextPattern, 'id'>)Register a text pattern that generates decorations on every render pass. Returns the pattern ID, which can be passed to `removePattern` to unregister it.
removePattern(id: string)Remove a previously registered pattern by the ID returned from `addPattern`.
clearPatterns()Remove all registered patterns and trigger a re-render.

Events

EventDetail typeDescription
rc-textarea-focusCustomEventNo description provided.
rc-textarea-blurCustomEventFired when the field loses focus
rc-textarea-changeCustomEventFired when the field value changes

Slots

NameDescription
(default)Accepts a native `<textarea>` element for form wiring.

CSS Custom Properties

PropertyDefaultDescription
--rc-textarea-border1px solid ButtonBorderBorder around the field
--rc-textarea-border-radius2pxBorder radius of the field
--rc-textarea-backgroundFieldBackground color of the field
--rc-textarea-colorFieldTextText color; falls back through --rc-text
--rc-textarea-font-familymonospaceFont family
--rc-textarea-font-size1emFont size
--rc-textarea-line-height1.5Line height
--rc-textarea-padding0.5emPadding inside the field area
--rc-textarea-focus-outline2px solid HighlightFocus ring outline
--rc-textarea-caret-colorFieldTextCaret color
--rc-textarea-active-line-bgtransparentActive line highlight color
--rc-textarea-gutter-bgCanvasGutter background color
--rc-textarea-gutter-colorGrayTextGutter text color
--rc-textarea-gutter-border1px solid ButtonBorderGutter right border

CSS Parts

No CSS parts are documented in the custom elements manifest.