Notes App
A full-featured notes app with search, colour coding, pinning, auto-save, and localStorage persistence.
Project Overview
Notes apps look simple but pack in a surprising amount of JavaScript: debounced auto-save, live search filtering, inline editing, and a two-panel layout. This is a polished, job-portfolio-quality project that demonstrates everything you have learned.
What you will build
- Two-panel layout: notes list on the left, editor on the right
- Create, edit, and delete notes
- Auto-save while typing (debounced, 500ms)
- Live search that filters the list as you type
- Pin notes to keep them at the top of the list
- Colour labels (6 pastel colours per note)
- Word count and last-edited timestamp in the editor
- All notes persisted in localStorage
Concepts used
- Two-panel layout with CSS Grid
- Debouncing with
setTimeout/clearTimeout - Active-note state management
- Live filtering with
Array.filter()and string search - Relative timestamps with
Intl.RelativeTimeFormat
Key Logic
Debounced auto-save
let saveTimer = null;
function scheduleSave() {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
saveNote(activeId, editorEl.value);
renderList();
showSavedIndicator();
}, 500);
}
editorEl.addEventListener('input', scheduleSave);
Live search filtering
function getVisible() {
const q = searchEl.value.toLowerCase();
if (!q) return notes;
return notes.filter(n =>
n.title.toLowerCase().includes(q) ||
n.body.toLowerCase().includes(q)
);
}
Relative time formatting
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
function timeAgo(timestamp) {
const diff = (timestamp - Date.now()) / 1000; // seconds (negative = past)
if (diff > -60) return rtf.format(Math.round(diff), 'second');
if (diff > -3600) return rtf.format(Math.round(diff / 60), 'minute');
if (diff > -86400) return rtf.format(Math.round(diff / 3600),'hour');
return rtf.format(Math.round(diff / 86400), 'day');
}
// e.g. "2 minutes ago", "yesterday"
Note data structure
const note = {
id: Date.now().toString(),
title: 'Untitled',
body: '',
color: '#fef9c3', // pastel yellow
pinned: false,
updatedAt: Date.now()
};
Complete Working Code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Notes</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #1e1e2e; color: #cdd6f4;
height: 100vh; overflow: hidden; }
.layout { display: grid; grid-template-columns: 280px 1fr; height: 100vh; }
/* Sidebar */
.sidebar-panel { background: #181825; display: flex; flex-direction: column;
border-right: 1px solid #313244; }
.sidebar-top { padding: .9rem; border-bottom: 1px solid #313244; display: flex; flex-direction: column; gap: .6rem; }
.app-title { font-size: 1.1rem; font-weight: 700; color: #cba6f7; }
.search-input {
padding: .5rem .75rem; background: #313244; border: none; border-radius: .5rem;
font-size: .85rem; color: #cdd6f4; outline: none; width: 100%;
}
.search-input::placeholder { color: #6c7086; }
.new-btn {
padding: .55rem; background: #cba6f7; color: #1e1e2e; border: none;
border-radius: .5rem; font-size: .85rem; font-weight: 700; cursor: pointer;
}
.new-btn:hover { background: #b4befe; }
.notes-list { flex: 1; overflow-y: auto; }
.note-item {
padding: .75rem .9rem; cursor: pointer; border-bottom: 1px solid #313244;
transition: background .1s; position: relative;
}
.note-item:hover { background: #313244; }
.note-item.active { background: #45475a; }
.note-item-title {
font-size: .9rem; font-weight: 600; color: #cdd6f4;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
padding-right: 1.5rem;
}
.note-item-preview {
font-size: .75rem; color: #6c7086; margin-top: .2rem;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.note-item-time { font-size: .7rem; color: #6c7086; margin-top: .2rem; }
.note-pin-icon { position: absolute; top: .6rem; right: .6rem; font-size: .75rem; }
.color-strip { position: absolute; left: 0; top: 0; bottom: 0; width: 4px; border-radius: 4px 0 0 4px; }
.note-empty-msg { text-align: center; padding: 2rem 1rem; color: #6c7086; font-size: .85rem; }
/* Editor */
.editor-panel { display: flex; flex-direction: column; }
.editor-toolbar {
display: flex; align-items: center; gap: .6rem; padding: .75rem 1.1rem;
border-bottom: 1px solid #313244; background: #181825;
}
.title-input {
flex: 1; background: none; border: none; font-size: 1.2rem; font-weight: 700;
color: #cdd6f4; outline: none;
}
.title-input::placeholder { color: #6c7086; }
.icon-btn {
background: none; border: none; color: #6c7086; font-size: 1.1rem;
cursor: pointer; padding: .3rem; border-radius: .3rem; transition: color .15s;
}
.icon-btn:hover { color: #cdd6f4; }
.icon-btn.pinned { color: #cba6f7; }
.editor-area {
flex: 1; padding: 1.25rem; background: #1e1e2e; border: none; resize: none;
font-size: .95rem; line-height: 1.7; color: #cdd6f4; font-family: inherit; outline: none;
}
.editor-area::placeholder { color: #6c7086; }
.editor-footer {
display: flex; justify-content: space-between; align-items: center;
padding: .5rem 1.1rem; border-top: 1px solid #313244; background: #181825;
font-size: .75rem; color: #6c7086;
}
.saved-badge { color: #a6e3a1; transition: opacity .3s; }
.colors-row { display: flex; gap: .35rem; }
.color-dot {
width: 16px; height: 16px; border-radius: 50%; cursor: pointer;
border: 2px solid transparent; transition: transform .1s;
}
.color-dot:hover { transform: scale(1.25); }
.color-dot.selected { border-color: #fff; }
.del-btn { background: none; border: none; color: #f38ba8; font-size: .8rem; cursor: pointer; }
.empty-editor { flex: 1; display: flex; align-items: center; justify-content: center;
color: #6c7086; font-size: .95rem; }
[hidden] { display: none !important; }
</style>
</head>
<body>
<div class="layout">
<!-- Left panel -->
<div class="sidebar-panel">
<div class="sidebar-top">
<span class="app-title">Notes</span>
<input class="search-input" id="search" placeholder="Search notes…" autocomplete="off">
<button class="new-btn" id="new-btn">+ New Note</button>
</div>
<div class="notes-list" id="notes-list"></div>
</div>
<!-- Right panel -->
<div class="editor-panel">
<div id="empty-editor" class="empty-editor">Select a note or create a new one</div>
<div id="editor-wrap" hidden style="display:flex;flex-direction:column;height:100vh">
<div class="editor-toolbar">
<input class="title-input" id="title-input" placeholder="Note title…">
<button class="icon-btn" id="pin-btn" title="Pin note">📌</button>
<button class="del-btn" id="del-btn">Delete</button>
</div>
<textarea class="editor-area" id="editor" placeholder="Start writing…"></textarea>
<div class="editor-footer">
<div class="colors-row" id="colors-row"></div>
<span id="word-count"></span>
<span class="saved-badge" id="saved-badge" style="opacity:0">Saved</span>
</div>
</div>
</div>
</div>
<script>
const STORAGE_KEY = 'notes_v1';
const COLORS = ['#fef9c3','#dcfce7','#dbeafe','#fce7f3','#f3e8ff','#ffedd5'];
function load() {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? []; }
catch { return []; }
}
function save() { localStorage.setItem(STORAGE_KEY, JSON.stringify(notes)); }
let notes = load();
let activeId = notes[0]?.id ?? null;
let saveTimer = null;
const notesList = document.querySelector('#notes-list');
const titleInput = document.querySelector('#title-input');
const editorEl = document.querySelector('#editor');
const pinBtn = document.querySelector('#pin-btn');
const delBtn = document.querySelector('#del-btn');
const searchEl = document.querySelector('#search');
const wordCount = document.querySelector('#word-count');
const savedBadge = document.querySelector('#saved-badge');
const editorWrap = document.querySelector('#editor-wrap');
const emptyEl = document.querySelector('#empty-editor');
const colorsRow = document.querySelector('#colors-row');
// Build colour dots
COLORS.forEach(c => {
const dot = document.createElement('div');
dot.className = 'color-dot';
dot.style.background = c;
dot.dataset.color = c;
colorsRow.appendChild(dot);
});
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
function timeAgo(ts) {
const diff = (ts - Date.now()) / 1000;
if (diff > -60) return rtf.format(Math.round(diff), 'second');
if (diff > -3600) return rtf.format(Math.round(diff / 60), 'minute');
if (diff > -86400) return rtf.format(Math.round(diff / 3600),'hour');
return rtf.format(Math.round(diff / 86400), 'day');
}
function getVisible() {
const q = searchEl.value.toLowerCase();
const filtered = q ? notes.filter(n =>
n.title.toLowerCase().includes(q) || n.body.toLowerCase().includes(q)
) : [...notes];
// Pinned first, then by updatedAt desc
return filtered.sort((a, b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
return b.updatedAt - a.updatedAt;
});
}
function renderList() {
notesList.innerHTML = '';
const visible = getVisible();
if (!visible.length) {
notesList.innerHTML = '<p class="note-empty-msg">No notes found.</p>';
return;
}
visible.forEach(n => {
const item = document.createElement('div');
item.className = `note-item${n.id === activeId ? ' active' : ''}`;
item.dataset.id = n.id;
const strip = document.createElement('div');
strip.className = 'color-strip';
strip.style.background = n.color;
const title = document.createElement('div');
title.className = 'note-item-title';
title.textContent = n.title || 'Untitled';
const preview = document.createElement('div');
preview.className = 'note-item-preview';
preview.textContent = n.body.slice(0, 60) || 'No content';
const time = document.createElement('div');
time.className = 'note-item-time';
time.textContent = timeAgo(n.updatedAt);
item.append(strip, title, preview, time);
if (n.pinned) {
const pin = document.createElement('span');
pin.className = 'note-pin-icon';
pin.textContent = '📌';
item.appendChild(pin);
}
notesList.appendChild(item);
});
}
function renderEditor() {
const note = notes.find(n => n.id === activeId);
if (!note) {
editorWrap.hidden = true;
emptyEl.style.display = 'flex';
return;
}
editorWrap.hidden = false;
emptyEl.style.display = 'none';
titleInput.value = note.title;
editorEl.value = note.body;
pinBtn.classList.toggle('pinned', note.pinned);
pinBtn.title = note.pinned ? 'Unpin note' : 'Pin note';
const words = note.body.trim() ? note.body.trim().split(/\s+/).length : 0;
wordCount.textContent = `${words} word${words !== 1 ? 's' : ''}`;
document.querySelectorAll('.color-dot').forEach(d => {
d.classList.toggle('selected', d.dataset.color === note.color);
});
}
function updateNote(id, changes) {
notes = notes.map(n => n.id === id ? { ...n, ...changes, updatedAt: Date.now() } : n);
save();
}
function showSaved() {
savedBadge.style.opacity = '1';
setTimeout(() => { savedBadge.style.opacity = '0'; }, 1500);
}
function scheduleSave() {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
if (!activeId) return;
updateNote(activeId, { title: titleInput.value, body: editorEl.value });
const words = editorEl.value.trim() ? editorEl.value.trim().split(/\s+/).length : 0;
wordCount.textContent = `${words} word${words !== 1 ? 's' : ''}`;
renderList();
showSaved();
}, 500);
}
// Events
document.querySelector('#new-btn').addEventListener('click', () => {
const note = { id: Date.now().toString(), title: '', body: '', color: COLORS[0], pinned: false, updatedAt: Date.now() };
notes = [note, ...notes];
activeId = note.id;
save();
renderList();
renderEditor();
titleInput.focus();
});
notesList.addEventListener('click', e => {
const item = e.target.closest('[data-id]');
if (!item) return;
activeId = item.dataset.id;
renderList();
renderEditor();
});
titleInput.addEventListener('input', scheduleSave);
editorEl.addEventListener('input', scheduleSave);
pinBtn.addEventListener('click', () => {
if (!activeId) return;
const note = notes.find(n => n.id === activeId);
updateNote(activeId, { pinned: !note.pinned });
renderList();
renderEditor();
});
delBtn.addEventListener('click', () => {
if (!activeId) return;
if (!confirm('Delete this note?')) return;
notes = notes.filter(n => n.id !== activeId);
activeId = notes[0]?.id ?? null;
save();
renderList();
renderEditor();
});
colorsRow.addEventListener('click', e => {
const dot = e.target.closest('.color-dot');
if (!dot || !activeId) return;
updateNote(activeId, { color: dot.dataset.color });
renderList();
renderEditor();
});
searchEl.addEventListener('input', () => renderList());
// Init
renderList();
renderEditor();
</script>
</body>
</html>
Code Explained
Debounced auto-save
Every keystroke in the editor calls scheduleSave(). This clears any pending timeout and sets a new one for 500ms. The save only actually runs if the user stops typing for 500ms — otherwise it keeps resetting. This pattern (debounce) prevents saving dozens of times per second on fast typers.
Immutable note updates
notes.map(n => n.id === id ? { ...n, ...changes, updatedAt: Date.now() } : n) creates a new array with a new note object containing the spread of old fields overridden by changes. The timestamp is always refreshed on every update so the list sorts correctly.
Intl.RelativeTimeFormat
Rather than formatting a raw date string, Intl.RelativeTimeFormat produces human-readable relative strings like "2 minutes ago" or "yesterday". The sign of the difference tells it past vs future; dividing by the right number selects the unit.
Two-panel layout with CSS Grid
grid-template-columns: 280px 1fr creates a fixed-width left panel and a flexible right panel. Setting height: 100vh; overflow: hidden on the body and letting each panel scroll independently keeps the toolbar and footer always visible.
Challenges
- Add Markdown preview: render
**bold**,*italic*, and# headingsin a preview pane next to the raw text. - Add drag-and-drop reordering of notes in the list using the HTML Drag and Drop API.
- Add tags to notes and filter the list by tag.
- Add a "Trash" screen — instead of permanent delete, move notes to trash and allow restore or permanent deletion.
- Add export: download all notes as a single JSON file, and import from JSON.