Loading
Create a browser-based note-taking app with live markdown rendering, localStorage persistence, full-text search, and tagging.
You'll build a complete note-taking application that runs entirely in the browser. Notes are written in Markdown, rendered in real time, persisted to localStorage, and searchable by content or tags. No server, no database, no build tools — just HTML, CSS, and JavaScript.
What you'll learn:
Create your project files:
The layout is a three-panel design: sidebar for note list, editor for writing, and preview for rendered output.
Use CSS Grid for the main layout and flexbox within panels:
Build a lightweight Markdown parser. This handles the most common syntax — headings, bold, italic, code, links, lists, and blockquotes:
Each note is a plain object stored as a JSON array in localStorage:
The sidebar shows all notes sorted by last-modified, with a text preview truncated to 80 characters:
Connect the editor textarea to the preview pane with a debounced update:
Collect all tags across notes and render clickable filter chips:
Export notes as a JSON file and import from a previously exported file:
Serve with npx serve . and open in the browser. All notes persist across sessions. To extend this further, consider adding note folders, Markdown table support, or syncing across devices with a simple WebSocket server.
markdown-notes/
├── index.html
├── style.css
├── app.js
└── markdown.js<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Markdown Notes</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<aside class="sidebar">
<div class="sidebar-header">
<input type="search" id="search" placeholder="Search notes..." />
<button id="new-note" title="New Note">+</button>
</div>
<div id="tag-filter" class="tag-filter"></div>
<ul id="note-list" class="note-list"></ul>
<div class="sidebar-footer">
<button id="export-btn">Export All</button>
<button id="import-btn">Import</button>
<input type="file" id="import-file" accept=".json" hidden />
</div>
</aside>
<main class="editor-area">
<div class="editor-header">
<input type="text" id="note-title" placeholder="Note title..." />
<input type="text" id="note-tags" placeholder="Tags (comma-separated)" />
<button id="delete-note" class="danger">Delete</button>
</div>
<div class="editor-split">
<textarea id="editor" placeholder="Write markdown here..."></textarea>
<div id="preview" class="preview"></div>
</div>
</main>
<script type="module" src="app.js"></script>
</body>
</html>* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Inter", system-ui, sans-serif;
background: #0a0a0f;
color: #e0e0e0;
height: 100vh;
display: grid;
grid-template-columns: 280px 1fr;
}
.sidebar {
background: rgba(255, 255, 255, 0.02);
border-right: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
padding: 1rem;
display: flex;
gap: 0.5rem;
}
.sidebar-header input {
flex: 1;
padding: 0.5rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
color: inherit;
}
.note-list {
flex: 1;
overflow-y: auto;
list-style: none;
}
.note-list li {
padding: 0.75rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
cursor: pointer;
}
.note-list li.active,
.note-list li:hover {
background: rgba(255, 255, 255, 0.05);
}
.note-list .note-title {
font-weight: 500;
font-size: 0.9rem;
}
.note-list .note-preview {
font-size: 0.75rem;
color: #6b6b75;
margin-top: 2px;
}
.editor-area {
display: flex;
flex-direction: column;
overflow: hidden;
}
.editor-header {
padding: 0.75rem 1rem;
display: flex;
gap: 0.75rem;
align-items: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.editor-header input {
padding: 0.5rem;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
color: inherit;
}
#note-title {
flex: 2;
font-size: 1.1rem;
}
#note-tags {
flex: 1;
}
.editor-split {
flex: 1;
display: grid;
grid-template-columns: 1fr 1fr;
overflow: hidden;
}
#editor {
resize: none;
padding: 1rem;
background: transparent;
border: none;
border-right: 1px solid rgba(255, 255, 255, 0.08);
color: #e0e0e0;
font-family: "JetBrains Mono", monospace;
font-size: 14px;
line-height: 1.6;
}
.preview {
padding: 1rem;
overflow-y: auto;
line-height: 1.8;
}
.preview h1,
.preview h2,
.preview h3 {
margin: 1em 0 0.5em;
}
.preview code {
background: rgba(255, 255, 255, 0.06);
padding: 0.15em 0.4em;
border-radius: 4px;
font-size: 0.9em;
}
.preview pre code {
display: block;
padding: 1rem;
overflow-x: auto;
}
.preview blockquote {
border-left: 3px solid #10b981;
padding-left: 1rem;
color: #a0a0a8;
}
.tag-filter {
padding: 0 1rem;
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.tag {
font-size: 0.7rem;
padding: 2px 8px;
border-radius: 10px;
background: rgba(16, 185, 129, 0.15);
color: #10b981;
cursor: pointer;
}
.tag.active {
background: #10b981;
color: #0a0a0f;
}
button {
padding: 0.5rem 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
background: rgba(255, 255, 255, 0.05);
color: inherit;
cursor: pointer;
}
button:hover {
background: rgba(255, 255, 255, 0.1);
}
.danger {
border-color: rgba(239, 68, 68, 0.3);
color: #ef4444;
}// markdown.js
export function parseMarkdown(text) {
let html = text
// Code blocks (must come before inline code)
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="lang-$1">$2</code></pre>')
// Inline code
.replace(/`([^`]+)`/g, "<code>$1</code>")
// Headings
.replace(/^### (.+)$/gm, "<h3>$1</h3>")
.replace(/^## (.+)$/gm, "<h2>$1</h2>")
.replace(/^# (.+)$/gm, "<h1>$1</h1>")
// Bold and italic
.replace(/\*\*\*(.+?)\*\*\*/g, "<strong><em>$1</em></strong>")
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.+?)\*/g, "<em>$1</em>")
// Links and images
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" />')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
// Blockquotes
.replace(/^> (.+)$/gm, "<blockquote>$1</blockquote>")
// Unordered lists
.replace(/^[*-] (.+)$/gm, "<li>$1</li>")
// Horizontal rules
.replace(/^---$/gm, "<hr />")
// Line breaks into paragraphs
.replace(/\n\n/g, "</p><p>")
.replace(/\n/g, "<br />");
// Wrap list items
html = html.replace(/(<li>.*<\/li>)/gs, "<ul>$1</ul>");
// Merge adjacent blockquotes
html = html.replace(/<\/blockquote><br \/><blockquote>/g, "<br />");
return `<p>${html}</p>`;
}// In app.js
const STORAGE_KEY = "markdown-notes";
function loadNotes() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch (err) {
console.error("Failed to load notes:", err);
return [];
}
}
function saveNotes(notes) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(notes));
} catch (err) {
console.error("Failed to save notes:", err);
}
}
function createNote() {
return {
id: crypto.randomUUID(),
title: "Untitled Note",
content: "",
tags: [],
createdAt: Date.now(),
updatedAt: Date.now(),
};
}function renderNoteList(notes, activeId, filter = "") {
const list = document.getElementById("note-list");
const lowerFilter = filter.toLowerCase();
const filtered = notes.filter((n) => {
if (!lowerFilter) return true;
return (
n.title.toLowerCase().includes(lowerFilter) ||
n.content.toLowerCase().includes(lowerFilter) ||
n.tags.some((t) => t.toLowerCase().includes(lowerFilter))
);
});
const sorted = filtered.sort((a, b) => b.updatedAt - a.updatedAt);
list.innerHTML = sorted
.map((note) => {
const preview = note.content.replace(/[#*`>\[\]]/g, "").slice(0, 80);
return `
<li data-id="${note.id}" class="${note.id === activeId ? "active" : ""}">
<div class="note-title">${escapeHtml(note.title)}</div>
<div class="note-preview">${escapeHtml(preview)}</div>
</li>`;
})
.join("");
}
function escapeHtml(str) {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}import { parseMarkdown } from "./markdown.js";
let notes = loadNotes();
let activeNoteId = notes[0]?.id ?? null;
let saveTimeout = null;
const editor = document.getElementById("editor");
const preview = document.getElementById("preview");
const titleInput = document.getElementById("note-title");
const tagsInput = document.getElementById("note-tags");
function loadNote(id) {
const note = notes.find((n) => n.id === id);
if (!note) return;
activeNoteId = id;
titleInput.value = note.title;
tagsInput.value = note.tags.join(", ");
editor.value = note.content;
preview.innerHTML = parseMarkdown(note.content);
renderNoteList(notes, activeNoteId, searchInput.value);
}
function saveActiveNote() {
const note = notes.find((n) => n.id === activeNoteId);
if (!note) return;
note.title = titleInput.value || "Untitled Note";
note.content = editor.value;
note.tags = tagsInput.value
.split(",")
.map((t) => t.trim())
.filter(Boolean);
note.updatedAt = Date.now();
saveNotes(notes);
renderNoteList(notes, activeNoteId, searchInput.value);
}
editor.addEventListener("input", () => {
preview.innerHTML = parseMarkdown(editor.value);
clearTimeout(saveTimeout);
saveTimeout = setTimeout(saveActiveNote, 400);
});
titleInput.addEventListener("input", () => {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(saveActiveNote, 400);
});
tagsInput.addEventListener("input", () => {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(saveActiveNote, 400);
});const searchInput = document.getElementById("search");
const tagFilterEl = document.getElementById("tag-filter");
let activeTag = null;
function getAllTags(notes) {
const tagMap = new Map();
notes.forEach((n) =>
n.tags.forEach((t) => {
tagMap.set(t, (tagMap.get(t) || 0) + 1);
})
);
return tagMap;
}
function renderTags() {
const tags = getAllTags(notes);
tagFilterEl.innerHTML = [...tags.entries()]
.sort((a, b) => b[1] - a[1])
.map(
([tag, count]) =>
`<span class="tag ${tag === activeTag ? "active" : ""}" data-tag="${tag}">${tag} (${count})</span>`
)
.join("");
}
tagFilterEl.addEventListener("click", (e) => {
const tag = e.target.dataset.tag;
if (!tag) return;
activeTag = activeTag === tag ? null : tag;
const filter = activeTag || searchInput.value;
renderNoteList(notes, activeNoteId, filter);
renderTags();
});
searchInput.addEventListener("input", () => {
renderNoteList(notes, activeNoteId, searchInput.value);
});document.getElementById("export-btn").addEventListener("click", () => {
const blob = new Blob([JSON.stringify(notes, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `notes-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
});
const importFile = document.getElementById("import-file");
document.getElementById("import-btn").addEventListener("click", () => {
importFile.click();
});
importFile.addEventListener("change", (e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const imported = JSON.parse(reader.result);
if (!Array.isArray(imported)) throw new Error("Invalid format");
notes = [...notes, ...imported.filter((n) => n.id && n.content)];
saveNotes(notes);
renderNoteList(notes, activeNoteId);
renderTags();
} catch (err) {
console.error("Import failed:", err);
alert("Invalid notes file.");
}
};
reader.readAsText(file);
});
// Keyboard shortcuts
document.addEventListener("keydown", (e) => {
// Ctrl/Cmd+N: New note
if ((e.ctrlKey || e.metaKey) && e.key === "n") {
e.preventDefault();
const note = createNote();
notes.push(note);
saveNotes(notes);
loadNote(note.id);
editor.focus();
}
// Ctrl/Cmd+/: Focus search
if ((e.ctrlKey || e.metaKey) && e.key === "/") {
e.preventDefault();
searchInput.focus();
}
// Ctrl/Cmd+S: Force save
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
saveActiveNote();
}
});
// Initialize
if (notes.length === 0) {
const note = createNote();
note.title = "Welcome";
note.content =
"# Welcome to Markdown Notes\n\nStart writing in **Markdown** and see it rendered live.";
notes.push(note);
saveNotes(notes);
}
loadNote(notes[0].id);
renderTags();