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