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.
<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">•</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">•</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.
| 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
| 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.jssetsrole="textbox"andaria-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-labelledbypointing at a visible label, oraria-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-pressedis 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
contenteditablewithout 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
::beforecontent, 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="قائمة">•</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="قائمة">•</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
.textareawith a preview is simpler and round-trips exactly;execCommandoutput would have to be converted, inconsistently. -
Not for code. No monospace, no indentation handling, no syntax
highlighting — and
contenteditablewill 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.