Last active
July 14, 2026 17:35
-
-
Save greggman/2c59103e58b93fd71f60d5c1ab07fecb to your computer and use it in GitHub Desktop.
Expanding Browser Tabs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| :root { | |
| --frame: #dee1e6; /* the frame Chrome paints behind the tabs — strip or side list */ | |
| --toolbar: #ffffff; | |
| --omnibox: #f1f3f4; | |
| --border: rgba(0, 0, 0, 0.10); | |
| --divider: rgba(0, 0, 0, 0.16); | |
| --text: #202124; | |
| --text-dim: #5f6368; | |
| --accent: #1a73e8; | |
| --page: #ffffff; | |
| --tab-active: #ffffff; /* the active tab lifts to the toolbar's color */ | |
| --label-bg: rgba(255, 255, 255, 0.96); | |
| --pop-shadow: 0 8px 28px rgba(0,0,0,0.20), 0 1px 3px rgba(0,0,0,0.12); | |
| --shadow: 0 22px 70px rgba(0, 0, 0, 0.35), 0 4px 14px rgba(0,0,0,0.18); | |
| } | |
| @media (prefers-color-scheme: dark) { | |
| :root { | |
| --frame: #202124; | |
| --toolbar: #35363a; | |
| --omnibox: #202124; | |
| --border: rgba(255, 255, 255, 0.10); | |
| --divider: rgba(255, 255, 255, 0.18); | |
| --text: #e8eaed; | |
| --text-dim: #9aa0a6; | |
| --accent: #8ab4f8; | |
| --page: #202124; | |
| --tab-active: #35363a; | |
| --label-bg: rgba(60, 64, 67, 0.96); | |
| --pop-shadow: 0 8px 28px rgba(0,0,0,0.5), 0 1px 3px rgba(0,0,0,0.4); | |
| } | |
| } | |
| * { box-sizing: border-box; } | |
| html, body { | |
| margin: 0; | |
| height: 100%; | |
| font-family: system-ui, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; | |
| -webkit-font-smoothing: antialiased; | |
| } | |
| body { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 14px; | |
| min-height: 100vh; | |
| padding: 20px; | |
| background: | |
| radial-gradient(1200px 800px at 20% 10%, #6a5acd55, transparent), | |
| radial-gradient(1000px 700px at 90% 90%, #ff7eb355, transparent), | |
| linear-gradient(135deg, #3a3a5c, #1a1a2e); | |
| color: var(--text); | |
| } | |
| /* ---------- The one control that lives OUTSIDE the browser ---------- | |
| Chrome shows one or the other: the tab strip across the top, or the | |
| vertical tab list down the side. Never both. */ | |
| .controls { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| color: #fff; | |
| font-size: 13px; | |
| padding: 8px 14px; | |
| border-radius: 20px; | |
| background: rgba(255, 255, 255, 0.12); | |
| border: 1px solid rgba(255, 255, 255, 0.18); | |
| backdrop-filter: blur(6px); | |
| cursor: pointer; | |
| user-select: none; | |
| flex-shrink: 0; | |
| } | |
| .controls input { accent-color: #8ab4f8; margin: 0; cursor: pointer; } | |
| /* ---------- Window ---------- | |
| A row: the vertical tab list (when shown) is a full-height column to the | |
| LEFT of everything, toolbar included. Everything else stacks to its right. */ | |
| .window { | |
| width: min(1440px, 100%); | |
| height: min(900px, 88vh); | |
| background: var(--frame); | |
| border-radius: 12px; | |
| box-shadow: var(--shadow); | |
| display: flex; /* row: [sidebar][main] */ | |
| border: 0.5px solid rgba(0,0,0,0.2); | |
| overflow: hidden; /* backstop: magnified icons can never escape the window */ | |
| } | |
| .main { | |
| flex: 1; | |
| min-width: 0; | |
| display: flex; | |
| flex-direction: column; /* column: [tab strip][toolbar][page] */ | |
| } | |
| .traffic { display: flex; gap: 8px; align-items: center; flex-shrink: 0; } | |
| .light { width: 12px; height: 12px; border-radius: 50%; } | |
| .light.red { background: #ff5f57; } | |
| .light.yellow { background: #febc2e; } | |
| .light.green { background: #28c840; } | |
| svg { display: block; } | |
| /* ================= Tab strip: a horizontal Dock ================= | |
| Chrome favicons are 16px, so these icons rest at 16px and the strip is only | |
| as tall as it needs to be. ONLY the icons grow, exactly like the Dock — no | |
| tab shows its own title; the name of the single icon under the pointer | |
| appears in one floating label. */ | |
| .tabstrip { | |
| position: relative; /* icons are absolutely placed by JS */ | |
| height: 36px; /* hugs the RESTING icons — no dead space above them */ | |
| background: var(--frame); | |
| flex-shrink: 0; | |
| overflow: visible; /* magnified icons grow DOWN out of the strip, over the toolbar — | |
| exactly how the Dock grows out over your windows */ | |
| z-index: 30; | |
| } | |
| .tabstrip .traffic { position: absolute; left: 16px; top: 50%; transform: translateY(-50%); } | |
| .newtab { | |
| position: absolute; | |
| right: 10px; top: 50%; | |
| transform: translateY(-50%); | |
| width: 24px; height: 24px; | |
| display: grid; place-items: center; | |
| border-radius: 50%; | |
| color: var(--text-dim); | |
| background: var(--frame); | |
| cursor: default; | |
| z-index: 2; | |
| } | |
| .newtab:hover { background: rgba(127,127,127,0.20); } | |
| .tab { | |
| position: absolute; | |
| left: 0; /* left + width driven by JS */ | |
| top: 10px; /* icons hang from a common baseline and grow downward */ | |
| display: flex; | |
| align-items: flex-start; | |
| justify-content: center; | |
| cursor: default; | |
| will-change: left, width; | |
| } | |
| /* The active tab is a Chrome tab shape drawn behind its icon — it grows with | |
| the icon, and at rest it reads as the familiar rounded-top tab running into | |
| the toolbar. */ | |
| .tab.active::before { | |
| content: ""; | |
| position: absolute; | |
| left: -5px; right: -5px; top: -8px; | |
| bottom: -10px; /* runs into the toolbar, so the two colors merge */ | |
| background: var(--tab-active); | |
| border-radius: 8px 8px 0 0; | |
| box-shadow: 0 1px 2px rgba(0,0,0,0.10); | |
| z-index: -1; | |
| } | |
| /* Chrome's hairline seam between adjacent tabs. It sits on the cell's right | |
| edge and keeps its RESTING size however big the icon swells — a divider that | |
| magnified along with the icon would read as a rule, not a seam. */ | |
| .tab::after { | |
| content: ""; | |
| position: absolute; | |
| right: 0; top: 1px; | |
| width: 1px; height: 14px; | |
| background: var(--divider); | |
| } | |
| /* No seam beside the active tab: its tab shape already ends the run. */ | |
| .tab.active::after, .tab.pre-active::after, .tab.last::after { display: none; } | |
| .tab .favicon { | |
| border-radius: 22%; | |
| display: grid; | |
| place-items: center; | |
| color: #fff; | |
| font-weight: 700; | |
| box-shadow: 0 1px 4px rgba(0,0,0,0.25); | |
| will-change: width, height, font-size; | |
| } | |
| /* The one floating name label, parked under whichever icon the pointer is on */ | |
| .tab-label { | |
| position: absolute; | |
| top: 0; /* top + left driven by JS: it rides under the hovered icon */ | |
| left: 0; | |
| transform: translateX(-50%); | |
| padding: 4px 10px; | |
| border-radius: 4px; | |
| background: var(--label-bg); | |
| border: 0.5px solid var(--border); | |
| box-shadow: 0 2px 8px rgba(0,0,0,0.20); | |
| font-size: 12px; | |
| color: var(--text); | |
| white-space: nowrap; | |
| pointer-events: none; | |
| opacity: 0; | |
| z-index: 3; | |
| will-change: left, opacity; | |
| } | |
| /* ---------- Toolbar: back, forward, reload, home, omnibox, profile, menu ---------- */ | |
| .toolbar { | |
| display: flex; | |
| align-items: center; | |
| gap: 4px; | |
| padding: 0 8px; | |
| height: 44px; | |
| background: var(--toolbar); | |
| flex-shrink: 0; | |
| position: relative; | |
| z-index: 10; | |
| } | |
| .ico { | |
| width: 30px; height: 30px; | |
| display: grid; place-items: center; | |
| border-radius: 50%; | |
| color: var(--text-dim); | |
| flex-shrink: 0; | |
| cursor: default; | |
| } | |
| .ico:hover { background: rgba(127,127,127,0.18); } | |
| /* Chrome's omnibox: a full-width pill, not a centered capsule */ | |
| .addr { | |
| flex: 1; | |
| height: 30px; | |
| background: var(--omnibox); | |
| border-radius: 16px; | |
| display: flex; | |
| align-items: center; | |
| gap: 9px; | |
| padding: 0 14px; | |
| color: var(--text); | |
| font-size: 13px; | |
| margin: 0 8px; | |
| min-width: 0; | |
| } | |
| .addr .url { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| .addr .url .dim { color: var(--text-dim); } | |
| .avatar { | |
| width: 24px; height: 24px; border-radius: 50%; | |
| background: linear-gradient(135deg, #4285f4, #34a853); | |
| color: #fff; font-size: 11px; font-weight: 700; | |
| display: grid; place-items: center; | |
| flex-shrink: 0; | |
| margin: 0 4px; | |
| } | |
| /* ---------- Vertical tab list: a full-height column, left of the toolbar ---------- */ | |
| .sidebar { | |
| width: 250px; | |
| flex-shrink: 0; | |
| background: var(--frame); | |
| border-right: 1px solid var(--divider); /* the seam against the rest of the browser */ | |
| padding: 8px; | |
| position: relative; | |
| z-index: 15; /* above the toolbar, so rows can pop out over it too */ | |
| border-radius: 12px 0 0 12px; | |
| display: flex; | |
| flex-direction: column; /* so the tab list can take the leftover height */ | |
| } | |
| /* With no tab strip there is no title bar, so the window controls live at the | |
| top of the side list — which is where Chrome puts them. */ | |
| .sb-head { | |
| display: flex; | |
| align-items: center; | |
| height: 26px; | |
| padding: 0 8px; | |
| flex-shrink: 0; | |
| } | |
| /* The side list's own toolbar: collapse, group, search — Chrome's defaults. */ | |
| .sb-toolbar { | |
| display: flex; | |
| align-items: center; | |
| gap: 2px; | |
| padding: 6px 4px 4px; | |
| flex-shrink: 0; | |
| } | |
| .sb-toolbar .ico { width: 26px; height: 26px; border-radius: 6px; } | |
| .sb-toolbar .spacer { flex: 1; } | |
| /* The list is a vertical Dock. Here there IS room for the names, so each row | |
| keeps its title inline and simply widens to fit it. It takes whatever height | |
| is left; if its rows don't fit, it wheel-scrolls rather than growing the | |
| sidebar. Rows are clipped vertically so they stay in their own lane — | |
| but NOT horizontally, so a magnified row still widens out over the page. */ | |
| .sb-list { | |
| position: relative; | |
| flex: 1 1 0; | |
| min-height: 0; | |
| margin-top: 2px; | |
| overflow-y: clip; /* `clip` (not `hidden`) so overflow-x can stay visible */ | |
| overflow-x: visible; | |
| overflow-clip-margin: 6px; /* just enough for a popped row's shadow */ | |
| } | |
| .sb-item { | |
| position: absolute; | |
| left: 0; top: 0; | |
| display: flex; | |
| align-items: center; | |
| height: 22px; | |
| width: 234px; | |
| padding: 0 8px; | |
| border-radius: 8px; | |
| color: var(--text); | |
| cursor: default; | |
| overflow: hidden; | |
| white-space: nowrap; | |
| background: var(--frame); | |
| will-change: top, height, width; | |
| } | |
| .sb-item.popped { | |
| background: var(--toolbar); | |
| box-shadow: var(--pop-shadow); | |
| z-index: 10; | |
| } | |
| /* Chrome marks the current tab with a tinted row, not a saturated one */ | |
| .sb-item.active { background: color-mix(in srgb, var(--accent) 16%, var(--frame)); } | |
| .sb-item.active.popped { background: color-mix(in srgb, var(--accent) 16%, var(--toolbar)); } | |
| .sb-item .favicon { | |
| border-radius: 22%; | |
| display: grid; place-items: center; | |
| color: #fff; font-weight: 700; | |
| flex-shrink: 0; | |
| will-change: width, height, font-size; | |
| } | |
| .sb-item .title { | |
| font-size: 13px; | |
| margin-left: 8px; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| flex: 1; | |
| } | |
| /* ---------- Content ---------- */ | |
| .content { flex: 1; overflow: auto; background: var(--page); } | |
| .hero { padding: 56px 60px; display: flex; flex-direction: column; gap: 18px; align-items: flex-start; } | |
| .hero h1 { margin: 0; font-size: 30px; font-weight: 500; } | |
| .hero p { margin: 0; color: var(--text-dim); font-size: 15px; max-width: 640px; line-height: 1.55; } | |
| .cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; width: 100%; margin-top: 12px; } | |
| .card { height: 120px; border-radius: 12px; | |
| background: linear-gradient(135deg, rgba(127,127,127,0.10), rgba(127,127,127,0.04)); | |
| border: 0.5px solid var(--border); } | |
| .hint { | |
| font-size: 13px; color: var(--text-dim); | |
| display: inline-flex; align-items: center; gap: 8px; | |
| padding: 8px 14px; border-radius: 20px; background: rgba(127,127,127,0.12); | |
| } | |
| /* ---------- The two modes: one or the other, never both ---------- */ | |
| .window.vertical .tabstrip { display: none; } | |
| .window:not(.vertical) .sidebar { display: none; } | |
| /* offscreen ruler for measuring natural title widths */ | |
| #ruler { | |
| position: absolute; visibility: hidden; white-space: nowrap; | |
| font-size: 13px; left: -9999px; top: -9999px; | |
| font-family: system-ui, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <span id="ruler"></span> | |
| <label class="controls"> | |
| <input type="checkbox" id="verticalTabs"> | |
| Vertical tabs | |
| </label> | |
| <div class="window" id="window"> | |
| <div class="sidebar" id="sidebar"> | |
| <div class="sb-head"> | |
| <div class="traffic"> | |
| <span class="light red"></span><span class="light yellow"></span><span class="light green"></span> | |
| </div> | |
| </div> | |
| <div class="sb-toolbar"> | |
| <span class="ico" title="Collapse side panel"> | |
| <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><rect x="1.5" y="2.5" width="13" height="11" rx="2"/><line x1="10" y1="2.5" x2="10" y2="13.5"/><path d="M6.5 6.2 4.5 8l2 1.8"/></svg> | |
| </span> | |
| <span class="spacer"></span> | |
| <span class="ico" title="Group tabs"> | |
| <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M1.8 5.2V4a1.5 1.5 0 0 1 1.5-1.5h2.2l1.2 1.4h4A1.5 1.5 0 0 1 12.2 5.4v.3"/><rect x="1.8" y="5.7" width="12.4" height="7.8" rx="1.5"/></svg> | |
| </span> | |
| <span class="ico" title="Search tabs"> | |
| <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="7" cy="7" r="4.5"/><path d="m10.4 10.4 3.1 3.1"/></svg> | |
| </span> | |
| <span class="ico" title="New tab"> | |
| <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg> | |
| </span> | |
| </div> | |
| <div class="sb-list" id="sbTabs"></div> | |
| </div> | |
| <div class="main"> | |
| <div class="tabstrip" id="tabstrip"> | |
| <div class="traffic"> | |
| <span class="light red"></span><span class="light yellow"></span><span class="light green"></span> | |
| </div> | |
| <div class="tab-label" id="tabLabel"></div> | |
| <div class="newtab" title="New Tab"> | |
| <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg> | |
| </div> | |
| </div> | |
| <div class="toolbar"> | |
| <span class="ico"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M13 8H3M7 4 3 8l4 4"/></svg></span> | |
| <span class="ico"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8h10M9 4l4 4-4 4"/></svg></span> | |
| <span class="ico"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M13.5 8a5.5 5.5 0 1 1-1.7-3.9"/><path d="M13.5 2v3h-3"/></svg></span> | |
| <span class="ico"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 7.2 8 2.5l5.5 4.7"/><path d="M4 6.8v6.7h8V6.8"/></svg></span> | |
| <div class="addr"> | |
| <svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><rect x="3" y="7" width="10" height="7" rx="1.5"/><path d="M5 7V5a3 3 0 0 1 6 0v2"/></svg> | |
| <span class="url"><span class="dim">https://</span>developer.chrome.com<span class="dim">/blog/tab-magnification</span></span> | |
| </div> | |
| <span class="avatar">G</span> | |
| <span class="ico"><svg width="4" height="16" viewBox="0 0 4 16" fill="currentColor"><circle cx="2" cy="3" r="1.6"/><circle cx="2" cy="8" r="1.6"/><circle cx="2" cy="13" r="1.6"/></svg></span> | |
| </div> | |
| <div class="content"> | |
| <div class="hero"> | |
| <h1>Tab magnification lands in Chrome</h1> | |
| <p>Sixty tabs, and not a favicon squeezed out of existence. Sweep the mouse across the tab strip: only the icons grow, up to 4× — biggest under the pointer, tapering off to each side — and the name of the one icon you are actually on appears below it. Tick <b>Vertical tabs</b> above the window to swap the strip for the side list, where there <i>is</i> room for names, so rows keep their titles inline and widen to fit. Either way, the wheel scrolls the tabs that run off the end.</p> | |
| <div class="hint"> | |
| <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><path d="M8 1v3M8 12v3M1 8h3M12 8h3"/><circle cx="8" cy="8" r="3"/></svg> | |
| Hover the tabs to magnify them | |
| </div> | |
| <div class="cards"><div class="card"></div><div class="card"></div><div class="card"></div></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ---- 60 tabs, with real-world-length page titles ---- | |
| const SITES = [ | |
| { title: "Tab magnification lands in Chrome | Chrome for Developers", short: "C", color: "#4285f4" }, | |
| { title: "css - How do I vertically center text with CSS? - Stack Overflow", short: "S", color: "#f48024" }, | |
| { title: "GitHub - facebook/react: The library for web and native user interfaces", short: "GH", color: "#24292f" }, | |
| { title: "Element.getBoundingClientRect() - Web APIs | MDN", short: "M", color: "#000000" }, | |
| { title: "Dock (macOS) - Wikipedia, the free encyclopedia", short: "W", color: "#636466" }, | |
| { title: "Show HN: I rebuilt the macOS Dock in 200 lines of CSS | Hacker News", short: "Y", color: "#ff6600" }, | |
| { title: "The 10 best espresso machines of 2026, tested and reviewed - The Verge", short: "V", color: "#5200ff" }, | |
| { title: "Everything announced at Google I/O in 12 minutes - YouTube", short: "▶", color: "#ff0000" }, | |
| { title: "r/webdev - What's the most over-engineered thing you've ever shipped?", short: "r", color: "#ff4500" }, | |
| { title: "Inbox (1,284) - claude@greggman.com - Gmail", short: "M", color: "#ea4335" }, | |
| { title: "requestAnimationFrame is not a timing function — and other lies", short: "B", color: "#0f9d58" }, | |
| { title: "Amazon.com: USB-C to USB-C Cable 100W 6ft (2-Pack) : Electronics", short: "a", color: "#ff9900" }, | |
| { title: "Figma — Chrome Redesign / Tab Strip Explorations (v4, final, FINAL)", short: "F", color: "#a259ff" }, | |
| { title: "Flight AA184 SFO → LHR — Check in for your trip | American Airlines", short: "AA", color: "#0078d2" }, | |
| { title: "Docs: dock-tabs-chrome — Notion", short: "N", color: "#111111" }, | |
| { title: "The Cheesecake Factory menu, prices and locations - DoorDash", short: "D", color: "#eb1700" }, | |
| { title: "Zoom Meeting — Weekly Design Sync (recurring) — Join from browser", short: "Z", color: "#2d8cff" }, | |
| { title: "Cubic-bezier.com — a better tool for cubic-bezier easing curves", short: "C", color: "#f24e1e" }, | |
| { title: "Linear — ENG-4471: Tab magnification jitters at high refresh rates", short: "L", color: "#5e6ad2" }, | |
| { title: "Slack | #design-systems | Anthropic", short: "#", color: "#4a154b" }, | |
| { title: "Can I use... CSS overflow: clip? | Browser support tables", short: "?", color: "#2c7cbd" }, | |
| { title: "WebGPU Fundamentals — Inter-stage variables and interpolation", short: "W", color: "#7a3ef0" }, | |
| { title: "Google Docs — Q3 Roadmap (Draft, do not circulate)", short: "≡", color: "#4285f4" }, | |
| { title: "Google Sheets — Espresso machine comparison (17 columns)", short: "▦", color: "#0f9d58" }, | |
| { title: "Google Calendar — Week of July 13, 2026", short: "31", color: "#4285f4" }, | |
| { title: "Chrome DevTools — Performance panel: what the flame chart is telling you", short: "D", color: "#0b57d0" }, | |
| { title: "npm — install size of left-pad, and why nobody learned anything", short: "n", color: "#cb3837" }, | |
| { title: "Twitch — someone is speedrunning a spreadsheet, 4.2k viewers", short: "T", color: "#9146ff" }, | |
| { title: "Netflix — Continue watching: Episode 4 (you fell asleep, again)", short: "N", color: "#e50914" }, | |
| { title: "Spotify — Deep Focus / Instrumental Study (247 songs, 14 hr)", short: "♪", color: "#1db954" }, | |
| { title: "Bug 1874321 — overflow-y: clip forces overflow-x to auto | Bugzilla", short: "B", color: "#ff9500" }, | |
| { title: "WHATWG HTML Standard — 4.10 Forms (you are on section 4.10.5.1.2)", short: "H", color: "#e34f26" }, | |
| { title: "CSS Tricks — A complete guide to Flexbox (bookmarked since 2019)", short: "T", color: "#ff7a18" }, | |
| { title: "Zillow — 3 bd, 2 ba, definitely-not-haunted Victorian, $1.4M", short: "Z", color: "#006aff" }, | |
| { title: "REI — Ultralight tent, 2-person, 981 g (23 reviews, 4.6 stars)", short: "R", color: "#4c8b2b" }, | |
| { title: "Wirecutter — The best USB-C hub, after 62 hours of testing", short: "W", color: "#000000" }, | |
| { title: "Apple Support — Why is my battery at 87% health after 9 months?", short: "", color: "#333333" }, | |
| { title: "Home Assistant — Living room lights are still, somehow, unavailable", short: "H", color: "#41bdf5" }, | |
| { title: "Cloudflare Dashboard — DNS records for greggman.com", short: "C", color: "#f38020" }, | |
| { title: "AWS Console — us-east-1 — Billing (do not look, it is fine)", short: "A", color: "#ff9900" }, | |
| { title: "Vercel — Deployment failed: Build exceeded maximum duration", short: "▲", color: "#000000" }, | |
| { title: "Datadog — p99 latency spiked at 03:14, nobody was awake", short: "D", color: "#632ca6" }, | |
| { title: "Jira — DESIGN-882: Make the tabs 2px taller (blocked, 14 comments)", short: "J", color: "#0052cc" }, | |
| { title: "Confluence — Meeting notes: the meeting about the other meeting", short: "C", color: "#0052cc" }, | |
| { title: "Wikipedia — Fitts's law - Wikipedia", short: "W", color: "#636466" }, | |
| { title: "Wikipedia — List of common misconceptions (3 hours later)", short: "W", color: "#636466" }, | |
| { title: "arXiv — 2601.04412 — Attention Is Still, Apparently, All You Need", short: "χ", color: "#b31b1b" }, | |
| { title: "Claude — Rewrite the tab strip mockup to look like Chrome", short: "✳", color: "#d97757" }, | |
| { title: "ChatGPT — no you are not in trouble, this is research", short: "◎", color: "#10a37f" }, | |
| { title: "Discord | #general | 47 unread mentions", short: "D", color: "#5865f2" }, | |
| { title: "LinkedIn — 12 people viewed your profile (all recruiters)", short: "in", color: "#0a66c2" }, | |
| { title: "Bluesky — everyone is posting about tab bars today", short: "B", color: "#0085ff" }, | |
| { title: "Mastodon — a very long thread about, again, tab bars", short: "m", color: "#6364ff" }, | |
| { title: "Google Maps — Directions to the good coffee place (11 min walk)", short: "◈", color: "#34a853" }, | |
| { title: "OpenTable — Reservation for 2 at 7:30 PM, Saturday", short: "O", color: "#da3743" }, | |
| { title: "United — Change flight — Fee: $0. Fare difference: $612.", short: "U", color: "#002244" }, | |
| { title: "Airbnb — Cabin with a hot tub and, allegedly, wifi", short: "A", color: "#ff5a5f" }, | |
| { title: "Strava — You are 4th on this segment (you were 3rd yesterday)", short: "S", color: "#fc4c02" }, | |
| { title: "Duolingo — Your 219-day streak is in danger, Gregg", short: "D", color: "#58cc02" }, | |
| { title: "about:blank", short: "", color: "#9aa0a6" }, | |
| ]; | |
| const ACTIVE_INDEX = 0; | |
| const ruler = document.getElementById("ruler"); | |
| function textWidth(str) { | |
| ruler.textContent = str; | |
| return Math.ceil(ruler.getBoundingClientRect().width); | |
| } | |
| // ============================================================ | |
| // Dock layout engine (1-D — drives both axes) | |
| // | |
| // Two rules make this behave like the real macOS Dock: | |
| // | |
| // 1. SIZE is a function of each item's distance from the cursor measured | |
| // in the RESTING layout. Never in the live layout — that feeds back on | |
| // itself (item grows -> its center moves -> it grows differently). | |
| // | |
| // 2. POSITION is anchored, not re-flowed. The item the pointer is over | |
| // stays pinned under the pointer, and every other item is chained | |
| // outward from it — items before it get pushed back, items after it get | |
| // pushed forward. A naive left-to-right reflow instead shoves the | |
| // hovered item out from under the cursor, which the Dock never does. | |
| // ============================================================ | |
| // Smooth cosine bump: 1 at the cursor, 0 at `spread` away. | |
| function falloff(dist, spread) { | |
| if (dist >= spread) return 0; | |
| return 0.5 * (1 + Math.cos((dist / spread) * Math.PI)); | |
| } | |
| function dockLayout(cursor, base, fulls, spread, pad, intensity) { | |
| const n = fulls.length; | |
| const sizes = new Array(n); | |
| const factors = new Array(n); | |
| // --- Rule 1: sizes come from the RESTING geometry --- | |
| for (let i = 0; i < n; i++) { | |
| const restCenter = pad + i * base + base / 2; | |
| const f = falloff(Math.abs(cursor - restCenter), spread) * intensity; | |
| factors[i] = f; | |
| sizes[i] = base + (fulls[i] - base) * f; | |
| } | |
| // --- Rule 2: pin the hovered item under the cursor, chain outward --- | |
| const raw = (cursor - pad) / base; | |
| const anchor = Math.min(n - 1, Math.max(0, Math.floor(raw))); | |
| const t = raw - anchor; // outside 0..1 past the ends; extrapolating there is fine | |
| const offsets = new Array(n); | |
| offsets[anchor] = cursor - t * sizes[anchor]; // cursor keeps the same fraction across the pinned item | |
| for (let i = anchor - 1; i >= 0; i--) offsets[i] = offsets[i + 1] - sizes[i]; // pushed back | |
| for (let i = anchor + 1; i < n; i++) offsets[i] = offsets[i - 1] + sizes[i - 1]; // pushed forward | |
| return { sizes, offsets, factors }; | |
| } | |
| // ---- The ramp: magnification tracks the cursor 1:1 while it moves, but | |
| // eases in on arrival and out on departure rather than snapping. | |
| // Also owns `scroll`, which is simply an offset applied to the RESTING | |
| // layout — so magnification and anchoring follow it for free. ---- | |
| function makeDock(render) { | |
| let cursor = -9999, intensity = 0, target = 0, raf = null; | |
| let scroll = 0, scrollMax = 0; | |
| function frame() { | |
| intensity += (target - intensity) * 0.28; | |
| if (Math.abs(target - intensity) < 0.002) intensity = target; | |
| render(cursor, intensity, scroll); | |
| raf = intensity !== target ? requestAnimationFrame(frame) : null; // settled: stop; next event restarts | |
| } | |
| const kick = () => { if (raf === null) raf = requestAnimationFrame(frame); }; | |
| return { | |
| move(pos) { cursor = pos; target = 1; kick(); }, | |
| leave() { target = 0; kick(); }, | |
| reset() { render(-9999, 0, scroll); }, | |
| setScrollMax(m) { | |
| scrollMax = Math.max(0, m); | |
| scroll = Math.min(scroll, scrollMax); | |
| }, | |
| wheel(delta) { | |
| if (scrollMax <= 0) return false; | |
| const next = Math.max(0, Math.min(scrollMax, scroll + delta)); | |
| if (next === scroll) return false; | |
| scroll = next; | |
| kick(); | |
| return true; | |
| }, | |
| }; | |
| } | |
| // ================= Horizontal Dock: the tab strip ================= | |
| // Icon-only, exactly like the Dock, and Chrome-sized: a favicon is 16px, so | |
| // that is the resting size. The item's SIZE is its icon's cell — titles play | |
| // no part in the layout at all; they live in one floating label. | |
| const TAB_ICON = 16; // resting icon size — Chrome's favicon | |
| const TAB_ICON_MAX = TAB_ICON * 4; // 64px under the pointer (3x of 16px is still tiny) | |
| const TAB_GAP = 14; // breathing room around each icon | |
| const TAB_BASE = TAB_ICON + TAB_GAP; // 30px per resting tab | |
| const TAB_FULL = TAB_ICON_MAX + TAB_GAP; | |
| const TAB_SPREAD = 110; | |
| const TAB_TOP = 10; // .tab's top in CSS — the label hangs off this | |
| const win = document.getElementById("window"); | |
| const tabstrip = document.getElementById("tabstrip"); | |
| const tabLabel = document.getElementById("tabLabel"); | |
| const tabEls = [], tabIcons = []; | |
| const tabFulls = SITES.map(() => TAB_FULL); // uniform: every icon magnifies by the same amount | |
| const TAB_LEFT = 82; // the traffic lights own the left end of the strip | |
| const TAB_RIGHT = 44; // the "+" owns the right end | |
| const TAB_REST = SITES.length * TAB_BASE; // width of the whole row at rest | |
| // The strip's usable track sits between the traffic lights and the "+". When | |
| // the row fits that track it is centered in it, like the Dock on a screen — | |
| // the free space either side is what icons get pushed out into. When it does | |
| // not fit (60 tabs rarely will), the row pins to the left of the track and | |
| // `scroll` slides it, so you can wheel to the icons that are off the end. | |
| function tabTrack() { return tabstrip.clientWidth - TAB_LEFT - TAB_RIGHT; } | |
| function tabPad(scroll) { | |
| const track = tabTrack(); | |
| return TAB_REST <= track | |
| ? TAB_LEFT + (track - TAB_REST) / 2 | |
| : TAB_LEFT - scroll; | |
| } | |
| function tabScrollMax() { return TAB_REST - tabTrack(); } | |
| SITES.forEach((s, i) => { | |
| const tab = document.createElement("div"); | |
| tab.className = "tab" | |
| + (i === ACTIVE_INDEX ? " active" : "") | |
| + (i === ACTIVE_INDEX - 1 ? " pre-active" : "") // the seam before the active tab is redundant | |
| + (i === SITES.length - 1 ? " last" : ""); | |
| tab.innerHTML = '<span class="favicon" style="background:' + s.color + '">' + s.short + '</span>'; | |
| tabstrip.appendChild(tab); | |
| tabEls.push(tab); | |
| tabIcons.push(tab.querySelector(".favicon")); | |
| }); | |
| function renderTabs(cursorX, intensity, scroll) { | |
| // Deliberately unclamped horizontally: `scroll` is already bounded in | |
| // wheel(), and clamping would shove the row sideways to fit, tearing the | |
| // pinned icon out from under the cursor — the exact thing the anchor exists | |
| // to prevent. The window's overflow is the backstop. | |
| const { sizes, offsets, factors } = dockLayout(cursorX, TAB_BASE, tabFulls, TAB_SPREAD, tabPad(scroll), intensity); | |
| let best = -1, bestF = 0, bestIcon = 0; | |
| for (let i = 0; i < tabEls.length; i++) { | |
| const cell = sizes[i]; | |
| const icon = cell - TAB_GAP; // the icon IS the magnified thing | |
| tabEls[i].style.left = offsets[i].toFixed(2) + "px"; | |
| tabEls[i].style.width = cell.toFixed(2) + "px"; | |
| const ic = tabIcons[i]; | |
| ic.style.width = icon.toFixed(2) + "px"; | |
| ic.style.height = icon.toFixed(2) + "px"; | |
| ic.style.fontSize = (icon * 0.42).toFixed(2) + "px"; | |
| if (factors[i] > bestF) { bestF = factors[i]; best = i; bestIcon = icon; } | |
| } | |
| // One label, riding just under whichever icon the pointer is actually on. | |
| if (best >= 0 && bestF > 0.05) { | |
| tabLabel.textContent = SITES[best].title; | |
| tabLabel.style.left = (offsets[best] + sizes[best] / 2).toFixed(2) + "px"; | |
| tabLabel.style.top = (TAB_TOP + bestIcon + 8).toFixed(2) + "px"; | |
| tabLabel.style.opacity = Math.min(1, (bestF - 0.05) / 0.35).toFixed(3); | |
| } else { | |
| tabLabel.style.opacity = "0"; | |
| } | |
| } | |
| // ================= Vertical Dock: the side tab list ================= | |
| // There IS room for names here, so rows keep their titles inline and widen to | |
| // fit them — while the icon still swells like the Dock's. | |
| const SB_ICON = 16; // the same 16px favicon as the strip | |
| const SB_ICON_MAX = SB_ICON * 4; // 64px | |
| const SB_BASE_H = 26; | |
| const SB_MAX_H = SB_ICON_MAX + 8; | |
| const SB_BASE_W = 234; | |
| const SB_SPREAD = 90; | |
| const SB_CHROME = 8 + 8 + 12; // padL + gap + padR (icon width is added live) | |
| const sbTabs = document.getElementById("sbTabs"); | |
| const sbEls = [], sbIcons = [], sbFullW = []; | |
| const sbFullH = SITES.map(() => SB_MAX_H); // the same magnified height for every row... | |
| SITES.forEach((s, i) => { | |
| const el = document.createElement("div"); | |
| el.className = "sb-item" + (i === ACTIVE_INDEX ? " active" : ""); | |
| el.innerHTML = | |
| '<span class="favicon" style="background:' + s.color + '">' + s.short + '</span>' + | |
| '<span class="title">' + s.title + '</span>'; | |
| sbTabs.appendChild(el); | |
| sbEls.push(el); | |
| sbIcons.push(el.querySelector(".favicon")); | |
| sbFullW.push(Math.max(SB_BASE_W, SB_CHROME + SB_ICON_MAX + textWidth(s.title))); // ...but only as wide as ITS title | |
| }); | |
| const SB_REST_H = SITES.length * SB_BASE_H; // if the rows do not fit, the list wheel-scrolls | |
| function renderSidebar(cursorY, intensity, scroll) { | |
| const { sizes, offsets, factors } = dockLayout(cursorY, SB_BASE_H, sbFullH, SB_SPREAD, -scroll, intensity); | |
| const n = sbEls.length; | |
| const H = sbTabs.clientHeight; | |
| // The tab strip may grow out over the toolbar — that is the Dock growing out | |
| // over your windows. The side list may NOT: it is a column with the page | |
| // beside it and the sidebar's own toolbar above it, so the row run stays in | |
| // its lane. Rather than clamp each row (which would let the run overlap | |
| // itself), slide the WHOLE run by one shift, which preserves the spacing the | |
| // dock just computed. The pointer loses its 1:1 grip on the anchored row | |
| // only at the very ends of the list, which is exactly what the real Dock | |
| // does against a screen edge. | |
| const start = offsets[0]; | |
| const end = offsets[n - 1] + sizes[n - 1]; | |
| let shift = 0; | |
| if (end - start <= H) { | |
| // The magnified run fits: keep it inside the box. | |
| if (start < 0) shift = -start; | |
| else if (end > H) shift = H - end; | |
| } else { | |
| // It over-fills the box: keep it COVERING the box, so no gap opens at | |
| // either end and there is always a row under the pointer. | |
| if (start > 0) shift = -start; | |
| else if (end < H) shift = H - end; | |
| } | |
| for (let i = 0; i < n; i++) { | |
| const el = sbEls[i], f = factors[i]; | |
| const icon = SB_ICON + (SB_ICON_MAX - SB_ICON) * f; | |
| el.style.top = (offsets[i] + shift).toFixed(2) + "px"; | |
| el.style.height = sizes[i].toFixed(2) + "px"; | |
| el.style.width = (SB_BASE_W + (sbFullW[i] - SB_BASE_W) * f).toFixed(2) + "px"; | |
| const ic = sbIcons[i]; | |
| ic.style.width = icon.toFixed(2) + "px"; | |
| ic.style.height = icon.toFixed(2) + "px"; | |
| ic.style.fontSize = (icon * 0.45).toFixed(2) + "px"; | |
| el.classList.toggle("popped", f > 0.02); | |
| } | |
| } | |
| const tabDock = makeDock(renderTabs); | |
| tabstrip.addEventListener("mousemove", (e) => tabDock.move(e.clientX - tabstrip.getBoundingClientRect().left)); | |
| tabstrip.addEventListener("mouseleave", () => tabDock.leave()); | |
| tabstrip.addEventListener("wheel", (e) => { | |
| // A trackpad swipe reports deltaX; a plain wheel only ever reports deltaY. | |
| // Take whichever is bigger so both drive this horizontal dock. | |
| const d = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; | |
| if (tabDock.wheel(d)) e.preventDefault(); | |
| }, { passive: false }); | |
| const sbDock = makeDock(renderSidebar); | |
| sbTabs.addEventListener("mousemove", (e) => sbDock.move(e.clientY - sbTabs.getBoundingClientRect().top)); | |
| sbTabs.addEventListener("mouseleave", () => sbDock.leave()); | |
| sbTabs.addEventListener("wheel", (e) => { | |
| // Wheel the rows themselves — no browser scrollbar. Only swallow the event | |
| // if the dock actually consumed it, so a dock already at an end does not | |
| // silently eat the page's scroll. | |
| if (sbDock.wheel(e.deltaY)) e.preventDefault(); | |
| }, { passive: false }); | |
| // ---- Vertical tabs: Chrome shows the strip OR the side list, never both ---- | |
| document.getElementById("verticalTabs").addEventListener("change", (e) => { | |
| win.classList.toggle("vertical", e.target.checked); | |
| relayout(); | |
| }); | |
| // Whichever dock is hidden measures as zero; this runs on every mode flip, so | |
| // the one coming back always re-measures before it is seen. | |
| function relayout() { | |
| tabDock.setScrollMax(tabScrollMax()); | |
| sbDock.setScrollMax(SB_REST_H - sbTabs.clientHeight); | |
| tabDock.reset(); | |
| sbDock.reset(); | |
| } | |
| window.addEventListener("resize", relayout); | |
| relayout(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| {"name":"Expanding Browser Tabs","settings":{},"filenames":["index.html","index.css","index.js"]} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment