Skip to content
Menu

Experiment #005

Spotlight Card

On mousemove, Alpine writes the pointer position into two CSS custom properties (--x, --y) on the card — plain style writes, naturally throttled by the render loop, no rAF needed. Two overlay layers consume them: a radial-gradient halo and a 1px border ring isolated with a two-layer mask-composite, both built on color-mix with currentColor so they adapt to light and dark themes. Only opacity is ever transitioned; the gradient position snaps to the cursor.

Scene

Technical details

Experiment
FX-005
Category
Cards
Stack
Tailwind + Alpine.js
Difficulty
Intermediate
Dependencies
none
Weight
HTML 2.0 KB · ALPINE 1.2 KB
Added
2026-08-24
Updated
2026-08-24

Lab notes

Tune the halo with the gradient radius (180–280px) and the color-mix percentage (6–12%) — past that it stops reading as light and starts reading as a stain. The border ring uses mask-composite: exclude, supported in all modern browsers. If the card wraps a link, mirror the hover state with :focus-within and center --x/--y so keyboard users get the highlight too.

Take the code

HTML — code.html
<!-- Spotlight Card — Tailwind + Alpine.js -->
<div
    x-data="{
        active: false,
        move(event) {
            const rect = this.$el.getBoundingClientRect()
            this.$el.style.setProperty('--x', `${event.clientX - rect.left}px`)
            this.$el.style.setProperty('--y', `${event.clientY - rect.top}px`)
        },
    }"
    @mousemove="move"
    @mouseenter="active = true"
    @mouseleave="active = false"
    style="--x: 50%; --y: 40%"
    class="relative w-72 border border-zinc-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-900"
>
    <!-- Halo: radial gradient centered on the cursor via --x/--y -->
    <div
        aria-hidden="true"
        :class="active ? 'opacity-100' : 'opacity-0'"
        class="pointer-events-none absolute inset-0 text-zinc-900 opacity-0
               transition-opacity duration-300 motion-reduce:transition-none
               dark:text-white"
        style="background: radial-gradient(220px circle at var(--x) var(--y), color-mix(in oklab, currentColor 8%, transparent), transparent 70%)"
    ></div>

    <!-- Border ring: same gradient, masked down to a 1px frame -->
    <div
        aria-hidden="true"
        :class="active ? 'opacity-100' : 'opacity-0'"
        class="pointer-events-none absolute inset-0 p-px text-zinc-900 opacity-0
               transition-opacity duration-300 motion-reduce:transition-none
               dark:text-white"
        style="background: radial-gradient(140px circle at var(--x) var(--y), color-mix(in oklab, currentColor 40%, transparent), transparent 70%); mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); mask-composite: exclude"
    ></div>

    <!-- Card content -->
    <div class="relative">
        <h3 class="text-sm font-semibold text-zinc-900 dark:text-white">Spotlight</h3>
        <p class="mt-2 text-sm leading-relaxed text-zinc-500 dark:text-zinc-400">
            Move your cursor across the card. A soft halo tracks it,
            and the border catches the light as it passes.
        </p>
    </div>
</div>
Alpine — alpine.js
// Spotlight Card — reusable Alpine.js component
//
// Register once at boot, then apply to any card:
//
//     <div
//         x-data="spotlightCard"
//         @mousemove="move"
//         @mouseenter="active = true"
//         @mouseleave="active = false"
//         class="relative ..."
//     >
//         <div aria-hidden="true" :class="active ? 'opacity-100' : 'opacity-0'" ...></div>
//         ...
//     </div>
//
// The component writes the pointer position into --x/--y on the root
// element. Overlay layers position their radial-gradient with
// `at var(--x) var(--y)` and toggle visibility on `active`.

document.addEventListener('alpine:init', () => {
    Alpine.data('spotlightCard', () => ({
        active: false,

        init() {
            // Sensible resting point before the first mousemove.
            this.$el.style.setProperty('--x', '50%')
            this.$el.style.setProperty('--y', '40%')
        },

        move(event) {
            const rect = this.$el.getBoundingClientRect()
            this.$el.style.setProperty('--x', `${event.clientX - rect.left}px`)
            this.$el.style.setProperty('--y', `${event.clientY - rect.top}px`)
        },
    }))
})

Adjacent

Specimens nearby

All Cards →
FX-006 Cards

Plate 03

Tilt Card

Leans toward the cursor, settles flat when it leaves.