# Glide Radio

> A radio group with one dot that travels between the rings like a drop of ink, stretching as it goes.

- Category: Inputs
- Version: 1.0.0
- Platforms: iOS, Android, web
- Dependencies: `react-native-reanimated`, `react-native-worklets`
- Requires: React Native New Architecture (Reanimated 4)

## Install

```bash
npx kinetik-ui add glide-radio
```

With the shadcn CLI:

```bash
npx shadcn@latest add https://kinetik-ui.dev/r/glide-radio.json
```

## Usage

```tsx
import { GlideRadio } from '@/components/kinetik/glide-radio';

<GlideRadio
  accessibilityLabel="Notify me about"
  options={[
    { value: 'all', label: 'Everything' },
    { value: 'mentions', label: 'Mentions and replies' },
    { value: 'none', label: 'Nothing' },
  ]}
  value={level}
  onValueChange={setLevel}
/>;
```

## When to use

- A single choice from a short, visible list in a settings screen or a form.
- Options that read best as plain rows, with an optional line of detail.

## When not to use

- Options that need a price or a badge to compare. Use Choice Cards.
- Choices that take effect one by one and can be combined. Use checkboxes (Ink Check).

## Notes

- One dot serves the whole group: its front follows a snappy spring and its back a smooth one, so it stretches as it travels and settles round.
- Rows are at least 48 pt tall and grow with their description.
- With reduced motion the dot jumps to the new ring.

## GlideRadioProps

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `options` | `readonly GlideRadioOption[]` |  |  |
| `value` | `string` |  |  |
| `onValueChange` | `(value: string) => void` |  |  |
| `accessibilityLabel` | `string` |  | Names the group for screen readers, e.g. "Notifications". |
| `haptics`? | `boolean` | `true` | A tick when the choice changes. Defaults to true. |
| `testID`? | `string` |  |  |
| `style`? | `StyleProp<ViewStyle>` |  |  |

## Accessibility

- Reduced motion: The dot jumps to the new ring without stretching.
- Screen readers: A radio group named by accessibilityLabel; each row is a radio that reads its label and description.
- Touch target: Each row is the target, at least 48 pt tall.

## Performance

Plain views; one dot driven by two shared values serves the whole group. 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 · 142 MB app memory
- Real-device measurement pending.

## Source

`components/kinetik/glide-radio.tsx`

```tsx
import { useEffect, useRef, useState } from 'react';
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, { 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';

export type GlideRadioOption = {
  value: string;
  label: string;
  description?: string;
  disabled?: boolean;
};

export type GlideRadioProps = {
  options: readonly GlideRadioOption[];
  value: string;
  onValueChange: (value: string) => void;
  /** Names the group for screen readers, e.g. "Notifications". */
  accessibilityLabel: string;
  /** A tick when the choice changes. Defaults to true. */
  haptics?: boolean;
  testID?: string;
  style?: StyleProp<ViewStyle>;
};

const RING = 22;
const DOT = 10;
const ROW = 48;

/**
 * A radio group with a single dot that travels. Choosing another option sends
 * the dot down (or up) the column of rings like a drop of ink: its front
 * races ahead, the back catches up, and it settles round in the new ring.
 */
export function GlideRadio({
  options,
  value,
  onValueChange,
  accessibilityLabel,
  haptics = true,
  testID,
  style,
}: GlideRadioProps) {
  const { colors } = useKinetikTheme();
  const reduced = useReduceMotion();
  const haptic = useHaptic(haptics);
  const [centers, setCenters] = useState<Record<string, number>>({});
  const lead = useSharedValue(0);
  const tail = useSharedValue(0);
  const placed = useRef(false);
  const target = centers[value];

  useEffect(() => {
    if (target === undefined) return;
    // The first placement is instant; after that the dot travels.
    if (reduced || !placed.current) {
      lead.set(target);
      tail.set(target);
    } else {
      lead.set(withSpring(target, spring('snappy')));
      tail.set(withSpring(target, spring('smooth')));
    }
    placed.current = true;
  }, [target, reduced, lead, tail]);

  const drop = useAnimatedStyle(() => {
    const a = lead.get();
    const b = tail.get();
    const stretch = Math.abs(a - b);
    return {
      opacity: target === undefined ? 0 : 1,
      top: Math.min(a, b) - DOT / 2,
      height: DOT + stretch,
      // A long drop thins a little, like ink pulled along.
      transform: [{ scaleX: 1 - Math.min(0.3, stretch / 120) }],
    };
  });

  return (
    <View
      testID={testID}
      accessibilityRole="radiogroup"
      accessibilityLabel={accessibilityLabel}
      style={style}
    >
      {options.map((option) => {
        const selected = option.value === value;
        return (
          <Pressable
            key={option.value}
            onPress={() => {
              if (selected) return;
              haptic('tick');
              onValueChange(option.value);
            }}
            disabled={option.disabled}
            onLayout={(e) => {
              const { y, height } = e.nativeEvent.layout;
              const c = y + height / 2;
              setCenters((m) => (m[option.value] === c ? m : { ...m, [option.value]: c }));
            }}
            accessibilityRole="radio"
            accessibilityLabel={[option.label, option.description].filter(Boolean).join(', ')}
            accessibilityState={{ checked: selected, disabled: option.disabled }}
            style={[styles.row, { opacity: option.disabled ? 0.4 : 1 }]}
          >
            <View
              style={[styles.ring, { borderColor: selected ? colors.azure : colors.borderStrong }]}
            />
            <Label option={option} selected={selected} />
          </Pressable>
        );
      })}
      <Animated.View
        pointerEvents="none"
        style={[styles.drop, { backgroundColor: colors.azure }, drop]}
      />
    </View>
  );
}

function Label({ option, selected }: { option: GlideRadioOption; selected: boolean }) {
  const { colors, font } = useKinetikTheme();
  return (
    <View style={styles.text}>
      <Text
        style={{
          color: colors.text,
          fontSize: font.size.md,
          fontWeight: selected ? font.weight.semibold : font.weight.regular,
        }}
      >
        {option.label}
      </Text>
      {option.description ? (
        <Text style={{ color: colors.textMuted, fontSize: font.size.sm }}>
          {option.description}
        </Text>
      ) : null}
    </View>
  );
}

const styles = StyleSheet.create({
  row: { minHeight: ROW, flexDirection: 'row', alignItems: 'center', gap: 14, paddingVertical: 6 },
  ring: { width: RING, height: RING, borderRadius: RING / 2, borderWidth: 2 },
  text: { flex: 1, gap: 1 },
  drop: {
    position: 'absolute',
    left: (RING - DOT) / 2,
    width: DOT,
    borderRadius: DOT / 2,
  },
});
```
