Magnet Button
A button that leans toward the finger from a field around it and presses on release.
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' },
});Installation
npx kinetik-ui add magnet-buttonpnpm dlx kinetik-ui add magnet-buttonbunx kinetik-ui add magnet-buttonyarn dlx kinetik-ui add magnet-buttonThe 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
- Install dependencies
npx expo install react-native-gesture-handler react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/magnet-button.tsx
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
fieldpoints 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
| Prop | Type | Default | Description |
|---|---|---|---|
childrenrequired | 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> | — |
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' },
});