# Tilt

**When to use:** Wrap content that should tilt toward the cursor in 3D. Pointer position drives a `rotateX`/`rotateY` tilt that flattens out near the center and ramps up (with an eased, non-linear curve) toward the edges. Use it for clean UI polish, like a row of pricing/plan cards that each tilt independently on hover, or as a low-level primitive for custom animation, like a hand of cards that all react to one shared cursor. Compose with `Flip` for a card that both tilts and flips.

**Keywords:** hover tilt effect, parallax card

**Import:** `import { Tilt, TiltGroup } from '@ahrowe/ui'`
**Types:** `import type { TiltProps, TiltHandle, TiltGroupProps } from '@ahrowe/ui'`

```tsx
import { Tilt } from '@ahrowe/ui';

// Basic hover tilt
<Tilt style={{ width: 220, height: 280 }}>
  <div>Hover me</div>
</Tilt>

// Limit the tilt angle, and tilt less on one axis than the other, good for a row
// of cards that should feel subtle rather than dramatic
<Tilt style={{ width: 180, height: 240 }} maxTilt={10} maxTiltX={4}>
  <PlanCard />
</Tilt>

// Lock tilt to a single axis
<Tilt style={{ width: 220, height: 280 }} lockAxis="y">
  <div>Only tilts left/right</div>
</Tilt>
```

**Imperative control (custom animation):**

```tsx
import { useRef } from 'react';
import { Tilt } from '@ahrowe/ui';
import type { TiltHandle } from '@ahrowe/ui';

const tiltRef = useRef<TiltHandle>(null);

<Tilt ref={tiltRef} style={{ width: 220, height: 280 }}>
  <div>Content</div>
</Tilt>

tiltRef.current?.setTilt(8, -12); // drive rotation directly, clamped to maxTilt/maxTiltX/maxTiltY
tiltRef.current?.reset();         // back to flat
```

**Tracking a shared cursor with `TiltGroup` (a hand of cards):**

```tsx
import { Tilt, TiltGroup } from '@ahrowe/ui';

<TiltGroup style={{ position: 'relative', width: 480, height: 220 }}>
  {cards.map((card, i) => (
    <div key={card.id} style={{ position: 'absolute', left: '50%', transform: `rotate(${fanAngle(i)}deg)` }}>
      <Tilt trackingMode="group">
        <CardFace card={card} />
      </Tilt>
    </div>
  ))}
</TiltGroup>
```

By default (`trackingMode="self"`), a `Tilt` only tilts while the pointer is over its own bounding box. With `trackingMode="group"`, it instead reads the cursor position from an ancestor `TiltGroup` and reacts to it relative to *its own* rect, so a whole hand of cards responds to one shared cursor, each computing its own tilt from the same pointer position, the way light falling across a fanned hand of physical cards would. `TiltGroup` renders a single plain `div`; give it its own size/position via `style`/`className`. If `trackingMode="group"` is set with no ancestor `TiltGroup`, it falls back to `"self"` tracking and logs a one-time dev warning.

**Composing with `Flip` (a tilting, flippable card):**

```tsx
import { Tilt, Flip } from '@ahrowe/ui';

<Tilt style={{ width: 220, height: 280 }}>
  <Flip style={{ width: '100%', height: '100%' }} perspective="none" front={<Face />} back={<Back />} />
</Tilt>
```

`Flip` sizes itself to fit `front`/`back`'s content by default, which usually isn't what you want nested inside `Tilt`, so size it to `100%` to fill `Tilt`'s surface instead. See [Flip.md](Flip.md) for the rest of `Flip`'s API.

**Sizing:** `Tilt` doesn't impose a size. Give the root an explicit size via `style`/`className` (e.g. `style={{ width: 220, height: 280 }}`).

**Put the card's visual surface on the content, not the root:** only the `surface` slot actually receives the 3D rotation, the root just establishes position and size. A background, shadow, or border-radius set via `className`/`style` on `Tilt` itself stays flat while the content tilts inside it. Put that styling on `children` (or target the `surface` slot via `classNames`/`styles`) so the whole visible surface tilts as one:

```tsx
// Wrong: the box stays flat, only the text inside it tilts
<Tilt className={styles.card} style={{ width: 220, height: 280 }}>Hover me</Tilt>

// Right: the visual surface is on the content that actually rotates
<Tilt style={{ width: 220, height: 280 }}>
  <div className={styles.cardFace}>Hover me</div>
</Tilt>
```

**Tilt tuning:** `maxTilt` (and the per-axis `maxTiltX`/`maxTiltY` overrides) clamp the rotation in degrees; the normalized cursor offset is passed through an easing curve (default export `defaultTiltEasing`) before being scaled by the clamp, so tilt stays flatter near the center and ramps up faster near the edges rather than scaling linearly. Pass your own `easing` function (`(t: number) => number`, `t` roughly in `[-1, 1]`) for a different feel.

**Motion:** tilt tracking is throttled to at most one update per animation frame, and eases in smoothly on entry/leave via a JS-driven catch-up (not a CSS transition, since CSS transitions can't gracefully follow a continuously-moving target). Under `prefers-reduced-motion: reduce`, live tilt tracking is disabled entirely and the element stays flat.

**Key props:**

| Prop | Type | Description |
|------|------|-------------|
| `children` | `ReactNode` | Content that tilts |
| `maxTilt` | `number` | Shared clamp for both axes, in degrees (default `15`) |
| `maxTiltX` / `maxTiltY` | `number` | Per-axis override for `maxTilt` |
| `perspective` | `number` | CSS `perspective` in px on the perspective wrapper (default `800`) |
| `easing` | `(t: number) => number` | Easing curve applied to the normalized cursor offset (default `defaultTiltEasing`) |
| `lockAxis` | `'x' \| 'y' \| 'none'` | Disables tilt on one axis entirely (default `'none'`) |
| `scaleOnHover` | `number` | Cosmetic `scale()` applied together with the tilt while tracking (default `1`) |
| `trackingMode` | `'self' \| 'group'` | `'self'` tracks this element's own bounding box; `'group'` reads a shared pointer from an ancestor `TiltGroup` (default `'self'`) |
| `disabled` | `boolean` | Disables tilt tracking entirely (default `false`) |
| `onTiltChange` | `(rotateX: number, rotateY: number) => void` | Fires on every applied tilt update |

**`TiltHandle`** (via `ref`):

| Method | Description |
|--------|-------------|
| `setTilt(rotateX, rotateY)` | Directly drives the tilt rotation, clamped to `maxTilt`/`maxTiltX`/`maxTiltY` |
| `reset()` | Returns to flat |

**`TiltGroupProps`:**

| Prop | Type | Description |
|------|------|-------------|
| `children` | `ReactNode` | The `Tilt`s (and any other content) inside the tracked area |
| `className` / `style` | | Root element; give it its own size/position |

**Global defaults:** `Tilt` adopts `ConfigProvider`, e.g. `defaultProps={{ Tilt: { maxTilt: 10 } }}`. See [ConfigProvider.md](ConfigProvider.md). `TiltGroup` doesn't adopt `ConfigProvider`; it has no prop surface beyond `children`/`className`/`style`.

**Slots:** `root` `perspective` `surface`
