Kinetik UI

Split Button

.md

A primary action with alternatives behind a chevron; a new pick rolls into the label as the button resizes.

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

Installation

Kinetik CLI
npx kinetik-ui add split-button

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/split-button.json

Install manually

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

Usage

import { SplitButton } from '@/components/kinetik/split-button';

<SplitButton
  options={[
    { value: 'merge', label: 'Merge', description: 'Keep every commit' },
    { value: 'squash', label: 'Squash and merge', description: 'One commit on main' },
  ]}
  value={method}
  onValueChange={setMethod}
  onPress={(m) => merge(m)}
/>;

The main half runs onPress with the chosen value. Picking another option in the menu only changes the choice; people press the button to run it, so a pick in the menu never acts by surprise.

When to use

  • One default action with two to five close variants: publish now or schedule, merge methods, export formats.
  • Actions where people tend to stick with their last choice. Persist value to remember it.

When not to use

  • Unrelated actions. Use Pop Menu.
  • Two options of equal weight. Use two buttons.

Notes

  • Every label is measured once, out of sight, so the button springs straight to the right width.
  • The menu opens in a transparent modal under the button's right edge and stays 12 pt inside the window.
  • With reduced motion the label and width change at once.

Props

SplitButtonProps

PropTypeDefaultDescription
optionsrequiredreadonly SplitButtonOption[]—
valuerequiredstring—The chosen option, which the main half runs.
onValueChangerequired(value: string) => void—
onPressrequired(value: string) => void—Runs the chosen option.
tone'ember' | 'azure''ember'Defaults to `ember`, the theme's strongest ink.
menuLabelstring'More options'Names the menu button. Defaults to 'More options'.
hapticsbooleantrueA tick when the menu opens and on a new choice. Defaults to true.
disabledbooleanfalse
testIDstring—
styleStyleProp<ViewStyle>—

Performance and accessibility

Performance

Budget
Plain views; labels are measured once. The menu is a modal mounted only while open. Nothing runs at rest.
Measured

Pixel 9 emulator, API 35, release build · interaction median 0.85× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 140 MB app memory

Real-device measurement pending.

Accessibility

Reduced motion
The label and width change at once, and the menu appears in place.
Screen readers
Two buttons: the action, named by the current label, and More options with an expanded state. Menu items read their description and mark the current one as selected.
Touch target
Both halves are 48 pt tall; the chevron half is 48 pt wide.

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/split-button.tsx
import { useEffect, useRef, useState } from 'react';
import {
  Modal,
  Pressable,
  StyleSheet,
  Text,
  View,
  useWindowDimensions,
  type StyleProp,
  type ViewStyle,
} from 'react-native';
import Animated, {
  useAnimatedStyle,
  useSharedValue,
  withSpring,
  withTiming,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';

import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { durations, easings, spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { minTouchTarget, withAlpha } from '../../lib/kinetik/tokens/tokens';

export type SplitButtonOption = {
  value: string;
  /** The button's label while this option is chosen, e.g. "Squash and merge". */
  label: string;
  /** A line under the label in the menu. */
  description?: string;
};

export type SplitButtonProps = {
  options: readonly SplitButtonOption[];
  /** The chosen option, which the main half runs. */
  value: string;
  onValueChange: (value: string) => void;
  /** Runs the chosen option. */
  onPress: (value: string) => void;
  /** Defaults to `ember`, the theme's strongest ink. */
  tone?: 'ember' | 'azure';
  /** Names the menu button. Defaults to 'More options'. */
  menuLabel?: string;
  /** A tick when the menu opens and on a new choice. Defaults to true. */
  haptics?: boolean;
  disabled?: boolean;
  testID?: string;
  style?: StyleProp<ViewStyle>;
};

const HEIGHT = 48;
const CHEVRON_W = 48;
const PAD = 20;
const MENU_W = 280;

/**
 * A primary action with close alternatives behind a chevron. Picking one in
 * the menu makes it the button's action: the old label rolls up and away,
 * the new one rolls in, and the button springs to its width.
 */
export function SplitButton({
  options,
  value,
  onValueChange,
  onPress,
  tone = 'ember',
  menuLabel = 'More options',
  haptics = true,
  disabled = false,
  testID,
  style,
}: SplitButtonProps) {
  const { colors, font, radius, shadow } = useKinetikTheme();
  const reduced = useReduceMotion();
  const haptic = useHaptic(haptics);
  const window = useWindowDimensions();
  const ink = tone === 'azure' ? colors.azure : colors.ember;
  const onInk = tone === 'azure' ? colors.onAzure : colors.onEmber;
  const current = options.find((o) => o.value === value) ?? options[0]!;

  // Label widths, measured once off screen, so the button can spring between them.
  const [widths, setWidths] = useState<Record<string, number>>({});
  const width = useSharedValue(0);
  const [roll, setRoll] = useState({ from: current.label, to: current.label, id: 0 });
  if (roll.to !== current.label) setRoll({ from: roll.to, to: current.label, id: roll.id + 1 });
  const t = useSharedValue(1);

  useEffect(() => {
    const w = widths[current.value];
    if (w === undefined) return;
    const target = w + PAD * 2;
    width.set(width.get() === 0 || reduced ? target : withSpring(target, spring('smooth')));
  }, [widths, current.value, reduced, width]);

  useEffect(() => {
    if (roll.id === 0) return;
    t.set(0);
    t.set(reduced ? 1 : withSpring(1, spring('smooth')));
  }, [roll.id, reduced, t]);

  // The menu.
  const anchor = useRef<View>(null);
  const [menu, setMenu] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
  const [open, setOpen] = useState(false);
  const m = useSharedValue(0);
  const openMenu = () => {
    setMenu(null);
    setOpen(true);
    haptic('tick');
    anchor.current?.measureInWindow((x, y, w, h) => setMenu({ x, y, w, h }));
  };
  useEffect(() => {
    if (!open || !menu) return;
    m.set(0);
    m.set(reduced ? 1 : withSpring(1, spring('smooth')));
  }, [open, menu, reduced, m]);
  const closeMenu = (then?: () => void) => {
    const done = () => {
      setOpen(false);
      then?.();
    };
    if (reduced) {
      m.set(0);
      done();
      return;
    }
    m.set(
      withTiming(0, { duration: durations.quick, easing: easings.exit }, (finished) => {
        if (finished) scheduleOnRN(done);
      }),
    );
  };

  const main = useAnimatedStyle(() => (width.get() > 0 ? { width: width.get() } : {}));
  const outgoing = useAnimatedStyle(() => ({
    opacity: 1 - t.get(),
    transform: [{ translateY: -t.get() * 16 }],
  }));
  const incoming = useAnimatedStyle(() => ({
    opacity: t.get(),
    transform: [{ translateY: (1 - t.get()) * 16 }],
  }));
  const chevron = useAnimatedStyle(() => ({
    transform: [{ translateY: m.get() * 2 - 2 }, { rotate: `${45 + m.get() * 180}deg` }],
  }));
  const panel = useAnimatedStyle(() => ({
    opacity: Math.min(1, m.get() * 2),
    transform: [{ translateY: (1 - m.get()) * -8 }, { scale: 0.9 + m.get() * 0.1 }],
  }));

  const label = { color: onInk, fontSize: font.size.md, fontWeight: font.weight.semibold } as const;
  const menuLeft = menu
    ? Math.max(12, Math.min(window.width - MENU_W - 12, menu.x + menu.w - MENU_W))
    : 0;

  return (
    <View testID={testID} style={[styles.root, style]}>
      {/* Measures every label so widths are known before they are needed. */}
      <View
        style={styles.measure}
        pointerEvents="none"
        accessibilityElementsHidden
        importantForAccessibility="no-hide-descendants"
      >
        {options.map((o) => (
          <Text
            key={o.value}
            style={label}
            onLayout={(e) => {
              const w = Math.ceil(e.nativeEvent.layout.width);
              setWidths((prev) => (prev[o.value] === w ? prev : { ...prev, [o.value]: w }));
            }}
          >
            {o.label}
          </Text>
        ))}
      </View>

      <View
        ref={anchor}
        collapsable={false}
        style={[
          styles.pill,
          { backgroundColor: ink, borderRadius: radius.pill, opacity: disabled ? 0.45 : 1 },
        ]}
      >
        <Pressable
          disabled={disabled}
          onPress={() => onPress(current.value)}
          accessibilityRole="button"
          accessibilityLabel={current.label}
          style={({ pressed }) => [{ opacity: pressed ? 0.75 : 1 }]}
        >
          <Animated.View style={[styles.main, main]}>
            {roll.from !== roll.to ? (
              <Animated.View key={`o${roll.id}`} style={[styles.layer, outgoing]}>
                <Text numberOfLines={1} style={label}>
                  {roll.from}
                </Text>
              </Animated.View>
            ) : null}
            <Animated.View key={`i${roll.id}`} style={[styles.layer, incoming]}>
              <Text numberOfLines={1} style={label}>
                {roll.to}
              </Text>
            </Animated.View>
          </Animated.View>
        </Pressable>
        <View style={[styles.divider, { backgroundColor: withAlpha(onInk, 0.25) }]} />
        <Pressable
          disabled={disabled}
          onPress={openMenu}
          accessibilityRole="button"
          accessibilityLabel={menuLabel}
          accessibilityState={{ expanded: open, disabled }}
          style={({ pressed }) => [styles.more, { opacity: pressed ? 0.75 : 1 }]}
        >
          <Animated.View style={[styles.chevron, { borderColor: onInk }, chevron]} />
        </Pressable>
      </View>

      <Modal
        visible={open}
        transparent
        animationType="none"
        onRequestClose={() => closeMenu()}
        statusBarTranslucent
        navigationBarTranslucent
      >
        <View style={StyleSheet.absoluteFill} onAccessibilityEscape={() => closeMenu()}>
          <Pressable
            style={StyleSheet.absoluteFill}
            onPress={() => closeMenu()}
            accessibilityRole="button"
            accessibilityLabel="Close menu"
          />
          <Animated.View
            style={[
              styles.panel,
              {
                left: menuLeft,
                top: menu ? menu.y + menu.h + 8 : 0,
                opacity: menu ? undefined : 0,
                width: MENU_W,
                backgroundColor: colors.surfaceRaised,
                borderColor: colors.border,
                borderRadius: radius.lg,
                boxShadow: shadow.lg,
                transformOrigin: 'right top',
              },
              panel,
            ]}
          >
            {options.map((o) => {
              const chosen = o.value === current.value;
              return (
                <Pressable
                  key={o.value}
                  onPress={() =>
                    closeMenu(() => {
                      if (chosen) return;
                      haptic('tick');
                      onValueChange(o.value);
                    })
                  }
                  accessibilityRole="menuitem"
                  accessibilityLabel={o.description ? `${o.label}, ${o.description}` : o.label}
                  accessibilityState={{ selected: chosen }}
                  style={({ pressed }) => [
                    styles.option,
                    {
                      borderRadius: radius.md,
                      backgroundColor: pressed ? colors.surfaceSunken : undefined,
                    },
                  ]}
                >
                  <View
                    style={[styles.check, { borderColor: chosen ? colors.azure : 'transparent' }]}
                  />
                  <View style={{ flex: 1, gap: 2 }}>
                    <Text
                      style={{
                        color: colors.text,
                        fontSize: font.size.md,
                        fontWeight: font.weight.medium,
                      }}
                    >
                      {o.label}
                    </Text>
                    {o.description ? (
                      <Text style={{ color: colors.textMuted, fontSize: font.size.sm }}>
                        {o.description}
                      </Text>
                    ) : null}
                  </View>
                </Pressable>
              );
            })}
          </Animated.View>
        </View>
      </Modal>
    </View>
  );
}

const styles = StyleSheet.create({
  root: { alignSelf: 'flex-start' },
  // Wide enough that no label wraps while it is measured.
  measure: {
    position: 'absolute',
    opacity: 0,
    top: 0,
    left: 0,
    width: 1000,
    alignItems: 'flex-start',
  },
  pill: { flexDirection: 'row', alignItems: 'center', height: HEIGHT, overflow: 'hidden' },
  main: { height: HEIGHT, minWidth: 80, justifyContent: 'center' },
  layer: { ...StyleSheet.absoluteFill, alignItems: 'center', justifyContent: 'center' },
  divider: { width: StyleSheet.hairlineWidth * 2, height: 22 },
  more: {
    width: CHEVRON_W,
    height: Math.max(HEIGHT, minTouchTarget),
    alignItems: 'center',
    justifyContent: 'center',
  },
  chevron: { width: 8, height: 8, borderRightWidth: 2, borderBottomWidth: 2 },
  panel: { position: 'absolute', padding: 6, borderWidth: StyleSheet.hairlineWidth },
  option: { flexDirection: 'row', alignItems: 'center', gap: 10, padding: 10, minHeight: 52 },
  // A tick drawn from two borders, shown only beside the chosen option.
  check: {
    width: 7,
    height: 13,
    marginHorizontal: 4,
    marginTop: -4,
    borderBottomWidth: 2,
    borderRightWidth: 2,
    transform: [{ rotate: '45deg' }],
  },
});