Spotlight Card
A card with a radial glow that follows the pointer, positioned with CSS variables and smoothed with GSAP.
05
Demo
Spotlight card
Move the pointer over this card — the glow follows it, smoothed with GSAP.
How it works
The card's background is a single radial-gradient positioned at two CSS custom properties, --x and --y, declared in the component's own styles. On pointermove, the handler converts the cursor position into a percentage of the card's own box and tweens those two variables with gsap.to() — GSAP interpolates the percentage value directly, so the glow glides to the new position instead of jumping.
Because the gradient itself is pure CSS, the browser composites it without Angular or GSAP touching layout — only the two custom property values change every frame.
The Angular way
--x/--ylive in component-scopedstyles, so the spotlight effect ships with the component instead of leaking into global CSS.viewChild.required('card')reads the host element; setup runs once fromafterNextRender().- The
savedtoggle is the one piece of real UI state here, so it's the only thing modeled as a signal — everything pointer-driven bypasses Angular entirely. DestroyRef.onDestroy()removes the listener and callsgsap.killTweensOf(card).
Source code
import { isPlatformBrowser } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
ElementRef,
PLATFORM_ID,
afterNextRender,
inject,
signal,
viewChild,
} from '@angular/core';
@Component({
selector: 'app-spotlight-card-demo',
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
.spotlight {
--x: 50%;
--y: 50%;
background: radial-gradient(
320px circle at var(--x) var(--y),
color-mix(in srgb, var(--color-primary) 18%, transparent),
transparent 70%
);
}
`,
],
template: `
<div
#card
class="spotlight relative w-full max-w-sm overflow-hidden rounded-3xl border border-border bg-card p-10 focus-within:ring-2 focus-within:ring-ring"
>
<div class="relative">
<h3 class="text-xl font-bold text-foreground">Spotlight card</h3>
<p class="mt-2 text-sm text-muted-foreground">
Move the pointer over this card — the glow follows it, smoothed with
GSAP.
</p>
<button
type="button"
class="mt-6 inline-flex items-center gap-1.5 rounded-lg border border-border bg-background px-3 py-1.5 text-sm font-medium text-foreground hover:border-primary/50 transition-colors"
[attr.aria-pressed]="saved()"
(click)="saved.set(!saved())"
>
<span aria-hidden="true">{{ saved() ? '★' : '☆' }}</span>
{{ saved() ? 'Saved' : 'Save' }}
</button>
</div>
</div>
`,
})
export default class SpotlightCardDemo {
private readonly destroyRef = inject(DestroyRef);
private readonly platformId = inject(PLATFORM_ID);
private readonly cardRef =
viewChild.required<ElementRef<HTMLElement>>('card');
private cleanup: (() => void) | null = null;
saved = signal(false);
constructor() {
afterNextRender(() => this.setup());
this.destroyRef.onDestroy(() => this.cleanup?.());
}
private async setup() {
if (!isPlatformBrowser(this.platformId)) return;
const card = this.cardRef().nativeElement;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
card.style.setProperty('--x', '50%');
card.style.setProperty('--y', '50%');
return;
}
const { gsap } = await import('gsap');
const onMove = (event: PointerEvent) => {
const rect = card.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * 100;
const y = ((event.clientY - rect.top) / rect.height) * 100;
gsap.to(card, {
'--x': `${x}%`,
'--y': `${y}%`,
duration: 0.4,
ease: 'power2.out',
overwrite: 'auto',
});
};
card.addEventListener('pointermove', onMove);
this.cleanup = () => {
card.removeEventListener('pointermove', onMove);
gsap.killTweensOf(card);
};
}
}
Implementation recipe
- Create the standalone component with
OnPush. - Add the static markup: a card with
background: radial-gradient(circle at var(--x) var(--y), ...)declared instyles. - Query the card with
viewChild.required<ElementRef>('card'). - Lazy-load GSAP with
await import('gsap')insideafterNextRender(), unless reduced motion is preferred. - Build the animation:
gsap.to(card, { '--x': x + '%', '--y': y + '%', overwrite: 'auto' }). - Add cleanup: remove the listener and kill tweens of the card in
destroyRef.onDestroy(). - Add reduced motion: set
--x/--yto a fixed center value once and skip attaching the pointer listener. - Test keyboard/accessibility: tab to the card's button and confirm
focus-withinshows a visible ring even without a pointer.
Accessibility notes
- the glow is decorative and marked aria-hidden
- visible focus-within state
- prefers-reduced-motion
Performance notes
- a single radial-gradient driven by CSS variables
- no layout reads inside the pointermove handler
Common pitfalls
- Reading
getBoundingClientRect()anywhere other than inside thepointermovehandler itself — computing it up front and caching it breaks as soon as the page scrolls or the card resizes. - Forgetting
overwrite: 'auto'— without it, GSAP queues a new tween on every pointer event instead of redirecting the existing one, and the glow lags behind the cursor. - Making the glow the only focus indicator — it's decorative and pointer-only, so keyboard users still need a real
focus-withinring.