
Writemark is a dependency-free Markdown editor Web Component that renders formatted content directly inside its editable surface.
Headings, lists, task checkboxes, tables, inline marks, and fenced code blocks appear in their rendered form while raw Markdown remains the component’s canonical value.
The editor registers as <writemark-editor> and works with classic scripts, ES modules, npm-based projects, and native HTML forms.
Features:
- Live inline rendering for supported Markdown structures.
- Canonical raw Markdown for application state and form submission.
- Live, source, split, and read-only preview modes.
- Slash commands, Markdown shortcuts, and keyboard formatting controls.
- Editable task lists, tables, code fences, blockquotes, and horizontal rules.
- Source-backed selection, undo, redo, find, replace, and clipboard handling.
- Native form association, validation, reset, disabled, and read-only states.
- Public actions and completion providers for host-defined editing workflows.
- Host-controlled paste and drop events for file upload integration.
- CSS custom properties and Parts for Shadow DOM styling.
- Sanitized built-in HTML rendering with blocked unsafe URL schemes.
- Zero runtime dependencies and no default network requests.
How To Use It:
Installation
Load the core JavaScript in your plain HTML page.
<script src="./dist/writemark-editor.global.js"></script>
The script registers <writemark-editor> and exposes helper exports through globalThis.WritemarkEditor.
A page served over HTTP can load the ES module build directly.
<script type="module" src="/dist/writemark-editor.js"></script>
You can also install and import the package via NPM.
npm install writemark-editor
import 'writemark-editor';
Basic Usage
Add the browser build, place the custom element in the document, and set multiline Markdown through the value property.
<script src="./dist/writemark-editor.global.js"></script>
<writemark-editor
id="release-notes"
name="notes"
label="Release notes"
placeholder="Type / to insert a block"
></writemark-editor>
<script>
customElements.whenDefined('writemark-editor').then(() => {
const editor = document.querySelector('#release-notes');
editor.value = [
'# Version 2.4',
'',
'- Added keyboard navigation',
'- Fixed table editing'
].join('\n');
editor.addEventListener('md-input', (event) => {
console.log(event.detail.value);
});
});
</script>
editor.value and editor.getMarkdown() return raw Markdown. editor.getHTML() returns rendered HTML from the built-in sanitizer, while editor.getText() and editor.getPlainText() return structural plain text.
const editor = document.querySelector('#release-notes');
console.log(editor.value);
console.log(editor.getMarkdown());
console.log(editor.getHTML());
console.log(editor.getText());
Choose an Editing Mode
The mode attribute selects the primary editing or viewing surface.
<writemark-editor mode="live"></writemark-editor> <writemark-editor mode="source"></writemark-editor> <writemark-editor mode="split"></writemark-editor> <writemark-editor mode="preview"></writemark-editor>
| Mode | Behavior |
|---|---|
live | Renders supported Markdown structures inside the editable surface. |
source | Shows the complete raw Markdown in a native textarea. |
split | Places the source textarea beside a rendered preview on wider screens. |
preview | Shows a focusable, read-only rendered view. |
An editing mode can also display an extra preview through preview="below", preview="side", or preview="inline-split".
<writemark-editor mode="live" preview="below" label="Project summary" ></writemark-editor>
Live mode is experimental on mobile browsers. Set
mode="source"for production editing on iOS, iPadOS, Android, and other software-keyboard environments.
Submit Raw Markdown in a Form
Writemark is a form-associated custom element. The browser submits its raw Markdown under the name attribute.
<form id="story-form">
<writemark-editor
name="story"
label="Story"
mode="live"
required
minlength="80"
maxlength="12000"
></writemark-editor>
<button type="submit">Save story</button>
<button type="reset">Reset</button>
</form>
<script>
const form = document.querySelector('#story-form');
form.addEventListener('submit', (event) => {
event.preventDefault();
const data = new FormData(form);
console.log(data.get('story'));
});
</script>
The component supports required, minlength, maxlength, disabled, native form reset, disabled fieldsets, checkValidity(), reportValidity(), and setCustomValidity().
defaultValue controls the reset target. Call commit() after loading or saving a document when the current Markdown should become the clean value and the next reset target.
const editor = document.querySelector('writemark-editor');
editor.value = loadedMarkdown;
editor.commit();
console.log(editor.dirty); // false
Add a Host Toolbar
Writemark does not render formatting controls. A host toolbar can call built-in actions through exec() and reflect the current selection through getActiveMarks().
<div id="editor-toolbar" role="toolbar" aria-label="Text formatting">
<button type="button" data-action="inline.bold" aria-label="Bold">
Bold
</button>
<button type="button" data-action="inline.italic" aria-label="Italic">
Italic
</button>
<button type="button" data-action="block.heading.2">
Heading 2
</button>
<button type="button" data-action="block.table">
Table
</button>
</div>
<writemark-editor
id="article-editor"
label="Article body"
></writemark-editor>
const editor = document.querySelector('#article-editor');
const toolbar = document.querySelector('#editor-toolbar');
toolbar.addEventListener('click', (event) => {
const button = event.target.closest('[data-action]');
if (!button) return;
const actionId = button.dataset.action;
const args = actionId === 'block.table'
? { rows: 2, cols: 3 }
- undefined;
editor.exec(actionId, args);
editor.focus();
});
function updateToolbar() {
const activeMarks = new Set(editor.getActiveMarks());
for (const button of toolbar.querySelectorAll('[data-action]')) {
const actionId = button.dataset.action;
button.disabled = !editor.canExec(actionId);
if (
actionId.startsWith('inline.') ||
actionId.startsWith('block.heading.')
) {
button.setAttribute(
'aria-pressed',
String(activeMarks.has(actionId))
);
}
}
}
editor.addEventListener('md-selection-change', updateToolbar);
editor.addEventListener('md-input', updateToolbar);
updateToolbar();
Formatting actions such as bold and italic work as toggle buttons. Insert actions such as table creation should remain ordinary buttons.
Insert Structured Markdown
The action API handles structural edits and keeps selection, undo, dirty state, and change events synchronized.
const editor = document.querySelector('#article-editor');
// Insert a three-column table with two body rows.
editor.exec('block.table', {
rows: 2,
cols: 3
});
// Insert a fenced TypeScript block.
editor.exec('block.codeFence', {
language: 'typescript'
});
// Apply a link to selected text.
editor.exec('inline.link', {
url: 'https://example.com/docs'
});
// Insert an image at the current source selection.
editor.exec('inline.image', {
alt: 'Editor workflow',
src: '/assets/editor-workflow.png'
});
Table row counts clamp from 1 to 20. Column counts clamp from 2 to 12. Table rows and columns can be changed later through table actions.
editor.exec('table.insertRowAfter');
editor.exec('table.insertColumnAfter');
editor.exec('table.deleteRow');
editor.exec('table.deleteColumn');
Save Drafts and Commit Revisions
md-input reports each canonical value change. md-change marks an explicit commit boundary.
const editor = document.querySelector('#article-editor');
let draftTimer;
editor.addEventListener('md-input', (event) => {
clearTimeout(draftTimer);
draftTimer = setTimeout(() => {
localStorage.setItem('article-draft', event.detail.value);
}, 500);
});
editor.addEventListener('md-change', (event) => {
saveRevision(event.detail.value);
});
document.querySelector('#save-revision').addEventListener('click', () => {
editor.commit();
});
Avoid assigning
editor.valueafter everymd-inputevent. The component already owns the active selection and undo transaction. Write a new value only when external state has changed.
Handle File Paste and Drop
The editor emits file events and leaves upload policy to the host application. Each event includes the files, the captured source insertion point, and an insertMarkdown callback.
async function uploadEditorFile(event) {
const { files, insertionPoint, insertMarkdown } = event.detail;
const file = files[0];
if (!file) return;
const result = await uploadAsset(file);
editor.setSelectionRange(insertionPoint, insertionPoint);
insertMarkdown(``);
}
editor.addEventListener('md-file-paste', uploadEditorFile);
editor.addEventListener('md-file-drop', uploadEditorFile);
Validate file type, size, permissions, filename, and returned URL in the host application. Long uploads also need a policy for stale insertion points when the document changes before the request completes.
Add a Custom Completion Provider
Completion providers can add mentions, templates, references, or remote search results to the editor. A provider defines how to detect a query, load items, and convert the accepted item into a source transaction.
const teamMembers = [
{ id: 'maya', label: 'Maya Chen' },
{ id: 'sam', label: 'Sam Rivera' }
];
editor.registerCompletionProvider({
id: 'team-member',
priority: 40,
triggers: ['@'],
match(context) {
const caretOffset =
context.selectionStart - context.currentLine.start;
const textBeforeCaret =
context.currentLine.text.slice(0, caretOffset);
const match = /@([\w-]*)$/.exec(textBeforeCaret);
if (
!match ||
context.selectionStart !== context.selectionEnd
) {
return null;
}
return {
from: context.selectionStart - match[0].length,
to: context.selectionStart,
query: match[1]
};
},
getItems(match, context, signal) {
if (signal.aborted) return [];
const query = match.query.toLowerCase();
return teamMembers
.filter((member) =>
member.label.toLowerCase().includes(query)
)
.map((member) => ({
id: member.id,
label: member.label,
detail: 'team member',
kind: 'mention'
}));
},
apply(item, match, context) {
const markdown =
`[${item.label}](/team/${item.id})`;
const caret = match.from + markdown.length;
return {
ok: true,
transaction: {
changes: [{
from: match.from,
to: match.to,
insert: markdown
}],
selectionBefore: {
start: context.selectionStart,
end: context.selectionEnd,
direction: context.selectionDirection
},
selectionAfter: {
start: caret,
end: caret,
direction: 'none'
},
undoGroup: 'team-member-completion'
}
};
}
});
Use the supplied
AbortSignalfor remote requests. Writemark cancels stale completion work when the query or active provider changes.
Attributes and Properties
Content and View
value(string): Sets the initial/reset Markdown as an attribute and the current canonical Markdown as a property.label(string): Adds a visible label and an accessible naming source.placeholder(string): Displays empty-state text. The default isWrite markdown....mode(live | source | split | preview): Selects the active editing or viewing surface. The default islive.preview(none | below | side | inline-split): Selects an optional preview placement. The default isnone.markdown-flavor(gfm | commonmark): Selects the supported Markdown feature profile. The default isgfm.render-debounce-ms(0to1000): Sets preview render debounce in milliseconds. The default is100.
Editing Behavior
tab-behavior(accessibility-first | editor-first): Controls ordinary Tab behavior outside structural contexts. The default isaccessibility-first.indent-string(tab | 2 | 2-spaces | 4 | 4-spaces): Controls indentation used by list and editor actions. The default is two spaces.spellcheck(true | false): Controls spellcheck on prose surfaces. Fenced code always disables spellcheck.readonly(boolean): Keeps content focusable and selectable while blocking edits.disabled(boolean): Blocks interaction and removes the field from form submission.dir(ltr | rtl | auto): Propagates text direction to active editing surfaces.
Forms and Validation
name(string): Sets the submitted form field name.required(boolean): Rejects empty and whitespace-only Markdown.minlength(number): Sets the minimum length for nonempty Markdown.maxlength(number): Sets the maximum Markdown length.aria-label(string): Supplies an accessible name to the active editor surface.aria-labelledby(string): References one or more naming elements.
Diagnostics
debug(number): Selects the diagnostic level. Level0disables diagnostics, level1reports input decisions, and level2adds selection and focus data.debug-log(boolean): Mirrors enabled diagnostics toconsole.debug.
API Methods
const editor = document.querySelector('writemark-editor');
// Focus the active live, source, or preview surface.
editor.focus();
// Blur the active internal surface.
editor.blur();
// Select the complete Markdown value.
editor.select();
// Set the canonical source selection.
editor.setSelectionRange(0, 12, 'forward');
// Return Markdown in the current source range.
editor.getSelectionMarkdown();
// Return canonical Markdown.
editor.getMarkdown();
// Set canonical Markdown.
editor.setMarkdown('# Updated document');
// Insert Markdown at the current selection.
editor.insertMarkdown('\n\nNew paragraph');
// Return sanitized rendered HTML.
editor.getHTML();
// Return structural plain text.
editor.getText();
// Alias of getText().
editor.getPlainText();
// Execute a built-in or custom action.
editor.exec('inline.bold');
// Check whether an action is currently available.
editor.canExec('inline.bold');
// Register or replace a custom action.
editor.registerAction(customAction);
// Remove a custom action.
editor.unregisterAction('insert.notice');
// Return the parsed block at the caret.
editor.getCurrentBlock();
// Return parsed blocks that intersect the selection.
editor.getSelectedBlocks();
// Return active block and inline action IDs.
editor.getActiveMarks();
// Register or replace a completion provider.
editor.registerCompletionProvider(completionProvider);
// Remove a completion provider.
editor.unregisterCompletionProvider('team-member');
// Find and select the next source match.
editor.find('draft', {
caseSensitive: false,
from: editor.selectionEnd,
wrap: true
});
// Replace the matching selection or next match.
editor.replace('draft', 'final', {
caseSensitive: false,
wrap: true
});
// Replace every nonoverlapping source match.
editor.replaceAll('TODO', 'Done', {
caseSensitive: true
});
// Mark the current value clean and set the reset target.
editor.commit();
// Restore defaultValue and clear dirty state.
editor.reset();
// Run native constraint validation.
editor.checkValidity();
// Run validation and expose the current message.
editor.reportValidity();
// Set or clear a custom validation error.
editor.setCustomValidity('Add at least one heading.');
Built-in Actions
// Editor and history.
editor.exec('editor.insertText', { text: 'New text' });
editor.exec('editor.replaceSelection', {
text: 'Replacement'
});
editor.exec('editor.insertParagraph');
editor.exec('editor.insertSoftBreak');
editor.exec('editor.smartEnter');
editor.exec('editor.smartTab');
editor.exec('editor.smartOutdent');
editor.exec('editor.smartBackspace');
editor.exec('editor.smartDelete');
editor.exec('editor.markdownShortcut');
editor.exec('editor.deleteSelection');
editor.exec('editor.selectAllExpand');
editor.exec('history.undo');
editor.exec('history.redo');
// Blocks and insertion.
editor.exec('block.paragraph');
editor.exec('block.heading.1');
editor.exec('block.heading.2');
editor.exec('block.heading.3');
editor.exec('block.heading.4');
editor.exec('block.heading.5');
editor.exec('block.heading.6');
editor.exec('block.bulletList');
editor.exec('block.orderedList');
editor.exec('block.taskList');
editor.exec('block.taskDone');
editor.exec('block.blockquote');
editor.exec('block.codeFence', { language: 'css' });
editor.exec('block.horizontalRule');
editor.exec('block.table', { rows: 2, cols: 4 });
// Tables and code.
editor.exec('table.insertRowAfter');
editor.exec('table.insertColumnAfter');
editor.exec('table.deleteRow');
editor.exec('table.deleteColumn');
editor.exec('code.setLanguage', {
language: 'javascript'
});
// Inline formatting.
editor.exec('inline.bold');
editor.exec('inline.italic');
editor.exec('inline.code');
editor.exec('inline.strikethrough');
editor.exec('inline.link', { url: '/guide' });
editor.exec('inline.image', {
alt: 'Guide cover',
src: '/images/guide-cover.png'
});
// Views and completion.
editor.exec('view.live');
editor.exec('view.source');
editor.exec('completion.close');
editor.exec('completion.next');
editor.exec('completion.previous');
editor.exec('completion.first');
editor.exec('completion.last');
editor.exec('completion.accept');
Module Exports
The ES module exposes the custom-element classes, renderer helpers, source parsers, and clipboard conversion utilities.
import {
WritemarkEditorElement,
MdLiveEditorElement,
renderMarkdown,
renderInlineMarkdown,
parseBlocks,
parseListItem,
parseHeading,
parseBlockquote,
htmlToMarkdown,
tsvToMarkdownTable
} from 'writemark-editor';
Events
All public events bubble and cross the Shadow DOM boundary. md-before-change is the only cancelable event.
// Fires before a canonical source transaction.
// Call preventDefault() to block the change.
editor.addEventListener('md-before-change', (event) => {
console.log(event.detail.nextValue);
});
// Fires after the canonical Markdown changes.
editor.addEventListener('md-input', (event) => {
console.log(event.detail.value);
});
// Fires after commit() or a source textarea change boundary.
editor.addEventListener('md-change', (event) => {
console.log(event.detail.value);
});
// Fires when the source-backed selection changes.
editor.addEventListener('md-selection-change', (event) => {
console.log(event.detail.selectionStart);
console.log(event.detail.selectionEnd);
});
// Fires after an action runs.
editor.addEventListener('md-action', (event) => {
console.log(event.detail.actionId);
});
// Fires when a completion popup opens.
editor.addEventListener('md-completion-open', (event) => {
console.log(event.detail.providerId);
});
// Fires when a completion popup closes.
editor.addEventListener('md-completion-close', (event) => {
console.log(event.detail.providerId);
});
// Fires after a completion item is accepted.
editor.addEventListener('md-completion-accept', (event) => {
console.log(event.detail.item);
});
// Fires after rendered preview HTML is generated.
editor.addEventListener('md-render', (event) => {
console.log(event.detail.html);
});
// Fires when a file is pasted.
editor.addEventListener('md-file-paste', (event) => {
console.log(event.detail.files);
});
// Fires when a file is dropped.
editor.addEventListener('md-file-drop', (event) => {
console.log(event.detail.files);
});
// Fires after live mode copies Markdown-backed content.
editor.addEventListener('md-copy', (event) => {
console.log(event.detail.markdown);
});
// Fires after live mode cuts Markdown-backed content.
editor.addEventListener('md-cut', (event) => {
console.log(event.detail.markdown);
});
// Fires after clipboard content enters the canonical source.
editor.addEventListener('md-paste', (event) => {
console.log(event.detail.kind);
});
// Fires when dirty state changes.
editor.addEventListener('md-dirty-change', (event) => {
console.log(event.detail.dirty);
});
// Fires when the selected diagnostic level emits a record.
editor.addEventListener('md-debug', (event) => {
console.log(event.detail);
});
// Fires after a recoverable editor error.
editor.addEventListener('md-error', (event) => {
console.error(
event.detail.phase,
event.detail.error
);
});
Styling and Customization
Writemark uses an open Shadow DOM. Regular descendant selectors do not reach internal controls. Set custom properties on the host and use ::part() for stable internal targets.
writemark-editor.article-field {
--md-editor-font: Inter, system-ui, sans-serif;
--md-editor-mono-font: "JetBrains Mono", monospace;
--md-editor-font-size: 15px;
--md-editor-line-height: 1.65;
--md-editor-min-height: 360px;
--md-editor-max-height: 70vh;
--md-editor-radius: 8px;
--md-editor-border-focus: #2563eb;
}
writemark-editor.article-field::part(code-header) {
font-weight: 700;
letter-spacing: 0.03em;
}
writemark-editor.article-field::part(preview) {
padding: 20px;
}
CSS Custom Properties
--md-editor-font: Sets the prose font stack.--md-editor-mono-font: Sets the code and source font stack.--md-editor-font-size: Sets the base editor font size. The default is15px.--md-editor-line-height: Sets the editor line height. The default is1.55.--md-editor-bg: Sets the editing surface background.--md-editor-fg: Sets the primary foreground color.--md-editor-muted: Sets muted labels and secondary text.--md-editor-token: Sets visible Markdown token color.--md-editor-border: Sets the default border color.--md-editor-border-focus: Sets the focused border color.--md-editor-radius: Sets the shared border radius. The default is10px.--md-editor-padding: Sets surface padding. The default is14px.--md-editor-min-height: Sets the minimum editor height. The default is220px.--md-editor-max-height: Sets the maximum editor height. The default isnone.--md-editor-focus-ring: Sets the outer focus ring.--md-editor-active-line-ring: Sets the focused live-block ring. The default isnone.--md-editor-active-line-bg: Sets the focused live-block background. The default istransparent.--md-editor-active-cell-ring: Sets the focused table-cell ring.--md-editor-active-cell-bg: Sets the focused table-cell background.--md-editor-popup-bg: Sets the completion popup background.--md-editor-popup-fg: Sets the completion popup foreground.--md-editor-popup-border: Sets the completion popup border.--md-editor-popup-shadow: Sets the completion popup shadow.--md-editor-preview-bg: Sets the preview background.--md-editor-preview-fg: Sets the preview foreground.--md-editor-code-bg: Sets code block and inline code backgrounds.--md-editor-code-header-bg: Sets fenced code header backgrounds.--md-editor-code-accent: Sets fenced code header accents.--md-editor-danger: Sets validation error color. The default is#b00020.--md-editor-transition-duration: Sets transition duration. The default is140ms.--md-editor-transition-ease: Sets transition easing.
CSS Parts
container: Targets the complete labeled component layout.label: Targets the visible editor label.editor: Targets the editor shell.live-editor: Targets the live editing surface.textarea: Targets the source textarea.preview: Targets the rendered preview.completion-popup: Targets the completion listbox.completion-item: Targets every completion option.completion-item-active: Targets the active completion option.line: Targets editable rendered lines and terminal anchors.checkbox: Targets task checkboxes.code-block: Targets complete fenced code blocks.code-header: Targets code-language headers.code-lines: Targets code-line containers.code-line: Targets individual editable code lines.table: Targets rendered tables.table-cell: Targets editable table cells.error: Targets the validation message region.status: Targets the screen-reader status region.
Split and side-preview layouts use two columns above
720pxand stack below that width. Reduced-motion preferences reduce editor transition and animation durations.
Keyboard and Clipboard Behavior
Typing / at the start of a line opens the built-in command menu. Markdown block shortcuts recognize headings, lists, task items, blockquotes, and related structures when their marker is followed by Space.
| Shortcut | Action |
|---|---|
Mod+B | Toggle bold. |
Mod+I | Toggle italic. |
Mod+E | Toggle inline code. |
Mod+K | Insert or edit a link. |
Mod+Shift+X | Toggle strikethrough. |
Mod+Alt+1 through Mod+Alt+6 | Toggle heading levels. |
Alternatives:
- WYSIWYG Markdown Editor for Mobile & Desktop Apps – OverType
- Dual-Mode WYSIWYG Rich Text & Markdown Editor in JavaScript
- Simple Markdown Editor with Real-Time Preview and Customizable Toolbar
- Full-featured WYSIWYG Markdown Editor – tui.editor
FAQs:
Q: Does Writemark require a JavaScript framework?
A: No. It is a native custom element with no runtime dependencies. It also works inside framework applications that can render and access a DOM element.
Q: Why does the editor have no formatting toolbar?
A: Formatting comes from Markdown shortcuts, slash commands, keyboard shortcuts, and public actions. The host application can build a toolbar that matches its own product UI.
Q: What value should the application save?
A: Save editor.value or editor.getMarkdown(). Do not serialize content from the Shadow DOM because the rendered live surface can hide canonical Markdown syntax.
Q: How do I get the submitted form value?
A: The component submits raw Markdown as the form value. Use editor.value or editor.getMarkdown() to read it programmatically.
Q: Does Writemark handle file uploads?
A: No. The component emits md-file-paste and md-file-drop events for files, but the host application is responsible for uploading and inserting Markdown links or images.







