Kinetik UI

Expand Toolbar

.md

Icon buttons where the picked one grows to show its name while its neighbours slide aside to make room.

v1.0.0Live on webreact-native-reanimatedreact-native-worklets
Component preview

Installation

Kinetik CLI
npx kinetik-ui add expand-toolbar

The 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

  1. Install dependencies
    npx expo install react-native-reanimated react-native-worklets
  2. Copy the source files
    • components/kinetik/expand-toolbar.tsx
    View source files

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

PropTypeDefaultDescription
itemsrequiredreadonly ExpandToolbarItem[]—
valuerequiredstring—
onValueChangerequired(key: string) => void—
accessibilityLabelrequiredstring—Names the toolbar, e.g. "Drawing tools".
hapticsbooleantrueA tick on change. Defaults to true.
testIDstring—
styleStyleProp<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 },
});