# Magnet Button

> A button that leans toward the finger from a field around it and presses on release.

- 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 magnet-button
```

With the shadcn CLI:

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

## Usage

```tsx
import { MagnetButton } from '@/components/kinetik/magnet-button';

<MagnetButton onPress={start}>Get started</MagnetButton>;
```

Touching anywhere in the field around the button (40 pt by default) pulls it toward the finger. Releasing over the button presses it; releasing elsewhere lets it spring back.

## When to use

- A hero call to action with space around it: landing, onboarding, empty states.
- Playful products where the button can invite the finger in.

## When not to use

- Tight layouts. The field reaches into neighbouring elements and needs room.
- Rows of buttons. Fields overlap and the pulls compete.

## Notes

- The field uses negative margins, so it does not change your layout. Give the button at least `field` points of free space on each side.
- The touch tracking never claims the gesture, so the button works inside scroll views.
- Reduced motion keeps the button still and only dims it under the finger.
- The button sizes to its label. Position it with `style`, e.g. `style={{ alignSelf: 'center' }}`.

## MagnetButtonProps

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `ReactNode` |  | Button label. A string gets the variant's text style; any node is rendered as is. |
| `onPress`? | `() => void` |  |  |
| `variant`? | `'ember' \| 'surface'` | `'ember'` | Defaults to `ember`. |
| `strength`? | `number` | `0.5` | How strongly the button follows the finger, 0–1. Defaults to 0.5. |
| `field`? | `number` | `40` | Width of the invisible field around the button that attracts the finger, in pt. Defaults to 40. |
| `maxPull`? | `number` | `14` | Maximum distance the button travels toward the finger, in pt. Defaults to 14. |
| `haptics`? | `boolean` | `true` | Plays a tick when the finger is captured and a tap on press. Defaults to true. |
| `disabled`? | `boolean` | `false` |  |
| `accessibilityLabel`? | `string` |  | Required when `children` is not a string. |
| `accessibilityHint`? | `string` |  |  |
| `style`? | `StyleProp<ViewStyle>` |  |  |

## Accessibility

- Reduced motion: The button stays put and only dims slightly while the finger is over it.
- Screen readers: Button role; 'activate' presses it.
- Touch target: At least 52 pt tall; the field adds 40 pt of reach on every side.

## Performance

Touch moves retarget two springs on the UI thread; 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 · 146 MB app memory
- Real-device measurement pending.

## Source

`components/kinetik/magnet-button.tsx`

```tsx
import type { ReactNode } 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, { useAnimatedStyle, useSharedValue, withSpring } 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 } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { glow, minTouchTarget } from '../../lib/kinetik/tokens/tokens';

export type MagnetButtonProps = {
  /** Button label. A string gets the variant's text style; any node is rendered as is. */
  children: ReactNode;
  onPress?: () => void;
  /** Defaults to `ember`. */
  variant?: 'ember' | 'surface';
  /** How strongly the button follows the finger, 0–1. Defaults to 0.5. */
  strength?: number;
  /** Width of the invisible field around the button that attracts the finger, in pt. Defaults to 40. */
  field?: number;
  /** Maximum distance the button travels toward the finger, in pt. Defaults to 14. */
  maxPull?: number;
  /** Plays a tick when the finger is captured and a tap on press. Defaults to true. */
  haptics?: boolean;
  disabled?: boolean;
  /** Required when `children` is not a string. */
  accessibilityLabel?: string;
  accessibilityHint?: string;
  style?: StyleProp<ViewStyle>;
};

/**
 * A button that leans toward the finger. Touching anywhere in the field around
 * it pulls the button closer; releasing over the button presses it. The field
 * never claims the gesture, so it is safe inside scroll views.
 */
export function MagnetButton({
  children,
  onPress,
  variant = 'ember',
  strength = 0.5,
  field = 40,
  maxPull = 14,
  haptics = true,
  disabled = false,
  accessibilityLabel,
  accessibilityHint,
  style,
}: MagnetButtonProps) {
  const { colors, radius, font, space, shadow } = useKinetikTheme();
  const reduced = useReduceMotion();
  const haptic = useHaptic(haptics);
  const k = Math.max(0, Math.min(1, strength));
  const live = !disabled && !reduced;

  const box = useSharedValue({ x: 0, y: 0, width: 0, height: 0 });
  const tx = useSharedValue(0);
  const ty = useSharedValue(0);
  const lift = useSharedValue(0);
  const captured = useSharedValue(false);

  const palette =
    variant === 'ember'
      ? { bg: colors.ember, fg: colors.onEmber, border: 'transparent', shadow: glow(colors.ember) }
      : {
          bg: colors.surfaceRaised,
          fg: colors.text,
          border: colors.borderStrong,
          shadow: shadow.sm,
        };

  const press = () => {
    haptic('tap');
    onPress?.();
  };
  const capture = () => haptic('tick');

  /** Moves the button toward (x, y). Returns true while the finger is over the button. */
  const follow = (x: number, y: number): boolean => {
    'worklet';
    const b = box.get();
    if (b.width === 0) return false;
    const dx = x - (b.x + b.width / 2);
    const dy = y - (b.y + b.height / 2);
    const inside =
      x >= b.x - 6 && x <= b.x + b.width + 6 && y >= b.y - 6 && y <= b.y + b.height + 6;
    if (live) {
      // Pull falls off with distance so the edge of the field is gentle.
      const reach = Math.max(b.width, b.height) / 2 + field;
      const d = Math.sqrt(dx * dx + dy * dy);
      const falloff = Math.max(0, 1 - d / (reach * 1.6));
      const px = Math.max(-maxPull, Math.min(maxPull, dx * 0.35 * k * (0.4 + falloff)));
      const py = Math.max(-maxPull, Math.min(maxPull, dy * 0.35 * k * (0.4 + falloff)));
      tx.set(withSpring(px, spring('snappy')));
      ty.set(withSpring(py, spring('snappy')));
    }
    if (inside !== captured.get()) {
      captured.set(inside);
      lift.set(withSpring(inside ? 1 : 0, spring('snappy', reduced)));
      if (inside) scheduleOnRN(capture);
    }
    return inside;
  };

  const release = (pressed: boolean) => {
    'worklet';
    tx.set(withSpring(0, spring('bouncy', reduced)));
    ty.set(withSpring(0, spring('bouncy', reduced)));
    lift.set(withSpring(0, spring('bouncy', reduced)));
    if (pressed) scheduleOnRN(press);
    captured.set(false);
  };

  const gesture = Gesture.Manual()
    .withTestId('magnet-button')
    .enabled(!disabled)
    // In the field the gesture stays passive, so a drag there still scrolls.
    // Once the finger is on the button the gesture claims the touch, so
    // wiggling a finger on the button does not scroll the page away.
    .onTouchesDown((e, manager) => {
      const t = e.allTouches[0];
      if (t && follow(t.x, t.y)) manager.activate();
    })
    .onTouchesMove((e, manager) => {
      const t = e.allTouches[0];
      if (t && follow(t.x, t.y)) manager.activate();
    })
    .onTouchesUp((_e, manager) => {
      release(captured.get());
      manager.end();
    })
    .onTouchesCancelled(() => {
      release(false);
    });

  const buttonStyle = useAnimatedStyle(() => ({
    transform: [
      { translateX: tx.get() },
      { translateY: ty.get() },
      { scale: 1 + lift.get() * (reduced ? -0.02 : 0.04) },
    ],
  }));
  // The label travels further than the button, giving the face a little depth.
  const labelStyle = useAnimatedStyle(() => ({
    transform: [{ translateX: tx.get() * 0.45 }, { translateY: ty.get() * 0.45 }],
  }));

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

  return (
    <GestureDetector gesture={gesture}>
      {/* collapsable={false}: a layout-only View is flattened away on Fabric and
          the gesture would have no native view to attach to. */}
      <View collapsable={false} style={[{ padding: field, margin: -field }, styles.field, style]}>
        <Animated.View
          accessible
          accessibilityRole="button"
          accessibilityLabel={
            accessibilityLabel ?? (typeof children === 'string' ? children : undefined)
          }
          accessibilityHint={accessibilityHint}
          accessibilityState={{ disabled }}
          accessibilityActions={[{ name: 'activate' }]}
          onAccessibilityAction={onAccessibilityAction}
          onLayout={(e) => box.set(e.nativeEvent.layout)}
          style={[
            styles.button,
            {
              minHeight: Math.max(minTouchTarget, 52),
              paddingHorizontal: space[8],
              borderRadius: radius.pill,
              backgroundColor: palette.bg,
              borderColor: palette.border,
              boxShadow: disabled ? undefined : palette.shadow,
              opacity: disabled ? 0.45 : 1,
            },
            buttonStyle,
          ]}
        >
          <Animated.View style={[styles.label, { gap: space[2] }, labelStyle]}>
            {typeof children === 'string' ? (
              <Text
                numberOfLines={1}
                style={{ color: palette.fg, fontSize: font.size.md, fontWeight: font.weight.bold }}
              >
                {children}
              </Text>
            ) : (
              children
            )}
          </Animated.View>
        </Animated.View>
      </View>
    </GestureDetector>
  );
}

const styles = StyleSheet.create({
  field: { alignSelf: 'flex-start' },
  button: {
    alignItems: 'center',
    justifyContent: 'center',
    borderWidth: StyleSheet.hairlineWidth,
  },
  label: { flexDirection: 'row', alignItems: 'center' },
});
```
