About this animation
A magnetic button with drag-responsive material and a gathering release light, with configurable label, material variant, palette, and fonts.
Why this one
Spatial attraction works on touch screens without hover or a hold timer. The target stays put, so the visual attraction does not move the action away from your thumb. Drag at least 12 points beyond an edge to cancel permanently for that touch; returning inside does not re-arm it. Preview methods never perform a product action.
How it works
- Exclusive Gesture Handler pan and tap recognizers accept continuous movement and simple taps without nested pressables.
- One position shared value drives the surface and its counter-shifted label. Independent pressure and release clocks cannot overwrite the gesture.
- The surface follows by up to 8 points in any direction; the label moves half that distance. Pressure reaches 0.97 scale in 100 ms.
- A spring with explicit mass returns the surface to center. Release velocity is capped before being mapped into the small magnetic coordinate space.
- On valid release, a blurred Skia light converges toward the Lucide arrow in 280 ms. The arrow moves 6 points and returns in 160 ms. The callback runs once on the JS thread.
- The demo alone schedules the 5-second preview loop. Real touches stop its timers; unmount clears timers and animations.
Install
npx expo install react-native-reanimated react-native-worklets react-native-gesture-handler expo-blur expo-linear-gradient @shopify/react-native-skiaCopy this folder without demo/. Wrap your app in GestureHandlerRootView and configure Reanimated/worklets for your host. Skia web hosts must load CanvasKit before rendering this component. Expo SDK 58 includes the native dependencies in Expo Go; use the SDK-compatible versions from piece.json.
Usage
import { TouchMagneticButton } from './touch-magnetic-button';
<TouchMagneticButton
label="Start a session"
accessibilityLabel="Start a session"
onPress={startSession}
/>const button = useRef<TouchMagneticButtonRef>(null);
<TouchMagneticButton
ref={button}
label="Continue"
accessibilityLabel="Continue to your workspace"
variant="outline"
colorScheme="light"
maxOffset={5}
labels={{ activated: 'Workspace ready', canceled: 'Canceled', hint: 'Release to continue. Drag away to cancel.' }}
onPress={openWorkspace}
style={{ maxWidth: 280 }}
testID="workspace-action"
/>
// Call from an event or effect, never during render:
button.current?.previewAt(0.8, 0.4);
button.current?.releasePreview();Props
| Prop | Type | Default | Purpose |
|---|---|---|---|
| label | string | required | Visible action; empty copy uses accessibilityLabel. |
| onPress | () => void | required | One callback per valid real release or accessible activation. |
| accessibilityLabel | string | required | Screen-reader name; supply a nonempty value. |
| disabled | boolean | false | Blocks action, gestures, and preview methods; resets feedback. |
| variant | glass / soft / outline | glass | Material; unknown runtime values use soft. |
| maxOffset | number | 8 | Maximum attraction, clamped to 0–8; nonfinite values use 8. |
| colorScheme | light / dark / auto | auto | Resolves all default theme colors. |
| palette | MagneticPalette | scheme palette | Complete background, surface, text, muted, border, accent, success, error colors. |
| fontFamily | string | system | Action typeface. |
| titleFontFamily / monoFontFamily | string | unused | Common host typography slots; no headings or digits are rendered. |
| labels | object | see below | All additional accessible copy. |
| onInteractionStart | () => void | none | Physical touch-down notification for canceling host previews. |
| style | StyleProp<ViewStyle> | none | Root override, including width. |
| testID | string | touch-magnetic-button | Root identifier. Children: -target, -surface, -label. |
labels.activated defaults to accessibilityLabel; labels.canceled defaults to "Canceled"; labels.hint defaults to "Release to activate. Drag away to cancel." No status text is overlaid on the action. All demo copy is supplied explicitly. No images, data IDs, or collection inputs are used.
The ref provides previewAt(xFraction, yFraction), releasePreview(), and reset(). Fractions clamp to 0–1; nonfinite fractions use 0.5. Releasing without an active preview is a no-op. Reset interrupts all feedback. Preview methods never call onPress. Controlled disabled, variant, theme, and content props take precedence over previews. There is no session or exercise API.
Layout
The component measures its own fixed target with onLayout; it does not read the screen size or add safe areas. At normal font scale its height is 56 points and maximum width 320 points. Text wraps and height grows with large fonts. The root uses flex: 1 with flexGrow: 0 so it can live in a vertical stack; override style for host layout. The demo uses scrollable document flow, caps content at 480 points, and reserves the upper-right corner for the catalog close control. Compact heights reduce spacing.
Accessibility
The stationary target has a button role, disabled state, label, hint, and accessible activation action. A simple tap is sufficient. Successful actions and canceled drags are announced. Reduced motion removes translation, scaling, traveling lights, and arrow movement; a 200 ms tint fade preserves feedback and actions. Touch targets remain at least 56 points tall. The parent supplies safe-area handling.
Compatibility
Built for Expo SDK 58, React Native 0.88.0, Reanimated 4.7.1, worklets 0.13.0, Gesture Handler 3.3, and Skia 2.14.0. Glass uses an internal BlurTargetView for Android SDK 31+; older Android retains the readable tinted surface. BlurView has its own matching radius and overflow clipping.
Assets and license
No image assets. icons.ts contains the arrow-right and sparkles paths copied from the internal Lucide kit (ISC license). Aurora is a fictional demo brand; its tile uses this piece's neutral material and pink accent. No reference identity assets are included.




