Markdown to Rich Text
Raw markdown types into an editor, its symbols tinted as they appear. Then the tab slides across to Preview, the source blurs away, and the same text renders as formatted rich text, block by block: headings, bold, lists, code and quotes.
# Release notes Everything is **faster** and *smoother* in v2. ## What’s new - A `loop` option on every animation - Springier easing, ~~no more jank~~ > Thanks to everyone who [sent feedback](#).
How to use it
- Paste the snippet just before
</head>. - Add
data-markdown-to-rich-textto a<pre>holding your markdown.
Options
Add any of these attributes to the same element.
data-speed | Typing time per character. Default 30 ms. |
data-loop | Go back to the markdown and type it again after a pause. |
data-hold | How long the preview stays before looping. Default 2600 ms. |
data-tabs="false" | Hide the Markdown and Preview tabs. |
It understands headings, bullet and numbered lists, quotes, bold, italic, code, strikethrough and links (drawn as underlined text). Both views share one space, so the box is as tall as the taller one from the start.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Markdown to Rich Text: raw markdown types itself into an editor pane,
// then the pane switches to Preview and the same text renders as formatted
// rich text, block by block.
// Usage:
// <pre data-markdown-to-rich-text>
// # Release notes
// Animations are **faster** and *smoother*.
// - New `loop` option
// </pre>
// Understands # and ## headings, - or * bullets, 1. numbered lines, > quotes,
// **bold**, *italic*, `code`, ~~strike~~ and [links](url) (drawn as
// underlined text).
// Optional: data-speed (ms per character, default 30), data-loop (go back
// to the markdown and type it again), data-hold (ms the preview stays before
// looping, default 2600), data-tabs="false" (hide the Markdown / Preview tabs).
var DEFAULTS = { speed: 30, hold: 2600 };
var css =
'.mdr{text-align:left;background:#fff;color:#1f2328;border-radius:14px;border:1px solid rgba(0,0,0,.08);' +
'box-shadow:0 20px 44px -16px rgba(40,10,70,.3);overflow:hidden}' +
'.mdr-tabs{position:relative;display:flex;gap:0.3em;padding:0.45em 0.6em;border-bottom:1px solid #eceef1;background:#fafafb;font-size:0.85em;font-weight:600}' +
'.mdr-tab{position:relative;z-index:1;padding:0.3em 0.8em;border-radius:6px;color:#8a8f98;transition:color .25s}' +
'.mdr-tab.mdr-on{color:var(--accent,#6a0dad)}' +
'.mdr-pill{position:absolute;top:0.45em;bottom:0.45em;left:0;border-radius:6px;background:var(--accent-soft,#f3e8ff)}' +
'.mdr-panes{display:grid}' +
'.mdr-raw,.mdr-out{grid-area:1/1;margin:0;padding:1em 1.1em;min-width:0}' +
'.mdr-raw{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:0.88em;line-height:1.65;white-space:pre-wrap;word-break:break-word;color:#3f3f46}' +
'.mdr-m{color:var(--accent,#6a0dad);opacity:.75}' +
'.mdr-h{font-weight:700;color:#18181b}' +
'.mdr-out{line-height:1.5}' +
'.mdr-out>*{margin:0 0 0.5em}' +
'.mdr-out h1{font-size:1.5em;line-height:1.2;margin-bottom:0.35em}' +
'.mdr-out h2{font-size:1.2em;line-height:1.25;margin-bottom:0.35em}' +
'.mdr-out ul,.mdr-out ol{padding-left:1.3em}' +
'.mdr-out li{margin:0.15em 0}' +
'.mdr-out li::marker{color:var(--accent,#6a0dad)}' +
'.mdr-out blockquote{padding:0.1em 0 0.1em 0.8em;border-left:3px solid var(--accent,#6a0dad);color:#52525b;font-style:italic}' +
'.mdr-out code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:0.88em;padding:0.1em 0.35em;border-radius:4px;background:#f1f1f4;color:#be185d}' +
'.mdr-link{color:var(--accent,#6a0dad);text-decoration:underline;text-underline-offset:2px}' +
'.mdr-caret{display:inline-block;width:2px;height:1.15em;margin-left:1px;vertical-align:-0.2em;background:var(--accent,#6a0dad)}' +
'.mdr-caret.mdr-blink{animation:mdr-blink 1.06s steps(1) infinite}@keyframes mdr-blink{50%{opacity:0}}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
function opt(d, k) { return d[k] !== undefined && d[k] !== '' ? d[k] : DEFAULTS[k]; }
function esc(t) { return t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
// Inline formatting for the rendered view.
function inline(t) {
return esc(t)
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/~~([^~]+)~~/g, '<s>$1</s>')
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '<span class="mdr-link">$1</span>');
}
// Markdown to HTML, one block per line (lists are grouped).
function render(src) {
var html = '', list = '';
src.split('\n').forEach(function (line) {
var m, tag = '';
if ((m = line.match(/^\s*[-*]\s+(.*)/))) tag = 'ul';
else if ((m = line.match(/^\s*\d+\.\s+(.*)/))) tag = 'ol';
if (list && tag !== list) { html += '</' + list + '>'; list = ''; }
if (tag) {
if (!list) { html += '<' + tag + '>'; list = tag; }
html += '<li>' + inline(m[1]) + '</li>';
} else if ((m = line.match(/^(#{1,3})\s+(.*)/))) {
html += '<h' + m[1].length + '>' + inline(m[2]) + '</h' + m[1].length + '>';
} else if ((m = line.match(/^>\s?(.*)/))) {
html += '<blockquote>' + inline(m[1]) + '</blockquote>';
} else if (line.trim()) {
html += '<p>' + inline(line) + '</p>';
}
});
if (list) html += '</' + list + '>';
return html;
}
// Tint the markdown syntax in the raw view.
function tint(src) {
return src.split('\n').map(function (line) {
var m = line.match(/^(#{1,3}\s|\s*[-*]\s|\s*\d+\.\s|>\s?)(.*)$/);
var head = /^#/.test(line);
var body = m ? m[2] : line;
body = esc(body).replace(/(\*\*|~~|`|\*|\[|\]\([^)]*\)?|\])/g, '<span class="mdr-m">$1</span>');
if (head) body = '<span class="mdr-h">' + body + '</span>';
return (m ? '<span class="mdr-m">' + esc(m[1]) + '</span>' : '') + body;
}).join('\n');
}
function setup(el) {
var d = el.dataset;
var lines = el.textContent.replace(/\r/g, '').split('\n');
while (lines.length && !lines[0].trim()) lines.shift();
while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
var indent = Math.min.apply(null, lines.filter(function (l) { return l.trim(); }).map(function (l) { return l.match(/^\s*/)[0].length; }));
var src = lines.map(function (l) { return l.slice(indent); }).join('\n');
var box = document.createElement('div');
box.className = 'mdr';
for (var i = 0; i < el.attributes.length; i++) {
var a = el.attributes[i];
if (a.name !== 'data-markdown-to-rich-text') box.setAttribute(a.name, a.value);
}
box.classList.add('mdr');
box.setAttribute('data-markdown-to-rich-text', '');
var tabs = null;
if (d.tabs !== 'false') {
tabs = document.createElement('div');
tabs.className = 'mdr-tabs';
tabs.setAttribute('aria-hidden', 'true');
tabs.innerHTML = '<span class="mdr-pill"></span><span class="mdr-tab mdr-on">Markdown</span><span class="mdr-tab">Preview</span>';
box.appendChild(tabs);
}
var panes = document.createElement('div');
panes.className = 'mdr-panes';
var raw = document.createElement('pre');
raw.className = 'mdr-raw';
raw.setAttribute('aria-hidden', 'true');
var out = document.createElement('div');
out.className = 'mdr-out';
out.innerHTML = render(src);
raw.innerHTML = tint(src); // both panes share one grid cell, so the box fits the taller
panes.appendChild(raw);
panes.appendChild(out);
box.appendChild(panes);
el.parentNode.replaceChild(box, el);
box._mdr = true;
var caret = document.createElement('span');
caret.className = 'mdr-caret';
return { el: box, d: d, src: src, raw: raw, out: out, tabs: tabs, caret: caret, visible: false, wake: null };
}
function tab(s, i, instant) {
if (!s.tabs) return;
var ts = s.tabs.querySelectorAll('.mdr-tab');
var t = ts[i];
ts.forEach(function (x, j) { x.classList.toggle('mdr-on', j === i); });
var pill = s.tabs.querySelector('.mdr-pill');
var props = { x: t.offsetLeft, width: t.offsetWidth };
if (instant) anime.utils.set(pill, props);
else anime.animate(pill, { x: props.x, width: props.width, duration: 380, ease: 'out(3)' });
}
function view(s, preview) {
s.raw.style.visibility = preview ? 'hidden' : '';
s.out.style.visibility = preview ? '' : 'hidden';
}
function whenVisible(s) {
return s.visible ? Promise.resolve() : new Promise(function (r) { s.wake = r; });
}
function type(s, speed) {
return new Promise(function (done) {
var n = 0;
s.caret.classList.remove('mdr-blink');
(function tick() {
s.raw.innerHTML = tint(s.src.slice(0, n));
s.raw.appendChild(s.caret);
if (n++ >= s.src.length) { done(); return; }
var ch = s.src.charAt(n - 1);
setTimeout(tick, speed * (0.5 + Math.random()) + (ch === '\n' ? speed * 6 : 0));
})();
});
}
function play(s) {
var speed = +opt(s.d, 'speed');
view(s, false);
tab(s, 0, true);
anime.utils.set(s.raw, { opacity: 1, filter: 'blur(0px)' });
s.raw.textContent = '';
sleep(400).then(function () {
return type(s, speed);
}).then(function () {
s.caret.classList.add('mdr-blink');
return sleep(900);
}).then(function () {
s.caret.remove();
tab(s, 1);
return new Promise(function (r) {
anime.animate(s.raw, { opacity: 0, filter: ['blur(0px)', 'blur(3px)'], duration: 280, ease: 'in(2)', onComplete: r });
});
}).then(function () {
view(s, true);
var blocks = Array.from(s.out.children).reduce(function (a, b) {
return a.concat(/^(UL|OL)$/.test(b.tagName) ? Array.from(b.children) : [b]);
}, []);
anime.animate(blocks, { opacity: [0, 1], y: [8, 0], duration: 450, delay: anime.stagger(90), ease: 'out(3)' });
if (s.d.loop === undefined || s.d.loop === 'false') return;
return sleep(blocks.length * 90 + 450 + +opt(s.d, 'hold')).then(function () { return whenVisible(s); }).then(function () {
return new Promise(function (r) { anime.animate(s.out, { opacity: 0, duration: 280, ease: 'in(2)', onComplete: r }); });
}).then(function () {
anime.utils.set(s.out, { opacity: 1 });
play(s);
});
});
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var s = e.target._mdrS;
s.visible = e.isIntersecting;
if (s.visible && s.wake) { var w = s.wake; s.wake = null; w(); }
});
}, { threshold: 0.3 });
document.querySelectorAll('[data-markdown-to-rich-text]').forEach(function (el) {
if (el._mdr) return;
el._mdr = true;
var s = setup(el);
s.el._mdrS = s;
if (reduced()) { view(s, true); tab(s, 1, true); return; }
view(s, false);
tab(s, 0, true);
io.observe(s.el);
whenVisible(s).then(function () { play(s); });
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><pre data-markdown-to-rich-text>
# Release notes
Animations are **faster** and *smoother*.
- New `loop` option
</pre>