# Pulse Toggle

> A switch whose thumb stretches mid-flight, lands as a rounded square and pulses when it turns on.

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

## Install

```bash
npx kinetik-ui add pulse-toggle
```

With the shadcn CLI:

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

## Usage

```tsx
import { PulseToggle } from '@/components/kinetik/pulse-toggle';

const [enabled, setEnabled] = useState(false);

<PulseToggle
  label="Release alerts"
  description="A nudge when a new build is ready"
  value={enabled}
  onValueChange={setEnabled}
/>;
```

With a `label` the whole row is the touch target and stretches to its container. Without one, give it an `accessibilityLabel`.

## When to use

- Settings that take effect immediately.
- Places where a little personality is welcome: onboarding, preferences, playful products.

## When not to use

- Choices that only apply after a Save button. Use a checkbox.
- More than two states. Use a segmented control.

## Notes

- `activeColor` changes the on colour per toggle; the thumb is always the theme highlight when on.
- The pulse ring only plays when the user turns the switch on, not on first render.

## PulseToggleProps

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `boolean` |  |  |
| `onValueChange` | `(value: boolean) => void` |  |  |
| `label`? | `string` |  | Visible label. Tapping it toggles too. Also used as the accessibility label. |
| `accessibilityLabel`? | `string` |  | Accessibility label when there is no visible label. |
| `description`? | `string` |  | Secondary line under the label. |
| `disabled`? | `boolean` | `false` |  |
| `activeColor`? | `string` |  | Track colour when on. Defaults to the theme ember. |
| `size`? | `PulseToggleSize` | `'md'` | Defaults to `md`. |
| `haptics`? | `boolean` | `true` | Plays a light tap when toggled. Defaults to true. |
| `style`? | `StyleProp<ViewStyle>` |  |  |

## Accessibility

- Reduced motion: Thumb slides with a short timing; no stretch and no pulse ring.
- Screen readers: Switch role with checked and disabled states; the visible label is the accessible name.
- Touch target: Row is at least 44 pt tall; a bare toggle gets an 8 pt hit slop.

## Performance

One spring and one 480 ms ring timing per toggle; nothing runs at rest.

- Measured: Pixel 9 emulator, API 35, release build · interaction median 1.18× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 149 MB app memory
- Real-device measurement pending.

## Source

`components/kinetik/pulse-toggle.tsx`

```tsx
import { useEffect, useRef } from 'react';
import {
  StyleSheet,
  Text,
  View,
  type AccessibilityActionEvent,
  type StyleProp,
  type ViewStyle,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
  interpolate,
  interpolateColor,
  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 { spring, timing } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { minTouchTarget, withAlpha } from '../../lib/kinetik/tokens/tokens';

export type PulseToggleSize = 'sm' | 'md';

export type PulseToggleProps = {
  value: boolean;
  onValueChange: (value: boolean) => void;
  /** Visible label. Tapping it toggles too. Also used as the accessibility label. */
  label?: string;
  /** Accessibility label when there is no visible label. */
  accessibilityLabel?: string;
  /** Secondary line under the label. */
  description?: string;
  disabled?: boolean;
  /** Track colour when on. Defaults to the theme ember. */
  activeColor?: string;
  /** Defaults to `md`. */
  size?: PulseToggleSize;
  /** Plays a light tap when toggled. Defaults to true. */
  haptics?: boolean;
  style?: StyleProp<ViewStyle>;
};

const DIM: Record<PulseToggleSize, { w: number; h: number; thumb: number }> = {
  sm: { w: 44, h: 26, thumb: 18 },
  md: { w: 54, h: 32, thumb: 24 },
};

/**
 * A switch whose thumb changes shape as it travels: a circle stretches into a
 * pill mid-flight and lands as a rounded square, releasing a pulse ring when it
 * turns on.
 */
export function PulseToggle({
  value,
  onValueChange,
  label,
  accessibilityLabel,
  description,
  disabled = false,
  activeColor,
  size = 'md',
  haptics = true,
  style,
}: PulseToggleProps) {
  const { colors, font, space, shadow } = useKinetikTheme();
  const reduced = useReduceMotion();
  const haptic = useHaptic(haptics);
  const on = activeColor ?? colors.ember;
  const { w, h, thumb } = DIM[size];
  const pad = (h - thumb) / 2;
  const inner = w - pad * 2;

  const progress = useSharedValue(value ? 1 : 0);
  const pressed = useSharedValue(0);
  const ring = useSharedValue(0);
  const first = useRef(true);

  useEffect(() => {
    progress.set(
      reduced
        ? withTiming(value ? 1 : 0, timing('quick'))
        : withSpring(value ? 1 : 0, spring('snappy')),
    );
    if (first.current) {
      first.current = false;
      return;
    }
    if (value && !reduced) {
      ring.set(0);
      ring.set(withTiming(1, timing('slow')));
    }
  }, [value, reduced, progress, ring]);

  const toggle = () => {
    haptic('tap');
    onValueChange(!value);
  };

  const tap = Gesture.Tap()
    .withTestId('pulse-toggle')
    .enabled(!disabled)
    .maxDuration(60_000)
    .onBegin(() => {
      pressed.set(withSpring(1, spring('snappy', reduced)));
    })
    .onFinalize(() => {
      pressed.set(withSpring(0, spring('snappy', reduced)));
    })
    .onEnd((_e, success) => {
      if (success) scheduleOnRN(toggle);
    });

  const onClear = withAlpha(on, 0);
  const track = useAnimatedStyle(() => ({
    backgroundColor: interpolateColor(progress.get(), [0, 1], [colors.track, on]),
    borderColor: interpolateColor(progress.get(), [0, 1], [colors.borderStrong, onClear]),
  }));

  const thumbStyle = useAnimatedStyle(() => {
    const p = progress.get();
    // Stretch peaks mid-travel; pressing adds a little extra width.
    const stretch = reduced ? 0 : Math.sin(Math.min(1, Math.max(0, p)) * Math.PI) * 0.55;
    const width = thumb * (1 + stretch + pressed.get() * 0.18);
    const x = pad + p * (inner - width);
    return {
      width,
      transform: [{ translateX: x }],
      borderRadius: interpolate(p, [0, 1], [thumb / 2, thumb * 0.3], 'clamp'),
      backgroundColor: interpolateColor(p, [0, 1], [colors.textMuted, colors.highlight]),
    };
  });

  const ringStyle = useAnimatedStyle(() => {
    const t = ring.get();
    return {
      opacity: t === 0 ? 0 : (1 - t) * 0.55,
      transform: [{ scale: 1 + t * 0.9 }],
    };
  });

  const onAccessibilityAction = (e: AccessibilityActionEvent) => {
    if (e.nativeEvent.actionName === 'activate' && !disabled) toggle();
  };

  const control = (
    <View style={{ width: w, height: h }}>
      <Animated.View
        pointerEvents="none"
        style={[styles.ring, { borderRadius: h / 2, borderColor: on }, ringStyle]}
      />
      <Animated.View style={[styles.track, { width: w, height: h, borderRadius: h / 2 }, track]}>
        <Animated.View
          style={[
            styles.thumb,
            { height: thumb, top: pad - StyleSheet.hairlineWidth, boxShadow: shadow.sm },
            thumbStyle,
          ]}
        />
      </Animated.View>
    </View>
  );

  return (
    <GestureDetector gesture={tap}>
      <View
        collapsable={false}
        accessible
        accessibilityRole="switch"
        accessibilityLabel={label ?? accessibilityLabel}
        accessibilityHint={description}
        accessibilityState={{ checked: value, disabled }}
        accessibilityActions={[{ name: 'activate' }]}
        onAccessibilityAction={onAccessibilityAction}
        hitSlop={label ? undefined : { top: 8, bottom: 8, left: 8, right: 8 }}
        style={[
          styles.row,
          { minHeight: minTouchTarget, gap: space[4], opacity: disabled ? 0.45 : 1 },
          label ? styles.stretch : null,
          style,
        ]}
      >
        {label ? (
          <View style={styles.labels}>
            <Text
              style={{ color: colors.text, fontSize: font.size.md, fontWeight: font.weight.medium }}
            >
              {label}
            </Text>
            {description ? (
              <Text style={{ color: colors.textMuted, fontSize: font.size.sm, marginTop: 2 }}>
                {description}
              </Text>
            ) : null}
          </View>
        ) : null}
        {control}
      </View>
    </GestureDetector>
  );
}

const styles = StyleSheet.create({
  row: { flexDirection: 'row', alignItems: 'center', alignSelf: 'flex-start' },
  stretch: { alignSelf: 'stretch' },
  labels: { flex: 1 },
  track: { borderWidth: StyleSheet.hairlineWidth, justifyContent: 'center' },
  thumb: { position: 'absolute', left: 0 },
  ring: { ...StyleSheet.absoluteFill, borderWidth: 2 },
});
```
