Spotlight Card
A card lit by a soft spotlight and glowing rim that follow the pointer, with a gentle hover lift.
Edge Network
312 points of presence
Serve every request from the region closest to your users, with automatic failover and 38 ms median latency.
GPU Workers
Now in public beta
Run inference next to your data. Scale to zero when idle and pay only for the milliseconds your model is busy.
Installation
pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/spotlight-card.jsonManual installation
Install the dependencies:
npm install motion-vThen copy these files into components/ui/spotlight-card/ (plus lib/utils.ts if you don't have it):
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { motion, useMotionValue, useReducedMotion, useSpring, useTransform } from 'motion-v'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<{
/** Spotlight colour — any CSS colour, theme tokens included. */
color?: string
/** Diameter of the spotlight in pixels. */
size?: number
/** How far the card lifts on hover, in pixels. `0` disables the lift. */
lift?: number
class?: HTMLAttributes['class']
}>(), {
color: 'var(--brand)',
size: 380,
lift: 4,
})
const reduced = useReducedMotion()
// Pointer position in px relative to the card. Springs smooth out jittery input
// without a Vue re-render per frame.
const mx = useMotionValue(-1000)
const my = useMotionValue(-1000)
const smooth = { stiffness: 500, damping: 40, mass: 0.4 }
const sx = useSpring(mx, smooth)
const sy = useSpring(my, smooth)
const spotlight = useTransform([sx, sy], ([x, y]) =>
`radial-gradient(${props.size / 2}px circle at ${x}px ${y}px, color-mix(in oklab, ${props.color} 16%, transparent), transparent 70%)`)
const rim = useTransform([sx, sy], ([x, y]) =>
`radial-gradient(${props.size / 2.5}px circle at ${x}px ${y}px, ${props.color}, transparent 75%)`)
// Two layers — padding box minus content box — leave only the 1px padding visible.
const ringMask = {
WebkitMask: 'linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)',
WebkitMaskComposite: 'xor',
mask: 'linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)',
maskComposite: 'exclude',
}
function place(x: number, y: number, instant = false) {
if (instant || reduced.value) {
mx.jump(x)
my.jump(y)
sx.jump(x)
sy.jump(y)
}
else {
mx.set(x)
my.set(y)
}
}
function onEnter(e: PointerEvent) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
// Start the light where the pointer entered instead of sweeping in from afar.
place(e.clientX - rect.left, e.clientY - rect.top, true)
}
function onMove(e: PointerEvent) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
place(e.clientX - rect.left, e.clientY - rect.top)
}
// Keyboard users get the light centred on the card when focus moves inside it.
function onFocusIn(e: FocusEvent) {
const el = e.currentTarget as HTMLElement
if (!el.matches(':hover'))
place(el.offsetWidth / 2, el.offsetHeight / 3, true)
}
</script>
<template>
<motion.div
:class="cn(
'group/spotlight relative isolate flex flex-col overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-sm transition-shadow duration-300 hover:shadow-lg',
props.class,
)"
:while-hover="reduced || !props.lift ? undefined : { y: -props.lift }"
:transition="{ type: 'spring', stiffness: 320, damping: 26 }"
@pointerenter="onEnter"
@pointermove="onMove"
@focusin="onFocusIn"
>
<!-- Soft wash of light inside the card -->
<motion.div
aria-hidden="true"
class="pointer-events-none absolute inset-0 -z-10 opacity-0 transition-opacity duration-500 group-focus-within/spotlight:opacity-100 group-hover/spotlight:opacity-100"
:style="{ background: spotlight }"
/>
<!-- Border glow: the same light, masked down to a 1px ring -->
<div
aria-hidden="true"
class="pointer-events-none absolute inset-0 z-10 rounded-[inherit] p-px opacity-0 transition-opacity duration-500 group-focus-within/spotlight:opacity-100 group-hover/spotlight:opacity-100"
:style="ringMask"
>
<motion.div class="absolute inset-0 rounded-[inherit]" :style="{ background: rim }" />
</div>
<div v-if="$slots.header" class="px-6 pt-6">
<slot name="header" />
</div>
<div class="flex-1 px-6 py-5">
<slot />
</div>
<div v-if="$slots.footer" class="flex items-center gap-3 border-t px-6 py-4">
<slot name="footer" />
</div>
</motion.div>
</template>export { default as SpotlightCard } from './SpotlightCard.vue'Usage
import { SpotlightCard } from '@/components/ui/spotlight-card'<SpotlightCard color="var(--chart-2)">
<template #header>Edge Network</template>
Serve every request from the closest region.
<template #footer><a href="/regions">Explore regions</a></template>
</SpotlightCard>API
| Prop | Type | Default | Description |
|---|---|---|---|
color | string | 'var(--brand)' | Spotlight and rim colour — any CSS colour or theme variable. |
size | number | 380 | Diameter of the spotlight in pixels. |
lift | number | 4 | How far the card rises on hover, in pixels. 0 disables it. |
| Slot | Description |
|---|---|
header | Top section (padded, no divider). |
default | Main content. |
footer | Bottom row, separated by a border. |
The pointer position lives in motion values, so moving across the card never re-renders Vue. When focus moves inside the card with the keyboard, the light settles at its centre. With prefers-reduced-motion the lift is disabled and the light jumps instead of easing.