A metal payment card you can turn with a finger and tilt with the phone, so its reflections move. A fling flips it to its back; it always settles on its front or back. Copy useCardMotion.ts into your app, then:
CardScreen.tsx
import { Backdrop, CARD_SIZE, Card } from "@apricot/assets";
import { FitCamera, Group, Scene } from "@apricot/react";
import { useTexture } from "@apricot/react/model";
import { GestureDetector } from "react-native-gesture-handler";
import { useCardMotion } from "./useCardMotion";
export function CardScreen() {
const { gesture, rotation } = useCardMotion();
const art = useTexture(require("./card-art.png"));
return (
<GestureDetector gesture={gesture}>
<Scene look="none" environment="studio" background="#0e0f12" style={{ flex: 1 }}>
<FitCamera size={[CARD_SIZE.width, CARD_SIZE.height]} margin={0.12} />
<Backdrop position={[0, 0, -3]} />
<Group rotation={rotation}>
<Card finish="brushed-steel" art={art} />
</Group>
</Scene>
</GestureDetector>
);
}The app root must be wrapped in GestureHandlerRootView (see Installation).
The hook#
useCardMotion.ts
// Apricot recipe: card-tilt
// Copy this file into your app. Drag to turn a card (it settles on its front
// or back, so a fling flips it); tilting the phone leans it a little, so its
// reflections move. Everything runs on the UI thread.
import { Gesture } from "react-native-gesture-handler";
import {
cancelAnimation,
SensorType,
useAnimatedReaction,
useAnimatedSensor,
useDerivedValue,
useSharedValue,
withSpring,
} from "react-native-reanimated";
export interface CardMotionOptions {
/** Largest lean from tilting the phone, in radians (default 0.35). */
tilt?: number;
/** Radians per point of drag (default 0.01). */
sensitivity?: number;
}
const clamp = (value: number, limit: number) => {
"worklet";
return Math.min(limit, Math.max(-limit, value));
};
// Small steps (0.002 rad, invisible): a phone lying still stops producing
// changes, so the scene can stop rendering.
const quantize = (value: number) => {
"worklet";
return Math.round(value * 500) / 500;
};
export function useCardMotion({ tilt = 0.35, sensitivity = 0.01 }: CardMotionOptions = {}) {
const yaw = useSharedValue(0);
const pitch = useSharedValue(0);
const leanX = useSharedValue(0);
const leanY = useSharedValue(0);
const neutral = useSharedValue<{ pitch: number; roll: number } | null>(null);
const sensor = useAnimatedSensor(SensorType.ROTATION, { interval: "auto" });
useAnimatedReaction(
() => sensor.sensor.value,
(reading) => {
if (!neutral.value) {
neutral.value = { pitch: reading.pitch, roll: reading.roll };
return;
}
// Re-centre slowly, so whatever angle the phone is held at becomes neutral.
const base = {
pitch: neutral.value.pitch + (reading.pitch - neutral.value.pitch) * 0.01,
roll: neutral.value.roll + (reading.roll - neutral.value.roll) * 0.01,
};
neutral.value = base;
const x = clamp((reading.pitch - base.pitch) * 0.9, tilt);
const y = clamp((reading.roll - base.roll) * 0.9, tilt);
// Smooth out sensor noise.
leanX.value += (x - leanX.value) * 0.25;
leanY.value += (y - leanY.value) * 0.25;
},
);
const gesture = Gesture.Pan()
.onBegin(() => {
cancelAnimation(yaw);
cancelAnimation(pitch);
})
.onChange((event) => {
yaw.value += event.changeX * sensitivity;
pitch.value = clamp(pitch.value + event.changeY * sensitivity, 0.6);
})
.onEnd((event) => {
// Settle on the nearest side, carrying the fling's speed.
const velocity = event.velocityX * sensitivity;
const side = Math.round((yaw.value + velocity * 0.15) / Math.PI) * Math.PI;
yaw.value = withSpring(side, { velocity, damping: 14, stiffness: 120 });
pitch.value = withSpring(0, { damping: 14 });
});
const rotation = useDerivedValue<readonly [number, number, number]>(() => [
quantize(pitch.value + leanX.value),
quantize(yaw.value + leanY.value),
0,
]);
return { gesture, rotation };
}It needs react-native-gesture-handler and react-native-reanimated (its rotation sensor; no extra dependency).
Options and result#
| Option | Default | Meaning |
|---|---|---|
tilt |
0.35 |
Largest lean from tilting the phone, radians |
sensitivity |
0.01 |
Radians per point of drag |
Returns { gesture, rotation }: pass gesture to <GestureDetector> around the scene and rotation to <Group rotation> around the card.
Notes#
- Reflections:
environment="studio"is a procedural photo studio (soft boxes and a gradient), evaluated in the shader: no environment map to generate or download.look="none"leaves it as the only light, which suits shiny objects. - Framing:
<FitCamera>keeps the card fully on screen at any size or orientation.CARD_SIZEis{ width: 3.37, height: 2.125, thickness: 0.032, radius: 0.125 }: the ISO payment card measured in inches, used as scene units. - Backdrop: glass shows, tints and bends what is behind it; over a plain background colour it looks like nothing.
<Backdrop>gives it a studio wall with faint lines. - Art: a PNG mask, opaque (white) where the card is engraved. How engraving looks depends on the finish: darker on bare metal, bright steel on matte black, white ink on paint, frosted on glass. Without
art, the face is plain. - Finishes:
brushed-steel(default),chrome,gold,rose-gold,matte-black,glossy-paint,glass. Switching swaps materials only; the card is not rebuilt.chip={false}removes the gold chip. - Tilt: the phone's current angle slowly becomes the neutral position, so it works however the phone is held. Tilt values are rounded to tiny steps, so a phone lying still lets the scene stop rendering.
Variations#
A finish picker:
FinishPicker.tsx
import { CARD_FINISHES, CARD_SIZE, Card, type CardFinish } from "@apricot/assets";
import { FitCamera, Group, Scene } from "@apricot/react";
import { useState } from "react";
import { Button, StyleSheet, View } from "react-native";
import { GestureDetector } from "react-native-gesture-handler";
import { useCardMotion } from "./useCardMotion";
const FINISHES = Object.keys(CARD_FINISHES) as CardFinish[];
export function FinishPicker() {
const [finish, setFinish] = useState<CardFinish>("gold");
const { gesture, rotation } = useCardMotion();
return (
<View style={{ flex: 1 }}>
<GestureDetector gesture={gesture}>
<Scene style={StyleSheet.absoluteFill} look="none" environment="studio" background="#0e0f12">
<FitCamera size={[CARD_SIZE.width, CARD_SIZE.height]} margin={0.2} />
<Group rotation={rotation}>
<Card finish={finish} />
</Group>
</Scene>
</GestureDetector>
<View style={{ position: "absolute", bottom: 40, left: 0, right: 0, flexDirection: "row", flexWrap: "wrap", justifyContent: "center" }}>
{FINISHES.map((f) => (
<Button key={f} title={f} onPress={() => setFinish(f)} />
))}
</View>
</View>
);
}- No sensor: pass
tilt: 0to lean only with the finger. - Your own card model: use a
<Model>withmaterial:overrides inside the<Group>instead of<Card>.