Browser

mail.example.com
No dependencies
Code format
React + Tailwind
import { useId, useRef, useState } from 'react';

const TABS = [
  { label: 'Inbox', url: 'mail.example.com' },
  { label: 'Docs', url: 'docs.example.com' },
  { label: 'Music', url: 'listen.example.com' },
];

const css = `
  @keyframes tabs-browser-lift-a { from { transform: translateY(5px); } }
  @keyframes tabs-browser-lift-b { from { transform: translateY(5px); } }
  @keyframes tabs-browser-load-a { from { transform: rotate(-360deg); } }
  @keyframes tabs-browser-load-b { from { transform: rotate(-360deg); } }
  .tabs-browser { width: 270px; overflow: hidden; border-radius: 12px; background: light-dark(#fbfcfd, #121417); box-shadow: inset 0 0 0 1px light-dark(#ebedf0, #232529), 0 14px 28px -16px light-dark(rgba(0, 0, 0, 0.21), rgba(0, 0, 0, 0.7)); }
  .tabs-browser__list { display: flex; gap: 2px; padding: 8px 12px 0; }
  .tabs-browser__tab {
    position: relative;
    display: flex;
    align-items: center;
    gap: 6px;
    width: 80px;
    height: 30px;
    padding: 0 8px;
    border: 0;
    border-radius: 9px 9px 0 0;
    background: none;
    color: light-dark(#5f626a, #a7abb2);
    font: inherit;
    font-size: 11.5px;
    font-weight: 600;
    text-align: left;
    cursor: pointer;
  }
  .tabs-browser__tab:not([aria-selected='true']) + .tabs-browser__tab:not([aria-selected='true'])::before { content: ''; position: absolute; left: -2px; top: 8px; width: 1px; height: 14px; background: light-dark(#dbdde1, #2e3035); }
  .tabs-browser__tab:hover:not([aria-selected='true']) { background: color-mix(in oklab, light-dark(#ebedf0, #232529) 50%, transparent); }
  .tabs-browser__tab[aria-selected='true'] { background: light-dark(#ebedf0, #232529); color: light-dark(#373a40, #f1f2f3); }
  .tabs-browser__tab[aria-selected='true']::before,
  .tabs-browser__tab[aria-selected='true']::after { content: ''; position: absolute; bottom: 0; width: 8px; height: 8px; }
  .tabs-browser__tab[aria-selected='true']::before { left: -8px; background: radial-gradient(circle at 0 0, transparent 8px, light-dark(#ebedf0, #232529) 8.5px); }
  .tabs-browser__tab[aria-selected='true']::after { right: -8px; background: radial-gradient(circle at 100% 0, transparent 8px, light-dark(#ebedf0, #232529) 8.5px); }
  .tabs-browser[data-flip='a'] .tabs-browser__tab[aria-selected='true'] { animation: tabs-browser-lift-a 0.3s cubic-bezier(0.3, 1.5, 0.5, 1); }
  .tabs-browser[data-flip='b'] .tabs-browser__tab[aria-selected='true'] { animation: tabs-browser-lift-b 0.3s cubic-bezier(0.3, 1.5, 0.5, 1); }
  .tabs-browser__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; }
  .tabs-browser__icon { flex: none; width: 12px; height: 12px; border-radius: 50%; background: light-dark(#6d7179, #7d8087); }
  .tabs-browser__tab[aria-selected='true'] .tabs-browser__icon { background: conic-gradient(light-dark(#373a40, #f1f2f3) 0 75%, light-dark(#6d7179, #7d8087) 0); }
  .tabs-browser[data-flip='a'] .tabs-browser__tab[aria-selected='true'] .tabs-browser__icon { animation: tabs-browser-load-a 0.7s cubic-bezier(0.4, 0, 0.2, 1); }
  .tabs-browser[data-flip='b'] .tabs-browser__tab[aria-selected='true'] .tabs-browser__icon { animation: tabs-browser-load-b 0.7s cubic-bezier(0.4, 0, 0.2, 1); }
  .tabs-browser__name { flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
  .tabs-browser__x { position: relative; flex: none; width: 8px; height: 8px; opacity: 0.6; }
  .tabs-browser__x::before, .tabs-browser__x::after { content: ''; position: absolute; left: 3.5px; top: -1px; width: 1px; height: 10px; background: currentColor; transform: rotate(45deg); }
  .tabs-browser__x::after { transform: rotate(-45deg); }
  .tabs-browser__page { padding: 8px 10px 12px; background: light-dark(#ebedf0, #232529); }
  .tabs-browser__url { display: flex; align-items: center; height: 24px; padding: 0 10px; border-radius: 12px; background: light-dark(#f3f4f7, #1a1c1f); color: light-dark(#50535b, #c6c8cd); font-size: 11px; }
  .tabs-browser__skel { display: grid; gap: 6px; padding: 12px 2px 0; }
  .tabs-browser__skel span { height: 7px; border-radius: 4px; background: light-dark(#dbdde1, #2e3035); }
  @media (prefers-reduced-motion: reduce) {
    .tabs-browser__tab, .tabs-browser__icon { animation: none !important; }
  }
`;

export function BrowserTabs() {
  const id = useId();
  const [sel, setSel] = useState(0);
  const [flip, setFlip] = useState(0);
  const tabs = useRef<(HTMLButtonElement | null)[]>([]);
  const select = (i: number) => {
    if (i === sel) return;
    setSel(i);
    setFlip((f) => f + 1);
  };
  const onKeyDown = (e: React.KeyboardEvent) => {
    const n = TABS.length;
    const next = { ArrowRight: (sel + 1) % n, ArrowLeft: (sel + n - 1) % n, Home: 0, End: n - 1 }[e.key];
    if (next === undefined) return;
    e.preventDefault();
    select(next);
    tabs.current[next]?.focus();
  };
  return (
    <>
      <style>
        {css}
      </style>
      <div className="tabs-browser" data-flip={flip ? (flip % 2 ? 'a' : 'b') : undefined}>
        <div className="tabs-browser__list" role="tablist" aria-label="Open pages" onKeyDown={onKeyDown}>
          {TABS.map((t, i) => (
            <button
              key={t.label}
              ref={(el) => {
                tabs.current[i] = el;
              }}
              className="tabs-browser__tab"
              type="button"
              role="tab"
              id={`${id}-tab-${i}`}
              aria-selected={sel === i}
              aria-controls={`${id}-panel-${i}`}
              tabIndex={sel === i ? 0 : -1}
              onClick={() => select(i)}
            >
              <span className="tabs-browser__icon" aria-hidden="true" />
              <span className="tabs-browser__name">{t.label}</span>
              <span className="tabs-browser__x" aria-hidden="true" />
            </button>
          ))}
        </div>
        {TABS.map((t, i) => (
          <div key={t.label} className="tabs-browser__page" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
            <span className="tabs-browser__url">{t.url}</span>
            <span className="tabs-browser__skel" aria-hidden="true">
              <span style={{ width: `${70 - i * 12}%` }} />
              <span style={{ width: '92%' }} />
              <span style={{ width: `${56 + i * 14}%` }} />
            </span>
          </div>
        ))}
      </div>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/tab-browser
Source on GitHub

About this component

Browser tabs with curved feet: the new tab lifts into place, its icon spins once as the page loads and the page skeleton fades in.

Dependencies
None
Difficulty
Intermediate
Triggers
click, state change, keyframe
Techniques
keyframes, radial gradient, conic gradient
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. Decoration is aria-hidden. Under prefers-reduced-motion the tab and icon change without lifting or spinning.

More tabs

See all
Bishop, ElizabethOne Art. Shelf 4, bay C.
Cabinet
09:30Morning. The office is filling up.
Clock
The hunter, with three stars in a row for his belt.
Constellation
Q1 · PlanResearch, roadmap and the first hires.
Cube