A markdown editor with GitHub-style Write and Preview tabs. The user types markdown in the textarea and can flip to a live preview at any time. Markdown is parsed by marked — a full CommonMark + GFM parser — so tables, task lists, fenced code blocks, autolinks, and inline HTML all work out of the box. The rendered output runs through sanitizeHtml using allowed-tags (an allowlist) or disallowed-tags (a denylist) — the two are mutually exclusive. <script> tags are always stripped unless you opt in via the scripts-enabled attribute, and <iframe> / <style> / similar dangerous tags are always stripped regardless of any setting.
Toolbar buttons are slotted via controls-top and controls-bottom — mix and match the built-in controls listed below or write your own by extending ButtonControl.
Drop in the tag with a placeholder and you get a markdown textarea with a Write/Preview toggle. No toolbars, no controls — just the bare editor.
<k-markdown-editor placeholder="Type some **markdown**..."></k-markdown-editor>
Wrap your toolbar buttons in a single <div slot="controls-top"> — that wrapper is the only element placed in the editor's controls-top slot, and you can lay it out however you like (the example below uses kempo-css's d-f for flex). Each control is its own custom element — import only the ones you need.
<k-markdown-editor placeholder="Write a comment...">
<div slot="controls-top" class="d-f">
<kc-menu label="Heading">
<k-icon slot="trigger" name="text_fields"></k-icon>
<kc-format-block tag="h1"></kc-format-block>
<kc-format-block tag="h2"></kc-format-block>
<kc-format-block tag="h3"></kc-format-block>
<kc-format-block tag="h4"></kc-format-block>
<kc-format-block tag="h5"></kc-format-block>
<kc-format-block tag="h6"></kc-format-block>
</kc-menu>
<kc-bold></kc-bold>
<kc-italic></kc-italic>
<kc-quote></kc-quote>
<kc-inline-code></kc-inline-code>
<kc-md-link></kc-md-link>
<kc-md-image></kc-md-image>
<kc-md-table></kc-md-table>
<kc-bullet-list></kc-bullet-list>
<kc-number-list></kc-number-list>
</div>
</k-markdown-editor>
The controls attribute lets you opt into a built-in toolbar without writing any slot markup. Set it directly on <k-markdown-editor> to one of the three levels below; the matching control modules are imported automatically.
Every available control: heading menu (H1–H6), bold, italic, strikethrough, quote, code, link, image (with dialog/dropdown), table builder, bulleted and numbered lists, speech-to-text.
<k-markdown-editor controls="full"></k-markdown-editor>
A balanced everyday toolbar: heading menu, bold, italic, quote, code, link, and lists.
<k-markdown-editor controls="normal"></k-markdown-editor>
Essential controls only: heading menu (H1, H3, H5), bold, italic, and lists. Ideal for simple comment forms.
<k-markdown-editor controls="minimal"></k-markdown-editor>
If you provide your own children with slot="controls-top" (or controls-bottom), they replace the corresponding fallback content from the preset — so you can layer custom controls on top of any level.
Same pattern for the bottom: wrap your action row in a single <div slot="controls-bottom">. The footer is hidden until something is slotted in. Mix the built-in markdown controls with your own action buttons to build a Slack/GitHub-style composer.
<k-markdown-editor placeholder="Write a comment...">
<div slot="controls-top" class="d-f">
<kc-bold></kc-bold>
<kc-italic></kc-italic>
<kc-md-link></kc-md-link>
</div>
<div slot="controls-bottom" class="d-f" style="align-items:center; width:100%;">
<small class="tc-muted">Markdown supported</small>
<button style="margin-left:auto">Comment</button>
</div>
</k-markdown-editor>
Set initial markdown via the value attribute. Children are reserved for slotted controls (and their own content like icons or labels), so they are not read as the editor's value — otherwise icon labels, separators, etc. would leak into what the user sees.
<k-markdown-editor value="# Hello Seeded from the value attribute. Try the Preview tab."></k-markdown-editor>
The component is form-associated. Set a name and the raw markdown is included in the form's FormData. required participates in the browser's standard validation when the editor is empty.
<form id="mdForm">
<label>Comment:</label>
<k-markdown-editor name="comment" required placeholder="Markdown supported">
<div slot="controls-top" class="d-f">
<kc-bold></kc-bold>
<kc-italic></kc-italic>
</div>
</k-markdown-editor>
<button type="submit">Submit</button>
</form>
Form data: (submit to see)
The disabled attribute prevents user interaction (including switching tabs) and excludes the field from form submission. The readonly attribute keeps the editor visible and lets the user switch to Preview, but the textarea cannot be edited; the value is still submitted with the form.
Inline HTML the user (or a custom control) types is filtered by either an allowlist or a denylist. The two attributes are mutually exclusive — if both are set, allowed-tags wins and a console warning fires.
Permits only the tags you list. Anything else is unwrapped (its text survives, the wrapper is dropped). Type the markdown below and flip to Preview — only strong, em, p, and br get through; the <img>, <table>, and <script> are scrubbed out.
<k-markdown-editor allowed-tags="strong,em,p,br"></k-markdown-editor>
Allows everything except the tags you list — useful when you only want to ban a couple of things rather than enumerate the whole set. Below, images are blocked but everything else (tables, code, etc.) renders normally.
<k-markdown-editor disallowed-tags="img"></k-markdown-editor>
To write your own toolbar button, extend ButtonControl from controls/Button.js. Override handleAction() and call the host’s public methods (wrapSelection, insertLinePrefix, replaceSelection, insertAtCursor) via this.host to manipulate the textarea. The base class auto-discovers the host via closest('[controlled]') and disables itself when required host methods are missing.
Below is a tiny control that drops a horizontal rule (--- on its own line) at the cursor — not common enough to ship as a built-in, but a clean example of using invokeHost.
import ButtonControl from './controls/Button.js';
import {
html
} from './lit-all.min.js';
// Inserts a horizontal rule (`---`) on its own line at the cursor.
export default class MarkdownHr extends ButtonControl {
static requires = ['insertAtCursor'];
static hostMode = 'write';
constructor() {
super();
this.title = 'Horizontal rule';
}
handleAction() {
this.invokeHost('insertAtCursor', '\n\n---\n\n');
}
render() {
return html`<slot>—</slot>`;
}
}
customElements.define('kc-md-hr', MarkdownHr);
Use the three sizing CSS variables together to control the editor dimensions. Set --height for the default size, --min-height to prevent it from shrinking, and --max-height to add a vertical scrollbar when content grows.
k-markdown-editor {
--height: 20rem;
--min-height: 10rem;
--max-height: 40rem;
}
new MarkdownEditor()src/utils/renderMarkdown.js (thin wrapper around marked)src/utils/marked.esm.js (vendored marked, ~42KB)sanitizeHtml utilityvalue: StringThe current markdown source. Reading returns whatever the user has typed; writing replaces the editor content. Initial value comes from the value attribute. (Children are reserved for slotted controls and are not read as the editor value — otherwise control content like icons / labels would leak in.)
name: StringThe form field name. When set inside a <form>, the component participates in form submission. The submitted value is the raw markdown string. Syncs to name attribute.
placeholder: StringPlaceholder text shown in the textarea when the editor is empty. Syncs to placeholder attribute.
disabled: BooleanWhen true, the editor is non-interactive (textarea is disabled, mode tabs are disabled) and excluded from form submission. Syncs to disabled attribute.
required: BooleanWhen true and the editor is empty, the element reports a valueMissing validity error. Syncs to required attribute.
readonly: BooleanWhen true, the user can switch to Preview but cannot edit the textarea. Form submission still includes the value. Syncs to readonly attribute.
mode: StringEither 'write' (default) or 'preview'. Syncs to mode attribute.
allowedTags: StringComma-separated allowlist for the rendered preview. Tags outside the list are unwrapped (their text survives, the wrapper is dropped). Pass "*" to allow every tag. Empty / unset uses sanitizeHtml's default (headings, paragraphs, lists, links, code, images, tables, etc.). Mutually exclusive with disallowed-tags — setting both warns and allowed-tags wins. Syncs to allowed-tags attribute.
disallowedTags: StringComma-separated denylist. Allows everything except the listed tags — useful when you want to block a couple of tags rather than enumerate the whole allowed set. Mutually exclusive with allowed-tags. Syncs to disallowed-tags attribute.
scriptsEnabled: BooleanWhen this attribute is present, <script> tags survive sanitization (provided they also pass the allow/deny check). When absent (the default), <script> — including its content — is always stripped to prevent XSS, regardless of any other configuration. Other dangerous tags (<iframe>, <style>, etc.) are always stripped. Only enable this for trusted input. Syncs to scripts-enabled attribute.
controls: StringActivates a built-in toolbar. Accepted values:
'none' / '' (default) — no built-in toolbar; use named slots to build your own.'minimal' — heading menu (H1, H3, H5), bold, italic, link, bulleted list, numbered list.'normal' — full heading menu (H1–H6), bold, italic, quote, code, link, lists.'full' — everything from normal plus strikethrough, image, table builder, and speech-to-text.The chosen set renders as fallback content inside the controls-top slot. Providing your own slot content replaces it. Setting a non-empty value also dynamically imports every built-in control module so consumers don't have to remember which imports each preset needs.
renderedHtml: String (getter)The current value rendered to HTML by renderMarkdown (marked, with GFM enabled) and then run through sanitizeHtml using allowedTags.
isEmpty: Boolean (getter)true when value.trim() is empty.
focus(): voidSwitches to write mode (if necessary) and focuses the textarea.
blur(): voidRemoves focus from the textarea.
clear(): voidEmpties the editor (equivalent to el.value = '').
setMode(mode): voidSets the mode to 'write' or 'preview'. Other values are ignored.
togglePreview(): voidFlips between 'write' and 'preview'.
getSelection(): { start, end, text }Returns the current textarea selection. Used by control subclasses; in preview mode returns a zero-length selection at index 0.
replaceSelection(text, options): voidReplaces the current selection with text. options.selectInserted (default true) controls whether the inserted text ends up selected or whether the cursor moves to the end of it.
wrapSelection(prefix, suffix, placeholder): voidWraps the current selection with prefix/suffix. If nothing is selected, inserts placeholder between them and selects it so the user can immediately overtype. suffix defaults to prefix.
insertAtCursor(text): voidInserts text at the cursor position; selection is collapsed to the end of the inserted text.
insertLinePrefix(prefix): voidPrefixes every selected line (or the current line if no selection) with prefix. Used for headings, blockquotes, list items, etc.
| Variable | Default | Description |
|---|---|---|
--height | 14rem | Height of the editor container |
--min-height | none | Minimum height of the editor |
--max-height | none | Maximum height before the editor scrolls internally |
--padding | 0.5rem 0.75rem | Padding inside the textarea / preview |
The component also uses kempo-css's global --c_border, --c_bg, --tc, --tc_muted, --radius, --animation_ms, and --focus_shadow; override those globally for site-wide tuning.
inputFires on every keystroke (or programmatic change via the selection-manipulation API). detail.value is the current markdown.
changeFires with a 300ms debounce whenever text is typed, and also on blur. detail.value is the current markdown. Useful for enabling/disabling a "Save" button during long typing sessions without waiting for blur.
mode-changedFires when the user switches between Write and Preview tabs. detail.mode is the new mode.
Each control is a separate custom element under src/components/controls/. Import the ones you want and slot them into controls-top or controls-bottom.
| Tag | File | Inserts |
|---|---|---|
<kc-bold> | Bold.js | **bold** |
<kc-italic> | Italic.js | _italic_ |
<kc-strikethrough> | Strikethrough.js | ~~strike~~ — GFM strikethrough; renders to <del>. |
<kc-inline-code> | InlineCode.js | Inline backticks for a single line, fenced ``` blocks for multi-line selections |
<kc-md-link> | MdLink.js | [text](url) with the URL portion pre-selected |
<kc-md-image> | MdImage.js | Opens a popover (URL + alt text) and inserts  at the cursor. The popover type is configurable via popup="dropdown" (default, anchored next to the button) or popup="dialog" (modal Dialog). |
<kc-md-table> | MdTable.js | Opens a small dropdown with cols × rows inputs and a Create button. Inserts a GFM markdown table (with Header 1, Header 2… placeholders and empty body cells) at the cursor. |
<kc-menu> | Menu.js | Renders a single toolbar button that opens a Dropdown containing whatever child controls you slot into the default slot. The trigger button content is fully customizable via the trigger slot (defaults to a menu icon); label sets the accessible name. |
<kc-format-block> | FormatBlock.js | Heading-block picker designed to go inside a <kc-menu>. tag="h1"…"h6" sets the heading level on the current line(s), swapping out any existing heading. |
<kc-md-speech-to-text> | MdSpeechToText.js | Reuses the standalone <k-speech-to-text> component — click the mic to start recording, click again to stop. The final transcript is inserted at the cursor when the user finishes speaking. Forwards language and continuous attributes through. |
<kc-bullet-list> | BulletList.js | - prefix on each selected line |
<kc-number-list> | NumberList.js | 1. , 2. , … numbering each non-empty selected line |
<kc-quote> | Quote.js | > prefix on each selected line |
All controls extend ButtonControl from controls/Button.js — see Custom Controls to write your own.