Switched to Dia browser. Immediately missed having my bookmarks on the new tab page. Safari does this by default. Chrome does this with any of a hundred extensions. Dia shows you its built-in chat page and offers no opinion on the matter.
So: a Manifest V3 extension. Vanilla JS, no build step, no dependencies. Pin bookmarks as tiles, search across them, organise them into sections, drag to reorder. The kind of thing that should take a weekend and did.
The newtab override that doesn't
Chrome extensions can declare chrome_url_overrides.newtab in the manifest to replace the new tab page. Works everywhere. Except Dia.
{
"chrome_url_overrides": {
"newtab": "newtab.html"
}
}
Dia ignores this entirely. New tabs navigate to chrome://start-page/, an internal URL that isn't documented anywhere I could find. The manifest declaration is silently eaten.
The workaround is a background service worker that intercepts the navigation:
// background.js
const NEWTAB_URL = chrome.runtime.getURL('newtab.html');
chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
if (changeInfo.url && (
changeInfo.url.startsWith('chrome://start-page/') ||
changeInfo.url === 'chrome://newtab/'
)) {
chrome.tabs.update(tabId, { url: NEWTAB_URL });
}
});
Seven lines. Took longer to figure out than to write. The chrome://start-page/ string was discovered by opening DevTools on a new tab and watching where the URL bar pointed. There's no Dia developer documentation that mentions it.
Architecture
Four files, no transpilation, no bundler:
js/storage.js : chrome.storage.sync persistence (bookmarks, sections, settings)
js/bookmarks.js : chrome.bookmarks API wrapper
js/app.js : UI (App, Picker, ContextMenu, Search, Clock)
css/style.css : dark theme
Everything is a plain object with methods. App owns the render loop. Storage talks to chrome.storage.sync so pins and preferences follow you across devices. Bookmarks wraps the Chrome API for searching and tree traversal.
The grid itself is CSS Grid with repeat(auto-fill, minmax(100px, 1fr)). Tiles flow naturally at any width. Favicons come from Chrome's internal _favicon API, which requires the favicon permission in the manifest and returns reasonable 32px icons for any URL.
Sections
Bookmarks are grouped into sections: horizontal dividers with a name label. The default section has no header. Add a new section and you get a ---- NEW SECTION ---- divider with its own + button and its own tile grid beneath it.
Each pinned bookmark stores three fields:
{ id: "bookmark_id", order: 0, sectionId: "default" }
Sections are stored separately:
{ id: "sec_1739801234567", name: "Work", order: 1 }
Deleting a section migrates its bookmarks to default. The migration appends after existing items rather than dumping them at position zero, which was the second approach, after the first one caused bookmarks to interleave unpredictably.
Drag-to-reorder and cross-section moves
Tiles are <a> elements with draggable="true". The HTML5 Drag and Drop API handles reordering within a section and moving tiles between sections.
Within-section reorder is straightforward: splice the dragged item out, splice it back at the target position, re-normalise orders:
const sectionItems = App.pinnedData
.filter(p => (p.sectionId || 'default') === targetSection)
.sort((a, b) => a.order - b.order);
const draggedLocalIdx = sectionItems.findIndex(p => p.id === draggedId);
const targetLocalIdx = sectionItems.findIndex(p => p.id === targetId);
const [movedItem] = sectionItems.splice(draggedLocalIdx, 1);
sectionItems.splice(targetLocalIdx, 0, movedItem);
sectionItems.forEach((p, i) => { p.order = i; });
Cross-section is trickier. The target section's items need to be captured before changing the dragged item's sectionId, otherwise the filter picks it up in both sections:
// Capture BEFORE changing sectionId
const targetSectionItems = App.pinnedData
.filter(p => (p.sectionId || 'default') === targetSection)
.sort((a, b) => a.order - b.order);
const targetLocalIdx = targetSectionItems.findIndex(p => p.id === targetId);
// Now safe to move
dragged.sectionId = targetSection;
targetSectionItems.splice(targetLocalIdx, 0, dragged);
targetSectionItems.forEach((p, i) => { p.order = i; });
One subtlety with <a> elements: the browser's default drag behaviour shows a link preview ghost instead of the tile. Override with setDragImage:
tile.addEventListener('dragstart', (e) => {
const rect = tile.getBoundingClientRect();
e.dataTransfer.setDragImage(tile, e.clientX - rect.left, e.clientY - rect.top);
setTimeout(() => tile.classList.add('dragging'), 0);
});
The setTimeout defers the opacity fade so the drag ghost is captured at full opacity before the CSS kicks in.
The order normalisation bug
This one was invisible for a while. After dragging tiles to reorder, the in-memory order fields update correctly. But chrome.storage.sync stores items in their insertion order. The array position in storage diverges from the order field values.
When a tile is unpinned, removePin re-normalises orders for the remaining items in that section. The original code:
const inSection = pinned.filter(p => (p.sectionId || 'default') === sectionId);
inSection.forEach((p, i) => { p.order = i; });
The filter preserves array order (insertion order), not order field order. After a drag-reorder, array position [2, 0, 1] might map to order fields [0, 1, 2]. Re-normalising by array position silently shuffles the tiles. No error. Just bookmarks in the wrong place next time you open a tab.
Fix: sort before normalising.
const inSection = pinned.filter(p => (p.sectionId || 'default') === sectionId);
inSection.sort((a, b) => a.order - b.order);
inSection.forEach((p, i) => { p.order = i; });
The section deletion collision
Same class of bug, different trigger. When a section is deleted, its bookmarks migrate to the default section. The original migration just reassigned sectionId:
pinned.filter(p => p.sectionId === sectionId)
.forEach(p => { p.sectionId = 'default'; });
The moved bookmarks kept their original order values: 0, 1, 2. The default section already had items at 0, 1, 2. Two sets of bookmarks claiming the same positions. The render loop sorts by order and picks whichever it encounters first. Bookmarks from different sections interleave unpredictably.
Fix: find the highest order in the default section, then append after it.
const defaultItems = pinned.filter(p => (p.sectionId || 'default') === 'default');
const maxOrder = defaultItems.reduce((max, p) => Math.max(max, p.order), -1);
const movedItems = pinned.filter(p => p.sectionId === sectionId)
.sort((a, b) => a.order - b.order);
movedItems.forEach((p, i) => {
p.sectionId = 'default';
p.order = maxOrder + 1 + i;
});
The favicon fallback loop
When a favicon fails to load, an onerror handler generates a fallback SVG: the first letter of the title in a coloured square:
img.onerror = () => {
img.src = this.fallbackFavicon(item.title);
};
If the fallback SVG also fails (malformed data URI, browser quirk, cosmic ray), onerror fires again. New fallback. Fires again. Every tile on the page entering an infinite loop simultaneously.
img.onerror = () => {
img.onerror = null;
img.src = this.fallbackFavicon(item.title);
};
One line. The kind of bug that never triggers until it does, and then it takes down the entire page.
The rest
A grid/list view toggle persisted to settings. A clock with minute-boundary sync. Bookmark search with keyboard navigation and a "pinned" badge for items already on the home page. Right-click context menu with rename, copy URL, open in new tab, and remove. Three search engines (Google, DuckDuckGo, Brave) cycled with a button inside the search bar.
All vanilla. No React, no build step, no node_modules. One CSS file with custom properties. var is never written but const is everywhere. It loads instantly because there's nothing to load.