DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

Building a Gmail Inbox from Scratch: One Array, Folders as Predicates, Undo for Free

An inbox looks like a mountain of features: eight folders, category tabs, search, multi-select with a tri-state header checkbox, hover actions, unread counts, a reading pane, archive, undo, and a keyboard model people build muscle memory around. Build it feature by feature and you get eight arrays that must agree with each other, counters that drift, and an undo that nobody wants to write. Model the data correctly and most of that list stops being work. Here is a working Gmail inbox in vanilla HTML, CSS and JS — no libraries, no images, every icon inline SVG.

One array, not seven

The instinct is to keep an inbox array, a starred array, a trash array. That is the bug factory: star a mail and two lists must agree; archive it and three must. Keep one flat array where every property a view cares about lives on the object itself.

const MESSAGES = [
  { id: 1, from: "Vercel", email: "ship@vercel.com",
    subject: "Deployment ready", snippet: "Build finished in 42s",
    dt: "2026-08-11 09:42",
    unread: true, starred: false, important: true,
    folder: "inbox", category: "primary", snoozed: false, attachments: [] },
  // ...
];
const byId = id => MESSAGES.find(m => m.id === id);
Enter fullscreen mode Exit fullscreen mode

A folder is a predicate

Once the data is flat, a folder is just a function answering "does this message belong here?". Notice Starred is a flag test, not a bucket — so a mail can be in Inbox and Starred at once without existing twice, and un-starring it needs zero cleanup anywhere else.

function matches(m, folder) {
  switch (folder) {
    case "inbox":   return m.folder === "inbox" && !m.snoozed;
    case "starred": return m.starred && m.folder !== "trash" && m.folder !== "spam";
    case "snoozed": return m.snoozed;
    case "sent":    return m.folder === "sent";
    case "drafts":  return m.folder === "drafts";
    case "archive": return m.folder === "archive";
    case "spam":    return m.folder === "spam";
    case "trash":   return m.folder === "trash";
  }
  return false;
}
Enter fullscreen mode Exit fullscreen mode

A new view later costs one case, not a new copy of the data plus a sync path.

The visible list is a filter chain

What you see is a short pipeline: folder, then (inbox only) the category tab, then the search text, then newest first. Writing it as one function means there is exactly one answer to "what is on screen", recomputed every render. No cached page, no stale copy after a mutation.

const state = { folder: "inbox", tab: "primary", query: "",
                sel: new Set(), cursor: 0, open: null };

function visible() {
  let list = MESSAGES.filter(m => matches(m, state.folder));
  if (state.folder === "inbox") list = list.filter(m => m.category === state.tab);
  const q = state.query.trim().toLowerCase();
  if (q) list = list.filter(m =>
    (m.from + " " + m.email + " " + m.subject + " " + m.snippet).toLowerCase().includes(q));
  return list.sort((a, b) => b.dt.localeCompare(a.dt));
}
Enter fullscreen mode Exit fullscreen mode

Unread is data, not a class you toggle

A row is bold and white because m.unread is true — full stop. The renderer composes the class list from the message and the state; handlers only change a field and call render(). The moment a click handler starts writing row.style.fontWeight, you have a second source of truth that will eventually disagree with the first.

const row = el("div", "gm-row"
  + (m.unread ? " unread" : "")
  + (state.sel.has(m.id) ? " picked" : "")
  + (i === state.cursor ? " cursor" : "")
  + (m.id === state.open ? " open" : ""));
row.onclick = () => openMail(m);
Enter fullscreen mode Exit fullscreen mode

Selection is a Set of ids, and select-all is genuinely tri-state

Store ids, never indexes and never DOM nodes — a re-sort, a search or an archive would leave index-based selection pointing at the wrong mail. The header checkbox then has three real states, and "some" is the DOM's indeterminate property, not a third CSS class.

function headerBoxState() {
  const rows = visible();
  const n = rows.filter(m => state.sel.has(m.id)).length;
  return n === 0 ? "none" : n === rows.length ? "all" : "some";
}
box.checked       = headerBoxState() === "all";
box.indeterminate = headerBoxState() === "some";

function prune() {                       // after archive / delete / filter change
  const ok = new Set(visible().map(m => m.id));
  [...state.sel].forEach(id => { if (!ok.has(id)) state.sel.delete(id); });
}
Enter fullscreen mode Exit fullscreen mode

That prune() matters more than it looks: without it an archived mail stays in the selection and gets swept up by the next bulk delete.

Controls inside a clickable row

The whole row opens the mail, but the star, the importance marker and the checkbox live inside it. Without stopPropagation() every star click also opens the conversation — the classic nested-interactive bug. Keep them real <button>s with aria-pressed so they stay keyboard-reachable and announce their state.

star.onclick = (e) => {
  e.stopPropagation();          // the row's handler must not fire
  m.starred = !m.starred;
  render();
};
Enter fullscreen mode Exit fullscreen mode

The hover rail is pure CSS

Gmail hides four actions exactly where the timestamp is and swaps them in on hover. Build the buttons into every row always so they exist in the accessibility tree and the tab order, and let CSS alone decide visibility. Use display:none rather than opacity:0, so an invisible button can never swallow a click.

.gm-date { display: block; }
.gm-acts { display: none; }
.gm-row:hover .gm-date { display: none; }
.gm-row:hover .gm-acts { display: flex; }

/* touch layout has no hover — keep the date, act from the reading pane */
.gm-app.narrow .gm-row:hover .gm-acts { display: none; }
.gm-app.narrow .gm-row:hover .gm-date { display: block; }
Enter fullscreen mode Exit fullscreen mode

Hover-only actions are a real accessibility trap. If the only way to archive is to hover, the feature does not exist on a phone.

Archive writes a field — so Undo is a snapshot

Never splice the array. Archiving sets folder = "archive", and the row vanishes purely because the inbox predicate stopped matching it. The object is untouched and still reachable by id, which is what makes Undo cheap: capture the fields you are about to change, mutate, and hand the Undo button a closure that writes them back. One generic wrapper covers archive, delete, snooze and mark-read — including the bulk case — because they differ only in the mutation passed in.

const KEYS = ["folder", "snoozed", "unread", "starred"];

function act(label, ids, mutate) {
  const before = ids.map(id => {
    const m = byId(id), snap = { m };
    KEYS.forEach(k => snap[k] = m[k]);
    return snap;
  });
  ids.forEach(id => mutate(byId(id)));
  prune();
  render();
  toast(label, () => {                                  // the Undo button
    before.forEach(s => KEYS.forEach(k => s.m[k] = s[k]));
    render();
  });
}

const archive = ids => act("Conversation archived", ids, m => { m.folder = "archive"; });
const trash   = ids => act("Moved to Trash",        ids, m => { m.folder = "trash";   });
Enter fullscreen mode Exit fullscreen mode

This is also how real clients behave: the mutation is optimistic and local, the server call catches up afterwards.

The reading pane is one field and two layouts

Opening a mail sets state.open and flips unread to false. Whether that renders as a split view or a full-screen reader is layout, not state — and the honest way to decide is to measure the component, not the viewport. A ResizeObserver on the root means the same widget works embedded in a wide article, in a sidebar, or on a phone; a viewport media query cannot promise that.

function openMail(m) { m.unread = false; state.open = m.id; render(); }
function closeMail()  { state.open = null; render(); }

new ResizeObserver(([e]) => {
  app.classList.toggle("narrow", e.contentRect.width < 760);
}).observe(app);
Enter fullscreen mode Exit fullscreen mode
.gm-app.reading .gm-listpane        { width: 340px; flex: none; }  /* split   */
.gm-app.narrow.reading .gm-listpane { display: none; }             /* stacked */
Enter fullscreen mode Exit fullscreen mode

Keyboard: a cursor index plus a map

Gmail power users never touch the mouse, and the model is small — add one number to state, a cursor index into the visible list, and every shortcut becomes a one-line entry in a lookup object.

const KEYMAP = {
  j: () => moveCursor(+1),           k: () => moveCursor(-1),
  x: () => toggleSel(atCursor().id), s: () => toggleStar(atCursor()),
  e: () => archive([atCursor().id]), "#": () => trash([atCursor().id]),
  r: () => toggleRead(atCursor()),   b: () => snooze([atCursor().id]),
  Enter: () => openMail(atCursor()), u: closeMail, "/": () => qInput.focus(),
  z: undo, "?": toggleKeySheet
};

document.addEventListener("keydown", e => {
  if (/^(INPUT|TEXTAREA)$/.test(e.target.tagName)) return;      // typing wins
  if (!hot && !app.contains(document.activeElement)) return;    // scoped to us
  const fn = KEYMAP[e.key];
  if (fn) { e.preventDefault(); fn(e); }
});
Enter fullscreen mode Exit fullscreen mode

Two guards do all the safety work. Bail out while the user is typing, so a / in the search box does not archive a mail. And only handle keys while the component is hot — pointer over it or focus inside — so a page with several widgets does not end up fighting over the keyboard. Call preventDefault() only for keys you actually handled.

What you never had to write

The sidebar's unread badges are one filter().length per folder, recomputed on render, so they cannot drift. Search is one more link in the chain and needs no index. The empty state is visible().length === 0, and it can even be honest about itself — the messages still exist, the predicate just stopped matching.

function unreadIn(folder) {
  return MESSAGES.filter(m => matches(m, folder) && m.unread).length;
}
Enter fullscreen mode Exit fullscreen mode

No counter to increment, no cache to invalidate, no sync routine between views. Keep one collection and derive every screen from it, and selection, undo, counts and search stop being features — they become consequences.

Drive the whole thing yourself here: https://dev48v.infy.uk/design/day59-gmail-inbox.html

Top comments (0)