Components
Liquid tabs: an animated tab indicator that stretches
A tab indicator that stretches toward the new tab, then pulls its tail in. Live demo, CSS and a little JS, React, timing values and the FLIP alternative.
A sliding tab indicator is standard. A liquid one is a small upgrade that people notice: when you click a tab to the right, the pill first stretches out to reach it, then its back edge catches up. For a moment the indicator covers both tabs, and that brief stretch is what gives it the feel of something soft being pulled rather than a block being moved.
The whole effect comes from one idea: animate the two edges of the pill separately, and delay whichever edge is trailing. This guide builds it with HTML, CSS and about twenty lines of JavaScript, then in React, and covers View Transitions and FLIP as alternatives.
Overview panel
How the animation is put together
The pill is absolutely positioned inside the tab bar, with its left and right set to match the selected tab. Changing tabs changes both values. What makes it liquid is that they do not change together.
| Part | Property | Duration | Delay | Easing |
|---|---|---|---|---|
| Leading edge | right when moving right, left when moving left | 260 ms | 0 ms | cubic-bezier(.3, .7, .4, 1) |
| Trailing edge | the other one | 260 ms | 100 ms | cubic-bezier(.3, .7, .4, 1) |
| Tab label colour | color | 200 ms | 80 ms | ease |
| Resize and first paint | left, right | none | none | instant |
The full move takes 360 ms. For the first 100 ms only the front edge travels, so the pill grows. Then the back edge starts, and because both edges use the same curve, the pill contracts back to the target tab's width. The label colour flips 80 ms in, just as the pill reaches the new tab.
The HTML
<div class="liq" role="tablist" aria-label="Plan view">
<span class="liq-pill" aria-hidden="true"></span>
<button class="liq-tab" type="button" role="tab" id="liq-t1"
aria-controls="liq-p" aria-selected="true">Overview</button>
<button class="liq-tab" type="button" role="tab" id="liq-t2"
aria-controls="liq-p" aria-selected="false" tabindex="-1">Activity</button>
<button class="liq-tab" type="button" role="tab" id="liq-t3"
aria-controls="liq-p" aria-selected="false" tabindex="-1">Files</button>
<button class="liq-tab" type="button" role="tab" id="liq-t4"
aria-controls="liq-p" aria-selected="false" tabindex="-1">Settings</button>
</div>
<p class="liq-panel" id="liq-p" role="tabpanel" aria-labelledby="liq-t1">Overview panel</p>
The pill is a sibling of the tabs, not part of any one of them, so it can travel freely. The markup follows the ARIA tabs pattern: only the selected tab is in the tab order (tabindex="-1" on the rest) and arrow keys move between them.
The CSS
.liq { position: relative; display: flex; gap: 2px; padding: 5px;
border-radius: 999px; background: #1c1f26; }
.liq-pill { position: absolute; top: 5px; bottom: 5px;
left: var(--l, 5px); right: var(--r, 5px);
border-radius: 999px; background: #fb9f3d; }
/* moving right: the right edge leads, the left edge follows */
.liq.is-fwd .liq-pill {
transition: right .26s cubic-bezier(.3, .7, .4, 1),
left .26s cubic-bezier(.3, .7, .4, 1) .1s; }
/* moving left: the reverse */
.liq.is-back .liq-pill {
transition: left .26s cubic-bezier(.3, .7, .4, 1),
right .26s cubic-bezier(.3, .7, .4, 1) .1s; }
.liq-tab { position: relative; z-index: 1; padding: 9px 16px;
border: 0; border-radius: 999px; background: none; color: #a1a1aa;
font: 600 14px Inter, sans-serif; cursor: pointer;
transition: color .2s ease .08s; }
.liq-tab[aria-selected="true"] { color: #140a00; }
.liq-tab:focus-visible { outline: 2px solid #fff; outline-offset: -2px; }
@media (prefers-reduced-motion: reduce) {
.liq.is-fwd .liq-pill, .liq.is-back .liq-pill, .liq-tab { transition: none; }
}
The pill has no transition at all unless the bar has a direction class. That is deliberate: on page load and on window resize the pill should jump to the right place, not slide there from the corner.
The JavaScript
const bar = document.querySelector('.liq');
const tabs = [...bar.querySelectorAll('.liq-tab')];
const panel = document.getElementById('liq-p');
let current = 0;
function place(i) {
const t = tabs[i];
bar.style.setProperty('--l', t.offsetLeft + 'px');
bar.style.setProperty('--r', (bar.clientWidth - t.offsetLeft - t.offsetWidth) + 'px');
}
function select(i, focus) {
if (i === current) return;
bar.classList.remove('is-fwd', 'is-back');
bar.classList.add(i > current ? 'is-fwd' : 'is-back');
tabs[current].setAttribute('aria-selected', 'false'); tabs[current].tabIndex = -1;
tabs[i].setAttribute('aria-selected', 'true'); tabs[i].tabIndex = 0;
if (focus) tabs[i].focus();
panel.setAttribute('aria-labelledby', tabs[i].id);
panel.textContent = tabs[i].textContent + ' panel';
current = i;
place(i);
}
tabs.forEach((t, i) => {
t.addEventListener('click', () => select(i, false));
t.addEventListener('keydown', e => {
const n = tabs.length;
const j = { ArrowRight: (i + 1) % n, ArrowLeft: (i - 1 + n) % n, Home: 0, End: n - 1 }[e.key];
if (j !== undefined) { e.preventDefault(); select(j, true); }
});
});
function reset() { bar.classList.remove('is-fwd', 'is-back'); place(current); }
reset();
addEventListener('resize', reset);
document.fonts?.ready.then(reset);
The direction class has to be set before the new --l and --r values, because a transition uses whatever transition rule is in effect when the value changes. Waiting on document.fonts.ready matters too: tab widths change once the web font loads, and the pill would otherwise sit a few pixels off.
The React version
import { useLayoutEffect, useRef, useState } from 'react';
export function LiquidTabs({ tabs, onChange }) {
const bar = useRef(null);
const refs = useRef([]);
const [active, setActive] = useState(0);
const [dir, setDir] = useState(null);
const [edges, setEdges] = useState({ l: 5, r: 5 });
useLayoutEffect(() => {
const measure = () => {
const t = refs.current[active];
setEdges({ l: t.offsetLeft, r: bar.current.clientWidth - t.offsetLeft - t.offsetWidth });
};
measure();
const onResize = () => { setDir(null); measure(); };
addEventListener('resize', onResize);
return () => removeEventListener('resize', onResize);
}, [active]);
const select = (i, focus) => {
if (i === active) return;
setDir(i > active ? 'is-fwd' : 'is-back');
setActive(i);
onChange?.(i);
if (focus) refs.current[i].focus();
};
const onKeyDown = (e, i) => {
const n = tabs.length;
const j = { ArrowRight: (i + 1) % n, ArrowLeft: (i - 1 + n) % n, Home: 0, End: n - 1 }[e.key];
if (j !== undefined) { e.preventDefault(); select(j, true); }
};
return (
<div ref={bar} role="tablist" className={'liq' + (dir ? ' ' + dir : '')}
style={{ '--l': edges.l + 'px', '--r': edges.r + 'px' }}>
<span className="liq-pill" aria-hidden="true" />
{tabs.map((label, i) => (
<button key={label} ref={el => (refs.current[i] = el)} className="liq-tab"
type="button" role="tab" aria-selected={i === active}
tabIndex={i === active ? 0 : -1}
onClick={() => select(i, false)} onKeyDown={e => onKeyDown(e, i)}>
{label}
</button>
))}
</div>
);
}
It uses the same CSS. The direction class and the new active index are set in the same state update, so React renders both together and the transition rule is in place when --l and --r change in the layout effect.
Alternatives: Framer Motion, View Transitions and FLIP
- Framer Motion
layoutId. Render<motion.span layoutId="pill" />inside the active tab and Framer Motion animates it between tabs. It is the least code, but both edges move together, so you get a slide rather than a stretch. - View Transitions. Give the pill
view-transition-name: tab-pilland wrap the state change indocument.startViewTransition(). The browser morphs the pill's box. To get the liquid stretch you would customize::view-transition-group(tab-pill)keyframes, which is more work than the two-edge transition here. - FLIP with transforms. Measure the old and new boxes, then animate
translateXandscaleX. It avoids layout on every frame, which helps on long or busy pages, butscaleXdistorts the rounded ends, so you need separate end caps to keep them round.
Timing that feels right
- Edge duration: 220 to 300 ms. Tabs are clicked often; anything slower than about 350 ms total makes the interface feel like it is waiting for the animation.
- Trailing delay: 80 to 120 ms. Below 60 ms the stretch is too short to see. Above 150 ms the pill looks like it is breaking in two.
- Curve: a fast-out ease such as
cubic-bezier(.3, .7, .4, 1). It starts quickly so the click feels answered, and settles softly. For more bounce, trycubic-bezier(.5, 1.6, .4, .8)on the trailing edge only; a small overshoot there reads as the liquid settling. The easing functions guide explains how to read these numbers. - Label colour: 200 ms, delayed 60 to 100 ms. If the colour changes before the pill arrives, the text flashes dark on a dark background.
Accessibility notes
- Use
role="tablist",role="tab"androle="tabpanel", witharia-selectedon the active tab andaria-controlsandaria-labelledbylinking tabs and panel. - Arrow keys, Home and End move between tabs and select them; Tab moves focus out to the panel. Only the active tab has
tabindex="0". - The pill is
aria-hidden. Selection is conveyed byaria-selected, and visually by both the pill and the label colour, so it does not depend on motion. - Under
prefers-reduced-motion: reducethe pill jumps to the new tab with no stretch. - Check the contrast of the selected label on the pill (dark text on
#fb9f3dhere) and of unselected labels on the bar.
Copy one you already like
Tab indicators are one of the places where a few milliseconds change the feel, and the delay between the two edges is hard to judge by eye. If a product's tabs feel better than yours, open the page, start the Capture Motion extension and click between tabs. It records the keyframes, durations, delays and easing of the indicator and exports them as HTML and CSS, React, GSAP, JSON, an AI prompt, or a Figma Motion timeline. The popup shows the code in CSS, React and GSAP tabs with a Copy button. To find the exact curve a site uses, see how to find the easing curve of an animation. Or start from a ready-made component in the Capture Motion gallery and export it.
Questions
What makes a tab indicator feel liquid?
The two edges move at different times. The leading edge moves first and the trailing edge follows about 100 ms later, so the pill stretches toward the target and then contracts, like a drop of liquid.
Is animating left and right bad for performance?
They trigger layout, but only for one absolutely positioned element inside a small tab bar, which is cheap. For long lists or low-end devices, use transform with scaleX or clip-path instead.
Can I do this with the View Transitions API?
Yes. Give the indicator a view-transition-name and wrap the state change in document.startViewTransition. The browser morphs it between positions, but both edges move together, so you lose the stretch unless you customize the keyframes.
Can I copy a tab animation from another site?
Yes. The Capture Motion Chrome extension records the keyframes, durations, delays and easing of the indicator as you click between tabs, and exports them as CSS, React or JSON.
