Expand Toolbar
Icon buttons where the picked one grows to show its name while its neighbours slide aside to make room.
import { useEffect, useState, type ReactNode } from 'react';
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
interpolateColor,
useAnimatedStyle,
useSharedValue,
withSpring,
} 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 ExpandToolbarItem = {
key: string;
label: string;
/** Receives the colour to draw in: azure when picked, muted otherwise. */
icon: (color: string) => ReactNode;
};
export type ExpandToolbarProps = {
items: readonly ExpandToolbarItem[];
value: string;
onValueChange: (key: string) => void;
/** Names the toolbar, e.g. "Drawing tools". */
accessibilityLabel: string;
/** A tick on change. Defaults to true. */
haptics?: boolean;
testID?: string;
style?: StyleProp<ViewStyle>;
};
const SIZE = minTouchTarget;
const PAD = 4;
/** Space between the icon and its label, and after the label. */
const LABEL_GAP = 6;
const LABEL_END = 14;
/**
* A row of icon buttons where the picked one grows to show its name. Its
* neighbours slide aside to make room, the tint follows it, and the label
* fades in as the button stretches.
*/
export function ExpandToolbar({
items,
value,
onValueChange,
accessibilityLabel,
haptics = true,
testID,
style,
}: ExpandToolbarProps) {
const { colors, font, radius } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const [widths, setWidths] = useState<Record<string, number>>({});
const label = { fontSize: font.size.sm, fontWeight: font.weight.semibold } as const;
return (
<View
testID={testID}
accessibilityRole="toolbar"
accessibilityLabel={accessibilityLabel}
style={[
styles.tray,
{ backgroundColor: colors.surface, borderColor: colors.border, borderRadius: radius.pill },
style,
]}
>
{/* Every label measured once, out of sight, so each button knows how far to grow. */}
<View
style={styles.measure}
pointerEvents="none"
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
{items.map((item) => (
<Text
key={item.key}
style={label}
onLayout={(e) => {
const w = Math.ceil(e.nativeEvent.layout.width);
setWidths((prev) => (prev[item.key] === w ? prev : { ...prev, [item.key]: w }));
}}
>
{item.label}
</Text>
))}
</View>
{items.map((item) => (
<Tool
key={item.key}
item={item}
selected={item.key === value}
labelWidth={widths[item.key] ?? 0}
reduced={reduced}
onPress={() => {
if (item.key === value) return;
haptic('tick');
onValueChange(item.key);
}}
/>
))}
</View>
);
}
type ToolProps = {
item: ExpandToolbarItem;
selected: boolean;
labelWidth: number;
reduced: boolean;
onPress: () => void;
};
function Tool({ item, selected, labelWidth, reduced, onPress }: ToolProps) {
const { colors, font, radius } = useKinetikTheme();
const t = useSharedValue(selected ? 1 : 0);
const press = useSharedValue(0);
useEffect(() => {
t.set(withSpring(selected ? 1 : 0, spring('smooth', reduced)));
}, [selected, reduced, t]);
const grow = labelWidth + LABEL_GAP + LABEL_END - 10;
const shell = useAnimatedStyle(() => ({
width: SIZE + grow * Math.max(0, t.get()),
backgroundColor: interpolateColor(t.get(), [0, 1], [colors.surface, colors.azureSoft]),
transform: [{ scale: 1 - press.get() * 0.06 }],
}));
const text = useAnimatedStyle(() => ({
opacity: Math.max(0, t.get() * 1.6 - 0.6),
transform: [{ translateX: (1 - t.get()) * -6 }],
}));
return (
<Pressable
onPress={onPress}
onPressIn={() => press.set(withSpring(1, spring('snappy', reduced)))}
onPressOut={() => press.set(withSpring(0, spring('bouncy', reduced)))}
accessibilityRole="button"
accessibilityLabel={item.label}
accessibilityState={{ selected }}
>
<Animated.View style={[styles.tool, { borderRadius: radius.pill }, shell]}>
<View style={styles.icon}>{item.icon(selected ? colors.azure : colors.textMuted)}</View>
<Animated.View style={[styles.label, text]}>
<Text
numberOfLines={1}
style={{
color: colors.azure,
fontSize: font.size.sm,
fontWeight: font.weight.semibold,
}}
>
{item.label}
</Text>
</Animated.View>
</Animated.View>
</Pressable>
);
}
const styles = StyleSheet.create({
tray: {
flexDirection: 'row',
alignSelf: 'flex-start',
padding: PAD,
gap: 2,
borderWidth: StyleSheet.hairlineWidth,
},
measure: {
position: 'absolute',
opacity: 0,
left: 0,
top: 0,
width: 1000,
alignItems: 'flex-start',
},
tool: { height: SIZE, flexDirection: 'row', alignItems: 'center', overflow: 'hidden' },
icon: { width: SIZE, height: SIZE, alignItems: 'center', justifyContent: 'center' },
label: { marginLeft: LABEL_GAP - 10 },
});Installation
npx kinetik-ui add expand-toolbarpnpm dlx kinetik-ui add expand-toolbarbunx kinetik-ui add expand-toolbaryarn dlx kinetik-ui add expand-toolbarThe 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/expand-toolbar.json
Install manually
- Install dependencies
npx expo install react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/expand-toolbar.tsx
Usage
import { ExpandToolbar } from '@/components/kinetik/expand-toolbar';
<ExpandToolbar
accessibilityLabel="Note tools"
items={[
{ key: 'pen', label: 'Sketch', icon: (c) => <PencilIcon color={c} /> },
{ key: 'text', label: 'Text', icon: (c) => <TextIcon color={c} /> },
{ key: 'photo', label: 'Photo', icon: (c) => <PhotoIcon color={c} /> },
]}
value={tool}
onValueChange={setTool}
/>;Each icon is a function that receives the colour to draw in: azure for the picked tool and a muted grey for the rest.
When to use
- Picking a mode or tool: drawing tools, editor modes, map layers, compact filters.
- Places where icons alone are enough most of the time, but the current one should be spelled out.
When not to use
- Navigating between screens. Use a tab bar, such as Dock Bar or Liquid Tabs.
- More than five or six tools. The row gets too wide on a phone once one is expanded.
Notes
- Labels are measured once, out of sight, so each button grows by exactly its label's width.
- The toolbar sizes to its content. Centre it with
alignSelf: 'center'or put it in a row. - With reduced motion the picked button shows its label without growing.
Props
ExpandToolbarProps
| Prop | Type | Default | Description |
|---|---|---|---|
itemsrequired | readonly ExpandToolbarItem[] | — | |
valuerequired | string | — | |
onValueChangerequired | (key: string) => void | — | |
accessibilityLabelrequired | string | — | Names the toolbar, e.g. "Drawing tools". |
haptics | boolean | true | A tick on change. Defaults to true. |
testID | string | — | |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- One shared value per button; two widths animate on a change, so the row lays out for the length of the spring. Nothing runs at rest.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 1.10× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 151 MB app memory. Measured with its own script that taps each tool in turn, since the default gestures miss the toolbar.
Real-device measurement pending.
Accessibility
- Reduced motion
- The picked button shows its label at once, without growing.
- Screen readers
- A toolbar of buttons named by their labels, the picked one marked as selected.
- Touch target
- Every button is at least 44 pt square.
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/expand-toolbar.tsx
import { useEffect, useState, type ReactNode } from 'react';
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
interpolateColor,
useAnimatedStyle,
useSharedValue,
withSpring,
} 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 ExpandToolbarItem = {
key: string;
label: string;
/** Receives the colour to draw in: azure when picked, muted otherwise. */
icon: (color: string) => ReactNode;
};
export type ExpandToolbarProps = {
items: readonly ExpandToolbarItem[];
value: string;
onValueChange: (key: string) => void;
/** Names the toolbar, e.g. "Drawing tools". */
accessibilityLabel: string;
/** A tick on change. Defaults to true. */
haptics?: boolean;
testID?: string;
style?: StyleProp<ViewStyle>;
};
const SIZE = minTouchTarget;
const PAD = 4;
/** Space between the icon and its label, and after the label. */
const LABEL_GAP = 6;
const LABEL_END = 14;
/**
* A row of icon buttons where the picked one grows to show its name. Its
* neighbours slide aside to make room, the tint follows it, and the label
* fades in as the button stretches.
*/
export function ExpandToolbar({
items,
value,
onValueChange,
accessibilityLabel,
haptics = true,
testID,
style,
}: ExpandToolbarProps) {
const { colors, font, radius } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const [widths, setWidths] = useState<Record<string, number>>({});
const label = { fontSize: font.size.sm, fontWeight: font.weight.semibold } as const;
return (
<View
testID={testID}
accessibilityRole="toolbar"
accessibilityLabel={accessibilityLabel}
style={[
styles.tray,
{ backgroundColor: colors.surface, borderColor: colors.border, borderRadius: radius.pill },
style,
]}
>
{/* Every label measured once, out of sight, so each button knows how far to grow. */}
<View
style={styles.measure}
pointerEvents="none"
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
{items.map((item) => (
<Text
key={item.key}
style={label}
onLayout={(e) => {
const w = Math.ceil(e.nativeEvent.layout.width);
setWidths((prev) => (prev[item.key] === w ? prev : { ...prev, [item.key]: w }));
}}
>
{item.label}
</Text>
))}
</View>
{items.map((item) => (
<Tool
key={item.key}
item={item}
selected={item.key === value}
labelWidth={widths[item.key] ?? 0}
reduced={reduced}
onPress={() => {
if (item.key === value) return;
haptic('tick');
onValueChange(item.key);
}}
/>
))}
</View>
);
}
type ToolProps = {
item: ExpandToolbarItem;
selected: boolean;
labelWidth: number;
reduced: boolean;
onPress: () => void;
};
function Tool({ item, selected, labelWidth, reduced, onPress }: ToolProps) {
const { colors, font, radius } = useKinetikTheme();
const t = useSharedValue(selected ? 1 : 0);
const press = useSharedValue(0);
useEffect(() => {
t.set(withSpring(selected ? 1 : 0, spring('smooth', reduced)));
}, [selected, reduced, t]);
const grow = labelWidth + LABEL_GAP + LABEL_END - 10;
const shell = useAnimatedStyle(() => ({
width: SIZE + grow * Math.max(0, t.get()),
backgroundColor: interpolateColor(t.get(), [0, 1], [colors.surface, colors.azureSoft]),
transform: [{ scale: 1 - press.get() * 0.06 }],
}));
const text = useAnimatedStyle(() => ({
opacity: Math.max(0, t.get() * 1.6 - 0.6),
transform: [{ translateX: (1 - t.get()) * -6 }],
}));
return (
<Pressable
onPress={onPress}
onPressIn={() => press.set(withSpring(1, spring('snappy', reduced)))}
onPressOut={() => press.set(withSpring(0, spring('bouncy', reduced)))}
accessibilityRole="button"
accessibilityLabel={item.label}
accessibilityState={{ selected }}
>
<Animated.View style={[styles.tool, { borderRadius: radius.pill }, shell]}>
<View style={styles.icon}>{item.icon(selected ? colors.azure : colors.textMuted)}</View>
<Animated.View style={[styles.label, text]}>
<Text
numberOfLines={1}
style={{
color: colors.azure,
fontSize: font.size.sm,
fontWeight: font.weight.semibold,
}}
>
{item.label}
</Text>
</Animated.View>
</Animated.View>
</Pressable>
);
}
const styles = StyleSheet.create({
tray: {
flexDirection: 'row',
alignSelf: 'flex-start',
padding: PAD,
gap: 2,
borderWidth: StyleSheet.hairlineWidth,
},
measure: {
position: 'absolute',
opacity: 0,
left: 0,
top: 0,
width: 1000,
alignItems: 'flex-start',
},
tool: { height: SIZE, flexDirection: 'row', alignItems: 'center', overflow: 'hidden' },
icon: { width: SIZE, height: SIZE, alignItems: 'center', justifyContent: 'center' },
label: { marginLeft: LABEL_GAP - 10 },
});