Design Lounge
Install

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

Open full size
1280 × 800 · 12.0 KB · 135 lines

Use it

Free to use · credit appreciated

1 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.

Brief

226 lines

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
│              └───────────┘ └───────────┘ └─────────┘                    │
└──────────────────────────────────────────────────────────────────────────┘
  • <main> — 720px column, 40px top padding: .kick, <h1>, .sub.
  • .strip — position:relative; width:720px; border-bottom:1px solid --line, with data-l / data-r attributes (“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 the mask-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 14px padding, 8px gap to an optional <span class="n"> pill.
        • <span class="ink" aria-hidden> — absolute, bottom:0; height:2px, left/width set by JS.
  • .panels — display:grid; margin-top:24px; nine <section class="panel" role="tabpanel" id="pN" aria-labelledby="tN" tabindex="0"> all at grid-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):

Phaset (ms)leftwidthTransition
rest0092—
stretch0→1800440.stretch: 180ms expo-out on both properties
settle180→38035684default: 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

scrollLeftdata-ldata-rMask
0–201right 40px fade
between11both fades
≥ scrollWidth − clientWidth − 210left 40px fade
no overflow (wide viewport)00none

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

RoleFamilySizeWeightLine-heightTrackingNotes
BrandLibre Caslon Text20px40010
KickerWork Sans11px6001.3+0.14emUPPERCASE --accent
Page h1Libre Caslon Text34px4001.15−0.01emitalic suffix in --ink-2
SubtitleWork Sans14px4001.50--ink-3
Tab labelWork Sans14px50010--ink-2; selected/hover --ink
Count pillWork Sans11px5001.401px 7px padding, 999px radius
Tile valueLibre Caslon Text28px4001.2−0.01emdelta 12px --positive
Tile labelWork Sans12px4001.40--ink-3
List rowWork Sans13px4001.50name 500; meta --ink-2; right --ink-3 tabular-nums
ProseWork Sans14px4001.50--ink-2, strong --ink 500

Motion

ElementTriggerPropertyFrom → ToDurationEasingNotes
.inkselect (phase 1)left, widthold box → min(oldL,newL), max(oldR,newR) − min(…)180ms--ease-out.stretch class swaps the transition
.inkselect (phase 2)left, widthspan → new tab’s offsetLeft/offsetWidth200ms--easestarts at t = 180ms via setTimeout
.panel outselectopacity1 → 0200ms--easevisibility hidden after 200ms
.panel inselectopacity, transform0, translateY(4px) → 1, none200msopacity --ease, transform --ease-out
.tabhover / selectcolor--ink-2 → --ink140ms--easebackground snaps
.scrollselect hidden tabscrollLeftnative smoothnative—scrollIntoView({inline:'nearest'})
masksscrollmask-imageattribute-driven, no transition0—

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, --panel background, radius 8px 8px 0 0. Selected (aria-selected="true"): --ink, pill inverted (--accent bg, --accent-ink text).
  • 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 when scrollLeft > 2, right when scrollLeft + clientWidth < scrollWidth − 2.
  • Status dots (lists): --accent active, --line-2 inactive/paused.

Accessibility

  • role="tablist" with aria-label; tabs role="tab", aria-selected, aria-controls, ids; panels role="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.
  • scrollIntoView on select with block:'nearest' so the page doesn’t jump; focus with preventScroll:true to 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-2 on --bg 9.8:1; --ink-3 on --bg 4.6:1; --accent-ink on --accent 10: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 #2d2822 bottom rule; tabs 44px tall, 0 14px padding, 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 200ms cubic-bezier(.2,.7,.2,1).
  • The stretch covers min(left) to max(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 #26211b with #7d7467 text.
  • 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
Designed bySusan AcharyaKathmandu

Studied

Tailwind CSS docs

Put each instruction next to its code. Draw the grid with thin lines and let the empty sides carry a faint hatch.

All sources