# Tilt (Moonarc)

A card leans toward the cursor in 3D, lifts slightly, catches a glare, and settles back on the active preset's spring when the cursor leaves. The pointer writes two custom properties; everything visible is CSS. Never binds under reduced motion.

- Import: `import Tilt from '@moonarc/core/Tilt'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/tilt.json`
- Tier B · category pointer · trigger pointer, hover
- Readout: `<Tilt max={12}>`
- Browser support: widely (every browser)
- Measured cost: 588 B raw JS · 346 B gzip · + runtime (with dependencies 2.3 kB raw; CSS 4.9 kB raw)
- Page: https://moonarc.dev/components/tilt/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `as` | `HTMLTag` | `'div'` | Element to render. |
| `max` | `number` | `12` | Maximum tilt in degrees. Over 15 shows the card's edge. |
| `scale` | `number` | `1.02` | Scale while hovered. |
| `perspective` | `number` | `800` | Perspective in px; smaller is more dramatic. |
| `glare` | `boolean` | `true` | A highlight that moves opposite the tilt. |

## Usage

```astro
<Tilt class="rounded-2xl border p-8">
  <img src="/card.png" alt="" />
</Tilt>
<Tilt max={6} glare={false} perspective={1200}>subtle</Tilt>
```

## Reduced motion

No tilt, no glare, no script: the card is static.

## With ClientRouter

Bound through the shared runtime: every instance after each navigation, aborted before the swap.

## Craft

- Following the cursor is near-instant (80 ms linear); returning uses the preset spring. Direct manipulation must not lag; release may have character.
- The settle curve is the page's active preset, so a "lively" page has cards that bounce back and a "snap" page has cards that click back.
- Rotation and glare both read the same two custom properties, so they can never disagree.
- transform-style: preserve-3d lets children with translateZ float above the surface.

## Replaces

- TiltedCard (React Bits)
- CardContainer / 3D card (Aceternity)
- vanilla-tilt
- react-parallax-tilt

## Source

```astro
---
/**
 * Tilt — a card leans toward the cursor in 3D and settles back when it leaves.
 *
 * Pointer position becomes two custom properties; the rotation, lift and
 * glare are CSS reading them. Moving is immediate; leaving transitions on the
 * active preset's curve so the settle has the same character as everything
 * else on the page. Under reduced motion the script never binds.
 */
import type { HTMLAttributes, HTMLTag } from 'astro/types';

interface Props extends HTMLAttributes<'div'> {
  /** Element to render. */
  as?: HTMLTag;
  /** Maximum tilt in degrees. */
  max?: number;
  /** Scale while hovered. */
  scale?: number;
  /** Perspective in px; smaller is more dramatic. */
  perspective?: number;
  /** A moving highlight on the surface. */
  glare?: boolean;
}

const { as: Tag = 'div', max = 12, scale = 1.02, perspective = 800, glare = true, class: className, style, ...rest } = Astro.props;
const inline = [`--ma-tilt-max:${max}deg`, `--ma-tilt-scale:${scale}`, `--ma-tilt-p:${perspective}px`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<Tag class:list={['ma-tilt', className]} data-ma-tilt data-glare={glare ? '' : undefined} style={inline} {...rest}>
  <slot />
</Tag>

<style is:global>
  @layer components {
    :where(.ma-tilt) {
      position: relative;
      /* --ma-px / --ma-py: pointer position as -1…1 from the centre, written by the script */
      transform: perspective(var(--ma-tilt-p, 800px)) rotateX(calc(var(--ma-py, 0) * var(--ma-tilt-max, 12deg) * -1)) rotateY(calc(var(--ma-px, 0) * var(--ma-tilt-max, 12deg))) scale(var(--ma-s, 1));
      transform-style: preserve-3d;
      transition: transform var(--ma-duration) var(--ma-ease);
    }
    :where(.ma-tilt[data-moving]) {
      transition-duration: 80ms;
      transition-timing-function: linear;
    }
    :where(.ma-tilt[data-glare])::after {
      content: '';
      position: absolute;
      inset: 0;
      border-radius: inherit;
      pointer-events: none;
      background: radial-gradient(farthest-corner circle at calc(50% + var(--ma-px, 0) * 50%) calc(50% + var(--ma-py, 0) * 50%), var(--ma-shine), transparent 60%);
      opacity: 0;
      transition: opacity var(--ma-duration-fast) var(--ma-ease-out);
    }
    :where(.ma-tilt[data-moving][data-glare])::after {
      opacity: 0.35;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-tilt) {
        transform: none;
        transition: none;
      }
    }
  }
</style>

<script>
  import { onMount } from '../lib/runtime';

  onMount<HTMLElement>('[data-ma-tilt]', (el, { signal, reducedMotion }) => {
    if (reducedMotion) return;
    const set = (x: number, y: number) => {
      el.style.setProperty('--ma-px', x.toFixed(3));
      el.style.setProperty('--ma-py', y.toFixed(3));
    };
    el.addEventListener(
      'pointermove',
      (e) => {
        const r = el.getBoundingClientRect();
        set(((e.clientX - r.left) / r.width) * 2 - 1, ((e.clientY - r.top) / r.height) * 2 - 1);
        el.style.setProperty('--ma-s', el.style.getPropertyValue('--ma-tilt-scale') || '1.02');
        el.dataset.moving = '';
      },
      { signal, passive: true },
    );
    el.addEventListener(
      'pointerleave',
      () => {
        delete el.dataset.moving;
        set(0, 0);
        el.style.setProperty('--ma-s', '1');
      },
      { signal },
    );
  });
</script>

```
