Kinetik UI

Morph Select

.md

A select whose field stretches open into its list; a highlight glides to your pick as it folds back.

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

Installation

Kinetik CLI
npx kinetik-ui add morph-select

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/morph-select.json

Install manually

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

Usage

import { MorphSelect } from '@/components/kinetik/morph-select';

<MorphSelect
  label="Time zone"
  options={[
    { value: 'lis', label: 'Lisbon', description: 'UTC+0' },
    { value: 'ist', label: 'Istanbul', description: 'UTC+3' },
  ]}
  value={zone}
  onValueChange={setZone}
/>;

When to use

  • One choice from a list of about four to twenty options in a form: a country, a time zone, a size.
  • Places where the options need a second line of detail.

When not to use

  • Two to five options people should compare at a glance. Use Choice Cards or a segmented control.
  • Long lists that need search. Use a searchable picker screen.

Notes

  • The trigger is measured and the panel grows out of it in its own modal, so it floats above everything and Back or a tap outside closes it.
  • The panel opens upwards when there is no room below.
  • Past maxVisible rows the list scrolls. Disabled options stay visible but cannot be chosen.
  • With reduced motion the list opens and closes at once.

Props

MorphSelectProps

PropTypeDefaultDescription
optionsrequiredreadonly MorphSelectOption[]—
valuerequiredstring | null—
onValueChangerequired(value: string) => void—
labelrequiredstring—Shown above the trigger and used as its accessible name.
placeholderstring'Choose'Shown in the trigger while nothing is chosen. Defaults to "Choose".
maxVisiblenumber6Rows shown before the list scrolls. Defaults to 6.
hapticsbooleantrueA tick on open and on choosing. Defaults to true.
testIDstring—
styleStyleProp<ViewStyle>—

Performance and accessibility

Performance

Budget
A modal of plain views: the panel height and every row read one shared value, and one more moves the highlight. Nothing runs at rest.
Measured

Pixel 9 emulator, API 35, release build · interaction median 0.94× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 154 MB app memory. p99 spikes are the first frame of the modal window opening.

Real-device measurement pending.

Accessibility

Reduced motion
The list opens and closes at once and the highlight jumps.
Screen readers
A button that reads the label and the current choice and says whether it is expanded; options are menu items with a selected state.
Touch target
The field is 52 pt tall and each option 48 pt.

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/morph-select.tsx
import { useEffect, useRef, useState } from 'react';
import {
  Modal,
  Pressable,
  ScrollView,
  StyleSheet,
  Text,
  View,
  useWindowDimensions,
  type StyleProp,
  type ViewStyle,
} from 'react-native';
import Animated, {
  interpolate,
  useAnimatedStyle,
  useSharedValue,
  withDelay,
  withSpring,
  withTiming,
  type SharedValue,
} 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';

export type MorphSelectOption = {
  value: string;
  label: string;
  /** A second line in the list, e.g. a time zone offset. */
  description?: string;
  disabled?: boolean;
};

export type MorphSelectProps = {
  options: readonly MorphSelectOption[];
  value: string | null;
  onValueChange: (value: string) => void;
  /** Shown above the trigger and used as its accessible name. */
  label: string;
  /** Shown in the trigger while nothing is chosen. Defaults to "Choose". */
  placeholder?: string;
  /** Rows shown before the list scrolls. Defaults to 6. */
  maxVisible?: number;
  /** A tick on open and on choosing. Defaults to true. */
  haptics?: boolean;
  testID?: string;
  style?: StyleProp<ViewStyle>;
};

const TRIGGER = 52;
const ROW = 48;
const PAD = 6;
const MARGIN = 12;

type Rect = { x: number; y: number; width: number; height: number };

/**
 * A select whose trigger grows into its list. The field stretches open in
 * place, the options slide in under it one after another, and a highlight
 * glides to the one you pick before the list folds back into the field.
 */
export function MorphSelect({
  options,
  value,
  onValueChange,
  label,
  placeholder = 'Choose',
  maxVisible = 6,
  haptics = true,
  testID,
  style,
}: MorphSelectProps) {
  const { colors, font, radius, space, shadow } = useKinetikTheme();
  const reduced = useReduceMotion();
  const haptic = useHaptic(haptics);
  const window = useWindowDimensions();
  const trigger = useRef<View>(null);
  const [visible, setVisible] = useState(false);
  const [anchor, setAnchor] = useState<Rect | null>(null);
  const t = useSharedValue(0);
  const index = options.findIndex((o) => o.value === value);
  const highlight = useSharedValue(Math.max(0, index));
  const selected = index >= 0 ? options[index] : undefined;

  const rows = Math.min(options.length, maxVisible);
  const listH = rows * ROW + PAD * 2;
  const rect = anchor ?? { x: MARGIN, y: 120, width: window.width - MARGIN * 2, height: TRIGGER };
  const room = window.height - (rect.y + rect.height) - MARGIN;
  const down = room >= listH || rect.y < window.height / 2;
  const fullH = rect.height + Math.min(listH, Math.max(ROW * 2, down ? room : rect.y - MARGIN));

  const open = () => {
    highlight.set(Math.max(0, index));
    setAnchor(null);
    setVisible(true);
    haptic('tick');
    trigger.current?.measureInWindow((x, y, width, height) => setAnchor({ x, y, width, height }));
  };

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

  const close = (chosen?: string) => {
    const done = () => {
      setVisible(false);
      if (chosen !== undefined) onValueChange(chosen);
    };
    if (reduced) {
      t.set(0);
      done();
      return;
    }
    // A chosen row holds its highlight for a beat before the list folds away.
    const wait = chosen === undefined ? 0 : 140;
    t.set(
      withDelay(
        wait,
        withTiming(0, { duration: durations.base, easing: easings.exit }, (finished) => {
          if (finished) scheduleOnRN(done);
        }),
      ),
    );
  };

  const choose = (i: number) => {
    const option = options[i];
    if (!option || option.disabled) return;
    haptic('tick');
    highlight.set(reduced ? i : withSpring(i, spring('snappy')));
    close(option.value);
  };

  const panel = useAnimatedStyle(() => {
    const h = interpolate(t.get(), [0, 1], [rect.height, fullH]);
    return {
      height: h,
      top: down ? rect.y : rect.y + rect.height - h,
      opacity: anchor ? 1 : 0,
    };
  });
  const scrim = useAnimatedStyle(() => ({ opacity: t.get() * 0.6 }));
  const chevron = useAnimatedStyle(() => ({
    transform: [{ rotate: `${t.get() * 180}deg` }],
  }));
  const pill = useAnimatedStyle(() => ({ transform: [{ translateY: highlight.get() * ROW }] }));

  const shown = selected?.label ?? placeholder;
  const field = (
    <View style={[styles.fieldRow, { paddingHorizontal: space[4] }]}>
      <Text
        numberOfLines={1}
        style={[
          styles.grow,
          { color: selected ? colors.text : colors.textSubtle, fontSize: font.size.md },
        ]}
      >
        {shown}
      </Text>
      <Animated.View style={visible ? chevron : undefined}>
        <Chevron color={colors.textMuted} />
      </Animated.View>
    </View>
  );

  return (
    <View testID={testID} style={[{ gap: space[2] }, style]}>
      <Text style={{ color: colors.text, fontSize: font.size.sm, fontWeight: font.weight.medium }}>
        {label}
      </Text>
      <Pressable
        ref={trigger}
        onPress={open}
        accessibilityRole="button"
        accessibilityLabel={`${label}, ${shown}`}
        accessibilityState={{ expanded: visible }}
        style={({ pressed }) => [
          styles.trigger,
          {
            backgroundColor: pressed ? colors.surfaceSunken : colors.surface,
            borderColor: colors.borderStrong,
            borderRadius: radius.md,
            opacity: visible && anchor ? 0 : 1,
          },
        ]}
      >
        {field}
      </Pressable>

      <Modal
        visible={visible}
        transparent
        animationType="none"
        onRequestClose={() => close()}
        statusBarTranslucent
        navigationBarTranslucent
      >
        <View style={StyleSheet.absoluteFill} onAccessibilityEscape={() => close()}>
          <Animated.View
            style={[StyleSheet.absoluteFill, { backgroundColor: colors.scrim }, scrim]}
          >
            <Pressable
              style={StyleSheet.absoluteFill}
              onPress={() => close()}
              accessibilityRole="button"
              accessibilityLabel="Close list"
            />
          </Animated.View>
          <Animated.View
            accessibilityRole="list"
            style={[
              styles.panel,
              {
                left: rect.x,
                width: rect.width,
                backgroundColor: colors.surfaceRaised,
                borderColor: colors.borderStrong,
                borderRadius: radius.md,
                boxShadow: shadow.lg,
                flexDirection: down ? 'column' : 'column-reverse',
              },
              panel,
            ]}
          >
            <Pressable
              onPress={() => close()}
              accessibilityRole="button"
              accessibilityLabel={`${label}, ${shown}`}
              accessibilityState={{ expanded: true }}
              style={styles.header}
            >
              {field}
            </Pressable>
            <View style={[styles.divider, { backgroundColor: colors.border }]} />
            <ScrollView
              style={styles.grow}
              contentContainerStyle={{ padding: PAD }}
              scrollEnabled={options.length > maxVisible}
              showsVerticalScrollIndicator={false}
            >
              <Animated.View
                pointerEvents="none"
                style={[
                  styles.pill,
                  { top: PAD, left: PAD, right: PAD, backgroundColor: colors.azureSoft },
                  { borderRadius: radius.sm, opacity: index >= 0 ? 1 : 0 },
                  pill,
                ]}
              />
              {options.map((option, i) => (
                <Row
                  key={option.value}
                  option={option}
                  index={i}
                  selected={i === index}
                  t={t}
                  onPress={() => choose(i)}
                />
              ))}
            </ScrollView>
          </Animated.View>
        </View>
      </Modal>
    </View>
  );
}

type RowProps = {
  option: MorphSelectOption;
  index: number;
  selected: boolean;
  t: SharedValue<number>;
  onPress: () => void;
};

function Row({ option, index, selected, t, onPress }: RowProps) {
  const { colors, font, space } = useKinetikTheme();
  // Rows arrive one after another as the panel opens.
  const motion = useAnimatedStyle(() => {
    const start = Math.min(0.5, 0.15 + index * 0.06);
    const p = interpolate(t.get(), [start, start + 0.45], [0, 1], 'clamp');
    return { opacity: p, transform: [{ translateY: (1 - p) * -8 }] };
  });
  return (
    <Animated.View style={motion}>
      <Pressable
        onPress={onPress}
        disabled={option.disabled}
        accessibilityRole="menuitem"
        accessibilityLabel={[option.label, option.description].filter(Boolean).join(', ')}
        accessibilityState={{ selected, disabled: option.disabled }}
        style={[styles.row, { paddingHorizontal: space[3], opacity: option.disabled ? 0.4 : 1 }]}
      >
        <View style={styles.grow}>
          <Text
            numberOfLines={1}
            style={{
              color: selected ? colors.azure : colors.text,
              fontSize: font.size.md,
              fontWeight: selected ? font.weight.semibold : font.weight.regular,
            }}
          >
            {option.label}
          </Text>
          {option.description ? (
            <Text numberOfLines={1} style={{ color: colors.textSubtle, fontSize: font.size.xs }}>
              {option.description}
            </Text>
          ) : null}
        </View>
        {selected ? <Tick color={colors.azure} /> : null}
      </Pressable>
    </Animated.View>
  );
}

function Chevron({ color }: { color: string }) {
  return (
    <View style={styles.icon}>
      <View style={[styles.stroke, { backgroundColor: color }, styles.chevronL]} />
      <View style={[styles.stroke, { backgroundColor: color }, styles.chevronR]} />
    </View>
  );
}

function Tick({ color }: { color: string }) {
  return (
    <View style={styles.icon}>
      <View style={[styles.stroke, { backgroundColor: color }, styles.tickShort]} />
      <View style={[styles.stroke, { backgroundColor: color }, styles.tickLong]} />
    </View>
  );
}

const styles = StyleSheet.create({
  trigger: { height: TRIGGER, borderWidth: 1, justifyContent: 'center' },
  header: { height: TRIGGER - 2, justifyContent: 'center' },
  fieldRow: { flexDirection: 'row', alignItems: 'center', gap: 8, height: TRIGGER - 2 },
  grow: { flex: 1 },
  panel: { position: 'absolute', borderWidth: 1, overflow: 'hidden' },
  divider: { height: StyleSheet.hairlineWidth },
  pill: { position: 'absolute', height: ROW },
  row: { height: ROW, flexDirection: 'row', alignItems: 'center', gap: 8 },
  icon: { width: 16, height: 16, alignItems: 'center', justifyContent: 'center' },
  stroke: { position: 'absolute', height: 1.8, borderRadius: 1 },
  chevronL: { width: 7, left: 2.6, top: 8, transform: [{ rotate: '40deg' }] },
  chevronR: { width: 7, right: 2.6, top: 8, transform: [{ rotate: '-40deg' }] },
  tickShort: { width: 5.5, left: 2.2, top: 9, transform: [{ rotate: '45deg' }] },
  tickLong: { width: 10, left: 4.6, top: 7.4, transform: [{ rotate: '-50deg' }] },
});