Cursor Glow Card
A card whose surface and border light up around the pointer, with an optional group where the hovered card stays lit and its siblings dim.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/glow-card.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "GlowCard" component plus a
"GlowCardGroup" wrapper (no runtime dependencies beyond React and the shared
cn() utility).
Contract
- GlowCard: forwardRef<HTMLDivElement, GlowCardProps> extending
Omit<React.HTMLAttributes<HTMLDivElement>, "color"> (the DOM `color`
attribute has to be dropped so the prop can be a token union); spread the
remaining props onto the root so it stays a drop-in card container.
- Props: glowRadius = 280 (px radius of the light, not the corner radius),
intensity = "medium" ("subtle" | "medium" | "bold"), borderGlow = true,
color = "chart-1" ("primary" | "chart-1".."chart-5"), disabled = false,
plus className / style / children.
- children are arbitrary and are NOT wrapped: the card owns the light layers
and the surface, never the content or its layout.
- GlowCardGroup: forwardRef<HTMLDivElement, GlowCardGroupProps> with
dim = 0.5 (0..1, how far un-lit siblings fade; 0 disables dimming). It adds
no layout of its own — the consumer passes the grid classes.
Behavior
- Two CSS custom properties carry the pointer: --glow-x / --glow-y in px from
the card's border box, plus --glow-a (0 or 1) for "is this card lit".
Split the ownership: React writes only the configuration vars
(--glow-radius, --glow-color, the alpha tiers) through the style prop, and
an effect writes only the runtime vars straight to the DOM. Every consumer
of a runtime var reads it as var(--glow-x, 50%) / var(--glow-a, 0), so the
fallbacks are also the server-render and reduced-motion resting state, and
the two writers can never fight over the same declaration.
- pointermove is rAF-throttled: store clientX/clientY per event, schedule one
frame, and do the single getBoundingClientRect read inside that frame.
Never measure per event. All pointer listeners are passive and nothing
calls preventDefault, so a touch drag starting on the card scrolls the page.
- Lit state is the union of two independent flags, hovering and focusing, so
the light does not blink out when focus leaves a card the cursor is still
over (and vice versa). Wire hover with pointerenter/pointerleave and
keyboard with focusin/focusout; focusout ignores a relatedTarget that is
still inside the card. Arriving by keyboard clears --glow-x/--glow-y so the
light sits in the card's centre instead of wherever the mouse last was.
- Never rely on the enter edge alone. The effect regularly (re)attaches with
the pointer already resting on the card — the media snapshot is false on the
first render, so a desktop wires up on the second pass, and a mid-session
query change re-runs the whole thing — and pointerenter/focusin do not fire
for a state that never changed. Seed the flags when wiring
(el.matches(":hover") behind the fine-pointer gate, el.contains(
document.activeElement)) and let pointermove promote hovering to true when
it arrives without an enter, otherwise the card stays dark until the cursor
leaves and comes back.
- Environment gating via useSyncExternalStore over two media queries, with a
server snapshot of false so SSR and the first client render agree:
· "(pointer: fine)" — on a coarse pointer no pointer listener is attached
at all, and pointerenter additionally ignores pointerType === "touch",
because a finger has no hover and would leave the card stuck lit;
focus still lights the card, so a keyboard on a tablet keeps working.
· "(prefers-reduced-motion: reduce)" — the card still lights on hover and
focus, but the light stops travelling: no pointermove listener, the glow
stays centred, and every opacity transition is disabled through
motion-reduce. Reduced motion must never mean "no feedback".
Both listeners live in the store's subscribe cleanup, so a mid-session
change to either query re-runs the wiring.
- Group coordination without a single window listener: the group provides a
context object with setCardLit(lit); each card reports its own edges, the
group keeps a counter and writes --glow-group-lit 1/0 onto its own element
by ref. Counting (not a boolean) is what stops the grid flashing while the
cursor crosses from one card to the next. Every lightable card's opacity is
then a pure CSS expression:
calc(1 - var(--glow-group-lit, 0) * (1 - var(--glow-a, 0)) * var(--glow-dim, 0))
— outside a group both group vars are missing, so it resolves to 1.
- Cleanup is total: remove all five listeners, cancelAnimationFrame any
pending frame, drop the runtime custom properties, and — if the card is
unmounted while lit — decrement the group counter so it never counts a
ghost card.
- disabled renders a plain card: no light layers, no listeners, and no
dimming term either. A disabled card never writes --glow-a, so keeping the
expression would make it the one card in a mixed grid that fades whenever a
sibling lights and can never light itself — opting out has to mean inert,
not permanently dimmed.
Rendering & styling
- Root: relative isolate rounded-xl border bg-card text-card-foreground
shadow-sm. `isolate` matters — the light layers are placed at -z-10 so they
paint above the card's own background but below the content, which is how
children stay unwrapped and keep the consumer's layout classes working.
- Three aria-hidden, pointer-events-none layers, all rounded-[inherit]:
1. surface wash — radial-gradient(circle var(--glow-radius) at
var(--glow-x, 50%) var(--glow-y, 50%), color-mix(in oklab,
var(--glow-color) <surface alpha>, transparent), transparent 72%)
2. border arc — the same gradient masked to the border ring with
mask: linear-gradient(black 0 0) content-box,
linear-gradient(black 0 0);
mask-composite: exclude (plus the -webkit- pair with xor), a 1px padding
and inset: calc(var(--glow-ring-width, 1px) * -1) so it sits on the
border box rather than the padding box. The keyword is there only to be
alpha 1 — a mask reads alpha, so the hue never reaches the screen, which
is also why it must NOT be currentColor: a consumer className like
text-foreground/70 would drop the mask alpha, stop punching out the
content box, and turn the border arc into a second full-surface wash.
3. outer bloom — a copy of the border arc inside a blurred wrapper. The
blur has to live on the wrapper: a filter on the masked element itself
would be clipped back to the 1px ring instead of blooming outward.
Layer opacity is var(--glow-a, 0), so one variable fades all three.
- Semantic tokens only: bg-card, text-card-foreground, border, plus
var(--primary) / var(--chart-1..5) mixed through color-mix for the light.
No hardcoded colour literals and no raw palette classes anywhere.
- Accessibility: every light layer is aria-hidden and pointer-events-none.
The glow is decoration that duplicates the hover/focus state assistive tech
already conveys, so it gets no live region — adding one would announce
every cursor sweep. Anything the consumer drops inside stays keyboard
reachable and must keep its own focus-visible ring; expose the lit state as
data-glow-active="true|false" for consumers who want to restyle from it.
- Merge the consumer className through cn(), and spread style last so
--glow-color and friends can be overridden per instance.
Customization levers
- Loudness: intensity is the one knob (surface wash %, border arc %, bloom
multiplier move together). Retune the three-tier table rather than
hand-tuning a single card.
- Reach: glowRadius 160–240 for dense grids, 400+ for a hero card. It also
drives the border arc, which is 0.85 of it.
- Colour: color picks the token; per-card branding is a style override of
--glow-color (any token, e.g. var(--accent)). Map a category to a chart
token to make a grid read as a legend.
- Border light: borderGlow={false} leaves only the surface wash — the right
call when the card sits against another bordered element. --glow-ring-width
thickens the arc to match a border-2 card.
- Group: dim 0.3 for a suggestion, 0.6+ for real spotlighting, 0 to keep the
glow but drop the dimming. Nest groups per row if you want dimming scoped
to a row instead of the whole grid.
- Surface: override rounded / border / bg / padding through className; the
light layers inherit the radius, so nothing else needs updating. Avoid
overflow-hidden — it clips the outer bloom.Concepts
- Pointer-to-variable pipeline — the cursor never becomes React state. It lands in
--glow-x/--glow-yon the card element, so a 120 Hz mouse repaints two gradients without re-rendering the card or a single child. - rAF-throttled measurement — the event handler only records coordinates; the
getBoundingClientRecthappens once inside the scheduled frame, which is what keeps a fast sweep off the layout-thrash path. - Two flags, one light — hover and focus are tracked separately and OR'd together, so tabbing inside a hovered card (or moving the mouse away from a focused one) never makes the light stutter.
- Masked border glow — the lit border is one element masked with
content-box+mask-composite: exclude; the blurred copy that blooms outside the card needs its filter on a wrapper, because a filter on the masked element itself gets clipped back to the ring. - Counted group state — the group tracks how many cards are lit, not whether one is, so crossing from card to card never dips through an unlit frame that would flash the whole grid back to full brightness.
- Dimming as pure CSS —
opacity: calc(1 - lit × (1 − a) × dim)derives every sibling's fade from three inherited numbers; nothing is pushed from JavaScript to the siblings, and outside a group the same expression resolves to 1. Adisabledcard omits the term entirely, so opting out of the light never turns into being the only card stuck dim. - Coarse-pointer honesty — a finger has no hover, so touch gets no listener and no glow rather than a card that latches on and stays lit; reduced motion keeps the highlight but parks it in the centre, because "less motion" is not "no feedback".
Cursor Trail
A canvas wake that follows the pointer — frame-sampled positions drawn as a tapering ribbon, an even bead chain or drifting sparks, dissolving on their own lifetime and never mounted on touch or under reduced motion.
Holographic Foil Card
A card surface that behaves like trading-card holo stock — an iridescent sheet, a fine foil grating and a specular highlight all re-aimed from the pointer.