Back to Code Bytes
3 min read
Tabs Sliding

Description

Tabs Sliding gives a segmented control its signature sliding pill: clicking a tab writes the new tab’s measured position onto a floating background element, and CSS transitions the transform and width to match. The first-paint guard suspends the transition on mount so the pill snaps into place without animating in from nothing. Both the pill and the tab labels transition together, giving the control a physical, connected feel.

Example

CSS

:root {
  --tabs-dur: 200ms;
  --tabs-ease: cubic-bezier(0.22, 1, 0.36, 1);
  --tabs-text-muted: rgba(15, 15, 15, 0.6);
  --tabs-text-active: #0f0f0f;
  --tabs-bar-bg: #eeeeee;
  --tabs-pill-bg: #ffffff;
}

/* The bar is just a flex container with padding for the pill
   to sit inside. Tabs sit on z-index: 1, the pill on z-index: 0,
   so labels read above the pill background. */
.t-tabs {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 3px;
  padding: 3px;
  border-radius: 48px;
  background: var(--tabs-bar-bg);
}
.t-tab {
  position: relative;
  appearance: none;
  border: 0;
  background: transparent;
  height: 30px;
  padding: 4px 12px;
  color: var(--tabs-text-muted);
  cursor: pointer;
  border-radius: 48px;
  z-index: 1;
  transition: color var(--tabs-dur) var(--tabs-ease);
}
.t-tab:not([aria-selected="true"]):hover,
.t-tab[aria-selected="true"] {
  color: var(--tabs-text-active);
}

/* The pill: width + transform are written inline by JS so
   the transition tweens between the previous and next
   measured positions. */
.t-tabs-pill {
  position: absolute;
  top: 3px;
  left: 0;
  height: 30px;
  width: 0;
  background: var(--tabs-pill-bg);
  border-radius: 48px;
  transform: translateX(0);
  transition:
    transform var(--tabs-dur) var(--tabs-ease),
    width var(--tabs-dur) var(--tabs-ease);
  will-change: transform, width;
  z-index: 0;
  pointer-events: none;
}

@media (prefers-reduced-motion: reduce) {
  .t-tabs-pill,
  .t-tab {
    transition: none !important;
  }
}

React

import { useLayoutEffect, useRef, useState } from "react";
import "./tabs-sliding.css"; // paste the CSS above

const TABS = ["Plan", "Debug", "Ask"];

export function TabsSliding() {
  const [active, setActive] = useState(0);
  const barRef = useRef<HTMLDivElement>(null);
  const pillRef = useRef<HTMLSpanElement>(null);
  const first = useRef(true);

  useLayoutEffect(() => {
    const bar = barRef.current;
    const pill = pillRef.current;
    if (!bar || !pill) return;
    const tab = bar.querySelectorAll<HTMLButtonElement>(".t-tab")[active];
    if (!tab) return;
    if (first.current) {
      pill.style.transition = "none";
      pill.style.transform = `translateX(${tab.offsetLeft}px)`;
      pill.style.width = `${tab.offsetWidth}px`;
      void pill.offsetWidth;
      pill.style.transition = "";
      first.current = false;
    } else {
      pill.style.transform = `translateX(${tab.offsetLeft}px)`;
      pill.style.width = `${tab.offsetWidth}px`;
    }
  }, [active]);

  return (
    <div className="t-tabs" role="tablist" ref={barRef}>
      <span className="t-tabs-pill" aria-hidden="true" ref={pillRef} />
      {TABS.map((label, i) => (
        <button
          key={label}
          type="button"
          className="t-tab"
          role="tab"
          aria-selected={active === i}
          onClick={() => setActive(i)}
        >
          {label}
        </button>
      ))}
    </div>
  );
}

Variables

VariableDefaultNotes
--tabs-dur200mssourced from --p16-dur
--tabs-easecubic-bezier(0.22, 1, 0.36, 1)sourced from --p16-ease
--tabs-text-mutedrgba(15, 15, 15, 0.6)sourced from --p16-text-muted
--tabs-text-active#0f0f0fsourced from --p16-text-active
--tabs-bar-bg#eeeeeesourced from --p16-bar-bg
--tabs-pill-bg#ffffffsourced from --p16-pill-bg

Credit

Adapted from Tabs Sliding on transitions.dev by Jakub Antalik. Original source: github.com/Jakubantalik/transitions.dev.