Skip to main content
← Back to Motion Recipes
beginnerInteraction

Spotlight Card

A card with a radial glow that follows the pointer, positioned with CSS variables and smoothed with GSAP.

standalone componentviewChildDestroyRefCSS custom propertiesgsap.to on CSS variableseasingoverwrite
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/--y live in component-scoped styles, so the spotlight effect ships with the component instead of leaking into global CSS.
  • viewChild.required('card') reads the host element; setup runs once from afterNextRender().
  • The saved toggle 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 calls gsap.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

  1. Create the standalone component with OnPush.
  2. Add the static markup: a card with background: radial-gradient(circle at var(--x) var(--y), ...) declared in styles.
  3. Query the card with viewChild.required<ElementRef>('card').
  4. Lazy-load GSAP with await import('gsap') inside afterNextRender(), unless reduced motion is preferred.
  5. Build the animation: gsap.to(card, { '--x': x + '%', '--y': y + '%', overwrite: 'auto' }).
  6. Add cleanup: remove the listener and kill tweens of the card in destroyRef.onDestroy().
  7. Add reduced motion: set --x/--y to a fixed center value once and skip attaching the pointer listener.
  8. Test keyboard/accessibility: tab to the card's button and confirm focus-within shows 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 the pointermove handler 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-within ring.