# Recipe: card tilt

> Copy-paste hook for a metal payment card that turns with a finger, flips on a fling, and leans with the phone's tilt sensor so its reflections move. Seven finishes and an engraved art mask.

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:

```tsx title="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](https://matthiasbrandolin.dev/apricot/installation)).

## The hook

```ts title="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_SIZE` is `{ 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:

```tsx title="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: 0` to lean only with the finger.
- **Your own card model:** use a `<Model>` with `material:` overrides inside the `<Group>` instead of `<Card>`.

## Related

- [Textures](https://matthiasbrandolin.dev/apricot/guides/textures#engraving-and-printing).
- [Camera](https://matthiasbrandolin.dev/apricot/guides/camera#fitcamera-fit-an-area-at-any-screen-size).
- [Assets reference](https://matthiasbrandolin.dev/apricot/reference/assets#product).
