Border Beam
BackgroundsA beam of light travels around the edge of a card.
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.
tilt
Lean in.
Settles on the preset spring.
glare="foil"
Catch the light.
Its colours slide with the pointer.
Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
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.
With foil: the same script as the default, 588 B raw. The foil path is part of it and adds nothing; measured on its own fixture.
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
every browser.
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
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>Owns the file, no dependency. The registry item also installs the shared runtime and the base tokens.
npx shadcn@latest add https://moonarc.dev/r/tilt.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy runtime.ts.
---
/**
* 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; glare="foil" is a holographic foil whose
* colours slide with the pointer. 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; 'foil' a holographic one, its colours sliding with the pointer. */
glare?: boolean | 'foil';
}
const { as: Tag = 'div', max = 12, scale = 1.02, perspective = 800, glare = true, class: className, style, ...rest } = Astro.props;
// foil: bands of colour across the card that slide against the pointer, with the shine over them, blended so they
// light the card's own colours; the bands move twice as far as the shine, which is what makes them read as foil. Its
// rules print with the first foil card on a page (one per render), so a page of plain tilts carries none of them
const printed = new WeakSet<Request>();
const FOIL =
"@layer components{:where(.ma-tilt[data-glare='foil'])::after{background:radial-gradient(farthest-corner circle at calc(50% + var(--ma-px,0)*50%) calc(50% + var(--ma-py,0)*50%),rgb(255 255 255/.55),transparent 55%),repeating-linear-gradient(115deg,oklch(.82 .16 20) 0%,oklch(.88 .15 95) 7%,oklch(.85 .14 170) 14%,oklch(.8 .15 250) 21%,oklch(.82 .16 320) 28%,oklch(.82 .16 20) 35%);background-size:100% 100%,300% 300%;background-position:0 0,calc(50% - var(--ma-px,0)*40%) calc(50% - var(--ma-py,0)*40%);mix-blend-mode:color-dodge}" +
":where(.ma-tilt[data-moving][data-glare='foil'])::after{opacity:.6}}";
const printFoil = glare === 'foil' && !printed.has(Astro.request) && (printed.add(Astro.request), true);
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 === 'foil' ? 'foil' : 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>
{printFoil && <style is:inline set:html={FOIL}></style>}| 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 | 'foil' | true | A highlight that moves opposite the tilt; 'foil' is a holographic foil, bands of colour sliding with the pointer under the shine, blended with color-dodge. |
No tilt, no glare, no script: the card is static.
ClientRouterBound through the shared runtime: every instance after each navigation, aborted before the swap.
Replaces: TiltedCard (React Bits) · CardContainer / 3D card (Aceternity) · vanilla-tilt · react-parallax-tilt. See the migration table.
A beam of light travels around the edge of a card.
A button or icon is pulled toward the cursor while it is nearby and springs back when it leaves.
A soft radial light follows the cursor over a card and fades in only while hovered.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown