Liquid Tabs
A tab bar whose indicator flows like liquid, stretching, thinning and melting into place.
import {
Blur,
Canvas,
Circle,
ColorMatrix,
Group,
Paint,
RoundedRect,
rect,
rrect,
} from '@shopify/react-native-skia';
import { useEffect, useState } from 'react';
import {
Platform,
Pressable,
StyleSheet,
View,
type StyleProp,
type ViewStyle,
} from 'react-native';
import Animated, {
interpolateColor,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
withDelay,
withSpring,
type SharedValue,
} from 'react-native-reanimated';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { minTouchTarget } from '../../lib/kinetik/tokens/tokens';
export type LiquidTab = { key: string; label: string };
export type LiquidTabsProps = {
tabs: readonly LiquidTab[];
/** Key of the selected tab. */
value: string;
onChange: (key: string) => void;
/** Indicator colour. Defaults to the theme ember. */
color?: string;
/** Label colour on the indicator. Defaults to the theme onEmber. */
activeTextColor?: string;
/** A tick on change. Defaults to true. */
haptics?: boolean;
/** Accessible name of the tab list. */
accessibilityLabel?: string;
style?: StyleProp<ViewStyle>;
};
// Each tab keeps a full 44 pt target inside the bar's padding.
const PAD = 4;
const HEIGHT = minTouchTarget + PAD * 2;
// Alpha threshold after a blur: shapes that overlap melt into one ("goo").
const GOO = [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 20, -8];
/**
* A segmented tab bar whose indicator flows like liquid: the leading edge
* races ahead, the trailing edge catches up, the body thins as it stretches
* and a droplet melts back into it on arrival.
*/
export function LiquidTabs({
tabs,
value,
onChange,
color,
activeTextColor,
haptics = true,
accessibilityLabel,
style,
}: LiquidTabsProps) {
const theme = useKinetikTheme();
const reduced = useReduceMotion();
const goo = GOO_SUPPORTED && !reduced;
const haptic = useHaptic(haptics);
const fill = color ?? theme.colors.ember;
const onFill = activeTextColor ?? theme.colors.onEmber;
const [width, setWidth] = useState(0);
const count = Math.max(1, tabs.length);
const index = Math.max(
0,
tabs.findIndex((t) => t.key === value),
);
const segment = width > 0 ? (width - PAD * 2) / count : 0;
const left = useSharedValue(index);
const right = useSharedValue(index + 1);
const drop = useSharedValue(index + 0.5);
useEffect(() => {
// Moving right, the right edge leads; moving left, the left edge leads.
const forward = index >= left.get();
const lead = spring('snappy', reduced);
const trail = spring('smooth', reduced);
left.set(withSpring(index, forward ? trail : lead));
right.set(withSpring(index + 1, forward ? lead : trail));
// The droplet leaves a beat late, so it trails the body and melts back in.
drop.set(withDelay(reduced ? 0 : 70, withSpring(index + 0.5, trail)));
}, [index, reduced, left, right, drop]);
const body = useDerivedValue(() => {
const x0 = PAD + left.get() * segment;
const x1 = PAD + right.get() * segment;
const w = Math.max(0, x1 - x0);
// Conserve volume: the more it stretches, the thinner it gets.
const stretch = segment > 0 ? Math.min(1, segment / Math.max(w, 1)) : 1;
const h = (HEIGHT - PAD * 2) * (0.62 + 0.38 * stretch);
const y = (HEIGHT - h) / 2;
return rrect(rect(x0, y, w, h), h / 2, h / 2);
});
const dropX = useDerivedValue(() => PAD + drop.get() * segment);
const dropR = useDerivedValue(() => {
const centre = (left.get() + right.get()) / 2;
// The droplet shows only while it lags behind the body.
const lag = Math.abs(drop.get() - centre);
return Math.min(1, lag * 2) * (HEIGHT - PAD * 2) * 0.3;
});
return (
<View
accessibilityRole="tablist"
accessibilityLabel={accessibilityLabel}
onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
style={[
styles.bar,
{ backgroundColor: theme.colors.track, borderRadius: theme.radius.pill },
style,
]}
>
{segment > 0 && (
<View pointerEvents="none" style={StyleSheet.absoluteFill}>
<Canvas style={StyleSheet.absoluteFill}>
<Group
layer={
<Paint>
<Blur blur={goo ? 5 : 0} />
<ColorMatrix matrix={goo ? GOO : IDENTITY} />
</Paint>
}
>
<RoundedRect rect={body} color={fill} />
{goo && <Circle cx={dropX} cy={HEIGHT / 2} r={dropR} color={fill} />}
</Group>
</Canvas>
</View>
)}
{tabs.map((tab, i) => (
<TabLabel
key={tab.key}
tab={tab}
i={i}
selected={i === index}
left={left}
right={right}
inactive={theme.colors.textMuted}
active={onFill}
onPress={() => {
if (tab.key === value) return;
haptic('tick');
onChange(tab.key);
}}
/>
))}
</View>
);
}
// CanvasKit (web) renders the alpha-threshold layer fully transparent, so the
// web keeps the stretch but skips the goo filter and droplet.
const GOO_SUPPORTED = Platform.OS !== 'web';
const IDENTITY = [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0];
function TabLabel({
tab,
i,
selected,
left,
right,
inactive,
active,
onPress,
}: {
tab: LiquidTab;
i: number;
selected: boolean;
left: SharedValue<number>;
right: SharedValue<number>;
inactive: string;
active: string;
onPress: () => void;
}) {
const { font } = useKinetikTheme();
// Label colour follows how much of the tab the liquid currently covers.
const textStyle = useAnimatedStyle(() => {
const cover = Math.max(0, Math.min(i + 1, right.get()) - Math.max(i, left.get()));
return { color: interpolateColor(Math.min(1, cover), [0, 1], [inactive, active]) };
});
return (
<Pressable
accessibilityRole="tab"
accessibilityState={{ selected }}
accessibilityLabel={tab.label}
onPress={onPress}
style={styles.tab}
>
<Animated.Text
numberOfLines={1}
style={[{ fontSize: font.size.sm, fontWeight: font.weight.semibold }, textStyle]}
>
{tab.label}
</Animated.Text>
</Pressable>
);
}
const styles = StyleSheet.create({
bar: { flexDirection: 'row', height: HEIGHT, padding: PAD, minHeight: minTouchTarget },
tab: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 6 },
});Installation
npx kinetik-ui add liquid-tabspnpm dlx kinetik-ui add liquid-tabsbunx kinetik-ui add liquid-tabsyarn dlx kinetik-ui add liquid-tabsThe CLI copies the source and installs native dependencies with npx expo install, so versions match your Expo SDK. With shadcn: npx shadcn@latest add https://kinetik-ui.dev/r/liquid-tabs.json
Install manually
- Install dependencies
npx expo install @shopify/react-native-skia react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/liquid-tabs.tsx
Usage
import { LiquidTabs } from '@/components/kinetik/liquid-tabs';
const [tab, setTab] = useState('for-you');
<LiquidTabs
accessibilityLabel="Feed"
tabs={[
{ key: 'for-you', label: 'For you' },
{ key: 'following', label: 'Following' },
{ key: 'nearby', label: 'Nearby' },
]}
value={tab}
onChange={setTab}
/>;When to use
- Two to five peer views of the same content: feeds, filters, time ranges.
- Top-of-screen switches where a little fluidity adds character.
When not to use
- More than five options or long labels; use a scrolling tab bar or a menu.
- Primary app navigation between unrelated sections. Use a bottom bar (see Dock Bar).
Notes
- The leading edge uses the snappy spring and the trailing edge the smooth one; that difference is the liquid stretch.
- Labels change colour by how much of their tab the liquid covers, so the colour sweeps with the motion.
- Pass
colorandactiveTextColortogether to keep the label readable on the indicator. - On the web the indicator stretches but skips the droplet and goo filter, which CanvasKit does not render.
Props
LiquidTabsProps
| Prop | Type | Default | Description |
|---|---|---|---|
tabsrequired | readonly LiquidTab[] | — | |
valuerequired | string | — | Key of the selected tab. |
onChangerequired | (key: string) => void | — | |
color | string | — | Indicator colour. Defaults to the theme ember. |
activeTextColor | string | — | Label colour on the indicator. Defaults to the theme onEmber. |
haptics | boolean | true | A tick on change. Defaults to true. |
accessibilityLabel | string | — | Accessible name of the tab list. |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- While switching, three springs drive one small blurred layer (body and droplet); nothing runs at rest.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 1.12× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 151 MB app memory
Real-device measurement pending.
Accessibility
- Reduced motion
- The indicator slides without stretching, droplet or goo filter.
- Screen readers
- Tab list with tabs that expose their selected state.
- Touch target
- Each tab spans its share of the bar and is 44 pt tall.
Try it on a device
Scan with a phone that has the Kinetik playground installed to open this demo with real haptics and sensors.
Source files
components/kinetik/liquid-tabs.tsx
import {
Blur,
Canvas,
Circle,
ColorMatrix,
Group,
Paint,
RoundedRect,
rect,
rrect,
} from '@shopify/react-native-skia';
import { useEffect, useState } from 'react';
import {
Platform,
Pressable,
StyleSheet,
View,
type StyleProp,
type ViewStyle,
} from 'react-native';
import Animated, {
interpolateColor,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
withDelay,
withSpring,
type SharedValue,
} from 'react-native-reanimated';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { minTouchTarget } from '../../lib/kinetik/tokens/tokens';
export type LiquidTab = { key: string; label: string };
export type LiquidTabsProps = {
tabs: readonly LiquidTab[];
/** Key of the selected tab. */
value: string;
onChange: (key: string) => void;
/** Indicator colour. Defaults to the theme ember. */
color?: string;
/** Label colour on the indicator. Defaults to the theme onEmber. */
activeTextColor?: string;
/** A tick on change. Defaults to true. */
haptics?: boolean;
/** Accessible name of the tab list. */
accessibilityLabel?: string;
style?: StyleProp<ViewStyle>;
};
// Each tab keeps a full 44 pt target inside the bar's padding.
const PAD = 4;
const HEIGHT = minTouchTarget + PAD * 2;
// Alpha threshold after a blur: shapes that overlap melt into one ("goo").
const GOO = [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 20, -8];
/**
* A segmented tab bar whose indicator flows like liquid: the leading edge
* races ahead, the trailing edge catches up, the body thins as it stretches
* and a droplet melts back into it on arrival.
*/
export function LiquidTabs({
tabs,
value,
onChange,
color,
activeTextColor,
haptics = true,
accessibilityLabel,
style,
}: LiquidTabsProps) {
const theme = useKinetikTheme();
const reduced = useReduceMotion();
const goo = GOO_SUPPORTED && !reduced;
const haptic = useHaptic(haptics);
const fill = color ?? theme.colors.ember;
const onFill = activeTextColor ?? theme.colors.onEmber;
const [width, setWidth] = useState(0);
const count = Math.max(1, tabs.length);
const index = Math.max(
0,
tabs.findIndex((t) => t.key === value),
);
const segment = width > 0 ? (width - PAD * 2) / count : 0;
const left = useSharedValue(index);
const right = useSharedValue(index + 1);
const drop = useSharedValue(index + 0.5);
useEffect(() => {
// Moving right, the right edge leads; moving left, the left edge leads.
const forward = index >= left.get();
const lead = spring('snappy', reduced);
const trail = spring('smooth', reduced);
left.set(withSpring(index, forward ? trail : lead));
right.set(withSpring(index + 1, forward ? lead : trail));
// The droplet leaves a beat late, so it trails the body and melts back in.
drop.set(withDelay(reduced ? 0 : 70, withSpring(index + 0.5, trail)));
}, [index, reduced, left, right, drop]);
const body = useDerivedValue(() => {
const x0 = PAD + left.get() * segment;
const x1 = PAD + right.get() * segment;
const w = Math.max(0, x1 - x0);
// Conserve volume: the more it stretches, the thinner it gets.
const stretch = segment > 0 ? Math.min(1, segment / Math.max(w, 1)) : 1;
const h = (HEIGHT - PAD * 2) * (0.62 + 0.38 * stretch);
const y = (HEIGHT - h) / 2;
return rrect(rect(x0, y, w, h), h / 2, h / 2);
});
const dropX = useDerivedValue(() => PAD + drop.get() * segment);
const dropR = useDerivedValue(() => {
const centre = (left.get() + right.get()) / 2;
// The droplet shows only while it lags behind the body.
const lag = Math.abs(drop.get() - centre);
return Math.min(1, lag * 2) * (HEIGHT - PAD * 2) * 0.3;
});
return (
<View
accessibilityRole="tablist"
accessibilityLabel={accessibilityLabel}
onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
style={[
styles.bar,
{ backgroundColor: theme.colors.track, borderRadius: theme.radius.pill },
style,
]}
>
{segment > 0 && (
<View pointerEvents="none" style={StyleSheet.absoluteFill}>
<Canvas style={StyleSheet.absoluteFill}>
<Group
layer={
<Paint>
<Blur blur={goo ? 5 : 0} />
<ColorMatrix matrix={goo ? GOO : IDENTITY} />
</Paint>
}
>
<RoundedRect rect={body} color={fill} />
{goo && <Circle cx={dropX} cy={HEIGHT / 2} r={dropR} color={fill} />}
</Group>
</Canvas>
</View>
)}
{tabs.map((tab, i) => (
<TabLabel
key={tab.key}
tab={tab}
i={i}
selected={i === index}
left={left}
right={right}
inactive={theme.colors.textMuted}
active={onFill}
onPress={() => {
if (tab.key === value) return;
haptic('tick');
onChange(tab.key);
}}
/>
))}
</View>
);
}
// CanvasKit (web) renders the alpha-threshold layer fully transparent, so the
// web keeps the stretch but skips the goo filter and droplet.
const GOO_SUPPORTED = Platform.OS !== 'web';
const IDENTITY = [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0];
function TabLabel({
tab,
i,
selected,
left,
right,
inactive,
active,
onPress,
}: {
tab: LiquidTab;
i: number;
selected: boolean;
left: SharedValue<number>;
right: SharedValue<number>;
inactive: string;
active: string;
onPress: () => void;
}) {
const { font } = useKinetikTheme();
// Label colour follows how much of the tab the liquid currently covers.
const textStyle = useAnimatedStyle(() => {
const cover = Math.max(0, Math.min(i + 1, right.get()) - Math.max(i, left.get()));
return { color: interpolateColor(Math.min(1, cover), [0, 1], [inactive, active]) };
});
return (
<Pressable
accessibilityRole="tab"
accessibilityState={{ selected }}
accessibilityLabel={tab.label}
onPress={onPress}
style={styles.tab}
>
<Animated.Text
numberOfLines={1}
style={[{ fontSize: font.size.sm, fontWeight: font.weight.semibold }, textStyle]}
>
{tab.label}
</Animated.Text>
</Pressable>
);
}
const styles = StyleSheet.create({
bar: { flexDirection: 'row', height: HEIGHT, padding: PAD, minHeight: minTouchTarget },
tab: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 6 },
});