Motion
Hero and ambient
Cursor spotlight, grid ripple, gradient field, compare slider, cursor trail, hero intro and card tilt: named, numbered, with exact values.
The effects people ask for on a first screen: a light that follows the cursor, a surface that reacts to a click, a background that never sits still. Every one runs inside MotionStage, so it plays only in view, stops when you touch it and shows its final state under prefers-reduced-motion. Ambient effects are decoration: keep them behind the content and never carry meaning in them.
Cursor spotlight
A pricing grid lit by a soft brand light and a line grid that only shows inside it. Also called mouse spotlight or holofote do cursor. Use it on pricing, bento and docs landing grids where hovering should reveal structure. The light eases to the pointer in 520ms through registered CSS properties. Ask an AI: "Use #380, a cursor spotlight, on our pricing grid."
Starter
$0/seat
3 projects, community support
Team
$24/seat
Unlimited projects, SSO, audit log
Scale
$96/seat
Priority support, 99.99% uptime SLA
Enterprise
Custom
Private cloud, dedicated engineer
Reference #380 · Pick in the gallery
Code
<script lang="ts">
import { MotionStage } from '@codeatlasdev/ui';
const plans = [
{ name: 'Starter', price: '$0', unit: '/seat', note: '3 projects, community support' },
{ name: 'Team', price: '$24', unit: '/seat', note: 'Unlimited projects, SSO, audit log' },
{ name: 'Scale', price: '$96', unit: '/seat', note: 'Priority support, 99.99% uptime SLA' },
{ name: 'Enterprise', price: 'Custom', unit: '', note: 'Private cloud, dedicated engineer' }
];
// Autoplay waypoints (x%, y%): one per cycle, the light eases between them.
const waypoints = [
[16, 26],
[74, 22],
[80, 76],
[24, 72]
];
// Pointer position in percent; null = follow the autoplay waypoint.
let pointer = $state<{ x: number; y: number } | null>(null);
function follow(e: PointerEvent) {
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
pointer = {
x: ((e.clientX - r.left) / r.width) * 100,
y: ((e.clientY - r.top) / r.height) * 100
};
}
</script>
<MotionStage label="Cursor spotlight: a soft light follows the pointer across a pricing grid" loop={1600}>
{#snippet children(m)}
{@const wp = waypoints[m.cycle % waypoints.length]}
{@const at = pointer ?? { x: wp[0], y: wp[1] }}
<div
class="spot"
class:spot--run={!m.reduced}
role="presentation"
style="--spot-x:{m.reduced ? 50 : at.x}%; --spot-y:{m.reduced ? 40 : at.y}%"
onpointermove={follow}
onpointerleave={() => (pointer = null)}
>
<ul class="spot__grid">
{#each plans as p}
<li class="spot__cell">
<h3 class="spot__name">{p.name}</h3>
<p class="spot__price">{p.price}<span>{p.unit}</span></p>
<p class="spot__note">{p.note}</p>
</li>
{/each}
</ul>
<!-- Two layers share one radial mask: a brand glow, and a grid of lines that only shows inside it. -->
<div class="spot__glow" aria-hidden="true"></div>
<div class="spot__lines" aria-hidden="true"></div>
</div>
{/snippet}
</MotionStage>
<style>
@property --spot-x { syntax: '<percentage>'; inherits: true; initial-value: 50%; }
@property --spot-y { syntax: '<percentage>'; inherits: true; initial-value: 40%; }
.spot {
position: relative;
inline-size: 100%;
max-inline-size: 36rem;
overflow: hidden;
border-radius: var(--ca-radius-lg);
background: var(--ca-surface-sunken);
outline: 1px solid var(--ca-outline-subtle);
touch-action: pan-y;
}
/* The eased follow: --spot-x and --spot-y are registered, so the browser interpolates them. */
.spot--run {
transition: --spot-x 520ms cubic-bezier(0.2, 0, 0, 1), --spot-y 520ms cubic-bezier(0.2, 0, 0, 1);
}
.spot__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
gap: 1px;
margin: 0;
padding: 0;
list-style: none;
background: var(--ca-outline-subtle);
}
.spot__cell { padding: var(--ca-space-4); background: var(--ca-surface-raised); }
.spot__name { margin: 0; font-size: var(--ca-font-size-body2); color: var(--ca-text-secondary); }
.spot__price {
margin: var(--ca-space-1) 0 0;
font-size: var(--ca-font-size-h4);
font-weight: var(--ca-font-weight-semibold);
color: var(--ca-text-primary);
}
.spot__price span { margin-inline-start: var(--ca-space-1); font-size: var(--ca-font-size-caption); font-weight: var(--ca-font-weight-regular); color: var(--ca-text-hint); }
.spot__note { margin: var(--ca-space-2) 0 0; font-size: var(--ca-font-size-caption); color: var(--ca-text-secondary); }
.spot__glow,
.spot__lines { position: absolute; inset: 0; pointer-events: none; }
.spot__glow {
background: radial-gradient(circle 10rem at var(--spot-x) var(--spot-y), color-mix(in oklab, var(--ca-brand-fill) 22%, transparent), transparent 72%);
}
.spot__lines {
background-image:
linear-gradient(to right, var(--ca-brand-fill) 1px, transparent 1px),
linear-gradient(to bottom, var(--ca-brand-fill) 1px, transparent 1px);
background-size: 1.5rem 1.5rem;
-webkit-mask-image: radial-gradient(circle 8rem at var(--spot-x) var(--spot-y), #000, transparent 72%);
mask-image: radial-gradient(circle 8rem at var(--spot-x) var(--spot-y), #000, transparent 72%);
opacity: 0.35;
}
@media (pointer: coarse) {
.spot { touch-action: none; }
}
</style>Grid ripple
Dots swell in a wave that spreads from the clicked cell, each waiting 70ms per cell of distance. Also called click ripple or ondulação em grade. Use it for empty states, hero backdrops and interactive posters. Ask an AI: "Use #381, a grid ripple from the click point, behind the hero."
Click anywhere on the grid
Reference #381 · Pick in the gallery
Code
<script lang="ts">
import { MotionStage } from '@codeatlasdev/ui';
const COLS = 14;
const ROWS = 8;
const cells = Array.from({ length: COLS * ROWS }, (_, i) => ({ c: i % COLS, r: Math.floor(i / COLS) }));
// Autoplay origins (col, row): a new ripple starts from the next one every cycle.
const origins = [
[3, 2],
[10, 5],
[6, 1],
[11, 2]
];
// The ripple is one number pair; the key restarts the CSS animation on every click.
let click = $state<{ c: number; r: number; n: number } | null>(null);
function at(e: PointerEvent) {
// Measure the dot grid itself, not the padded frame, so the cell under the pointer is exact.
const grid = (e.currentTarget as HTMLElement).querySelector('.rip__grid');
if (!grid) return;
const r = grid.getBoundingClientRect();
click = {
c: Math.min(COLS - 1, Math.max(0, Math.floor(((e.clientX - r.left) / r.width) * COLS))),
r: Math.min(ROWS - 1, Math.max(0, Math.floor(((e.clientY - r.top) / r.height) * ROWS))),
n: (click?.n ?? 0) + 1
};
}
</script>
<MotionStage label="Grid ripple: a wave of dots spreads from the point that was clicked" loop={2200}>
{#snippet children(m)}
{@const o = origins[m.cycle % origins.length]}
{@const src = click ?? { c: o[0], r: o[1], n: m.cycle }}
<div class="rip" role="presentation" onpointerdown={at}>
<p class="rip__hint">Click anywhere on the grid</p>
<!-- Keyed: a new click (or cycle) remounts the dots so every delay restarts from the new origin. -->
{#key src.n}
<div class="rip__grid" style="--cols:{COLS}" aria-hidden="true">
{#each cells as d}
{@const dist = Math.hypot(d.c - src.c, d.r - src.r)}
<i class="rip__dot" class:rip__dot--run={!m.reduced} style="--d:{Math.round(dist * 70)}ms"></i>
{/each}
</div>
{/key}
</div>
{/snippet}
</MotionStage>
<style>
.rip {
position: relative;
inline-size: 100%;
max-inline-size: 34rem;
padding: var(--ca-space-4);
border-radius: var(--ca-radius-lg);
background: var(--ca-surface-sunken);
outline: 1px solid var(--ca-outline-subtle);
cursor: crosshair;
touch-action: manipulation;
}
.rip__hint {
margin: 0 0 var(--ca-space-3);
font-size: var(--ca-font-size-caption);
color: var(--ca-text-secondary);
text-align: center;
}
.rip__grid {
display: grid;
grid-template-columns: repeat(var(--cols), 1fr);
gap: var(--ca-space-3);
justify-items: center;
}
.rip__dot {
inline-size: 0.5rem;
block-size: 0.5rem;
border-radius: 50%;
background: var(--ca-outline-strong);
}
/* Each dot waits its distance from the origin (70ms per cell), swells, and settles back. */
.rip__dot--run {
animation: rip-wave 700ms cubic-bezier(0.2, 0, 0, 1) var(--d) both;
}
@keyframes rip-wave {
0% { transform: scale(1); background: var(--ca-outline-strong); }
35% { transform: scale(2.6); background: var(--ca-brand-fill); }
100% { transform: scale(1); background: var(--ca-outline-strong); }
}
</style>Animated gradient field
A KPI card on a mesh gradient whose hue and angle drift through registered CSS properties (9s, ease-in-out, alternating). Also called aurora background or gradiente animado. Use it for hero and dashboard headers that need ambient color without canvas or JavaScript. Move the pointer to paint it. Ask an AI: "Use #382, an animated gradient field, on the revenue card."
Q3 revenue
$2.48M
+18.4% vs last quarter
Reference #382 · Pick in the gallery
Code
<script lang="ts">
import { MotionStage } from '@codeatlasdev/ui';
// Takeover: the pointer paints the field. x picks the hue turn, y the gradient angle.
let hue = $state(0);
let angle = $state(135);
let driven = $state(false);
function paint(e: PointerEvent) {
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
hue = Math.round(((e.clientX - r.left) / r.width) * 90);
angle = Math.round(((e.clientY - r.top) / r.height) * 360);
driven = true;
}
</script>
<MotionStage label="Animated gradient field: a mesh of brand color slowly shifts hue and angle" loop={6000}>
{#snippet children(m)}
<div
class="field"
class:field--run={m.playing && !driven}
role="presentation"
style={driven ? `--hue:${hue}; --angle:${angle}deg` : undefined}
onpointermove={paint}
>
<div class="field__copy">
<p class="field__eyebrow">Q3 revenue</p>
<p class="field__value">$2.48M</p>
<p class="field__delta">+18.4% vs last quarter</p>
</div>
</div>
{/snippet}
</MotionStage>
<style>
/* Registered custom properties: the browser can interpolate an angle and a hue number, a plain var cannot. */
@property --hue { syntax: '<number>'; inherits: true; initial-value: 0; }
@property --angle { syntax: '<angle>'; inherits: true; initial-value: 135deg; }
.field {
display: grid;
align-items: end;
inline-size: 100%;
max-inline-size: 34rem;
aspect-ratio: 16 / 9;
padding: var(--ca-space-5);
border-radius: var(--ca-radius-lg);
outline: 1px solid var(--ca-outline-subtle);
/* The hue turn shifts each stop around the brand color; oklab mixing keeps the blend clean. */
background:
radial-gradient(circle at 20% 15%, color-mix(in oklch, var(--ca-brand-fill), var(--ca-semantic-info) calc(var(--hue) * 1%)) 0%, transparent 55%),
radial-gradient(circle at 85% 80%, color-mix(in oklch, var(--ca-brand-light), var(--ca-semantic-secondary) calc(var(--hue) * 1%)) 0%, transparent 60%),
linear-gradient(var(--angle), var(--ca-surface-sunken), var(--ca-surface-raised));
touch-action: pan-y;
}
.field--run { animation: field-drift 9s ease-in-out infinite alternate; }
@keyframes field-drift {
from { --hue: 0; --angle: 135deg; }
to { --hue: 90; --angle: 315deg; }
}
.field__copy { color: var(--ca-text-primary); }
.field__eyebrow { margin: 0; font-size: var(--ca-font-size-caption); color: var(--ca-text-secondary); }
.field__value { margin: var(--ca-space-1) 0 0; font-size: var(--ca-font-size-h2); font-weight: var(--ca-font-weight-semibold); }
.field__delta { margin: var(--ca-space-1) 0 0; font-size: var(--ca-font-size-body2); color: var(--ca-semantic-success); }
@media (pointer: coarse) {
.field { touch-action: none; }
}
</style>Before and after slider
A divider clips the old design over the new one and snaps to 25%, 50% or 75% on a spring. Arrow keys move it by 10, Shift by 25. Also called compare slider or slider de antes e depois. Use it for redesigns, theme comparisons and changelogs. Ask an AI: "Use #383, a before and after slider, in the release notes."
After
Invoice #4821
$1,240.00
Paid on Oct 12 by Ana Lima
Before
INV-4821 / status: PAID
1240
usr=alima ts=1728691200
Reference #383 · Pick in the gallery
Code
<script lang="ts">
import { MotionStage, type MotionCtx } from '@codeatlasdev/ui';
import { Spring } from 'svelte/motion';
// The divider is a spring: it snaps to 25, 50 or 75 when released, and arrows step it by 10.
const pos = new Spring(50, { stiffness: 0.14, damping: 0.62 });
const snaps = [25, 50, 75];
const stops = [22, 78, 50];
let box: HTMLElement | undefined = $state();
let dragging = false;
// The stage context, captured once so autoplay can run in an effect (never write state in the template).
let stage = $state.raw<MotionCtx>();
$effect(() => {
if (!stage?.playing || dragging) return;
pos.target = stops[stage.cycle % stops.length];
});
function nearest(v: number) {
return snaps.reduce((a, b) => (Math.abs(b - v) < Math.abs(a - v) ? b : a));
}
function setFrom(clientX: number) {
if (!box) return;
const r = box.getBoundingClientRect();
pos.set(Math.min(100, Math.max(0, ((clientX - r.left) / r.width) * 100)), { instant: true });
}
function down(e: PointerEvent) {
dragging = true;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
setFrom(e.clientX);
}
function move(e: PointerEvent) {
if (dragging) setFrom(e.clientX);
}
function up() {
if (!dragging) return;
dragging = false;
pos.target = nearest(pos.target);
}
function key(e: KeyboardEvent) {
const step = e.shiftKey ? 25 : 10;
const next =
e.key === 'ArrowLeft' ? pos.target - step
: e.key === 'ArrowRight' ? pos.target + step
: e.key === 'Home' ? 0
: e.key === 'End' ? 100
: null;
if (next === null) return;
e.preventDefault();
pos.target = Math.min(100, Math.max(0, next));
}
</script>
<MotionStage label="Before and after compare: a divider with spring snap reveals the redesigned invoice" loop={2200}>
{#snippet children(m)}
<div
class="cmp"
bind:this={box}
{@attach () => { stage = m; }}
role="presentation"
onpointerdown={down}
onpointermove={move}
onpointerup={up}
onpointercancel={up}
>
<!-- After sits underneath, before is clipped on top: the divider reveals one over the other. -->
<div class="cmp__pane cmp__pane--after">
<p class="cmp__tag">After</p>
<p class="cmp__title">Invoice #4821</p>
<p class="cmp__amount">$1,240.00</p>
<p class="cmp__meta">Paid on Oct 12 by Ana Lima</p>
</div>
<div class="cmp__pane cmp__pane--before" style="--p:{m.reduced ? 50 : pos.current}%">
<p class="cmp__tag">Before</p>
<p class="cmp__title">INV-4821 / status: PAID</p>
<p class="cmp__amount">1240</p>
<p class="cmp__meta">usr=alima ts=1728691200</p>
</div>
<div
class="cmp__handle"
role="slider"
tabindex="0"
aria-label="Compare before and after"
aria-orientation="horizontal"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow={Math.round(m.reduced ? 50 : pos.target)}
aria-valuetext="{Math.round(m.reduced ? 50 : pos.target)}% before"
style="--p:{m.reduced ? 50 : pos.current}%"
onkeydown={key}
>
<span class="cmp__grip" aria-hidden="true"></span>
</div>
</div>
{/snippet}
</MotionStage>
<style>
.cmp {
position: relative;
inline-size: 100%;
max-inline-size: 32rem;
block-size: 11rem;
overflow: hidden;
border-radius: var(--ca-radius-lg);
outline: 1px solid var(--ca-outline-subtle);
user-select: none;
touch-action: pan-y;
cursor: ew-resize;
}
.cmp__pane {
position: absolute;
inset: 0;
padding: var(--ca-space-4) var(--ca-space-5);
box-sizing: border-box;
inline-size: 100%;
display: flex;
flex-direction: column;
justify-content: center;
gap: var(--ca-space-1);
}
.cmp__pane--after { background: var(--ca-surface-raised); color: var(--ca-text-primary); }
.cmp__pane--before {
background: var(--ca-surface-sunken);
color: var(--ca-text-secondary);
font-family: var(--ca-font-mono);
clip-path: inset(0 calc(100% - var(--p)) 0 0);
}
.cmp__tag {
margin: 0;
align-self: flex-start;
padding: 0 var(--ca-space-2);
border-radius: var(--ca-radius-pill);
background: var(--ca-brand-fill);
color: var(--ca-brand-contrast);
font-size: var(--ca-font-size-caption);
font-family: var(--ca-font-sans);
}
.cmp__pane--before .cmp__tag { background: var(--ca-text-secondary); color: var(--ca-surface-raised); }
.cmp__title { margin: 0; font-size: var(--ca-font-size-body2); overflow-wrap: anywhere; }
.cmp__amount { margin: 0; font-size: var(--ca-font-size-h3); font-weight: var(--ca-font-weight-semibold); }
.cmp__meta { margin: 0; font-size: var(--ca-font-size-caption); }
.cmp__handle {
position: absolute;
inset-block: 0;
inset-inline-start: var(--p);
inline-size: var(--ca-touch-target);
min-block-size: var(--ca-touch-target);
margin-inline-start: calc(var(--ca-touch-target) / -2);
display: grid;
place-items: center;
cursor: ew-resize;
outline: none;
}
.cmp__handle::before {
content: '';
position: absolute;
inset-block: 0;
inline-size: 2px;
background: var(--ca-brand-fill);
}
.cmp__grip {
position: relative;
inline-size: 1.75rem;
block-size: 1.75rem;
border-radius: 50%;
background: var(--ca-brand-fill);
box-shadow: var(--ca-shadow-md);
}
.cmp__handle:focus-visible .cmp__grip { outline: var(--ca-border-width-focus) solid var(--ca-focus-ring); outline-offset: var(--ca-focus-ring-offset); }
@media (pointer: coarse) {
.cmp { touch-action: none; }
}
</style>Cursor trail
A pool of 14 dots, each easing 0.32 of the way toward the one ahead, so the chain draws a trail behind the pointer. Only transform is written, and the frame loop stops when the demo leaves the screen. Also called mouse trail or rastro do cursor. Use it sparingly on playful or portfolio pages. Ask an AI: "Use #384, a cursor trail, on the portfolio hero."
Design review
Move the pointer to drag a trail
Reference #384 · Pick in the gallery
Code
<script lang="ts">
import { MotionStage, type MotionCtx } from '@codeatlasdev/ui';
// A fixed pool of 14 dots: no node is created or destroyed while the pointer moves. Each dot
// eases toward the one ahead of it (the head follows the pointer), so the chain draws the trail.
const N = 14;
let box = $state<HTMLElement>();
let head = { x: 0, y: 0 };
let target = { x: 0, y: 0 };
let raf = 0;
let stage = $state.raw<MotionCtx>();
// Waking the loop when autoplay starts: an effect, never a write inside the template.
$effect(() => {
if (stage?.playing) wake();
});
let inView = true;
let started = $state(false);
const dots: HTMLElement[] = [];
const pos = Array.from({ length: N }, () => ({ x: 0, y: 0 }));
const ids = Array.from({ length: N }, (_, i) => i);
// Runs once per frame while something moves (per frame, 0.32 easing per dot).
function frame(now: number) {
raf = 0;
if (stage?.playing && box) {
// Autoplay: a lissajous path stands in for the pointer.
const r = box.getBoundingClientRect();
const k = now / 1000;
target = { x: r.width / 2 + Math.cos(k * 2.1) * r.width * 0.34, y: r.height / 2 + Math.sin(k * 3.2) * r.height * 0.3 };
if (!started) {
for (const p of pos) Object.assign(p, target);
started = true;
}
}
let moving = false;
let prev = target;
for (let i = 0; i < N; i++) {
const p = pos[i];
p.x += (prev.x - p.x) * 0.32;
p.y += (prev.y - p.y) * 0.32;
if (Math.abs(prev.x - p.x) + Math.abs(prev.y - p.y) > 0.3) moving = true;
dots[i].style.transform = `translate3d(${p.x}px, ${p.y}px, 0) scale(${1 - i / (N + 4)})`;
prev = p;
}
if ((moving || stage?.playing) && inView) raf = requestAnimationFrame(frame);
}
function wake() {
// Reduced motion renders no dots, so there is nothing to move: never start the loop.
if (!raf && inView && !stage?.reduced) raf = requestAnimationFrame(frame);
}
function move(e: PointerEvent) {
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
target = { x: e.clientX - r.left, y: e.clientY - r.top };
if (!started) {
// First move: snap the whole chain to the pointer so it does not fly in from the corner.
for (const p of pos) Object.assign(p, target);
started = true;
}
wake();
}
// The loop exists only while the demo is on screen.
function watch(node: HTMLElement) {
const io = new IntersectionObserver(([e]) => {
inView = e.isIntersecting;
if (inView) wake();
else {
cancelAnimationFrame(raf);
raf = 0;
}
});
io.observe(node);
return () => {
io.disconnect();
cancelAnimationFrame(raf);
};
}
</script>
<MotionStage label="Cursor trail: a chain of dots eases after the pointer" loop={4000}>
{#snippet children(m)}
<div class="trail" role="presentation" bind:this={box} onpointermove={move} {@attach watch} {@attach () => { stage = m; }}>
<div class="trail__copy">
<p class="trail__title">Design review</p>
<p class="trail__sub">Move the pointer to drag a trail</p>
</div>
{#if !m.reduced}
{#each ids as i}
<i class="trail__dot" class:trail__dot--on={started} bind:this={dots[i]} aria-hidden="true"></i>
{/each}
{/if}
</div>
{/snippet}
</MotionStage>
<style>
.trail {
position: relative;
inline-size: 100%;
max-inline-size: 34rem;
block-size: 11rem;
overflow: hidden;
border-radius: var(--ca-radius-lg);
background: var(--ca-surface-sunken);
outline: 1px solid var(--ca-outline-subtle);
touch-action: pan-y;
}
.trail__copy {
position: absolute;
inset: 0;
display: grid;
place-content: center;
gap: var(--ca-space-1);
text-align: center;
pointer-events: none;
}
.trail__title { margin: 0; font-size: var(--ca-font-size-h4); font-weight: var(--ca-font-weight-semibold); color: var(--ca-text-primary); }
.trail__sub { margin: 0; font-size: var(--ca-font-size-body2); color: var(--ca-text-secondary); }
.trail__dot {
position: absolute;
inset-block-start: -0.5rem;
inset-inline-start: -0.5rem;
inline-size: 1rem;
block-size: 1rem;
border-radius: 50%;
background: var(--ca-brand-fill);
opacity: 0;
pointer-events: none;
}
.trail__dot--on { opacity: 0.55; }
@media (pointer: coarse) {
.trail { touch-action: none; }
}
</style>Hero intro sequence
Eyebrow, headline, copy and buttons enter one after another: 520ms each, 90ms apart, rising 14px. The static first frame is already the finished layout. Also called staggered hero entrance or intro escalonada do hero. Use it on the first screen of a landing page. Ask an AI: "Use #385, a hero intro sequence, on the home page."
New in 4.2
Ship the dashboard your team asked for
Pick a page, hand the number to your AI, review the pull request.
Reference #385 · Pick in the gallery
Code
<script lang="ts">
import { Button, MotionStage } from '@codeatlasdev/ui';
</script>
<MotionStage label="Hero intro: eyebrow, headline, copy and buttons enter one after another" loop={4200}>
{#snippet children(m)}
<!-- Keyed on the cycle: a new cycle mounts fresh nodes, so every delay restarts. The static
first frame (server HTML, reduced motion) is the finished layout: nothing starts hidden. -->
{#key m.cycle}
<section class="intro" class:intro--run={m.playing && m.cycle > 0}>
<p class="intro__eyebrow" style="--i:0">New in 4.2</p>
<h3 class="intro__title" style="--i:1">Ship the dashboard your team asked for</h3>
<p class="intro__lead" style="--i:2">Pick a page, hand the number to your AI, review the pull request.</p>
<div class="intro__cta" style="--i:3">
<Button>Open the gallery</Button>
<Button variant="outlined">Read the docs</Button>
</div>
</section>
{/key}
{/snippet}
</MotionStage>
<style>
.intro { display: grid; gap: var(--ca-space-3); justify-items: start; inline-size: 100%; max-inline-size: 30rem; }
.intro > * { margin: 0; }
.intro__eyebrow {
padding: 0 var(--ca-space-3);
border-radius: var(--ca-radius-pill);
background: var(--ca-brand-fill);
color: var(--ca-brand-contrast);
font-size: var(--ca-font-size-caption);
}
.intro__title { font-size: var(--ca-font-size-h2); line-height: 1.15; font-weight: var(--ca-font-weight-semibold); color: var(--ca-text-primary); text-wrap: balance; }
.intro__lead { color: var(--ca-text-secondary); font-size: var(--ca-font-size-body1); }
.intro__cta { display: flex; flex-wrap: wrap; gap: var(--ca-space-2); }
/* Each child waits 90ms times its position (--i), then rises 14px and fades in. `both` holds the
hidden first keyframe during the delay, so only a running intro ever hides anything. */
.intro--run > * {
animation: intro-in 520ms cubic-bezier(0.2, 0, 0, 1) calc(var(--i) * 90ms) both;
}
@keyframes intro-in {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }
}
</style>Card tilt with glare
A payment card leans up to 10 degrees toward the pointer on a spring while a white glare slides across it. Off on touch screens. Also called 3D tilt or card inclinado com brilho. Use it on product cards, pricing tiers and badges. Ask an AI: "Use #386, a card tilt with glare, on the pricing tiers."
CodeAtlas Pay
4821 7730 0916 2204
Reference #386 · Pick in the gallery
Code
<script lang="ts">
import { MotionStage, type MotionCtx } from '@codeatlasdev/ui';
import { Spring } from 'svelte/motion';
// The card leans up to 10 degrees toward the pointer; rotation uses a spring so it settles softly.
const MAX = 10;
const rx = new Spring(0, { stiffness: 0.12, damping: 0.55 });
const ry = new Spring(0, { stiffness: 0.12, damping: 0.55 });
let gx = $state(50);
let gy = $state(30);
let fine = $state(true);
// Autoplay sweep: one stop per cycle.
const sweep = [
[-0.7, -0.5],
[0.7, -0.4],
[0.6, 0.6],
[-0.6, 0.5]
];
let stage = $state.raw<MotionCtx>();
$effect(() => {
if (!stage?.playing) return;
const s = sweep[stage.cycle % sweep.length];
lean(s[0], s[1]);
});
function lean(nx: number, ny: number) {
ry.target = nx * MAX;
rx.target = -ny * MAX;
gx = 50 + nx * 50;
gy = 50 + ny * 50;
}
function move(e: PointerEvent) {
if (!fine) return;
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
lean(((e.clientX - r.left) / r.width - 0.5) * 2, ((e.clientY - r.top) / r.height - 0.5) * 2);
}
function rest() {
rx.target = 0;
ry.target = 0;
gx = 50;
gy = 30;
}
function probe(node: HTMLElement) {
const mq = matchMedia('(hover: hover) and (pointer: fine)');
const set = () => (fine = mq.matches);
set();
mq.addEventListener('change', set);
return () => mq.removeEventListener('change', set);
}
</script>
<MotionStage label="Card tilt with glare: a credit card leans toward the pointer and catches the light" loop={1500}>
{#snippet children(m)}
<div class="tilt" role="presentation" {@attach probe} {@attach () => { stage = m; }} onpointermove={move} onpointerleave={rest}>
<div
class="tilt__card"
style="--rx:{m.reduced || !fine ? 0 : rx.current}deg; --ry:{m.reduced || !fine ? 0 : ry.current}deg; --gx:{gx}%; --gy:{gy}%"
>
<p class="tilt__brand">CodeAtlas Pay</p>
<p class="tilt__num">4821 7730 0916 2204</p>
<div class="tilt__row">
<span>Ana Lima</span>
<span>09/29</span>
</div>
<div class="tilt__glare" aria-hidden="true"></div>
</div>
</div>
{/snippet}
</MotionStage>
<style>
.tilt { perspective: 700px; inline-size: 100%; padding: var(--ca-space-2); }
.tilt__card {
position: relative;
aspect-ratio: 1.586;
inline-size: min(20rem, 100%);
margin-inline: auto;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: var(--ca-space-5);
border-radius: var(--ca-radius-lg);
overflow: hidden;
background: linear-gradient(135deg, var(--ca-brand-dark), var(--ca-brand-main) 60%, var(--ca-brand-light));
color: var(--ca-brand-contrast);
box-shadow: var(--ca-shadow-lg);
transform: rotateX(var(--rx)) rotateY(var(--ry));
transform-style: preserve-3d;
}
.tilt__brand { margin: 0; font-weight: var(--ca-font-weight-semibold); }
.tilt__num { margin: 0; font-family: var(--ca-font-mono); font-size: var(--ca-font-size-lg); letter-spacing: 0.04em; }
.tilt__row { display: flex; justify-content: space-between; font-size: var(--ca-font-size-body2); }
/* The glare is a white radial highlight that follows --gx / --gy, always on top of the card. */
.tilt__glare {
position: absolute;
inset: 0;
pointer-events: none;
background: radial-gradient(circle at var(--gx) var(--gy), rgb(255 255 255 / 0.38), transparent 55%);
mix-blend-mode: soft-light;
}
@media (hover: none), (pointer: coarse) {
.tilt__card { transform: none; }
}
</style>