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

9 CSS Tab Panel Transition Patterns to Copy

A css tab panel roundup: Height-morph times its stretch by the pixel gap between panels; stack shrinks by index distance; elastic-slide overshoots by a spring

Auto-plays · click a tile to jump to its section · all nine in one zip

None of the nine panels below fetch, navigate, or load anything real — every panel body is static filler text or a short list baked straight into the HTML, and nothing here calls Math.random or reaches for a library. Swap the filler for real content and the transition logic underneath doesn't need to change at all.

Anatomy of nine tab-panel transitions — where the one layer of calculation runs

Every pattern below reads the same trigger — a tab gets clicked or arrow-keyed into — and answers a different question with it. Height-morph asks how far apart two panels' measured heights are. The index-distance patterns ask how many tabs were skipped. The click-driven ones ask where on the tab bar you clicked. The spring pattern asks how hard to overshoot before settling. That's the css tab transition this page is actually about: not nine coats of paint on the same slide, but nine different values feeding the same swap.

Seven of the nine (01, 02, 03, 04, 06, 07, 08) compute a number once in JavaScript, apply it as a single style or class change, and let a CSS transition interpolate the rest. Two (05, 09) recompute their value on every requestAnimationFrame tick instead — pattern 05 rewrites blur and opacity every frame from a progress ratio, pattern 09 re-solves a spring equation every frame. Pattern 07 also runs a requestAnimationFrame loop, but only to redraw the on-screen degree counter next to the demo; the flip itself is one CSS transition on transform, so it sits on the CSS-transition side of that split.

This is a companion piece to 9 Segmented Control UI Patterns — Copy-Paste, not a repeat of it — that post computes where the sliding indicator pill sits behind the labels. This one starts after a tab is already selected and computes what happens to the panel content underneath: its height, its clip shape, its depth, its blur, its angle, or its spring position. Different layer, different math.

Code per pattern

Each block below is the exact function that owns that pattern's one calculated value, copied straight out of its index.html — nothing merged, nothing renamed.

01Tab Container a Measured Height Gap Stretches

The measured heights of the outgoing and incoming panel are read right before the swap, and the gap between them in pixels — getBoundingClientRect(), not a guess — sets how long the container's wrap.style.height takes to interpolate to the next panel's height. A bigger gap means a longer transition; a near-identical gap barely animates at all. Good fit for settings screens, profile tabs, and FAQ sections where panels rarely share a height.

Tab Container a Measured Height Gap Stretches
Open in a new tab

01 · Tab Container a Measured Height Gap Stretches — Measured area 42.901% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

getBoundingClientRect()wrap.style.height180~620ms
    var fromH = rectHeight(active);
    var toH = rectHeight(idx);
    var diff = Math.abs(toH - fromH); // calc layer: measured height difference between the two panels (px)
    var dur = Math.min(MAX_MS, Math.max(MIN_MS, BASE_MS + diff * MS_PER_PX)); // calc layer: the bigger the difference, the longer the transition

    wrap.style.setProperty('--tp-dur', dur.toFixed(0) + 'ms');
    wrap.style.height = fromH.toFixed(2) + 'px';
    wrap.getBoundingClientRect(); // forced reflow — lock in the starting height before the target height is set

    tabs[active].classList.remove('is-active');
    tabs[active].setAttribute('aria-selected', 'false');
    tabs[active].setAttribute('tabindex', '-1');
    panels[active].setAttribute('hidden', '');

    tabs[idx].classList.add('is-active');
    tabs[idx].setAttribute('aria-selected', 'true');
    tabs[idx].setAttribute('tabindex', '0');
    panels[idx].removeAttribute('hidden');

    requestAnimationFrame(function () {
      wrap.style.height = toH.toFixed(2) + 'px';
    });

    diffEl.textContent = Math.round(diff);

02Slide Panel an Index-difference Value Steers

idx - active gives both the sign (which way to slide) and, once you take its absolute value, the distance (how many tabs got skipped). Skip more than one tab and the travel duration stretches by STEP_MS per extra tab, so a jump from tab 1 to tab 4 visibly takes longer than a next-door click. translateX() moves the track; a resize listener re-runs the same placement math whenever the viewport width changes, because the travel distance is a pixel value derived from viewport.clientWidth.

Slide Panel an Index-difference Value Steers
Open in a new tab

02 · Slide Panel an Index-difference Value Steers — Measured area 6.432% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateX()STEP_MS인덱스 부호
  function select(idx) {
    if (idx === active) return;
    var delta = idx - active; // calc layer: new index minus previous index
    var dir = delta > 0 ? 1 : -1; // calc layer: the sign decides the slide direction
    var dist = Math.abs(delta); // calc layer: how many tabs were skipped
    var dur = Math.min(MAX_MS, BASE_MS + (dist - 1) * STEP_MS); // calc layer: skipping more tabs makes the move take longer
    var w = viewport.clientWidth;

    track.style.setProperty('--sp-dur', dur.toFixed(0) + 'ms');
    track.style.transform = 'translateX(' + (-idx * w).toFixed(2) + 'px)';

    tabs[active].classList.remove('is-active');
    tabs[active].setAttribute('aria-selected', 'false');
    tabs[active].setAttribute('tabindex', '-1');
    panels[active].setAttribute('aria-hidden', 'true');

    tabs[idx].classList.add('is-active');
    tabs[idx].setAttribute('aria-selected', 'true');
    tabs[idx].setAttribute('tabindex', '0');
    panels[idx].setAttribute('aria-hidden', 'false');

    distEl.textContent = String(dist);
    dirEl.textContent = dir > 0 ? '→' : '←';
    active = idx;
  }

03Clip-path Panel a Click-position Ratio Wipes

The clicked tab's x position becomes a ratio of the full tab-bar width — 0 at the left edge, 1 at the right. That ratio seeds a clip-path: inset() starting slit, so the new panel wipes into view from the exact horizontal point you clicked instead of from a fixed edge. Suits product-spec tabs and pricing-plan panels where the click position is already meaningful to the reader.

Clip-path Panel a Click-position Ratio Wipes
Open in a new tab

03 · Clip-path Panel a Click-position Ratio Wipes — Measured area 7.335% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

clipPathinset()클릭 x 비율
    var barRect = tabsBar.getBoundingClientRect();
    var x = (typeof originX === 'number') ? originX : (tabs[idx].getBoundingClientRect().left + tabs[idx].getBoundingClientRect().width / 2);
    var r = barRect.width > 0 ? Math.min(1, Math.max(0, (x - barRect.left) / barRect.width)) : 0.5; // calc layer: click x position -> ratio (0 to 1) of the tab-bar width
    var pct = (r * 100).toFixed(1) + '%';

    tabs[active].classList.remove('is-active');
    tabs[active].setAttribute('aria-selected', 'false');
    tabs[active].setAttribute('tabindex', '-1');
    panels[active].classList.remove('is-visible');
    panels[active].setAttribute('hidden', '');

    var next = panels[idx];
    next.style.transition = 'none';
    next.style.clipPath = 'inset(0 calc(100% - ' + pct + ') 0 ' + pct + ')'; // calc layer: start as a zero-width slit at the ratio point
    next.removeAttribute('hidden');
    next.getBoundingClientRect();
    next.style.transition = '';
    requestAnimationFrame(function () {
      next.style.clipPath = 'inset(0 0 0 0)'; // next frame switches to a full reveal -> the transition draws the wipe shape
      next.classList.add('is-visible');
    });

    tabs[idx].classList.add('is-active');
    tabs[idx].setAttribute('aria-selected', 'true');
    tabs[idx].setAttribute('tabindex', '0');

04Stack Panel an Index-distance Value Lays Back

Every panel gets its integer distance from the active tab written onto a --d custom property; the farther a panel sits from the active index, the smaller and dimmer it renders and the further back it sits on the z-axis, so outgoing panels lay back like a stack of cards instead of just disappearing. Reaches for image-gallery caption tabs and portfolio category switches, where the "next card in line" feeling is the point.

Stack Panel an Index-distance Value Lays Back
Open in a new tab

04 · Stack Panel an Index-distance Value Lays Back — Measured area 37.975% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

--dSCALE_STEPtranslateZ()
  function apply() {
    panels.forEach(function (p, i) {
      var d = Math.abs(i - active); // calc layer: integer index distance from the active tab
      p.style.setProperty('--d', String(d));
      p.setAttribute('aria-hidden', d === 0 ? 'false' : 'true');
    });
    var scale = (1 - SCALE_STEP).toFixed(2); // the very next layer's (distance 1) scale value, shown as the representative figure
    distEl.textContent = '1';
    scaleEl.textContent = scale;
  }

  function select(idx) {
    if (idx === active) return;
    tabs[active].classList.remove('is-active');
    tabs[active].setAttribute('aria-selected', 'false');
    tabs[active].setAttribute('tabindex', '-1');
    tabs[idx].classList.add('is-active');
    tabs[idx].setAttribute('aria-selected', 'true');
    tabs[idx].setAttribute('tabindex', '0');
    active = idx;
    apply();
  }

05Crossfade Panel a Progress Value Blurs Then Clears

This is one of the two patterns that never hands the interpolation to CSS — a requestAnimationFrame loop divides elapsed time by DUR_MS to get progress t from 0 to 1, then on every single tick rewrites the outgoing panel's blur and opacity proportional to t and the incoming panel's proportional to (1 - t). The two panels cross like a shifting camera focus, which is a natural home for blog category tabs and before/after image panels.

Crossfade Panel a Progress Value Blurs Then Clears
Open in a new tab

05 · Crossfade Panel a Progress Value Blurs Then Clears — Measured area 8.026% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

filter blur()BLUR_MAXt·(1-t)
    inEl.removeAttribute('hidden');
    inEl.classList.add('is-front');
    setT(outEl, 0, false);
    setT(inEl, 0, true);

    var start = 0;
    function frame(ts) {
      if (!start) start = ts;
      var t = Math.min(1, (ts - start) / DUR_MS); // calc layer: elapsed time measured / total duration = progress t (0 to 1)
      setT(outEl, t, false);
      setT(inEl, t, true);
      tEl.textContent = t.toFixed(2);
      if (t < 1) {
        raf = requestAnimationFrame(frame);
      } else {
        raf = 0;
        outEl.setAttribute('hidden', '');
        outEl.classList.remove('is-front');
        active = idx;
      }
    }
    raf = requestAnimationFrame(frame);

06Vertical Accordion Panel a Measured-height Value Folds

Unselected panels sit with max-height folded to zero, and the newly selected panel alone gets its max-height raised to a value read from el.firstElementChild.scrollHeight — its real measured content height — unfolding vertically in place instead of swapping sideways. A transitionend listener (onCollapseEnd) waits for the collapse to actually finish before hiding the element, and a resize listener re-measures that height whenever the viewport changes, since it's a pixel value tied to the current layout. Built for mobile FAQ tabs and narrow sidebar panels.

Vertical Accordion Panel a Measured-height Value Folds
Open in a new tab

06 · Vertical Accordion Panel a Measured-height Value Folds — Measured area 32.296% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password

scrollHeightmaxHeighttransitionend
  function measure(el) {
    return el.firstElementChild.scrollHeight; // calc layer: the content's measured height (px) — the max-height target value
  }

  function openPanel(el, instant) {
    el.removeAttribute('hidden');
    var h = measure(el);
    if (instant) { el.style.transition = 'none'; }
    el.style.maxHeight = h + 'px';
    if (instant) { el.getBoundingClientRect(); el.style.transition = ''; }
    hEl.textContent = String(h);
  }

  function onCollapseEnd(e) {
    if (e.propertyName !== 'max-height') return;
    var el = e.currentTarget;
    if (el.style.maxHeight === '0px') el.setAttribute('hidden', '');
  }

  function closePanel(el, instant) {
    if (instant) { el.style.transition = 'none'; }
    el.style.maxHeight = '0px';
    if (instant) { el.getBoundingClientRect(); el.style.transition = ''; el.setAttribute('hidden', ''); }
  }

073D-rotate Panel a Progress-angle Value Flips

The visible flip is a single CSS transition on transform: rotateY(), set once and left to the browser — but a parallel requestAnimationFrame loop reads the same elapsed-time ratio to redraw a live degree counter next to the card, purely for the reader watching the number climb from 0 to 180. Past 90 degrees the outgoing face hides via backface-visibility and the incoming face takes over the rest of the arc. A fit for card-style stats tabs and settings-panel switches that want a physical "turning over" read.

3D-rotate Panel a Progress-angle Value Flips
Open in a new tab

07 · 3D-rotate Panel a Progress-angle Value Flips — Measured area 36.107% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

rotateY()perspectivebackface-visibility
  function tickAngle(start, dir) {
    function step(ts) {
      var t = Math.min(1, (ts - start) / DUR_MS); // calc layer: elapsed time measured / total duration = progress t (0 to 1)
      var angle = Math.round(t * 180); // calc layer: convert progress into a 0-180deg rotateY angle value
      angleEl.textContent = String(angle);
      if (t < 1 && busy) { raf = requestAnimationFrame(step); } else { raf = 0; }
    }
    raf = requestAnimationFrame(step);
  }

08Radial-mask Panel a Click-coordinate Value Spreads

The clicked tab label's center coordinate becomes the center point of a clip-path: circle() on the incoming panel, and the radius grows from 0 to the panel's own diagonal length — Math.hypot(width, height) — so the reveal spreads outward from wherever you actually clicked rather than from a corner. Reserved for landing-page feature tabs and promo-banner panels chasing a punchy reveal.

Radial-mask Panel a Click-coordinate Value Spreads
Open in a new tab

08 · Radial-mask Panel a Click-coordinate Value Spreads — Measured area 6.911% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

clipPathcircle()getBoundingClientRect()
    var cy = tabRect.top + tabRect.height / 2 - wrapRect.top;
    var r = Math.hypot(wrapRect.width, wrapRect.height); // calc layer: the panel's diagonal length = the radius target value

    var incoming = panels[idx];
    var outgoing = panels[active];

    incoming.style.transition = 'none';
    incoming.style.clipPath = 'circle(0px at ' + cx.toFixed(1) + 'px ' + cy.toFixed(1) + 'px)';
    incoming.removeAttribute('hidden');
    incoming.classList.add('is-front');
    incoming.getBoundingClientRect();
    incoming.style.transition = '';
    rEl.textContent = '0';

    tabs[active].classList.remove('is-active');
    tabs[active].setAttribute('aria-selected', 'false');
    tabs[active].setAttribute('tabindex', '-1');
    tabs[idx].classList.add('is-active');
    tabs[idx].setAttribute('aria-selected', 'true');
    tabs[idx].setAttribute('tabindex', '0');

    requestAnimationFrame(function () {
      incoming.style.clipPath = 'circle(' + r.toFixed(1) + 'px at ' + cx.toFixed(1) + 'px ' + cy.toFixed(1) + 'px)';
      rEl.textContent = String(Math.round(r));
    });

09Slide Panel a Spring-constant Value Overshoots Then Settles

The other pattern that skips CSS transitions entirely — a requestAnimationFrame loop re-solves force = err * STIFFNESS - vel * DAMPING every tick, integrates it into velocity and position, and writes translateX() from that position directly, so the panel can genuinely overshoot its target and get pulled back by that excess before settling, the way a spring would. A resize listener recomputes the target travel width when the viewport changes; aimed at playful landing-page tabs and interactive demo panels.

Slide Panel a Spring-constant Value Overshoots Then Settles
Open in a new tab

09 · Slide Panel a Spring-constant Value Overshoots Then Settles — Measured area 8.585% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

STIFFNESSDAMPINGrequestAnimationFrame()
  function frame(ts) {
    if (!lastTs) lastTs = ts;
    var dt = Math.min(0.032, (ts - lastTs) / 1000);
    lastTs = ts;
    var err = target - pos;
    if (!crossed && Math.sign(err) !== Math.sign(startErr) && startErr !== 0) crossed = true; // calc layer: once it passes the target, start tracking the overshoot
    if (crossed) overshootPeak = Math.max(overshootPeak, Math.abs(pos - target));
    var force = err * STIFFNESS - vel * DAMPING; // calc layer: spring force = displacement*stiffness - velocity*damping
    vel += force * dt;
    pos += vel * dt;
    track.style.transform = 'translateX(' + pos.toFixed(2) + 'px)';
    overEl.textContent = overshootPeak.toFixed(1);
    if (Math.abs(target - pos) < 0.4 && Math.abs(vel) < 2) {
      pos = target; vel = 0; lastTs = 0;
      track.style.transform = 'translateX(' + pos.toFixed(2) + 'px)';
      raf = 0;
      return;
    }
    raf = requestAnimationFrame(frame);
  }

Where values swap the panel — height gap, index distance, progress

Pattern Value it computes Recomputed via Resize re-measures Frames moved (of 23)
01 measured height gap (px) CSS transition on height — 9
02 index-distance travel width CSS transition on transform yes 13
03 click-x ratio (0–1) CSS transition on clip-path — 12
04 integer index distance CSS transition via --d — 11
05 progress t (0–1) requestAnimationFrame — 14
06 measured content height (px) CSS transition on max-height yes 7
07 progress angle (0–180deg) CSS transition on transform — 13
08 click coordinate + diagonal radius CSS transition on clip-path — 8
09 spring position requestAnimationFrame yes 13

Where a single JS write hands off to a CSS transition — 01, 02, 03, 04, 06, 07, 08

Seven patterns compute one number, set it once, and let the browser's own transition engine interpolate every frame in between. That's why their code fences above are mostly setup: measure, compute, set a style or custom property once, done.

Where a frame loop recomputes the value every tick — 05, 09

Two patterns never delegate to a CSS transition at all — pattern 05's blur/opacity pair and pattern 09's spring position are both rewritten by hand inside a requestAnimationFrame callback on every single frame, which is also why they're the only two that can genuinely overshoot (09) or hold at a fractional blur value mid-flight (05) rather than following an easing curve the browser picked.

Where resize specifically forces a re-measure — 02, 06, 09

Only three of the nine attach a resize listener, and all three share one trait: their computed value is a pixel distance, not a percentage. Pattern 02's slide travel is -idx * viewport.clientWidth, pattern 06's fold height is a measured scrollHeight, and pattern 09's spring target is a pixel offset — resize the window and all three go stale until they're re-measured, so each wires a resize listener to redo the same math the initial switch already ran.

Where it breaks — accessibility, reduced-motion, arrow-key navigation

Every one of the nine shares the same ARIA tab wiring underneath its own transition: role="tablist" on the tab bar, role="tab" on each button, role="tabpanel" on each panel, aria-selected flipped on the pair being swapped, and aria-controls pointing the active tab at its panel. Roving tabindex follows the selection too — the code fences above all show tabindex moving from '0' to '-1' and back as active changes, which is what lets arrow keys (and Home/End, jumping to the first or last tab) move focus without a mouse.

What prefers-reduced-motion turns off

All nine check for reduced motion and skip straight to the end state instead of interpolating — no height morph, no slide, no wipe, no spring overshoot. Pattern 09 is the most explicit about it: its select() function branches on reduce before it ever starts the spring loop, snapping pos straight to target and skipping requestAnimationFrame entirely rather than just shortening a CSS transition duration to near-zero.

Where a tab-panel css component actually breaks first

The corner case isn't the transition — it's what happens if a panel's content changes height, shape, or position after the swap already measured it. Pattern 01 and pattern 06 both read a height once, before the transition starts; content that lazily grows taller mid-transition (an image finishing its own load, for instance) will finish the transition at the wrong size. That's a reason to keep panel content synchronous, not a flaw specific to any one pattern here.

The zip below carries all nine index.html files and their three variants, password-protected with na4hmpde — type it in by hand rather than copy-pasting it from a page that also shows the plain answer next to it, since that defeats the point of asking at all.

What changes when three constants move — and what doesn't

Variant Item Constant Base → variant What changes
01 Height-morph tab container MS_PER_PX 1.1 → 1.8 larger height differences should stretch the transition further, so the panel settles more slowly
04 Stack-depth panel SCALE_STEP 0.08 → 0.12 panels farther from the active index should shrink more per step, deepening the stack
09 Elastic-slide panel DAMPING 20 → 32 the overshoot should shrink, so the slide barely passes its target before settling

None of the three touches the ARIA attributes, the roving tabindex, or the reduced-motion branch — turning a constant up or down only changes how a value is scaled, never whether keyboard focus or prefers-reduced-motion gets respected.

Picking a pattern for the panel you're actually building

If the panels genuinely differ in height — a settings screen, a profile card, an FAQ block — reach for pattern 01 first; it's the only one that measures the actual gap and times itself to it, rather than assuming every panel is the same size. For a narrower mobile layout where vertical space is the constraint rather than height difference, pattern 06's accordion fold does the same measuring job but keeps everything in one column instead of swapping sideways.

For a dashboard with ordered sub-tabs, or an onboarding flow with numbered steps, the css tab component that actually communicates progress is pattern 02 — the slide direction and distance both come from the index difference, so jumping from step 1 to step 4 visibly travels further than stepping from 1 to 2. That directional cue is wasted on tabs with no inherent order, like a set of unrelated product categories.

Landing pages and promos are the one place where a click-origin reveal earns its keep: pattern 08's circle spreads from the exact spot a visitor tapped, which reads as responsive in a way a generic slide doesn't. Save it for a single hero-style tab set rather than a long settings list, where nine radial reveals in a row would just be noisy.

Where can I find css tab switch animation examples?

Every pattern above is the example — the code fence under each heading is copied straight out of that pattern's own index.html, not a simplified stand-in for it, and the live demo box next to it opens its own HTML/CSS/JS tabs so you can read the full file without leaving the page. If you're specifically after a tab switch animation css codepen-style writeup, that's what the demo boxes are for: a working result plus every line behind it, side by side.

Common questions about these nine tab-panel transitions

Why do patterns 06 and 08 move on so few of the 23 measured frames?

Both are one-shot switches rather than continuously running animations, so movement only exists for the length of a single transition — that's expected, not a bug. Pattern 06 moved on only 7 of 23 frames but still logged 32.3 cumulative area, meaning its fold covers a lot of screen space quickly rather than gradually. Pattern 08 moved on 8 of 23 frames with a much smaller 6.9 cumulative area, because a circular reveal starts from a near-zero radius — the first several ticks barely touch any pixels until the circle grows large enough to matter. Compare that with pattern 05's 14 of 23 (a requestAnimationFrame loop rewriting blur every tick) to see the difference a per-frame driver makes.

Is there a plain css tab design here with no JavaScript at all?

No — every one of the nine intentionally computes something (a measured height, an index distance, a click ratio, a spring force) in JavaScript before or during the swap. A pure CSS :checked/:target toggle can switch a tab panel instantly, but it can't read a real measured height or a real click coordinate, which is exactly the one calculated layer each pattern here is built around.

Do any of these nine panels fetch or load real content?

No. Every panel body is static filler text or a short list already sitting in the HTML — nothing here calls fetch, loads an image, or reaches for an external library. Swap the filler for your own content and the transition logic above doesn't need to change.

Sources: MDN — Element.getBoundingClientRect() · MDN — clip-path · MDN — transform · MDN — Using the tab role · MDN — prefers-reduced-motion

Related reading: 9 Segmented Control UI Patterns — Copy-Paste · CSS Bottom Navigation Bar: 9 Transition Patterns · 9 CSS Glitch Effect Transitions (No Codepen) — the first computes where the selector itself sits, one layer above what this post covers; the second computes state-driven index transitions the same way pattern 02 computes its slide direction, just for a bottom nav instead of a tab bar; the third also drives every visible frame from a computed value, but toward a glitch offset instead of a height, a clip shape, or a spring position.

Enter the archive password

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