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
| Variable | Default | Notes |
|---|---|---|
--tabs-dur | 200ms | sourced from --p16-dur |
--tabs-ease | cubic-bezier(0.22, 1, 0.36, 1) | sourced from --p16-ease |
--tabs-text-muted | rgba(15, 15, 15, 0.6) | sourced from --p16-text-muted |
--tabs-text-active | #0f0f0f | sourced from --p16-text-active |
--tabs-bar-bg | #eeeeee | sourced from --p16-bar-bg |
--tabs-pill-bg | #ffffff | sourced from --p16-pill-bg |
Credit
Adapted from Tabs Sliding on transitions.dev by Jakub Antalik. Original source: github.com/Jakubantalik/transitions.dev.