Markdown Editor
A Write/Preview markdown box with a format toolbar, keyboard shortcuts, list continuation and a live word count.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/markdown-editor.json'use client'
import { useRef, useState } from 'react'
import './markdown-editor.css'
const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
const inline = (s: string) => esc(s)
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>')
.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>')
/** Tiny markdown → HTML for previews: headings, lists, quotes, paragraphs, inline marks. Escapes HTML first. */
export function md(src: string) {
const out: string[] = []
let list: 'ul' | 'ol' | null = null
const close = () => { if (list) { out.push(`</${list}>`); list = null } }
for (const line of src.split('\n')) {
const h = line.match(/^(#{1,3}) (.*)/), ul = line.match(/^[-*] (.*)/), ol = line.match(/^\d+\. (.*)/), q = line.match(/^> (.*)/)
if (ul || ol) { const t = ul ? 'ul' : 'ol'; if (list !== t) { close(); out.push(`<${t}>`); list = t } out.push(`<li>${inline((ul ?? ol)![1])}</li>`); continue }
close()
if (h) out.push(`<h${h[1].length + 1}>${inline(h[2])}</h${h[1].length + 1}>`)
else if (q) out.push(`<blockquote>${inline(q[1])}</blockquote>`)
else if (line.trim()) out.push(`<p>${inline(line)}</p>`)
}
close()
return out.join('')
}
export interface MarkdownEditorProps {
defaultValue?: string
onValueChange?: (v: string) => void
rows?: number
tab?: 'write' | 'preview'
toolbar?: boolean
fill?: 'dark' | 'light'
className?: string
}
/** Markdown textarea with toolbar, shortcuts, list continuation and preview. */
export function MarkdownEditor({ defaultValue = '', onValueChange, rows = 7, tab: t0 = 'write', toolbar = true, fill = 'dark', className }: MarkdownEditorProps) {
const [v, setV] = useState(defaultValue)
const [tab, setTab] = useState(t0)
const ta = useRef<HTMLTextAreaElement>(null)
const set = (n: string, s?: number, e?: number) => {
setV(n); onValueChange?.(n)
if (s !== undefined) requestAnimationFrame(() => { ta.current?.focus(); ta.current?.setSelectionRange(s, e ?? s) })
}
const wrap = (a: string, b = a, ph = 'text') => {
const el = ta.current; if (!el) return
const { selectionStart: s, selectionEnd: e } = el
const sel = v.slice(s, e) || ph
set(v.slice(0, s) + a + sel + b + v.slice(e), s + a.length, s + a.length + sel.length)
}
const prefix = (p: string) => {
const el = ta.current; if (!el) return
const s = v.lastIndexOf('\n', el.selectionStart - 1) + 1
set(v.slice(0, s) + p + v.slice(s), el.selectionStart + p.length)
}
const key = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const m = e.metaKey || e.ctrlKey
if (m && e.key === 'b') { e.preventDefault(); return wrap('**') }
if (m && e.key === 'i') { e.preventDefault(); return wrap('*') }
if (m && e.key === 'k') { e.preventDefault(); return wrap('[', '](https://)', 'link') }
if (e.key === 'Enter' && !e.shiftKey) {
const el = e.currentTarget, pos = el.selectionStart
const s = v.lastIndexOf('\n', pos - 1) + 1
const cur = v.slice(s, pos)
const m2 = cur.match(/^(\s*)([-*]|(\d+)\.) (.*)$/)
if (!m2) return
e.preventDefault()
if (!m2[4]) return set(v.slice(0, s) + v.slice(pos), s)
const bullet = m2[3] ? `${Number(m2[3]) + 1}.` : m2[2]
const ins = `\n${m2[1]}${bullet} `
set(v.slice(0, pos) + ins + v.slice(pos), pos + ins.length)
}
}
const words = v.trim() ? v.trim().split(/\s+/).length : 0
const tools: [string, string, () => void, React.ReactNode][] = [
['Heading', '', () => prefix('## '), <b key="h">H</b>],
['Bold', '⌘B', () => wrap('**'), <b key="b">B</b>],
['Italic', '⌘I', () => wrap('*'), <i key="i" style={{ fontFamily: 'serif', fontSize: 15 }}>I</i>],
['Code', '', () => wrap('`'), <svg key="c" viewBox="0 0 24 24"><path d="m9 8-4 4 4 4M15 8l4 4-4 4" /></svg>],
['Link', '⌘K', () => wrap('[', '](https://)', 'link'), <svg key="l" viewBox="0 0 24 24"><path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1" /></svg>],
['Bulleted list', '', () => prefix('- '), <svg key="u" viewBox="0 0 24 24"><path d="M9 6h11M9 12h11M9 18h11M4.5 6h0M4.5 12h0M4.5 18h0" strokeWidth={2.4} /></svg>],
]
return (
<div className={['sd-mde', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-mde__head">
<div role="tablist" aria-label="Mode" style={{ display: 'flex' }}>
{(['write', 'preview'] as const).map((x) => <button key={x} type="button" role="tab" className="sd-mde__tab" aria-selected={tab === x} onClick={() => setTab(x)}>{x === 'write' ? 'Write' : 'Preview'}</button>)}
</div>
{toolbar && tab === 'write' && (
<div className="sd-mde__bar" role="toolbar" aria-label="Formatting">
{tools.map(([l, k, f, ic]) => <button key={l} type="button" className="sd-mde__b" aria-label={k ? `${l} (${k})` : l} title={k ? `${l} ${k}` : l} onMouseDown={(e) => e.preventDefault()} onClick={f}>{ic}</button>)}
</div>
)}
</div>
{tab === 'write'
? <textarea ref={ta} className="sd-mde__ta" rows={rows} aria-label="Markdown" placeholder="Write in markdown…" value={v} onChange={(e) => set(e.target.value)} onKeyDown={key} />
: <div className="sd-mde__pv" role="region" aria-label="Preview" style={{ height: rows * 20.8 + 24 }}>{v.trim() ? <div dangerouslySetInnerHTML={{ __html: md(v) }} /> : <p className="sd-mde__empty">Nothing to preview</p>}</div>}
<div className="sd-mde__foot"><span>Markdown supported</span><span aria-live="polite">{words} {words === 1 ? 'word' : 'words'}</span></div>
</div>
)
}.sd-mde { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-mde[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-mde :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-mde :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-mde .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-mde { width: min(100%, 340px); border-radius: 16px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; }
.sd-mde:focus-within { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-mde__head { display: flex; align-items: center; gap: 2px; padding: 6px 6px 0; border-bottom: 1px solid var(--line); }
.sd-mde__tab { position: relative; padding: 8px 10px 10px; font-size: 13px; color: var(--mute) !important; }
.sd-mde__tab[aria-selected='true'] { color: var(--fg) !important; font-weight: 500; }
.sd-mde__tab[aria-selected='true']::after { content: ''; position: absolute; left: 8px; right: 8px; bottom: -1px; height: 2px; border-radius: 2px; background: var(--fg); }
.sd-mde__bar { margin-left: auto; display: flex; gap: 1px; padding-bottom: 4px; }
.sd-mde__b { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 7px; color: var(--mute) !important; font-size: 13px; }
.sd-mde__b:hover { background: var(--soft); color: var(--fg) !important; }
.sd-mde__b svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.sd-mde__ta { display: block; width: 100%; padding: 12px 14px; border: 0; background: none; color: var(--fg); font: 13px/1.6 ui-monospace, SFMono-Regular, monospace; resize: none; outline: 0; }
.sd-mde__pv { padding: 12px 14px; font-size: 14px; line-height: 1.55; overflow-y: auto; }
.sd-mde__pv h1, .sd-mde__pv h2, .sd-mde__pv h3 { margin: 0 0 6px; font-size: 17px; letter-spacing: -0.01em; }
.sd-mde__pv p { margin: 0 0 8px; }
.sd-mde__pv ul, .sd-mde__pv ol { margin: 0 0 8px; padding-left: 20px; }
.sd-mde__pv code { padding: 1px 5px; border-radius: 5px; background: var(--soft); font: 12.5px ui-monospace, monospace; }
.sd-mde__pv a { color: inherit; text-underline-offset: 2px; }
.sd-mde__pv blockquote { margin: 0 0 8px; padding-left: 10px; border-left: 2px solid var(--line); color: var(--mute); }
.sd-mde__empty { color: var(--mute); }
.sd-mde__foot { display: flex; justify-content: space-between; padding: 8px 14px; border-top: 1px solid var(--line); font-size: 11.5px; color: var(--mute); font-variant-numeric: tabular-nums; }
@media (prefers-reduced-motion: reduce) { .sd-mde *, .sd-mde *::before, .sd-mde *::after { animation: none !important; transition: none !important; } }Overview
Toolbar buttons and ⌘B / ⌘I / ⌘K wrap the selection in markdown. Pressing Enter inside a list continues it; Enter on an empty item ends it.
Preview renders headings, bold, italic, code, links and lists with a tiny built-in parser, no dependency.
When to use
- Comments, issue descriptions, notes, READMEs.
When not to use
- Non-technical audiences: offer a rich-text editor.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/markdown-editor.json$
npx shadcn@latest add https://sundaeui.dev/r/markdown-editor.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/markdown-editor.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/markdown-editor.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy markdown-editor.tsx and markdown-editor.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { MarkdownEditor } from '@/components/sundae/markdown-editor'
<MarkdownEditor />Props
| Prop | Type | Default |
|---|---|---|
value / defaultValueMarkdown text. | string | — |
onValueChangeCalled on change. | (v: string) => void | — |
rowsVisible rows. | number | 7 |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Write and Preview are tabs; the toolbar is a labelled toolbar of buttons with their shortcuts in the label.
- Preview is a region with real headings and lists.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-mde; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- ⌘/Ctrl + B
- Bold
- ⌘/Ctrl + I
- Italic
- ⌘/Ctrl + K
- Link
- Enter in a list
- Continue the list
FAQ
- Is Markdown Editor free?
- Yes. Markdown Editor is free for personal and commercial projects, with full source on this page.
- How do I install Markdown Editor?
- Run npx shadcn@latest add https://sundaeui.dev/r/markdown-editor.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Markdown Editor support keyboard and screen readers?
- Write and Preview are tabs; the toolbar is a labelled toolbar of buttons with their shortcuts in the label. Preview is a region with real headings and lists.
- Does Markdown Editor respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →- Pro
Mention Input
Forms · Refined Character Limit Field
Forms · RefinedOutline Editor
Forms · RefinedSearch Snippet Preview
Forms · RefinedOTP Field
Forms · Refined- Pro
Drop Zone
Forms · Refined