MagicCard

Card spotlight driven by pointer position.

Live demo

01

Lightning fast

Pointer events drive a single radial gradient β€” no rerenders, no jank.

Theme aware

Inherits surface and border tokens, so light and dark modes both look right.

Zero deps

Pure Svelte 5 + scoped CSS. Lift the file, drop it in, ship.

Rust accent

gradientColor="#b3502c"

Moss accent

gradientColor="#6e7d4e"

Sky accent

gradientColor="#5b82c4"

Implementation

02
MagicCard.svelte
<script>
  import MagicCard from '$lib/components/MagicCard.svelte';
</script>
​
<MagicCard gradientColor="#146ef5" gradientOpacity={0.15}>
  <article class="card">
    <h3>Lightning fast</h3>
    <p>Pointer events drive a single radial gradient.</p>
  </article>
</MagicCard>

MagicCard tracks the pointer relative to its bounding box and writes the position into a CSS custom property. A radial gradient overlay uses that property as its centre, creating the spotlight. On pointer leave the gradient fades via a CSS transition. Everything is scoped β€” no globals leak.

Logic explainer

03

What Does It Do? (Plain English)

MagicCard is a card wrapper that paints a soft radial spotlight following the cursor as it moves across the card. The spotlight fades in on hover, tracks the mouse in real time, and fades out when the cursor leaves. The content inside the card sits on top of the spotlight β€” perfectly readable, with the lit gradient peeking around the edges.

Think of shining a torch on a dark wall: wherever you point, there's a bright glowing circle. Move the torch, the circle follows. Walk away, the circle fades. That's MagicCard.

How It Works (Pseudo-Code)

state:
  mouseX     = -gradientSize          // start off-screen so spotlight is hidden
  mouseY     = -gradientSize
  isHovering = false

derive bg:
  `radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px, ${gradientColor}, transparent 100%)`

events:
  on mouseenter:
    isHovering = true
    // CSS opacity transition fades spotlight in

  on mousemove(e):
    rect = e.currentTarget.getBoundingClientRect()
    mouseX = e.clientX - rect.left           // X relative to card
    mouseY = e.clientY - rect.top            // Y relative to card
    // bg is recomputed reactively, gradient repositions instantly

  on mouseleave:
    isHovering = false
    mouseX = -gradientSize                    // park gradient off-screen
    mouseY = -gradientSize
    // CSS opacity transition fades spotlight out

render:
  <div onmouseenter onmousemove onmouseleave>
    <div class="spotlight" style="background: {bg}; opacity: {isHovering ? gradientOpacity : 0}"></div>
    <div class="content"> {@render children?.()} </div>
  </div>

The "magic" is one line of $derived: every time mouseX or mouseY changes, the radial-gradient string rebuilds with new coordinates, and Svelte writes the new background to the spotlight layer. There's no rAF loop, no observer β€” just reactivity.

The Core Concept: Reactive Radial Gradient

A radial gradient is a CSS function that takes a centre point, a radius, and a colour ramp. MagicCard parameterises all three:

background: radial-gradient(
  200px circle             /* size */
  at 150px 100px,          /* centre, in card-relative coordinates */
  #146ef5,                 /* core colour */
  transparent 100%         /* fades to fully transparent at the radius */
);

When the cursor moves, the at coordinates change and the entire gradient re-paints β€” but it stays on the GPU because background is a composited property, not a layout-affecting one. Even at 144 Hz the recomputation is essentially free.

The real machinery is one Svelte 5 $derived:

let bg = $derived(
  `radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px, ${gradientColor}, transparent 100%)`
);

Whenever mouseX or mouseY is reassigned, Svelte invalidates bg and any element binding to it gets the new string. The opacity transition handles the fade-in/out separately, so a fast cursor never produces a flash β€” the spotlight follows smoothly from the moment the mouse enters.

Two-Layer Architecture: Spotlight Behind Content

The card renders two stacked layers:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                                        β”‚
β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”‚  ← Layer 2 (z-10): your content
β”‚   β”‚                              β”‚    β”‚     (text, images, buttons)
β”‚   β”‚      Your content here       β”‚    β”‚
β”‚   β”‚                              β”‚    β”‚
β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚
β”‚                                        β”‚
β”‚           ●  ← Spotlight                β”‚  ← Layer 1 (z-0): radial gradient
β”‚              radial-gradient            β”‚     pointer-events: none
β”‚                                        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The spotlight sits at z-0 with pointer-events: none, which keeps it from intercepting clicks meant for the content. The content sits at z-10, fully interactive. The host card has the mouseenter/mousemove/mouseleave handlers, so the cursor's position is tracked regardless of which child it's hovering over.

The "Park Off-Screen" Trick

When the mouse leaves, the spotlight fades out via opacity β€” but during the fade, the gradient is still painted at its last position. If the user hovers back in quickly, they'd see the spotlight pop into the centre rather than appear under the cursor. To prevent that, mouseleave moves the gradient off the card entirely:

function handleMouseLeave() {
  isHovering = false;
  mouseX = -gradientSize;
  mouseY = -gradientSize;
}

With gradientSize = 200, the centre is now at (-200, -200) β€” outside the card. The fade-out completes with the gradient invisible regardless. The next mouseenter triggers mousemove, which writes a new position before the opacity has finished transitioning back up.

Coordinate Conversion: Page β†’ Card

event.clientX is the mouse position in viewport coordinates. The gradient needs card-relative coordinates so (0, 0) is the card's top-left. The conversion is one getBoundingClientRect:

rect = card.getBoundingClientRect()    // card's position in viewport
x    = event.clientX - rect.left       // mouse X within the card
y    = event.clientY - rect.top        // mouse Y within the card

Example: card at viewport position (100, 200), cursor at (150, 250). The cursor is at (50, 50) inside the card β€” that's where the gradient centre should sit.

getBoundingClientRect is fast enough to call on every mousemove. The compositor doesn't care β€” background is GPU-accelerated.

State Flow Diagram

                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚  IDLE                  β”‚
                    β”‚  isHovering = false    β”‚
                    β”‚  mouseX, mouseY parked β”‚
                    β”‚  off-screen            β”‚
                    β”‚  spotlight opacity = 0 β”‚
                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                β”‚
                  mouseenter   β”‚
                                β–Ό
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚  HOVERING              β”‚
                    β”‚  isHovering = true     β”‚
                    β”‚  spotlight fades in    β”‚
                    β”‚  to gradientOpacity    β”‚
                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                β”‚
                                β”‚ mousemove(e)
                                β–Ό
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚  TRACKING              β”‚
                    β”‚  mouseX, mouseY = e    β”‚
                    β”‚  bg recomputes via     β”‚
                    β”‚  $derived              β”‚
                    β”‚  spotlight repositions β”‚
                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                β”‚
                                β”‚ β†Ί same state, $derived
                                β”‚   re-runs on each move
                                β”‚
                                β”‚ mouseleave
                                β–Ό
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚  PARKING               β”‚
                    β”‚  isHovering = false    β”‚
                    β”‚  mouseX, mouseY β†’ off  β”‚
                    β”‚  spotlight fades out   β”‚
                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                β”‚
                                β–Ό
                          back to IDLE

Props Reference

Prop Type Default Description
gradientSize number 200 Spotlight radius in pixels.
gradientColor string '#262626' Core colour of the spotlight; fades to transparent.
gradientOpacity number 0.8 Spotlight opacity at full intensity (0–1).
borderColor string '#146ef5' Reserved for border-highlight variants.
class string '' Extra classes for the card wrapper.
children Snippet β€” Content to render on top of the spotlight.

Edge Cases

Situation Behaviour
Card is wider than gradientSize Γ— 2 Most of the card is unlit; the spotlight is genuinely a torch beam rather than a wash. This is the intended look.
gradientOpacity = 0 Spotlight is invisible even on hover. The component still tracks the cursor β€” useful if a parent wants to disable the effect via prop without unmounting.
gradientOpacity = 1 Spotlight is at full intensity; the card looks like a single bright disc following the cursor.
Touch device with no mouse events mouseenter/mousemove never fire; isHovering stays false; the spotlight is permanently hidden. The card content is unaffected.
Card sits inside a scrolling container getBoundingClientRect reports viewport-relative coordinates, which already account for scroll. The maths is correct without extra adjustment.
Card resized during hover The next mousemove reads a fresh rect; the gradient repositions correctly. No flicker.
User has prefers-reduced-motion: reduce The opacity fade is instant rather than a 300 ms transition. The gradient still tracks the cursor β€” that's direct manipulation, not animation.
class overrides position to static The spotlight needs a positioned ancestor to absolute-position itself against. The card wrapper already sets position: relative; user classes should not override it.

Dependencies

  • Svelte 5.x β€” $state, $derived, $props, and snippets. The single $derived for bg is the heart of the component.
  • $lib/utils β€” cn() for class merging.
  • TailwindCSS β€” utility classes for layout (the host app must have Tailwind configured).
  • Zero animation libraries β€” the fade is pure CSS, the gradient repositioning is pure reactivity.

File Structure

src/lib/components/MagicCard.svelte         # implementation
src/lib/components/MagicCard.md             # this file
src/lib/components/MagicCard.test.ts        # vitest unit tests
src/routes/magiccard/+page.svelte           # demo page
src/lib/types.ts                            # MagicCardProps

API

04
PropTypeDefaultDescription
gradientColorstring"#146ef5"Hex colour of the spotlight gradient. Six-digit RGB or eight-digit RGBA.
gradientOpacitynumber0.150 – 1. Strength of the overlay at the spotlight centre.
gradientSizenumber200Radius of the radial gradient in pixels.