Nº 073 of 520
Tabs with morphing underline
Nine scrollable tabs whose 2px underline stretches to span old and new tab, then settles (180ms + 200ms), with a panel crossfade and fade masks at the strip's overflow edges.
Preview
Use it
Free to use · credit appreciated1 Once, in your project
npx skills add SusanAcharya/Design-Lounge 2 Then ask
Using the Design Lounge skill, build "Tabs with morphing underline" (piece tabs-morphing-underline) in my stack. Match its brief; restyle it onto my product's theme.
No skill? Paste the brief instead. It carries the numbers, tokens and behaviour.
<!-- Design Lounge Nº 073 · "Tabs with morphing underline" · designlounge.vercel.app -->
# Tabs with morphing underline
> **Build brief for a coding agent.** Rebuild this piece in the reader's stack. If they haven't said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don't "improve" them.
## What it is
A tab strip for a newsletter publication's settings ("Wren · Morning Dispatch"): nine tabs (some with count pills) in a 720px strip that scrolls horizontally, and a 2px olive underline that morphs rather than jumps. On change the underline first **stretches** to cover both the old and the new tab (180ms, expo-out), then **settles** onto the new tab (200ms, standard ease). The panel below crossfades over 200ms with a 4px rise. Where tabs overflow, the strip's edges fade to transparent via a mask that only appears on the side that actually has more content. Dark warm brown, serif headings, one olive accent.
## Reference behaviour
1. Initial state: "Overview" selected; underline exactly under it; the Overview panel shows three stat tiles (Open rate 58.2 %, Click rate 14.9 %, Net new +1,204). The strip is scrolled to the start: no left fade, a 40px right fade (tabs 7–9 are partly hidden).
2. Click "Revenue" (three tabs to the right): phase 1 — the underline's `left` snaps to Overview's left and its `width` grows to reach Revenue's right edge over 180ms; phase 2 — after 180ms `left` moves to Revenue's left and `width` shrinks to Revenue's width over 200ms. Total 380ms. The Overview panel fades out while Revenue fades in (200ms, the incoming panel also rises from `translateY(4px)`).
3. Click a tab to the left: same two phases; phase 1 grows leftward (left moves, width grows), phase 2 the right edge retracts.
4. Click a partly hidden tab (e.g. "Team"): it scrolls into view (smooth) and the underline morphs across the scrolling strip; the left fade appears once `scrollLeft > 2px`; the right fade disappears when the end is reached.
5. Keyboard: Tab focuses the selected tab only. → / ← move to the next/previous tab (wrapping) and select it immediately (automatic activation); Home / End go to first/last. Selection scrolls the tab into view. Tab again moves focus into the visible panel (`tabindex="0"`).
6. Hover a tab: text brightens to `--ink` and the tab gets a `--panel` background with 8px top radii. The count pill on the selected tab inverts to olive with dark text.
7. Resizing re-measures the underline without animation; fonts loading late also re-place it.
## Structure
```
1280 × 800
┌──────────────────────────────────────────────────────────────────────────┐
│ Wren Publications / Morning Dispatch │ 56
├──────────────────────────────────────────────────────────────────────────┤
│ PUBLICATION │ 720 column, centred
│ Morning Dispatch — weekday letter (Libre Caslon 34) │
│ Sent 06:30 Europe/Oslo · 41,208 subscribers · … │
│ ┌───────────────────── 720 ─────────────────────┐ │
│ │Overview Subscribers 41k Posts 184 Revenue Integ…│ ▒▒│ │ tabs 44 tall, fade 40
│ │▔▔▔▔▔▔▔▔ │ │ 2px underline
│ └───────────────────────────────────────────────┘ │ 1px hairline
│ ┌ Open rate ┐ ┌ Click rate ┐ ┌ Net new ┐ │ panel, 24 above
│ │ 58.2 % +1.4│ │ 14.9 % +0.3│ │ +1,204 │ │ tiles 3-col, 12 gap
│ └───────────┘ └───────────┘ └─────────┘ │
└──────────────────────────────────────────────────────────────────────────┘
```
… 186 more lines. Copy or download for the full brief. Loading the source…Brief
226 linesBuild brief for a coding agent. Rebuild this piece in the reader’s stack. If they haven’t said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don’t “improve” them.
What it is
A tab strip for a newsletter publication’s settings (“Wren · Morning Dispatch”): nine tabs (some with count pills) in a 720px strip that scrolls horizontally, and a 2px olive underline that morphs rather than jumps. On change the underline first stretches to cover both the old and the new tab (180ms, expo-out), then settles onto the new tab (200ms, standard ease). The panel below crossfades over 200ms with a 4px rise. Where tabs overflow, the strip’s edges fade to transparent via a mask that only appears on the side that actually has more content. Dark warm brown, serif headings, one olive accent.
Reference behaviour
- Initial state: “Overview” selected; underline exactly under it; the Overview panel shows three stat tiles (Open rate 58.2 %, Click rate 14.9 %, Net new +1,204). The strip is scrolled to the start: no left fade, a 40px right fade (tabs 7–9 are partly hidden).
- Click “Revenue” (three tabs to the right): phase 1 — the underline’s
leftsnaps to Overview’s left and itswidthgrows to reach Revenue’s right edge over 180ms; phase 2 — after 180msleftmoves to Revenue’s left andwidthshrinks to Revenue’s width over 200ms. Total 380ms. The Overview panel fades out while Revenue fades in (200ms, the incoming panel also rises fromtranslateY(4px)). - Click a tab to the left: same two phases; phase 1 grows leftward (left moves, width grows), phase 2 the right edge retracts.
- Click a partly hidden tab (e.g. “Team”): it scrolls into view (smooth) and the underline morphs across the scrolling strip; the left fade appears once
scrollLeft > 2px; the right fade disappears when the end is reached. - Keyboard: Tab focuses the selected tab only. → / ← move to the next/previous tab (wrapping) and select it immediately (automatic activation); Home / End go to first/last. Selection scrolls the tab into view. Tab again moves focus into the visible panel (
tabindex="0"). - Hover a tab: text brightens to
--inkand the tab gets a--panelbackground with 8px top radii. The count pill on the selected tab inverts to olive with dark text. - Resizing re-measures the underline without animation; fonts loading late also re-place it.
Structure
1280 × 800
┌──────────────────────────────────────────────────────────────────────────┐
│ Wren Publications / Morning Dispatch │ 56
├──────────────────────────────────────────────────────────────────────────┤
│ PUBLICATION │ 720 column, centred
│ Morning Dispatch — weekday letter (Libre Caslon 34) │
│ Sent 06:30 Europe/Oslo · 41,208 subscribers · … │
│ ┌───────────────────── 720 ─────────────────────┐ │
│ │Overview Subscribers 41k Posts 184 Revenue Integ…│ ▒▒│ │ tabs 44 tall, fade 40
│ │▔▔▔▔▔▔▔▔ │ │ 2px underline
│ └───────────────────────────────────────────────┘ │ 1px hairline
│ ┌ Open rate ┐ ┌ Click rate ┐ ┌ Net new ┐ │ panel, 24 above
│ │ 58.2 % +1.4│ │ 14.9 % +0.3│ │ +1,204 │ │ tiles 3-col, 12 gap
│ └───────────┘ └───────────┘ └─────────┘ │
└──────────────────────────────────────────────────────────────────────────┘
<main>— 720px column, 40px top padding:.kick,<h1>,.sub..strip—position:relative; width:720px; border-bottom:1px solid --line, withdata-l/data-rattributes (“0”/“1”) driving the masks..scroll—overflow-x:auto; overflow-y:hidden, scrollbar hidden (scrollbar-width:none+::-webkit-scrollbar{display:none}); this is the element that receives themask-image.<div class="tabs" role="tablist" aria-label>—display:flex; gap:4px; width:max-content; position:relative; padding-bottom:1px.<button class="tab" role="tab" id="tN" aria-controls="pN" aria-selected tabindex>× 9 — 44px tall,0 14pxpadding, 8px gap to an optional<span class="n">pill.<span class="ink" aria-hidden>— absolute,bottom:0; height:2px,left/widthset by JS.
.panels—display:grid; margin-top:24px; nine<section class="panel" role="tabpanel" id="pN" aria-labelledby="tN" tabindex="0">all atgrid-area:1/1.
Tabs and panels: Overview (3 tiles) · Subscribers 41k (3-row list: Sofie Marthinsen, Jonas Refsnes, Ada Kvernmo paused) · Posts 184 (3 issues: “The port that stopped waiting” issue 184 Mon 28 Sep; “Three rate decisions, one krone” 183; “Counting the cod by ear” 182) · Revenue (MRR kr 86,420 +3.1 %; Paid subscribers 1,842 +38; Churn 1.6 %) · Integrations 3 (Fjord Bank Payments, Nord Post CMS connected; Halden Analytics token expired) · Domains (dispatch.wren.pub default; morning.example.no custom; both “TLS ok”) · Webhooks 2 (subscriber.created, post.published → hooks.example.no/wren, 200) · Team 6 (Liv Marstrand owner, Henrik Dahl editor, Sigrid Aune writer) · Settings (one prose paragraph: sending window 06:30–06:45, reply-to hello@wren.pub, double opt-in on).
Underline phases, worked example
With tab boxes measured as Overview {l: 0, w: 92} and Revenue {l: 356, w: 84} (values depend on font metrics — always measure):
| Phase | t (ms) | left | width | Transition |
|---|---|---|---|---|
| rest | 0 | 0 | 92 | — |
| stretch | 0→180 | 0 | 440 | .stretch: 180ms expo-out on both properties |
| settle | 180→380 | 356 | 84 | default: 200ms standard ease on both |
Selecting a tab to the left (Revenue → Overview): stretch sets left: 0; width: 440 (left edge travels, right edge holds), settle sets left: 0; width: 92. The min/max computation makes both directions symmetrical without a branch.
Overflow and mask state
scrollLeft | data-l | data-r | Mask |
|---|---|---|---|
| 0–2 | 0 | 1 | right 40px fade |
| between | 1 | 1 | both fades |
≥ scrollWidth − clientWidth − 2 | 1 | 0 | left 40px fade |
| no overflow (wide viewport) | 0 | 0 | none |
edges() runs on scroll (passive), on resize, and once after initial placement and after document.fonts.ready (font swap changes scrollWidth).
Tokens
:root {
/* colour — warm dark brown, olive accent */
--bg: #16130f;
--panel: #1e1a15; /* tiles, lists, tab hover */
--panel-2: #26211b; /* count pills */
--line: #2d2822; /* hairlines */
--line-2: #3b342c; /* inactive status dots */
--ink: #f3ede3;
--ink-2: #b9b0a2; /* tab labels, list meta */
--ink-3: #7d7467; /* subtitles, pills, right-aligned meta */
--accent: #b5c27a; /* underline, kicker, selected pill, focus, status dots */
--accent-ink: #151a08;
--positive: #9ccf8e;
/* type */
--serif: "Libre Caslon Text", Georgia, serif;
--sans: "Work Sans", system-ui, sans-serif;
/* layout */
--strip-w: 720px;
--tab-h: 44px;
--tab-pad: 14px;
--tab-gap: 4px;
--fade: 40px; /* edge mask width */
--ink-h: 2px;
--r: 10px; /* tiles, lists */
--r-tab: 8px 8px 0 0;
/* motion */
--t-fast: 140ms; /* tab colour */
--t-stretch: 180ms; /* underline phase 1 */
--t-settle: 200ms; /* underline phase 2 */
--t-xfade: 200ms; /* panel crossfade */
--ease: cubic-bezier(.2, .7, .2, 1);
--ease-out: cubic-bezier(.16, 1, .3, 1);
}
Typography
| Role | Family | Size | Weight | Line-height | Tracking | Notes |
|---|---|---|---|---|---|---|
| Brand | Libre Caslon Text | 20px | 400 | 1 | 0 | |
| Kicker | Work Sans | 11px | 600 | 1.3 | +0.14em | UPPERCASE --accent |
| Page h1 | Libre Caslon Text | 34px | 400 | 1.15 | −0.01em | italic suffix in --ink-2 |
| Subtitle | Work Sans | 14px | 400 | 1.5 | 0 | --ink-3 |
| Tab label | Work Sans | 14px | 500 | 1 | 0 | --ink-2; selected/hover --ink |
| Count pill | Work Sans | 11px | 500 | 1.4 | 0 | 1px 7px padding, 999px radius |
| Tile value | Libre Caslon Text | 28px | 400 | 1.2 | −0.01em | delta 12px --positive |
| Tile label | Work Sans | 12px | 400 | 1.4 | 0 | --ink-3 |
| List row | Work Sans | 13px | 400 | 1.5 | 0 | name 500; meta --ink-2; right --ink-3 tabular-nums |
| Prose | Work Sans | 14px | 400 | 1.5 | 0 | --ink-2, strong --ink 500 |
Motion
| Element | Trigger | Property | From → To | Duration | Easing | Notes |
|---|---|---|---|---|---|---|
.ink | select (phase 1) | left, width | old box → min(oldL,newL), max(oldR,newR) − min(…) | 180ms | --ease-out | .stretch class swaps the transition |
.ink | select (phase 2) | left, width | span → new tab’s offsetLeft/offsetWidth | 200ms | --ease | starts at t = 180ms via setTimeout |
.panel out | select | opacity | 1 → 0 | 200ms | --ease | visibility hidden after 200ms |
.panel in | select | opacity, transform | 0, translateY(4px) → 1, none | 200ms | opacity --ease, transform --ease-out | |
.tab | hover / select | color | --ink-2 → --ink | 140ms | --ease | background snaps |
.scroll | select hidden tab | scrollLeft | native smooth | native | — | scrollIntoView({inline:'nearest'}) |
| masks | scroll | mask-image | attribute-driven, no transition | 0 | — |
Reduced motion: transitions 1ms; scrollIntoView uses behavior:'auto'. The two-phase timing collapses to an instant jump.
States
- Tab default:
--ink-2, transparent. Hover:--ink,--panelbackground, radius8px 8px 0 0. Selected (aria-selected="true"):--ink, pill inverted (--accentbg,--accent-inktext). - Tab focus-visible:
box-shadow: inset 0 0 0 2px var(--accent)(inset so it isn’t clipped by the scroll container). - Panel focus-visible: 2px accent outline, 8px offset.
- Overflow masks:
data-l="1"→ left 40px fade;data-r="1"→ right 40px fade; both → both; neither → no mask. Thresholds: left whenscrollLeft > 2, right whenscrollLeft + clientWidth < scrollWidth − 2. - Status dots (lists):
--accentactive,--line-2inactive/paused.
Accessibility
role="tablist"witharia-label; tabsrole="tab",aria-selected,aria-controls, ids; panelsrole="tabpanel",aria-labelledby,tabindex="0".- Roving tabindex: selected tab
0, others−1. Arrow keys select (automatic activation) — if your panels are expensive, switch to manual activation (arrows move focus, Enter/Space selects) and say so. scrollIntoViewon select withblock:'nearest'so the page doesn’t jump; focus withpreventScroll:trueto avoid a second scroll.- Inactive panels are
visibility:hidden, so only the active panel’s content is in the tab order. - The underline is
aria-hidden. - Masks are purely visual; hidden tabs remain reachable via keyboard and scroll.
- Contrast:
--ink-2on--bg9.8:1;--ink-3on--bg4.6:1;--accent-inkon--accent10:1. - Hit targets: tabs 44px tall; pills are not interactive.
Responsive rules
- ≥ 1280: as specified (720px strip).
- 1024–1279: unchanged.
- 768–1023: column and strip
calc(100vw − 64px); more tabs overflow; masks do the work. - < 640: strip full-bleed with 16px gutters; tab padding 12px; tiles become one column; the count pills hide except on the selected tab.
Acceptance checklist
- Strip is 720px with a 1px
#2d2822bottom rule; tabs 44px tall,0 14pxpadding, 4px gaps; underline 2px#b5c27a. - Changing tabs runs two phases: stretch to span both tabs over 180ms
cubic-bezier(.16,1,.3,1), then settle over 200mscubic-bezier(.2,.7,.2,1). - The stretch covers
min(left)tomax(right)regardless of direction. - Panels crossfade over 200ms; the incoming panel rises 4px; inactive panels are
visibility:hidden. - Right fade (40px) visible on load; left fade appears after scrolling > 2px; right fade disappears at the end.
- Scrollbar is hidden but the strip scrolls with trackpad/wheel and via
scrollIntoView. - → ← Home End move and select with wrapping; only the selected tab is tabbable; Tab then enters the panel.
- Selected tab’s count pill is olive with dark text; others are
#26211bwith#7d7467text. - Focus ring is an inset 2px olive box-shadow (not clipped by the scroll container).
- Underline is placed without animation on load, on resize and after
document.fonts.ready. - Reduced motion: no stretch/settle, no smooth scroll.
Implementation notes
Two-phase morph = two transitions and one timeout. Swap the transition definition with a class for phase 1, then remove it and set the final box:
.ink { position: absolute; bottom: 0; height: 2px; background: var(--accent);
transition: left 200ms var(--ease), width 200ms var(--ease); }
.ink.stretch { transition: left 180ms var(--ease-out), width 180ms var(--ease-out); }
function morph(from, to) {
const a = box(from), b = box(to), l = Math.min(a.l, b.l), r = Math.max(a.l + a.w, b.l + b.w);
ink.classList.add('stretch'); ink.style.left = l + 'px'; ink.style.width = (r - l) + 'px';
clearTimeout(busy);
busy = setTimeout(() => { ink.classList.remove('stretch'); ink.style.left = b.l + 'px'; ink.style.width = b.w + 'px'; }, 180);
}
box(i) is { l: tab.offsetLeft, w: tab.offsetWidth } — offsets relative to .tabs, which is position:relative and scrolls together with the underline, so scrolling never desyncs them.
Edge masks driven by scroll position — put the mask on the scrolling element and switch it with data attributes:
.strip[data-r="1"] .scroll { mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 40px), transparent); }
.strip[data-l="1"] .scroll { mask-image: linear-gradient(90deg, transparent, #000 40px, #000 100%); }
.strip[data-l="1"][data-r="1"] .scroll { mask-image: linear-gradient(90deg, transparent, #000 40px, #000 calc(100% - 40px), transparent); }
function edges() {
strip.dataset.l = scroll.scrollLeft > 2 ? '1' : '0';
strip.dataset.r = scroll.scrollLeft + scroll.clientWidth < scroll.scrollWidth - 2 ? '1' : '0';
}
scroll.addEventListener('scroll', edges, { passive: true });
Common mistakes: putting the underline outside the scrolling element (it stops tracking once the strip scrolls); using transform: scaleX for the stretch (distorts the rounded ends); forgetting to disable the transition for the initial placement (the underline slides in from width:0 on load); relying on mask on .strip instead of .scroll (the mask then clips the panel’s focus ring).
Details
- Palette
- Type
- Libre Caslon Text · Work Sans
- Motion
- Subtle motion
- Build
- One session
- Tags
Studied
Tailwind CSS docsPut each instruction next to its code. Draw the grid with thin lines and let the empty sides carry a faint hatch.
All sourcesOn the shelves