Kempo UI Icon Kempo UI

Kempo UI

Kempo UI Icon
Base Components
Utils

Markdown Editor

Table of Contents

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.

Basic Usage

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>

With Controls

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>

Pre-Configured Controls

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.

controls="full"

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>

controls="normal"

A balanced everyday toolbar: heading menu, bold, italic, quote, code, link, and lists.

<k-markdown-editor controls="normal"></k-markdown-editor>

controls="minimal"

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.

Bottom Controls

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>
Markdown supported

Default Value

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&#10;&#10;Seeded from the value attribute. Try the Preview tab."></k-markdown-editor>

Form Usage

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)

Disabled / Read-only

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.

Tag Filtering

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.

allowed-tags

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>

disallowed-tags

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>

Custom Controls

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>&mdash;</slot>`;
}
}
customElements.define('kc-md-hr', MarkdownHr);

Sizing Example

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;
}

JavaScript Reference

Constructor

Extends ShadowComponent
new MarkdownEditor()

Requirements

Properties

value: String

The 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: String

The 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: String

Placeholder text shown in the textarea when the editor is empty. Syncs to placeholder attribute.

disabled: Boolean

When true, the editor is non-interactive (textarea is disabled, mode tabs are disabled) and excluded from form submission. Syncs to disabled attribute.

required: Boolean

When true and the editor is empty, the element reports a valueMissing validity error. Syncs to required attribute.

readonly: Boolean

When true, the user can switch to Preview but cannot edit the textarea. Form submission still includes the value. Syncs to readonly attribute.

mode: String

Either 'write' (default) or 'preview'. Syncs to mode attribute.

allowedTags: String

Comma-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: String

Comma-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: Boolean

When 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: String

Activates a built-in toolbar. Accepted values:

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.

Methods

focus(): void

Switches to write mode (if necessary) and focuses the textarea.

blur(): void

Removes focus from the textarea.

clear(): void

Empties the editor (equivalent to el.value = '').

setMode(mode): void

Sets the mode to 'write' or 'preview'. Other values are ignored.

togglePreview(): void

Flips 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): void

Replaces 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): void

Wraps 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): void

Inserts text at the cursor position; selection is collapsed to the end of the inserted text.

insertLinePrefix(prefix): void

Prefixes every selected line (or the current line if no selection) with prefix. Used for headings, blockquotes, list items, etc.

CSS Variables

VariableDefaultDescription
--height14remHeight of the editor container
--min-heightnoneMinimum height of the editor
--max-heightnoneMaximum height before the editor scrolls internally
--padding0.5rem 0.75remPadding 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.

Events

input

Fires on every keystroke (or programmatic change via the selection-manipulation API). detail.value is the current markdown.

change

Fires 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-changed

Fires when the user switches between Write and Preview tabs. detail.mode is the new mode.

Built-in Controls

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.

TagFileInserts
<kc-bold>Bold.js**bold**
<kc-italic>Italic.js_italic_
<kc-strikethrough>Strikethrough.js~~strike~~ — GFM strikethrough; renders to <del>.
<kc-inline-code>InlineCode.jsInline 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.jsOpens a popover (URL + alt text) and inserts ![alt](url) 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.jsOpens 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.jsRenders 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.jsHeading-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.jsReuses 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.js1. , 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.