Kinetik UI

Segment Bar

.md

A segmented control in a rounded panel whose pill glides to the pick, with an action button that pops.

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

Installation

Kinetik CLI
npx kinetik-ui add segment-bar

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/segment-bar.json

Install manually

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

Usage

import { SegmentBar } from '@/components/kinetik/segment-bar';

<SegmentBar
  label="File format"
  options={[
    { value: 'png', label: 'png' },
    { value: 'jpg', label: 'jpg' },
    { value: 'heic', label: 'heic' },
  ]}
  value={format}
  onChange={setFormat}
  action={{ icon: <DownloadIcon />, label: 'Save image', onPress: save }}
/>;

When to use

  • Two to four short options that change how the next action behaves, like a format before saving.
  • Mode switches in an editor panel.

When not to use

  • Navigation between screens: use tabs.
  • Long labels or more than four options: use a select.

Notes

  • value is typed from your options, so onChange hands back the same union.
  • The action button is optional and pops when pressed.

Props

SegmentBarProps

PropTypeDefaultDescription
optionsrequiredreadonly SegmentOption<T>[]—
valuerequiredT—
onChangerequired(value: T) => void—
labelrequiredstring—Names the group for screen readers, e.g. "File format".
actionSegmentBarAction—A round button after the segments, e.g. save or erase. It pops when pressed.
disabledbooleanfalse
hapticsbooleantrueA tick when the selection changes. Defaults to true.
styleStyleProp<ViewStyle>—

Performance and accessibility

Performance

Budget
The pill moves with one UI-thread spring per change; at rest nothing animates.
Measured

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

Real-device measurement pending.

Accessibility

Reduced motion
The pill jumps to the selection and the action button does not pop.
Screen readers
A radio group with a name; each option is a radio with its checked state; the action button has its own name.
Touch target
Options and the action button are at least 44 pt.

Source files

components/kinetik/segment-bar.tsx
import { useEffect, useRef, useState, type ReactNode } from 'react';
import {
  Pressable,
  StyleSheet,
  Text,
  View,
  type LayoutChangeEvent,
  type StyleProp,
  type ViewStyle,
} from 'react-native';
import Animated, {
  useAnimatedStyle,
  useSharedValue,
  withSequence,
  withSpring,
  withTiming,
} from 'react-native-reanimated';

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

export type SegmentOption<T extends string> = {
  value: T;
  label: string;
};

export type SegmentBarAction = {
  icon: ReactNode;
  /** Accessible name, e.g. "Save image". */
  label: string;
  onPress: () => void;
};

export type SegmentBarProps<T extends string> = {
  options: readonly SegmentOption<T>[];
  value: T;
  onChange: (value: T) => void;
  /** Names the group for screen readers, e.g. "File format". */
  label: string;
  /** A round button after the segments, e.g. save or erase. It pops when pressed. */
  action?: SegmentBarAction;
  disabled?: boolean;
  /** A tick when the selection changes. Defaults to true. */
  haptics?: boolean;
  style?: StyleProp<ViewStyle>;
};

type Slot = { x: number; width: number };

/**
 * A segmented control in a rounded panel: a soft pill glides to the option
 * you pick and settles with a little give, labels brighten as it arrives, and
 * an optional button at the end pops when pressed.
 */
export function SegmentBar<T extends string>({
  options,
  value,
  onChange,
  label,
  action,
  disabled = false,
  haptics = true,
  style,
}: SegmentBarProps<T>) {
  const { colors, radius, space, font } = useKinetikTheme();
  const reduced = useReduceMotion();
  const haptic = useHaptic(haptics);
  const slots = useRef<Record<string, Slot>>({});
  const [measured, setMeasured] = useState(0);
  const x = useSharedValue(0);
  const w = useSharedValue(0);
  const placed = useRef(false);

  useEffect(() => {
    const slot = slots.current[value];
    if (!slot) return;
    // The first placement jumps; later ones glide.
    if (!placed.current || reduced) {
      x.set(slot.x);
      w.set(slot.width);
      placed.current = true;
      return;
    }
    x.set(withSpring(slot.x, spring('smooth')));
    w.set(withSpring(slot.width, spring('smooth')));
  }, [value, measured, reduced, x, w]);

  const onSlotLayout = (key: string) => (e: LayoutChangeEvent) => {
    const { x: sx, width } = e.nativeEvent.layout;
    slots.current[key] = { x: sx, width };
    setMeasured((m) => m + 1);
  };

  const pill = useAnimatedStyle(() => ({
    width: w.get(),
    transform: [{ translateX: x.get() }],
    opacity: w.get() > 0 ? 1 : 0,
  }));

  return (
    <View
      style={[
        styles.panel,
        {
          backgroundColor: colors.surfaceRaised,
          borderRadius: radius.lg,
          paddingLeft: space[2],
          paddingRight: action ? space[1] : space[2],
          gap: space[2],
          opacity: disabled ? 0.45 : 1,
        },
        style,
      ]}
    >
      <View
        accessibilityRole="radiogroup"
        accessibilityLabel={label}
        style={[styles.group, { backgroundColor: colors.surface, borderRadius: radius.md }]}
      >
        <Animated.View
          pointerEvents="none"
          style={[
            styles.pill,
            { backgroundColor: withAlpha(colors.text, 0.14), borderRadius: radius.sm },
            pill,
          ]}
        />
        {options.map((o) => {
          const on = o.value === value;
          return (
            <Pressable
              key={o.value}
              accessibilityRole="radio"
              accessibilityLabel={o.label}
              accessibilityState={{ checked: on, disabled }}
              disabled={disabled}
              onLayout={onSlotLayout(o.value)}
              onPress={() => {
                if (on) return;
                haptic('tick');
                onChange(o.value);
              }}
              style={styles.option}
            >
              <Text
                numberOfLines={1}
                style={{
                  color: on ? colors.text : colors.textMuted,
                  fontSize: font.size.sm,
                  fontWeight: on ? font.weight.semibold : font.weight.medium,
                }}
              >
                {o.label}
              </Text>
            </Pressable>
          );
        })}
      </View>
      {action ? <PopButton action={action} disabled={disabled} /> : null}
    </View>
  );
}

function PopButton({ action, disabled }: { action: SegmentBarAction; disabled: boolean }) {
  const { colors } = useKinetikTheme();
  const reduced = useReduceMotion();
  const pop = useSharedValue(0);
  const style = useAnimatedStyle(() => ({
    transform: [{ translateY: pop.get() * 3 }, { scale: 1 - pop.get() * 0.14 }],
  }));
  return (
    <Pressable
      accessibilityRole="button"
      accessibilityLabel={action.label}
      disabled={disabled}
      onPress={() => {
        if (!reduced) {
          pop.set(withSequence(withTiming(1, timing('quick')), withSpring(0, spring('bouncy'))));
        }
        action.onPress();
      }}
      style={({ pressed }) => [
        styles.action,
        { backgroundColor: pressed ? withAlpha(colors.text, 0.06) : 'transparent' },
      ]}
    >
      <Animated.View style={style}>{action.icon}</Animated.View>
    </Pressable>
  );
}

const styles = StyleSheet.create({
  panel: { minHeight: 56, flexDirection: 'row', alignItems: 'center' },
  group: { flex: 1, flexDirection: 'row', padding: 3 },
  pill: { position: 'absolute', top: 3, bottom: 3, left: 0 },
  option: {
    flex: 1,
    minHeight: minTouchTarget,
    alignItems: 'center',
    justifyContent: 'center',
    paddingHorizontal: 8,
  },
  action: {
    width: minTouchTarget,
    height: minTouchTarget,
    borderRadius: minTouchTarget / 2,
    alignItems: 'center',
    justifyContent: 'center',
  },
});