# player card

## component specification v2.1

---

## overview

A card component that floats in space and reacts to cursor proximity with a physics-based tilt. The interaction is designed to feel elegant and physical — like a card suspended on a cushion of air that responds to attention without being jittery or mechanical.

This specification is framework-agnostic. Any implementation target (react, vue, svelte, web components, vanilla js) can reproduce this behavior from the principles described here.

---

## mental model

The component has one node that does two jobs via padding:

- **card-wrap** — owns all mouse/pointer events. Its padding creates a buffer zone around the card that activates hover before the cursor touches the card visually. Receives all transforms (tilt, float, scale).
- **card** — the visual surface only. Fills the wrap's content box.

```
[ card-wrap ]         ← pointer events, transforms applied here
  │                     padding: 15% creates the hit buffer
  │                     margin: -15% cancels layout shift
  └── [ card ]        ← visual surface, width/height: 100%
        └── [ gloss ] ← light reflection overlay
```

**normalization is read from `wrap.getBoundingClientRect()`** when no padding is present. When padding is added (see hit buffer below), normalization must switch to `card.getBoundingClientRect()` so the tilt math stays anchored to the card's visual boundary, not the padded hit area.

---

## structure

### card-wrap

- `position: relative`
- `width: 280px`, `height: 400px` — the card's intrinsic dimensions
- `transform-style: preserve-3d`
- `will-change: transform`
- `cursor: pointer`
- owns: `mousemove`, `mouseleave` (or pointer equivalents)
- receives all transform updates from the animation loop

**when hit buffer padding is applied:**

- `padding: 15%` — expands the hit zone ~42px on each side
- `margin: -15%` — negative margin cancels layout shift so the card stays centered
- `box-sizing: content-box` — ensures padding does not compress the card dimensions

### card

- `width: 100%`, `height: 100%` — fills the wrap's content box (280×400px)
- `border-radius: 20px`
- `overflow: hidden`
- `transform-style: preserve-3d`
- no transform of its own — all transforms live on card-wrap

### gloss

- `position: absolute; inset: 0` on the card
- `pointer-events: none`
- a radial gradient that shifts position to simulate a moving light source tracking the tilt angle

---

## physics model

The tilt uses a **direct spring-damper system**. The spring chases the raw cursor target with no intermediate smoothing layer — this directness is what gives the motion its expressiveness.

### signal chain

```
cursor position
  → raw target (targetRx, targetRy)   ← set directly on mousemove
    → spring (currentRx, currentRy)   ← physics applied each frame
      → transform applied to card-wrap
```

### spring parameters

| parameter         | value  | description                                                          |
| ----------------- | ------ | -------------------------------------------------------------------- |
| `STIFFNESS`       | `0.12` | how eagerly the card chases the target. Higher = snappier            |
| `DAMPING`         | `0.75` | how much velocity is preserved each frame. Higher = less oscillation |
| `MAX_TILT`        | `18°`  | maximum rotation at the card edge                                    |
| tilt x multiplier | `0.8`  | vertical axis tilt slightly reduced vs horizontal                    |

Each frame:

```
springX = STIFFNESS × (targetRx − currentRx)
springY = STIFFNESS × (targetRy − currentRy)
velX = velX × DAMPING + springX
velY = velY × DAMPING + springY
currentRx += velX
currentRy += velY
```

### normalization

Cursor position is normalized against the **wrap's bounding rect** (or `card` rect when padding is present):

```javascript
const rect = wrap.getBoundingClientRect(); // use card.getBoundingClientRect() if padding is applied
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const normX = (e.clientX - cx) / (rect.width * 0.6);
const normY = (e.clientY - cy) / (rect.height * 0.6);
```

The `* 0.6` divisor is intentional — `±1.0` is reached at 60% of the distance to the edge, not at the edge itself. This means the card saturates its full tilt range well before the cursor reaches the border, giving the motion expressiveness even at a modest 18°. Cursors beyond the `0.6` threshold produce values above `±1.0`, allowing natural overshoot at the margins.

### float

A continuous sinusoidal offset runs independent of hover state:

```javascript
floatT += 0.018; // per frame
targetFloat = Math.sin(floatT) * floatAmplitude;
currentFloat = lerp(currentFloat, targetFloat, 0.06);
```

| state    | float amplitude |
| -------- | --------------- |
| idle     | `8px`           |
| hovering | `18px`          |

Float lerp rate: `0.06` — deliberately slow so the amplitude shift is gradual, not a pop.

### scale

A subtle scale animates on hover to reinforce lift:

```javascript
currentScale = lerp(currentScale, targetScale, 0.09);
```

| state    | scale  |
| -------- | ------ |
| idle     | `1.0`  |
| hovering | `1.04` |

Scale lerp rate: `0.09`. **scale is applied to `card-wrap`**, the same node as tilt and float — never to the card directly.

### full transform per frame

```javascript
wrap.style.transform = `
  translateY(${currentFloat * -1}px)
  rotateX(${currentRx}deg)
  rotateY(${currentRy}deg)
  scale(${currentScale})
`;
```

---

## perspective

- `perspective`: `1200px` on the scene container
- `perspective-origin`: `50% 50%`
- **`perspective` belongs on the scene container**, not on card-wrap or card

---

## shadow

Shadow responds to hover state to reinforce the depth illusion:

```javascript
card.style.boxShadow = `
  0 ${shadowY}px ${shadowBlur}px rgba(0,0,0,${shadowOpacity}),
  0 ${depth}px 30px rgba(0,0,0,0.25),
  inset 0 1px 0 rgba(255,255,255,0.08)
`;
```

| property        | idle   | hover                         |
| --------------- | ------ | ----------------------------- |
| shadow Y offset | `30px` | `50px + (currentFloat × 0.5)` |
| shadow blur     | `50px` | `80px`                        |
| shadow opacity  | `0.35` | `0.55`                        |
| depth layer Y   | `15px` | `40px`                        |

The dynamic Y offset ties the shadow to the float animation — the shadow stretches as the card rises.

---

## gloss

A radial gradient overlay that shifts to simulate a moving light source:

```javascript
const gx = 50 - (currentRy / MAX_TILT) * 30;
const gy = 50 - (currentRx / MAX_TILT) * 30;
gloss.style.background = `radial-gradient(ellipse at ${gx}% ${gy}%, rgba(255,255,255,0.18) 0%, transparent 65%)`;
```

| state | intensity | position           |
| ----- | --------- | ------------------ |
| idle  | `0.10`    | fixed at `30% 20%` |
| hover | `0.18`    | tracks tilt angle  |

---

## adjustable parameters

| parameter                 | current value | effect of increasing            | effect of decreasing         |
| ------------------------- | ------------- | ------------------------------- | ---------------------------- |
| `MAX_TILT`                | `18°`         | more dramatic, game-like        | more subtle, editorial       |
| `STIFFNESS`               | `0.12`        | snappier, more responsive       | floatier, more delayed       |
| `DAMPING`                 | `0.75`        | less bounce, settles faster     | more oscillation, elastic    |
| normalization divisor     | `0.6`         | tilt saturates closer to center | tilt saturates only at edge  |
| `perspective`             | `1200px`      | less depth visible              | more dramatic 3D effect      |
| wrap padding (hit buffer) | `15%`         | earlier hover activation        | tighter, closer to card edge |
| float amplitude idle      | `8px`         | more restless at rest           | flatter, calmer              |
| float amplitude hover     | `18px`        | more lift on hover              | subtler lift                 |
| float speed (`+= 0.018`)  | `0.018`       | faster oscillation              | slower, more languid         |
| float lerp rate           | `0.06`        | faster amplitude shift          | slower, smoother transition  |
| scale hover               | `1.04`        | more pop on hover               | flatter hover state          |
| scale lerp rate           | `0.09`        | faster scale transition         | slower, more gradual         |
| gloss travel range        | `30`          | wider specular sweep            | more fixed light source      |
| tilt x multiplier         | `0.8`         | equal vertical/horizontal       | less vertical tilt           |

---

## design token candidates

### spatial tokens

```
--card-width
--card-height
--card-border-radius
--card-hit-buffer-padding        (default: 15%)
```

### motion tokens

```
--tilt-max-angle                 (default: 18deg)
--tilt-stiffness                 (default: 0.12)
--tilt-damping                   (default: 0.75)
--tilt-norm-divisor              (default: 0.6)
--float-amplitude-idle           (default: 8px)
--float-amplitude-hover          (default: 18px)
--float-speed                    (default: 0.018)
--float-lerp-rate                (default: 0.06)
--scale-hover                    (default: 1.04)
--scale-lerp-rate                (default: 0.09)
--perspective-distance           (default: 1200px)
```

### surface tokens

```
--card-background
--card-border-color
--card-border-width
--card-shadow-idle
--card-shadow-hover
--gloss-intensity-idle           (default: 0.10)
--gloss-intensity-hover          (default: 0.18)
--gloss-travel-range             (default: 30)
--accent-line-gradient
```

### content tokens (per card tier or rarity)

```
--card-rating-color
--card-position-badge-background
--card-position-badge-color
--card-stat-label-color
--card-player-name-color
--card-club-color
```

---

## reduced motion

When `prefers-reduced-motion: reduce` is set:

- disable the float animation (floatOffset = 0 always)
- disable the tilt animation (all targets remain 0)
- disable scale animation (scale remains 1.0)
- gloss remains fixed at idle position
- shadow remains at idle values

Check once on component mount and pass as a flag into the animation loop.

---

## implementation notes for agents

1. **run one animation loop per card instance** — each card owns its own `requestAnimationFrame` chain and its own physics state.

2. **all physics state is local** — `currentRx`, `currentRy`, `velX`, `velY`, `floatT`, `currentScale` are instance variables, not module-level globals.

3. **cancel the animation loop on unmount** — store the `requestAnimationFrame` id and call `cancelAnimationFrame` when the component is destroyed.

4. **normalization reference depends on padding:**
  - no padding → use `wrap.getBoundingClientRect()`
  - padding applied → use `card.getBoundingClientRect()`
     The card's rect is always the correct visual anchor; the wrap's rect is only reliable when it matches the card's size exactly.

5. **do not use CSS transitions on card-wrap transform** — the spring system is the transition. Mixing CSS transitions with JS-driven transforms causes conflicts and stuttering.

6. **`perspective` belongs on the scene container**, not card-wrap or card. If each card is rendered in isolation (e.g. in a grid), each needs its own scene wrapper with `perspective` set.

7. **`box-sizing: content-box` is required on card-wrap when padding is applied** — if the box model is reset globally (`* { box-sizing: border-box }`), the padding will compress the card dimensions. Override explicitly.

8. **all transforms on card-wrap, never on card** — applying scale or any other transform to the card directly misaligns the normalization reference.

---

## known constraints and trade-offs

| constraint                                              | reason                                                  | mitigation                                                       |
| ------------------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------- |
| normalization reference changes with padding            | padded wrap rect no longer matches card rect            | switch to `card.getBoundingClientRect()` when padding is present |
| `box-sizing: content-box` required when padding applied | border-box would shrink the card inside the padded wrap | set explicitly, don't rely on global reset                       |
| `perspective` on scene container, not card              | perspective on a transformed node compounds distortion  | one scene wrapper per card                                       |
| no CSS transitions on wrap transform                    | conflicts with JS spring, causes stuttering             | spring is the transition layer                                   |
| all transforms on wrap, not card                        | transforms on card misalign the normalization anchor    | card is purely visual                                            |

---

## version history

| version | change                                                                                                                                                                                                     |
| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| v1.0    | initial spec — separate hit zone node, card-based normalization                                                                                                                                            |
| v2.0    | corrected architecture — single wrap node with padding as hit buffer                                                                                                                                       |
| v2.1    | updated to match confirmed working implementation (v16); normalization rule clarified for padded vs unpadded states; canonical source of truth is player_card_tilt_v16_v1_exact.Html + 15% padding on wrap |
