Editor

.editor is a small rich-text field: a toolbar over a contenteditable region, with the resulting HTML mirrored into a hidden input so an ordinary form post carries it. It is the most cautious component in Deck, and the caution is warranted — read the note on execCommand before choosing it.

When to use it

Use it where a reader needs bold, a list and a link in a short piece of text — a comment, a description, a note — and a full editing surface would be too much. For plain text use .textarea, which is simpler in every way.

Type in it. The count updates and turns red past the limit.
<div class="editor" data-target="#dx-editor-value" data-limit="280"> <div class="editor-toolbar"> <button class="editor-tool" data-cmd="bold" aria-pressed="false" aria-label="Bold"><strong>B</strong></button> <button class="editor-tool" data-cmd="italic" aria-pressed="false" aria-label="Italic"><em>I</em></button> <div class="editor-sep"></div> <button class="editor-tool" data-cmd="insertUnorderedList" aria-pressed="false" aria-label="Bulleted list">&bull;</button> <button class="editor-tool editor-tool-wide" data-cmd="createLink" aria-label="Add a link">Link</button> </div> <div class="editor-content" data-placeholder="Write a note"></div> <div class="editor-footer"> <span class="editor-count">0 / 280</span> </div> </div> <input type="hidden" id="dx-editor-value" name="note">
<div class="editor" data-target="#dx-editor-value" data-limit="280">
  <div class="editor-toolbar">
    <button class="editor-tool" data-cmd="bold" aria-pressed="false" aria-label="Bold"><strong>B</strong></button>
    <button class="editor-tool" data-cmd="italic" aria-pressed="false" aria-label="Italic"><em>I</em></button>
    <div class="editor-sep"></div>
    <button class="editor-tool" data-cmd="insertUnorderedList" aria-pressed="false" aria-label="Bulleted list">&bull;</button>
    <button class="editor-tool editor-tool-wide" data-cmd="createLink" aria-label="Add a link">Link</button>
  </div>
  <div class="editor-content" data-placeholder="Write a note"></div>
  <div class="editor-footer">
    <span class="editor-count">0 / 280</span>
  </div>
</div>
<input type="hidden" id="dx-editor-value" name="note">

data-target is the selector of the hidden input to mirror into, and data-limit the character count. Both are optional; without a target the editor still works and you read innerHTML yourself, or listen for deck:change.

The toolbar

.editor-tool is a square button carrying data-cmd; .editor-tool-wide is the same with room for a word. .editor-sep is a one-pixel divider, and .editor-select a dropdown for block format.

The pressed state is [aria-pressed="true"] — the accessible state doing the styling, as with .tab. After every command and every selection change, deck-extras.js reads document.queryCommandState for each tool and writes the attribute back, so the toolbar reflects the caret rather than the last button pressed.

The execCommand problem

document.execCommand is how this component applies formatting, and it is deprecated. It is worth being plain about what that means, because the component is useful and the caveat is real.

  • It is not going away soon. Every browser implements it and the web depends on it too heavily to remove. Deprecated here means unmaintained, not scheduled for deletion.
  • The HTML it produces varies by engine. Bold might be <b>, <strong> or a <span style> depending on the browser and what was already applied. If you store the output, you are storing something inconsistent.
  • There is no replacement. The standards work that would have replaced it stalled. Real editors — ProseMirror, Lexical, TipTap — reimplement editing from scratch over a document model, which is tens of kilobytes and a different kind of component.

Sanitise the output on the server. Always, without exception. A contenteditable region accepts pasted HTML — Deck intercepts paste and inserts plain text, which handles the common case — but the field is still user-controlled markup arriving at your server, and the browser is not a validator.

Content styling

.editor-content styles what is inside it the way .prose does: > * + * for the vertical rhythm, more space before headings, list padding, and a blockquote treatment.

.editor-content:empty::before takes its text from data-placeholder — the same trick as the CSS-only tooltip, and the reason a placeholder needs no extra element and no script to hide it.

src/24-media.css adds the rules for an image or a figure pasted into the region, so a screenshot dropped into a comment does not break out of the frame.

The character count

.editor-count shows the length, and .is-over turns it red when data-limit is exceeded. It is a soft limit — nothing stops the reader typing past it, which is deliberate: silently refusing keystrokes reads as a broken keyboard.

Because it is soft, the server has to enforce it. And because the count is not in a live region, a screen-reader user is not told when they pass the limit — put role="status" on .editor-count if the limit matters.

Classes

Generated from src/23-inputs.css by tools/docs/extract.mjs.

Classes, generated from the stylesheet source
Class Layer What it does Source
.editor deck.components A toolbar over a contenteditable region. Deck styles the chrome; deck.js wires the commands. The content area inherits .prose, so what you type looks the way it will look when it is published. 23-inputs.css:425
.editor-content deck.components The editable region. Its placeholder comes from data-placeholder through :empty::before, so it needs no element and no script to hide it. 23-inputs.css:481
.editor-count deck.components The character count. .is-over turns it red past data-limit — a soft limit, since silently refusing keystrokes reads as a broken keyboard. The server has to enforce it, and role="status" makes it audible. 23-inputs.css:520
.editor-footer deck.components Sets display, align-items, gap, padding, and 4 more. 23-inputs.css:507
.editor-select deck.components Sets block-size, padding-inline, border, border-radius, and 4 more. 23-inputs.css:467
.editor-sep deck.components Sets inline-size, block-size, background, margin-inline. 23-inputs.css:466
.editor-tool deck.components Sets inline-size, block-size, display, place-items, and 6 more. 23-inputs.css:451
.editor-tool-wide deck.components Sets inline-size, padding-inline. 23-inputs.css:465
.editor-toolbar deck.components The tool row. Pressed state is [aria-pressed="true"], which deck-extras.js rewrites from queryCommandState after every command and selection change, so the toolbar reflects the caret rather than the last button pressed. 23-inputs.css:439
.is-over deck.components No declarations. 23-inputs.css:521

Tokens

Design tokens this component reads
Token Live Declared value
--surface light-dark(var(--ink-0), var(--ink-950))
--surface-2 light-dark(var(--ink-25), var(--ink-900))
--surface-hover light-dark(var(--ink-50), var(--ink-900))
--line light-dark(var(--ink-200), oklch(28% .016 var(--hue-neutral)))
--text light-dark(var(--ink-900), var(--ink-100))
--text-muted light-dark(var(--ink-600), var(--ink-400))
--focus light-dark(var(--brand-500), var(--brand-300))
--ring 0 0 0 3px light-dark(oklch(from var(--focus) l c h / .32), oklch(from var(--focus) l c h / .42))
--brand-soft light-dark(var(--brand-50), oklch(26% .05 var(--hue-brand)))
--brand-soft-text light-dark(var(--brand-800), var(--brand-200))
--bad-700 oklch(44% calc(var(--chroma-status) * 1.1) var(--hue-bad))
--r-sm 7px
--space-1 .25rem
--space-2 .5rem
--space-3 .75rem
--space-5 1.25rem
--space-6 1.5rem

Accessibility

  • The region is given a role by the script. deck-extras.js sets role="textbox" and aria-multiline="true" on .editor-content, so it is announced as a text field rather than as a block of prose.
  • It still needs a label. The role gives it a type, not a name. Add aria-labelledby pointing at a visible label, or aria-label.
  • Toolbar buttons need labels too. "B" is announced as the letter B. Every example here carries aria-label, and the visible glyph is decorative.
  • aria-pressed is kept in sync with the caret, which is the part most hand-built toolbars get wrong — a bold button that stays pressed after the caret leaves bold text is actively misleading.
  • The toolbar is not a role="toolbar", so it is a series of tab stops rather than one with arrow keys. For a toolbar of four that is acceptable; for a dozen it is a lot of tabbing to reach the content.
  • Formatting shortcuts are the browser's. Ctrl/Cmd+B and +I work inside a contenteditable without Deck doing anything — worth knowing, because it means keyboard users are not dependent on the toolbar.
  • The link command uses prompt(). A browser dialog is focus-trapping and accessible, but it is also jarring and cannot be styled. For anything beyond a prototype, replace it with a modal.
  • The placeholder is ::before content, so like every generated-content placeholder it is not a reliable accessible name. It is a hint; the label carries the name.
  • The count is not announced. See above.

Right to left

The toolbar is a flex row, the separator uses margin-inline, and the content's list indentation is padding-inline-start — so the whole component mirrors with no rule of its own, and a list inside it indents from the correct edge.

<div dir="rtl" class="editor"> <div class="editor-toolbar"> <button class="editor-tool" data-cmd="bold" aria-pressed="false" aria-label="عريض"><strong>B</strong></button> <div class="editor-sep"></div> <button class="editor-tool" data-cmd="insertUnorderedList" aria-pressed="false" aria-label="قائمة">&bull;</button> </div> <div class="editor-content" data-placeholder="اكتب ملاحظة"></div> </div>
<div dir="rtl" class="editor">
  <div class="editor-toolbar">
    <button class="editor-tool" data-cmd="bold" aria-pressed="false" aria-label="عريض"><strong>B</strong></button>
    <div class="editor-sep"></div>
    <button class="editor-tool" data-cmd="insertUnorderedList" aria-pressed="false" aria-label="قائمة">&bull;</button>
  </div>
  <div class="editor-content" data-placeholder="اكتب ملاحظة"></div>
</div>

Reduced motion

Tools transition their background on hover, and the frame transitions its border and ring on focus. The global reset collapses both to .01ms under prefers-reduced-motion: reduce. Nothing else moves.

Printing

The editor prints as prose, and it is handled deliberately. There is no .editor rule in src/99-print.css — but there is a second @layer deck.print block at the end of src/24-media.css, and it covers all three parts:

.editor-toolbar, .editor-footer { display: none !important; }
.editor-content { max-block-size: none !important; overflow: visible !important; }

So the formatting buttons and the character count are dropped, and the content region loses both its height cap and its scrollbar — which means a long document prints in full rather than being cut off at the visible height. That last line is the one that matters, and it is easy to miss.

Deck keeps print rules for a component beside that component rather than gathering them all in src/99-print.css, so checking only that file gives the wrong answer for anything in src/24-media.css.

Overriding it

@layer app.components {
  /* A taller editing area */
  .editor-content { min-block-size: 12rem; }

  /* A toolbar that scrolls rather than wrapping */
  .editor-toolbar { flex-wrap: nowrap; overflow-x: auto; }

  /* Announce the character count */
  /* markup: <span class="editor-count" role="status">…</span> */
}

When not to use it

  • Not for plain text. Use .textarea. It is a real form control with real validation, and it needs no script.
  • Not for a document. Tables, images with captions, undo across a long session, collaborative editing — none of that is here. Use a real editor library.
  • Not for Markdown. If the stored format is Markdown, a .textarea with a preview is simpler and round-trips exactly; execCommand output would have to be converted, inconsistently.
  • Not for code. No monospace, no indentation handling, no syntax highlighting — and contenteditable will happily insert <div>s into what you thought was a code block.
  • Not without server-side sanitising. Not a style preference. The field produces HTML from user input, and only the server can decide what is safe.