Skip to content

Components / Pointer

Tilt

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.

Live demo

tilt

Lean in.

Settles on the preset spring.

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

588 B raw

346 B gzip.

Uses the shared runtime (1.9 kB raw, once per site). With those included: 2.3 kB raw.

CSS 4.9 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · widely available

every browser.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import Tilt from '@moonarc/core/Tilt';
---
<Tilt class="rounded-2xl border p-8">
  <img src="/card.png" alt="" />
</Tilt>
<Tilt max={6} glare={false} perspective={1200}>subtle</Tilt>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the shared runtime and the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/tilt.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy runtime.ts.

Tilt.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>

Props

PropTypeDefaultDescription
asHTMLTag'div'Element to render.
maxnumber12Maximum tilt in degrees. Over 15 shows the card's edge.
scalenumber1.02Scale while hovered.
perspectivenumber800Perspective in px; smaller is more dramatic.
glarebooleantrueA highlight that moves opposite the 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.

Why it is built this way

Replaces: TiltedCard (React Bits) · CardContainer / 3D card (Aceternity) · vanilla-tilt · react-parallax-tilt. See the migration table.

Border Beam

Backgrounds

A beam of light travels around the edge of a card.

newly · Chrome 120 · Firefox 128 · Safari 16.4always

Magnetic

Pointer

A button or icon is pulled toward the cursor while it is nearby and springs back when it leaves.

every browserpointerhover

Spotlight

Pointer

A soft radial light follows the cursor over a card and fades in only while hovered.

every browserpointerhover

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown