Swish UI
All componentsCards and layout Free

Sliding tab indicator

The dark pill behind the active tab slides and resizes to the tab you pick while the panel below cross fades.

It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.

Demo.tsx

"use client";
import { useLayoutEffect, useRef, useState } from "react";
import styles from "./demo.module.css";

const tabs = [
  { id: "overview", label: "Overview", title: "A courtyard house near Anjuna", text: "Four bedrooms around a shaded courtyard, a pool under the mango tree and a ten minute walk to the beach.", chips: ["8 guests", "Pool", "Fast Wi-Fi"] },
  { id: "rooms", label: "Rooms", title: "Four bedrooms, all en suite", text: "Two kings on the ground floor, a twin and a loft room upstairs with a view over the paddy fields.", chips: ["2 king", "1 twin", "1 loft"] },
  { id: "dining", label: "Dining", title: "Breakfast by Rosa, every day", text: "Fresh fruit, poi bread and eggs your way. Dinner can be booked the day before for up to eight.", chips: ["Breakfast", "Chef on request"] },
  { id: "reviews", label: "Reviews", title: "4.9 from 126 stays", text: "\"The courtyard at night with the lanterns on is the reason we are coming back next year.\" Maya, June", chips: ["Clean 5.0", "Location 4.8"] },
];

export default function Demo() {
  const list = useRef<HTMLDivElement>(null);
  const indicator = useRef<HTMLSpanElement>(null);
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const [active, setActive] = useState(0);

  // Move the pill to the active tab, and again whenever the tab sizes change (fonts, resize).
  useLayoutEffect(() => {
    const place = () => {
      const tab = tabRefs.current[active]!;
      indicator.current!.style.transform = `translateX(${tab.offsetLeft}px)`;
      indicator.current!.style.width = `${tab.offsetWidth}px`;
    };
    place();
    const ro = new ResizeObserver(place);
    ro.observe(list.current!);
    return () => ro.disconnect();
  }, [active]);

  const onKeyDown = (e: React.KeyboardEvent) => {
    const keys: Record<string, number> = { ArrowRight: active + 1, ArrowLeft: active - 1, Home: 0, End: tabs.length - 1 };
    if (!(e.key in keys)) return;
    e.preventDefault();
    const next = (keys[e.key] + tabs.length) % tabs.length;
    setActive(next);
    tabRefs.current[next]?.focus();
  };

  return (
    <div className={styles.root}>
      <div className={styles.box}>
        <div ref={list} className={styles.list} role="tablist" aria-label="Villa Azul" onKeyDown={onKeyDown}>
          <span ref={indicator} className={styles.indicator} aria-hidden="true" />
          {tabs.map((t, i) => (
            <button
              key={t.id}
              ref={(node) => { tabRefs.current[i] = node; }}
              type="button"
              role="tab"
              id={`tab-${t.id}`}
              aria-selected={i === active}
              aria-controls={`panel-${t.id}`}
              tabIndex={i === active ? 0 : -1}
              className={styles.tab}
              onClick={() => setActive(i)}
            >
              {t.label}
            </button>
          ))}
        </div>
        <div className={styles.panels}>
          {tabs.map((t, i) => (
            <section
              key={t.id}
              id={`panel-${t.id}`}
              role="tabpanel"
              aria-labelledby={`tab-${t.id}`}
              tabIndex={0}
              className={`${styles.panel} ${i === active ? styles.current : ""}`}
            >
              <h3>{t.title}</h3>
              <p>{t.text}</p>
              <div className={styles.chips}>{t.chips.map((c) => <span key={c}>{c}</span>)}</div>
            </section>
          ))}
        </div>
      </div>
    </div>
  );
}

demo.module.css

.root { width: 100%; height: 100%; display: grid; place-items: center; padding: 16px; overflow: hidden; }
.box { width: min(480px, 100%); display: grid; gap: 14px; }
.list { position: relative; display: flex; width: fit-content; max-width: 100%; padding: 4px; border-radius: 999px; background: var(--white); border: 1px solid var(--line); }
.indicator { position: absolute; left: 0; top: 4px; bottom: 4px; border-radius: 999px; background: var(--ink); transition: transform .4s var(--out), width .4s var(--out); }
.tab { position: relative; min-height: 36px; padding: 0 14px; border: 0; border-radius: 999px; background: none; font-size: 14px; font-weight: 600; color: var(--muted); cursor: pointer; white-space: nowrap; transition: color .25s; }
.tab:hover { color: var(--ink); }
.tab[aria-selected="true"] { color: var(--white); }
.tab:focus-visible { outline-offset: 2px; }

.panels { display: grid; }
.panel { grid-area: 1 / 1; display: grid; align-content: start; gap: 8px; padding: 18px; border-radius: 16px; background: var(--white); border: 1px solid var(--line); opacity: 0; visibility: hidden; transform: translateY(6px); transition: opacity .25s, transform .35s var(--out), visibility 0s .25s; }
.current { opacity: 1; visibility: visible; transform: none; transition: opacity .3s .08s, transform .4s .08s var(--out), visibility 0s; }
.panel h3 { margin: 0; font-size: 17px; letter-spacing: -0.02em; }
.panel p { font-size: 14px; color: var(--muted); }
.chips { display: flex; flex-wrap: wrap; gap: 6px; padding-top: 2px; }
.chips span { padding: 4px 10px; border-radius: 999px; background: var(--accent-pale); color: var(--accent-text); font-size: 12px; font-weight: 600; }
@media (max-width: 400px) { .tab { padding: 0 11px; font-size: 13px; } }
@media (prefers-reduced-motion: reduce) { .indicator, .panel, .current { transition: none; transform: none; } }

Use it for

  • Switching between a few views of the same item, like overview, rooms and reviews
  • Segmented filters with two to five options

Avoid

  • More tabs than fit on one line on a phone
  • Navigation between pages, which should be links

How it works

A layout effect measures the active tab offsetLeft and offsetWidth and writes them to the indicator as translateX and width, both transitioned on the shared ease out curve. A ResizeObserver on the tab list re-measures when fonts load or the box resizes. All panels share one grid cell; the active one fades in and rises 6px while the old one fades out, and visibility hides inactive panels from focus and screen readers.

Accessibility

Uses the tablist, tab and tabpanel roles with aria-selected and aria-controls. Roving tabindex: arrow keys, Home and End move between tabs and activate them. Reduced motion jumps the pill and swaps panels without movement.

Found on