Kinetik UI

Magnet Button

.md

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

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

Installation

Kinetik CLI
npx kinetik-ui add magnet-button

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/magnet-button.json

Install manually

  1. Install dependencies
    npx expo install react-native-gesture-handler react-native-reanimated react-native-worklets
  2. Copy the source files
    • components/kinetik/magnet-button.tsx
    View source files

Usage

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' }}.

Props

MagnetButtonProps

PropTypeDefaultDescription
childrenrequiredReactNode—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`.
strengthnumber0.5How strongly the button follows the finger, 0–1. Defaults to 0.5.
fieldnumber40Width of the invisible field around the button that attracts the finger, in pt. Defaults to 40.
maxPullnumber14Maximum distance the button travels toward the finger, in pt. Defaults to 14.
hapticsbooleantruePlays a tick when the finger is captured and a tap on press. Defaults to true.
disabledbooleanfalse
accessibilityLabelstring—Required when `children` is not a string.
accessibilityHintstring—
styleStyleProp<ViewStyle>—

Performance and accessibility

Performance

Budget
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.

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.

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/magnet-button.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' },
});