Copy & paste
Source Code
<div class="ei">
<div class="ei-bar">
<label class="ei-all"><input type="checkbox" id="eiAll"><span>Select</span></label>
<input type="search" class="ei-search" id="eiSearch" placeholder="Search mail…" aria-label="Search">
<span class="ei-unread" id="eiUnread">0 unread</span>
</div>
<ul class="ei-list" id="eiList"></ul>
</div>*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#f1f5f9;display:flex;justify-content:center;padding:30px 16px}
.ei{width:100%;max-width:520px;background:#fff;border:1px solid #e2e8f0;border-radius:14px;overflow:hidden;box-shadow:0 14px 36px -24px rgba(0,0,0,.3)}
.ei-bar{display:flex;align-items:center;gap:12px;padding:10px 14px;border-bottom:1px solid #eef2f6}
.ei-all{display:flex;align-items:center;gap:6px;font-size:12px;color:#64748b;font-weight:600}
.ei-all input{width:15px;height:15px;accent-color:#6366f1}
.ei-search{flex:1;border:1px solid #e2e8f0;border-radius:8px;padding:7px 11px;font-size:13px;font-family:inherit;outline:none}
.ei-search:focus{border-color:#6366f1}
.ei-unread{font-size:11.5px;font-weight:700;color:#4f46e5;white-space:nowrap}
.ei-list{list-style:none}
.ei-row{display:flex;align-items:center;gap:11px;padding:11px 14px;border-bottom:1px solid #f1f5f9;cursor:pointer;position:relative}
.ei-row:hover{background:#f8fafc}
.ei-row.is-unread{background:#fbfdff}
.ei-row.is-unread .ei-from,.ei-row.is-unread .ei-subj{font-weight:800;color:#0f172a}
.ei-dot{width:7px;height:7px;border-radius:50%;background:#3b82f6;flex-shrink:0;visibility:hidden}
.ei-row.is-unread .ei-dot{visibility:visible}
.ei-check{width:15px;height:15px;accent-color:#6366f1;flex-shrink:0}
.ei-star{background:none;border:none;cursor:pointer;font-size:15px;color:#cbd5e1;flex-shrink:0;line-height:1}
.ei-star.is-on{color:#f59e0b}
.ei-main{flex:1;min-width:0}
.ei-from{font-size:13px;color:#334155}
.ei-subj{font-size:13px;color:#475569;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ei-subj b{color:#0f172a}
.ei-time{font-size:11px;color:#94a3b8;flex-shrink:0}
.ei-row.is-checked{background:#eef2ff}
.ei-empty{padding:30px;text-align:center;color:#94a3b8;font-size:13px}var DATA = [
{ id:1, from:'GitHub', subj:'Your weekly digest — 12 new stars', time:'9:24', unread:true, star:false },
{ id:2, from:'Figma', subj:'Ada commented on “Dashboard v3”', time:'8:10', unread:true, star:true },
{ id:3, from:'Stripe', subj:'Your payout of $2,140 is on the way', time:'Yesterday', unread:false, star:false },
{ id:4, from:'Linear', subj:'3 issues assigned to you this sprint', time:'Yesterday', unread:true, star:false },
{ id:5, from:'Vercel', subj:'Deployment ready: fwd-tools (production)', time:'Mon', unread:false, star:true },
{ id:6, from:'Notion', subj:'Weekly team notes are ready to review', time:'Mon', unread:false, star:false }
];
var list = document.getElementById('eiList');
var unreadEl = document.getElementById('eiUnread');
var search = document.getElementById('eiSearch');
var allBox = document.getElementById('eiAll');
var checked = {};
function highlight(text, q) {
if (!q) return text;
var i = text.toLowerCase().indexOf(q.toLowerCase());
if (i === -1) return text;
return text.slice(0, i) + '<b>' + text.slice(i, i + q.length) + '</b>' + text.slice(i + q.length);
}
function render() {
var q = search.value.trim();
list.innerHTML = '';
var rows = DATA.filter(function (m) { return !q || (m.from + ' ' + m.subj).toLowerCase().indexOf(q.toLowerCase()) > -1; });
if (!rows.length) { list.innerHTML = '<li class="ei-empty">No matching mail.</li>'; }
rows.forEach(function (m) {
var li = document.createElement('li');
li.className = 'ei-row' + (m.unread ? ' is-unread' : '') + (checked[m.id] ? ' is-checked' : '');
li.innerHTML = '<span class="ei-dot"></span>' +
'<input type="checkbox" class="ei-check"' + (checked[m.id] ? ' checked' : '') + ' aria-label="Select">' +
'<button class="ei-star' + (m.star ? ' is-on' : '') + '" aria-label="Star">' + (m.star ? '\u2605' : '\u2606') + '</button>' +
'<div class="ei-main"><div class="ei-from">' + m.from + '</div><div class="ei-subj">' + highlight(m.subj, q) + '</div></div>' +
'<span class="ei-time">' + m.time + '</span>';
li.querySelector('.ei-check').addEventListener('click', function (e) { e.stopPropagation(); checked[m.id] = e.target.checked; li.classList.toggle('is-checked', e.target.checked); syncAll(); });
li.querySelector('.ei-star').addEventListener('click', function (e) { e.stopPropagation(); m.star = !m.star; render(); });
li.addEventListener('click', function () { if (m.unread) { m.unread = false; render(); } });
list.appendChild(li);
});
unreadEl.textContent = DATA.filter(function (m) { return m.unread; }).length + ' unread';
}
function syncAll() {
var visible = DATA.length, on = Object.keys(checked).filter(function (k) { return checked[k]; }).length;
allBox.checked = on === visible && visible > 0;
allBox.indeterminate = on > 0 && on < visible;
}
allBox.addEventListener('change', function () { DATA.forEach(function (m) { checked[m.id] = allBox.checked; }); allBox.indeterminate = false; render(); });
search.addEventListener('input', render);
render();Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month, 7 days free), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Email Inbox — Mail List with Unread, Star, Select-All and Search

An email inbox list is the dense, interaction-rich list at the heart of any mail or messaging app: unread rows stand out, clicking marks read, stars toggle, a header checkbox selects all, and search filters live. This snippet builds that whole interaction model from a data array, in plain HTML, CSS, and vanilla JavaScript.
Unread emphasis and click-to-read
Unread messages get a bolder sender and subject, a subtle tinted background, and a blue dot; clicking a row marks it read and the emphasis drops away. This read/unread state lives on each message object and drives both the styling and the header's "N unread" counter, so they always agree. It's the core inbox behaviour, reproduced faithfully.
Per-row controls without misfires
Each row carries a checkbox and a star, and both stopPropagation so toggling them doesn't also trigger the row's click-to-read. This event-isolation detail is what stops the classic bug where starring an email accidentally opens it. Stars persist on the message object and re-render in place.
Tri-state select-all
The header checkbox is a proper tri-state control: checked when every message is selected, unchecked when none are, and indeterminate (a dash) when only some are — using the real DOM indeterminate property. Selecting individual rows updates it, and toggling it selects or clears everything, the exact behaviour of Gmail-style bulk selection.
Live search with highlighting
Typing in the search box filters the list by sender and subject as you go, and the matching substring in the subject is wrapped in <b> so you can see why a row matched. An empty-state message appears when nothing matches. Filtering is a view concern over the source array, so selection and read state survive a search.
Data-driven and portable
The entire list renders from a DATA array, so swapping in real messages — or a fetch — is a one-line change, and every interaction mutates that data and re-renders, keeping the UI consistent. It's a compact, dependency-free reference for the inbox list pattern you'd otherwise build piecemeal.
Step by step
How to Use
- 1Paste HTML, CSS, and JSAn inbox renders with unread rows, stars, and a search bar.
- 2Click a messageIt marks read and the unread count updates.
- 3Star and selectToggle a star or a checkbox without opening the row.
- 4Select allThe header checkbox shows a tri-state and bulk-selects.
- 5SearchFilter by sender or subject with live highlighting.
- 6Use your dataReplace the DATA array with real messages or a fetch.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
Each message object has an unread flag. Unread rows get bolder text, a tint, and a dot via a class, and clicking a row sets unread to false and re-renders. The header's "N unread" counter is computed from the same data on every render, so the styling and the count can never drift apart.
The row itself has a click handler that marks the message read (and would open it in a real app). Without stopPropagation, clicking the star or checkbox would bubble up and also trigger that handler — so starring an email would open it. Stopping propagation isolates the per-row controls from the row action, which is the correct, expected behaviour.
The header checkbox reflects the selection: checked when all messages are selected, unchecked when none are, and indeterminate (shown as a dash) when only some are — set via the DOM indeterminate property, which cannot be done in HTML. Toggling individual rows recomputes it, and clicking it selects or clears every row.
No. Search only filters which rows are displayed; it does not modify the underlying DATA array or the selection map. So selected and read states persist through a search, and clearing the query restores the full list intact with those states preserved.
Hold the messages and a selection set in state and render rows from them; each interaction updates state and the framework re-renders. Compute the unread count and the select-all tri-state as derived values, setting the indeterminate property via a ref/directive. Filter for search in render. Tailwind users swap the classes for utilities; the data-driven structure is the same.