GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

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

A tab animation css codepen is easy to fork, but 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. Push the same transition out to the side of the viewport and it becomes a sidebar drawer; scale it up until whole pages swap and it lands in CSS screen transition effects.

01Sliding underline indicator

Three radios behind three labels

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.

Sliding underline indicator
Open in a new tab

01 · Sliding underline indicator — Measured area 2.301% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

:checkedtranslateX300ms

Arrow keys for free

Because it's a native radio group, the browser already moves focus with the arrow keys with zero JavaScript written for it. Behind the underline, a ::after ghost leaves on the same @keyframes but 120ms late, so the bar overshoots its target and settles back with a visible trail.

.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 that resizes on the way

A pill-shaped background rides behind the tabs and slides to whichever one is checked, resizing its width with scaleX on the way there.

Pill background move
Open in a new tab

02 · Pill background move — Measured area 3.272% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateXscaleX:checked

Fixed widths in the file

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. A delayed ::after copy of the pill runs behind it, so for a moment two pills overlap as the real one changes slots.

$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

Three panels in one grid cell

The three panels share one grid cell, stacked directly on top of each other, and only the checked one's opacity rises to 1.

Panel fade
Open in a new tab

03 · Panel fade — Measured area 2.14% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

opacitygrid 겹침220ms

Why nothing jumps

Because nothing ever leaves its slot in the layout, the switch never causes the surrounding content to jump. In the demo loop the incoming panel rises from 8px below and drifts 2px past zero before settling, giving the fade a direction instead of a flat crossfade.

.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

A track three times as wide

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.

Panel slide
Open in a new tab

04 · Panel slide — Measured area 2.853% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateXoverflow:hidden300ms

Why a css tab slide animation needs one timeline

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. The track stays untouched while the underline alone gains a delayed trail layer, so the light bar reads as lagging a beat behind the heavy track it belongs to.

.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

Dropping the radio group

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.

Icon tabs
Open in a new tab

05 · Icon tabs — Measured area 0.301% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password

role=tablistaria-selectedscale

One function, two input methods

The icon of the newly active tab is the only thing that scales up, with a circular ghost ring blooming and fading behind it so keyboard-only travel still leaves a visible beat.

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

Snapping on every tab boundary

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.

Horizontal scroll-snap tabs
Open in a new tab

06 · Horizontal scroll-snap tabs — Measured area 2.21% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

scroll-snap-typeoverflow-xtranslateX

The same translateX, multiplied

The underline below still tracks the checked tab with the same translateX trick as item 01, just multiplied by that fixed width. Its easing is now an lerp curve that overshoots the target by 12.6% before settling, with a trail drawn behind it — the scroll snaps hard while the indicator flows.

.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

The same idea, axis swapped

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.

Vertical tabs
Open in a new tab

07 · Vertical tabs — Measured area 1.549% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateY:checked300ms

Travel set from the row height

That's why the bar's travel distance is set from the row height rather than hard-coded per tab, with the same lerp trail running vertically behind it so a long list keeps its direction readable.

.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

A spring that overshoots

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.

Bounce tab
Open in a new tab

08 · Bounce tab — Measured area 7.166% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

cubic-bezier 스프링scaletranslateX

Why the label color stays put

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. A trail lingers briefly where the indicator came from, showing exactly which slot it jumped across from.

.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

The same tablist, one 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.

Tab count badge
Open in a new tab

09 · Tab count badge — Measured area 2.66% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

role=tablistaria-selectedscale

A glance instead of reading

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. A fading glow behind the badge keeps the trace of the pop, so on screens where the counts change often, the last change stays followable.

.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

Two failures that slipped through

When the tab strip has to stay pinned as the page moves, the sticky header variants sort out the overlap that fixed layer creates before it reaches the tabs.

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

Native radios versus a hand-built tablist

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;

What reduced motion leaves

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.

Where the rest lives

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 css tab animation 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. Past roughly ten tabs, where the labels need grouping of their own, a mega menu navigation pattern is the better container.

Enter the archive password

The password is inside this article. You will find it as you read.