Tilt Card
A surface with a small amount of depth. The pointer position sets a rotation on
two axes, capped by maxTilt, and the card scales up slightly while it is held.
Because the rotation is driven by the pointer rather than a fixed animation, the
card settles wherever the cursor leaves it.
The effect is deliberately narrow: only the wrapper rotates, so text stays readable and anything inside keeps its own layout and semantics. Keyboard focus on a control inside produces a fixed, gentler tilt, which means the state is not hover-only.
Usage
Wrap the content in the card and give it a surface of your own. The component
adds relative and the transform; the border, padding and background belong to
the content.
Depth on demand
Move the pointer across the surface and the card leans with it. Tab to the button to see the focus tilt.
<template>
<div class="flex min-h-[360px] w-full items-center justify-center p-6">
<MTiltCard class="w-72 rounded-2xl border border-default bg-elevated p-6 shadow-lg">
<div class="flex flex-col gap-5">
<span class="grid size-11 place-items-center rounded-xl bg-primary/10 text-primary">
<UIcon
class="size-5"
name="i-lucide-layers"
/>
</span>
<div>
<p class="text-base font-semibold text-highlighted">
Depth on demand
</p>
<p class="mt-1 text-sm text-muted">
Move the pointer across the surface and the card leans with it. Tab
to the button to see the focus tilt.
</p>
</div>
<UButton
class="w-fit"
color="neutral"
label="View project"
size="sm"
variant="outline"
/>
</div>
</MTiltCard>
</div>
</template>
The card does not add a tab stop of its own. If the surface needs to be reachable, put a real link or button inside it, as the example does.
Customize
Turn on parallax and mark descendants with data-tilt-depth from 0 to 1.
A layer at 0.9 travels almost the full range while 0.15 barely moves, which
is enough to separate a photo, its gradient and a caption without any extra
markup.
Field notes
Night drive
Each layer moves at its own depth, so the photo, the copy and the badge separate as the card tilts.
<template>
<div class="flex min-h-[380px] w-full items-center justify-center p-6">
<MTiltCard
:glare-opacity="0.35"
:max-tilt="16"
:parallax="true"
class="w-72 overflow-hidden rounded-3xl border border-default bg-black shadow-xl"
>
<div class="relative h-80">
<img
alt="A road at night"
class="absolute -inset-3 h-[calc(100%+1.5rem)] w-[calc(100%+1.5rem)] object-cover opacity-80"
data-tilt-depth="0.15"
draggable="false"
src="https://picsum.photos/seed/tilt-night/640/640"
>
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/10 to-transparent" />
<div
class="absolute inset-x-5 bottom-5"
data-tilt-depth="0.55"
>
<p class="text-xs font-medium tracking-[0.2em] text-white/60 uppercase">
Field notes
</p>
<p class="mt-1 text-lg font-semibold text-white">
Night drive
</p>
<p class="mt-2 text-sm text-white/70">
Each layer moves at its own depth, so the photo, the copy and the
badge separate as the card tilts.
</p>
</div>
<span
class="absolute top-4 right-4 rounded-full bg-white/10 px-3 py-1 text-xs text-white backdrop-blur"
data-tilt-depth="0.9"
>03</span>
</div>
</MTiltCard>
</div>
</template>
The glare is a single absolutely positioned layer with a radial gradient whose centre follows the pointer. It never lights up on devices without a fine hover pointer, and the pointer handlers return early there, so a phone does not tilt or show a highlight it cannot control.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
maxTilt | number | 12 | Maximum rotation in degrees at the edge of the card. The centre is always flat. |
scale | number | 1.03 | Scale applied while the pointer is over the card. |
perspective | number | 900 | CSS perspective in pixels. Smaller values make the rotation look stronger. |
glare | boolean | true | Render the glare highlight. |
glareOpacity | number | 0.25 | Peak opacity of the glare, between 0 and 1. |
parallax | boolean | false | Move descendants marked with data-tilt-depth as the pointer moves. |
class | string | - | Extra classes for the card surface. |
Accessibility
- The card is a plain
divand adds no role, no tab stop and no focus trap, so links and buttons inside keep their native semantics. - A control inside the card produces a fixed tilt on
focusin, and the tilt resets onfocusout, so keyboard users get the same emphasis as pointer users. - The glare is
aria-hidden; it is decoration and never carries information. - Pointer handlers return early on touch devices and under
prefers-reduced-motion, so the card stays flat rather than tilting from a tap. - Rotation is capped at
maxTilton both axes, which keeps text upright enough to read at every pointer position.
Performance
- Pointer movement writes CSS custom properties on one element and, when parallax is on, a transform on each marked layer. No Vue state changes, so moving the pointer never re-renders the card.
- The rotation is a single composited
transformwith a 200 ms CSS transition; nothing is measured on every frame. - The pointer is measured once per event with one
getBoundingClientRect(). - There is no animation frame loop and no timer in the component.
- The glare adds one
background-imagelayer and stays at zero opacity when the device has no fine pointer, so it never paints a highlight on touch.