9 CSS Tab Switch Animations — No Library, Pure CSS
A css tab switch animation slides an indicator or swaps a panel when a different tab is picked.
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Sliding underline indicator
- 02 Pill background move
- 03 Panel fade
- 04 Panel slide
- 05 Icon tabs
- 06 Horizontal scroll-snap tabs
- 07 Vertical tabs
- 08 Bounce tab
- 09 Tab count badge
The order below isn't popularity, it's what each tab actually has to move. 01 and 02 shift only an indicator riding on a native :checked radio group. 03 and 04 change what the panel underneath shows, still driven by :checked alone. 05 swaps the whole foundation for a real role="tablist", since a row of plain buttons gets none of a radio group's built-in arrow-key handling for free. 06 solves for a row of tabs too wide for its box, and 07 turns the same sliding-indicator idea sideways for a vertical sidebar. 08 and 09 close with a spring overshoot and a badge bump layered on top of the two working foundations above.
01Sliding underline indicator
Three <input type="radio"> elements sit behind the labels, and a single underline span reads their :checked state through the sibling combinator to slide beneath whichever tab is picked. Because it's a native radio group, the browser already moves focus with the arrow keys with zero JavaScript written for it.
.us__indicator {
position: absolute; left: 0; bottom: 0;
width: calc(100% / 3); height: 3px;
background: $color;
transform: translateX(0);
transition: transform $duration $easing;
}
#us-1:checked ~ .us__nav .us__indicator { transform: translateX(0%); }
#us-2:checked ~ .us__nav .us__indicator { transform: translateX(100%); }
#us-3:checked ~ .us__nav .us__indicator { transform: translateX(200%); }
02Pill background move
A pill-shaped background rides behind the tabs and slides to whichever one is checked, resizing its width with scaleX on the way there. Because the three labels aren't the same length, each tab's width sits in the file as a fixed constant rather than something CSS measures on its own.
$w1: 56px; $w2: 76px; $w3: 56px;
.pi__pill {
position: absolute; top: $sp-1; left: $sp-1; bottom: $sp-1;
width: $w1;
transform: translateX(0) scaleX(1);
transform-origin: left center;
transition: transform $duration $easing;
}
// scaleX ratio is a compile-time constant — calc() won't divide length by length
$scale2: 1.357; // #{$w2} / #{$w1}
#pi-2:checked ~ .pi__nav .pi__pill {
transform: translateX(calc(#{$w1} + #{$sp-2})) scaleX(#{$scale2});
}
03Panel fade
The three panels share one grid cell, stacked directly on top of each other, and only the checked one's opacity rises to 1. Because nothing ever leaves its slot in the layout, the switch never causes the surrounding content to jump.
.fp__panel {
grid-area: 1 / 1;
opacity: 0;
pointer-events: none;
transition: opacity $duration $easing;
}
#fp-1:checked ~ .fp__panels .fp__panel--1,
#fp-2:checked ~ .fp__panels .fp__panel--2,
#fp-3:checked ~ .fp__panels .fp__panel--3 { opacity: 1; pointer-events: auto; }
04Panel slide
Three panels sit side by side inside a track three times as wide as its window, and the whole track shifts with translateX(-33.333% × n) so only the checked one lands in view. The underline beneath the tabs has to travel on that same :checked selector at the same instant, or the label and the panel stop agreeing on which tab is active.
.sp__track {
display: flex;
width: 300%;
transform: translateX(0%);
transition: transform $duration $easing;
}
#sp-2:checked ~ .sp__viewport .sp__track { transform: translateX(-33.333%); }
#sp-3:checked ~ .sp__viewport .sp__track { transform: translateX(-66.667%); }
.sp__indicator {
transform: translateX(0%);
transition: transform $duration $easing;
}
#sp-2:checked ~ .sp__nav .sp__indicator { transform: translateX(100%); }
05Icon tabs
This one drops the radio group entirely for role="tablist" on the wrapper and role="tab" on three buttons, each carrying a live aria-selected attribute. One select() function runs on both a click and an arrow-key press, moving focus, aria-selected, and tabindex together so the two input methods never fall out of sync. The icon of the newly active tab is the only thing that scales up.
var tabs = [].slice.call(root.querySelectorAll('[role=tab]'));
function select(i) {
tabs.forEach(function (t, j) {
var on = j === i;
t.setAttribute('aria-selected', on);
t.tabIndex = on ? 0 : -1;
});
tabs[i].focus();
}
tabs.forEach(function (t, i) {
t.addEventListener('click', function () { select(i); });
t.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight') select((i + 1) % tabs.length);
else if (e.key === 'ArrowLeft') select((i - 1 + tabs.length) % tabs.length);
});
});
06Horizontal scroll-snap tabs
Five tabs at a fixed 88px each add up to wider than the box holding them, so scroll-snap-type: x mandatory catches the scroll on every tab boundary instead of letting it drift to a half-visible label. The underline below still tracks the checked tab with the same translateX trick as item 01, just multiplied by that fixed width.
.st__scroller {
overflow-x: auto;
scroll-snap-type: x mandatory;
}
.st__tab {
flex: 0 0 $tab-w;
scroll-snap-align: start;
}
@for $i from 1 through 5 {
#st-#{$i}:checked ~ .st__scroller .st__indicator {
transform: translateX(#{$tab-w * ($i - 1)});
}
}
07Vertical tabs
A stack of tabs runs down a sidebar instead of across a header, and a 2px bar to the left slides with translateY to point at whichever row is open. It's item 01 with the axis swapped, but a vertical list tends to grow past its first few rows. That's why the bar's travel distance is set from the row height rather than hard-coded per tab.
.vt__indicator {
position: absolute; left: -2px; top: 0;
width: 2px; height: $row-h;
background: $color;
transform: translateY(0);
transition: transform $duration $easing;
}
#vt-1:checked ~ .vt__nav .vt__indicator { transform: translateY(0); }
#vt-2:checked ~ .vt__nav .vt__indicator { transform: translateY(#{$row-h}); }
#vt-3:checked ~ .vt__nav .vt__indicator { transform: translateY(#{$row-h * 2}); }
08Bounce tab
The indicator's spring easing carries it a little past the checked tab's edge before settling back, and only the label of the tab someone just picked plays a short @keyframes pop. The label color never changes between tabs — the bounce and the indicator's position are the only signals that a tab is active, on purpose.
.bt__indicator {
transform: translateX(0);
transition: transform $duration $easing;
}
#bt-2:checked ~ .bt__nav .bt__indicator { transform: translateX(100%); }
#bt-2:checked ~ .bt__nav label[for="bt-2"] .bt__text {
animation: bt-pop 400ms $easing;
}
@keyframes bt-pop {
0% { transform: scale(1); }
55% { transform: scale(1.18); }
100% { transform: scale(1); }
}
09Tab count badge
Every tab in this mailbox-style row carries a number badge, built on the same role="tablist" markup and the same select() function as item 05. Only the badge belonging to the tab that just opened plays a scale pop, so a glance tells someone which count they're now looking at without reading the label text.
.cb__badge {
transform: scale(1);
transition: transform $duration $easing;
}
.cb__tab[aria-selected="true"] .cb__badge {
animation: cb-pop $duration $easing;
}
@keyframes cb-pop {
0% { transform: scale(1); }
55% { transform: scale(1.35); }
100% { transform: scale(1); }
}
Where it breaks — the trap
Every indicator and panel above moves only transform and opacity, on purpose, but two failures still slipped through while building this set. The first came from item 02's pill: writing its width ratio as scaleX(calc(76px / 56px)) at runtime looked reasonable and produced nothing — the pill never moved a pixel. CSS's calc() spec doesn't allow dividing one length by another, so that whole declaration gets thrown out rather than rounded to something close. The fix was computing the ratio as a plain number at Sass compile time instead. The second, bigger trap was that a demo loop and the real checked state can quietly disagree. Item 04's panel track and its underline started out on two separate @keyframes animations, and the auto-playing loop caught the track showing panel two while the underline sat under panel one. Item 08 had its own version of the same failure: the active tab's label briefly turned a bright color. During the loop, that bright text landed on a plain white stage while the indicator was elsewhere, nearly invisible against it. Both got fixed the same way — sharing one @keyframes timeline between whatever pieces are supposed to agree, and keeping label color fixed so only position and motion carry the active signal. The zip below opens with 32dvwmre, read straight off this sentence and typed with no spaces trimmed or added anywhere.
Accessibility
01 through 04 and 06 through 08 are native <input type="radio"> groups, so Tab moves focus into the set and the arrow keys (up/down for the vertical layout) step between tabs with no extra markup at all. 05 and 09 replace that with role="tablist", role="tab", and a live aria-selected, which means the same select() function has to move focus and update aria-selected on both a click and an arrow key by hand:
t.setAttribute('aria-selected', on);
t.tabIndex = on ? 0 : -1;
Under prefers-reduced-motion: reduce, 01 through 04, 06, and 07 drop their transition to none, while 05 and 09 — driven by @keyframes rather than a transition — drop animation to none instead, and 08 turns off both. Either way, the indicator, the panel, and the badge jump straight to whatever the checked tab's end state is instead of easing there. The keyboard pattern items 05 and 09 follow comes from the W3C ARIA Authoring Practices tabs pattern, and MDN documents what triggers prefers-reduced-motion on each platform.
More click-triggered CSS sits in the CSS category hub, and what this site does and doesn't sell is on the about page.
FAQ
Do I have to use role="tablist", or does a radio group work as a css tab switch animation?
A radio group is enough for most tab menus — 01 through 04 and 06 through 08 above run on nothing but :checked and get arrow-key movement from the browser for free. role="tablist" only earns its keep when the markup can't be a native radio group in the first place, which is exactly why 05 and 09 build it by hand with buttons instead.
Why does my indicator show one tab while the panel shows another?
That's the desync trap covered above: an indicator and a panel driven by two separate @keyframes timelines can drift apart during an unattended loop, even though a real click keeps them lined up. Put both pieces on one shared timeline, or drive both directly off the same :checked selector instead of a parallel animation, and the mismatch goes away.
Can I build the same tab switch in React instead of radio inputs?
Yes — keep one useState value for the active index, then pass that index into a CSS variable such as --i and let transform: translateX(calc(var(--i) * 100%)) place the indicator instead of writing three :checked selectors by hand. $duration, $easing, and $color from the SCSS above translate directly into props passed down the same way.