
Loading the page…
Loading the page…
How Fluid Tab Bar stretches a pill between tabs with two springs and a layout computed in worklets, and six traps of Reanimated and SVG on the web.
Written with AI: Claude Opus 5.5, through Claude
6 lessons learned building it
The product
Fluid Tab Bar — Animated Liquid Bottom Navigation for React Native & ExpoFree · Mobile component

The product
Free · Mobile component
A floating bottom tab bar for React Native whose highlight stretches from tab to tab like liquid. Two styles (a pill behind the icon, or a tab that expands to show its label), badges that pop, icons that light up as the pill passes. Reanimated 4, light and dark, touch and mouse.
Fluid Tab Bar is a floating bottom navigation for React Native and Expo whose highlight moves like a drop of liquid: its leading edge darts to the tab you pick, its trailing edge follows, so it stretches and thins on the way and settles round on arrival. Icons it passes light up, badges pop when their count grows, and in the expand style the focused tab opens to show its label. It works in light and dark, with a finger and with the mouse.

This article is for anyone animating layout with Reanimated 4 who wants it to run in a browser through React Native Web as well as on a phone. It explains the two springs and the layout that runs on the UI thread, then six traps: two met on Fluid Tab Bar itself and four on its siblings in the same kit (Swipe Deck, Morph Text and Choice Chips), all about Reanimated or SVG on the web.
Everything is three shared values, each a fractional tab index: pos, where the focus is, and left and right, the pill's two edges. When the focused tab changes, all three spring to the new index, but not with the same spring: the edge on the side of travel uses LEAD, the other TRAIL.
// pos: where the focus is, as a fractional tab index; left and right: the pill's two edges, the same way.
const pos = useSharedValue(index);
const left = useSharedValue(index);
const right = useSharedValue(index);
useEffect(() => {
const from = previous.current;
previous.current = index;
if (from === index) return;
if (reduceMotion) {
pos.value = withTiming(index, QUICK);
left.value = withTiming(index, QUICK);
right.value = withTiming(index, QUICK);
return;
}
pos.value = withSpring(index, BODY);
left.value = withSpring(index, index > from ? TRAIL : LEAD);
right.value = withSpring(index, index > from ? LEAD : TRAIL);
}, [index, pos, left, right, reduceMotion]);The constants are a spring's physical terms (stiffness k, damping c, mass m), and two numbers tell how each one moves: its natural frequency ω₀ = √(k / m) and its damping ratio ζ = c / (2·√(k·m)).
LEAD (k 420, c 22, m 0.6): ω₀ ≈ 26.5 rad/s, ζ ≈ 0.69, quick and slightly bouncy.TRAIL (k 150, c 24, m 0.9): ω₀ ≈ 12.9 rad/s, ζ ≈ 1.03, half as fast and critically damped, so it never overshoots.BODY (k 240, c 20, m 0.7), for pos: ω₀ ≈ 18.5 rad/s, ζ ≈ 0.77.The leading edge arrives first and the trailing one catches up, which is the stretch. With reduce motion on, all three glide with the same 180 ms timing and the pill never stretches.
The pill's edges are fractional indices, so turning them into points needs the tabs' layout on every frame. That layout is a handful of small worklet functions: tabWidth (an equal share in pill; in expand, the icon plus its label in proportion to how focused the tab is), tabX (where a tab starts, with the room left over spread between them), boxAt (the box at a fractional index, interpolated between two tabs) and cover (how much the pill covers a tab's icon: 1 once the icon's centre is 14 points inside both edges, 0 outside).
Because tabWidth reads pos, the tabs in expand re-flow every frame as the focus moves, with no React render: the labels' widths are measured once, off screen, into a shared value.
The pill's own style reads those helpers. Stretched, the pill thins a little, as a drop does: its height drops by 0.09 × (width ÷ width at rest − 1), never by more than 14%.
Each tab draws its icon twice, in the muted colour and in the colour on the pill, and cross-fades them by max(weight(pos), cover), so an icon lights up while the pill sweeps over it on its way, not only on arrival. A badge does the same with its colours: its ring is the bar's colour on the bar and the pill's colour on the pill, so no grey ring sits on the pill, and on the pill it turns the pill's text colour with digits in the pill's colour, so it still reads. The first version of the badge took that cover as a function.
The icons and the demos' pictures are react-native-svg. On the web its <Svg> is a real <svg> element in the page, which brings two things a phone never shows. Gradient ids belong to the whole page, so the travel demo, which draws several landscapes on one screen, builds each landscape's ids from its name. And layering follows CSS, which the sibling Choice Chips found out.
Both are about timings, and both showed up only when frames were sampled one by one.
Swipe Deck, a card stack, throws each card off with two timings, one per axis, and tells the deck first that a card is leaving and then that it's gone.
npm run check -- fluid-tab-bar: its rules, TypeScript, a web export of the product alone, and 421 KB of JavaScript gzipped, within the kit's 600 KB budget.variant="expand" makes the focused tab grow to show its label; pill (the default) keeps the tabs equal and the pill round the icon.tint is the pill's colour and onTint the focused icon's and label's colour on it.height (64) sets the bar, and the pill and icons follow.icon is any component that takes color, size and focused; TabIcons has nine to start with. badge is a count (99+ past 99) or true for a dot.LEAD and TRAIL: the bigger the gap between their natural frequencies, the longer the stretch.Fluid Tab Bar — Animated Liquid Bottom Navigation for React Native & Expo isn’t installed with a command: get it from its page.
Written by
Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork
Full-stack developer and founder of UpFork · .NET, React/Next.js and WebGL
This version is signed in UpFork’s ledger as entry #214, so anyone can check it hasn’t changed since it was published. Check it.
Something wrong in it, or against the rules? Report it.

Behind the build · Confirm Button — Two-Step Delete & Hold-to-Confirm Button for React Native & Expo
How Confirm Button draws a liquid cancel bubble with Skia geometry and a Reanimated state machine, and seven traps of React Native Skia on the web.
Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork10 min readAI
7 lessons


Behind the build · Arc Picker — Curved Dial Carousel & Filter Picker for React Native & Expo
How Arc Picker puts items on a true circle from one Reanimated shared value and one pan, and eight web traps in Gesture Handler, Skia, haptics and ARIA.
Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork9 min readAI
8 lessons


Behind the build · Marketly – Trading platform landing page
How Marketly's three.js Earth turns towns on at dusk, draws price charts between lit cities, and three rendering traps found while building it.
Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork8 min readAI
3 lessons
module-level helpers could be written in any order, as plain JavaScript function declarations can, since they are hoisted.
once cover was added, the social demo came up blank on the web. cover, a worklet helper, had been written above boxAt, which it calls. On the web a worklet's closure is taken where the worklet is defined, so cover held a boxAt that wasn't initialized yet.
cover moved below boxAt, and every helper in the file now comes after the helpers it calls. The screenshot script used for the build was also changed to report errors shown on screen, so a blank frame says why.
the build log ("Worklet helpers are closed over at definition, so cover must come after boxAt"); commit d9a27d5; fluid-tab-bar.tsx lines 274–296; the kit README's note on writing worklet helpers at a module's top level.
the badge's ring didn't take the pill's colour when the pill covered it. The shared values cover reads (the pill's edges and the layout) were hidden inside the function passed as a prop, out of sight of the badge's animated style, so the style didn't follow them.
the badge takes the object of shared values itself, geometry, and its index, at, and calls cover(geometry, at) inside its own style. The ring then matched the pill.
the build log ("The ring now matches the pill"); fluid-tab-bar.tsx lines 401–407; the build notes, which record the rule.
on the web the icon of a selected chip painted under the fill. React Native Web gives every View position: relative, and the fill is absolutely positioned, but an <svg> from react-native-svg isn't positioned at all, and in CSS positioned boxes paint above unpositioned ones whatever their order.
the icon is wrapped in a View, which is positioned, so it paints in its place in the order: "In a View: on the web a bare SVG would paint under the fill, which is positioned."
Choice Chips' chip.tsx line 280; the kit README, "Positioned layers paint over bare SVG on the web".
in a keyboard test of the chips in Chrome, Tab moved the focus and Enter toggled a chip, but Space didn't.
on the web the chip has an onKeyDown that, for Space, prevents the page from scrolling and calls onPress itself; for both keys it also makes the fill grow from the chip's centre, since there is no pointer to grow it from.
the build log of the keyboard test; Choice Chips' chip.tsx lines 194–203.
sampling Morph Text's DOM frame by frame, a leaving "+" showed scale(1.44945) at full opacity for one frame before shrinking. On the web an animation's first frame can carry a timestamp from just before it started, so the timing's progress is slightly negative, and the stock curves extrapolate it into a jump the wrong way. (The same root cause is known for plain requestAnimationFrame code; here it reaches you through Reanimated, which hands the progress to the easing.)
Morph Text's own easing worklets clamp their input to 0..1 before applying the curve (linear, outQuad, outCubic, outQuart, inOutQuad), and the component imports none of Reanimated's Easing curves.
the build's frame samples; Morph Text's easing.ts and its comment ("a leaving "+" flashed at 1.45× for a frame"); the kit README, "Easing at negative time".
the music demo's "Add to playlist" button calls swipe('up') with no speed, so the card's x stays where it is, and undo after such a swipe didn't work.
"gone" rides on the timing of the axis the card leaves along (y for up, x for left and right), and "leaving" is scheduled before either timing starts, so the deck always hears them in that order. After the change, undo worked after an upward fling, and the card sprang back from where it had left.
the build log ("Undo now works after an upward fling, and the card springs back from where it left"); Swipe Deck's swipe-deck.tsx lines 280 and 283–292.
// Said first, so the deck hears "leaving" before "gone", whatever finishes first.
scheduleOnRN(onStart, direction);
drag.value = withTiming(1, { duration });
// It's gone when the axis it leaves along gets there (the other may not move at all).
const done = (finished?: boolean) => {
'worklet';
if (finished) scheduleOnRN(onGone);
};
x.value = withTiming(target.x, timing, direction === 'up' ? undefined : done);
y.value = withTiming(target.y, timing, direction === 'up' ? done : undefined);/** How focused tab `at` is while the focus is at `pos`: 1 on it, falling to 0 a tab away. */
function weight(pos: number, at: number) {
'worklet';
return Math.max(0, 1 - Math.abs(pos - at));
}
/** Tab `i`'s width: an equal share in 'pill'; in 'expand', its icon, plus its label as it gains focus. */
function tabWidth(g: Geometry, i: number) {
'worklet';
if (!g.expand) return (g.widthValue.value - PAD * 2) / g.count;
return g.slot + (g.labels.value[i] + LABEL_GAP + 6) * weight(g.pos.value, i);
}
/** Where tab `i` starts. In 'expand', the room the tabs leave is spread evenly between them. */
function tabX(g: Geometry, i: number) {
'worklet';
const inner = g.widthValue.value - PAD * 2;
if (!g.expand) return PAD + (inner / g.count) * i;
let total = 0;
let before = 0;
for (let k = 0; k < g.count; k++) {
const w = tabWidth(g, k);
total += w;
if (k < i) before += w;
}
const gap = g.count > 1 ? Math.max(0, inner - total) / (g.count - 1) : 0;
return PAD + before + gap * i;
}
/** The pill's width in 'pill': hugging the icon rather than filling a wide tab. */
function pillWidth(g: Geometry) {
'worklet';
return Math.min(tabWidth(g, 0), g.slot * 1.5);
}
/** A tab's box, or between two tabs' boxes for a fractional index: where the pill's edges stand. */
function boxAt(g: Geometry, at: number) {
'worklet';
const lo = Math.max(0, Math.min(g.count - 1, Math.floor(at)));
const hi = Math.min(g.count - 1, lo + 1);
const t = Math.max(0, Math.min(1, at - lo));
const x = tabX(g, lo) * (1 - t) + tabX(g, hi) * t;
const w = tabWidth(g, lo) * (1 - t) + tabWidth(g, hi) * t;
if (g.expand) return { x, w };
const p = pillWidth(g);
return { x: x + (w - p) / 2, w: p };
}
/**
* How much the pill covers tab `at`'s icon right now: 1 well inside it, 0 outside. As the pill sweeps past a tab on its
* way, that tab's icon lights up and its badge's ring takes the pill's color, so nothing gray sits on the pill.
*/
function cover(g: Geometry, at: number) {
'worklet';
const l = boxAt(g, g.left.value).x;
const end = boxAt(g, g.right.value);
const center = tabX(g, at) + (g.expand ? g.slot / 2 : tabWidth(g, at) / 2);
return Math.max(0, Math.min(1, Math.min(center - l, end.x + end.w - center) / 14));
} const pillStyle = useAnimatedStyle(() => {
const l = boxAt(geometry, left.value).x;
const end = boxAt(geometry, right.value);
const w = Math.max(slot, end.x + end.w - l);
// Stretched, it thins a little, as a drop does; at rest it's its full height.
const rest = boxAt(geometry, pos.value).w;
const squeeze = Math.min(0.14, Math.max(0, w / Math.max(rest, 1) - 1) * 0.09);
return { left: l, width: w, top: PAD + (slot * squeeze) / 2, height: slot * (1 - squeeze) };
}); const style = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
opacity: Math.min(1, scale.value * 2),
borderColor: interpolateColor(cover(geometry, at), [0, 1], [ring, pill]),
backgroundColor: interpolateColor(cover(geometry, at), [0, 1], [color, onPill]),
}));
const digits = useAnimatedStyle(() => ({ color: interpolateColor(cover(geometry, at), [0, 1], ['#FFFFFF', pill]) }));
Comments
0 comments
No comments yet. Questions, what you built with it, what could be better: its developer reads them.
Sign in to comment.
Sign in to comment