Display

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

const FINE_POINTER = "(pointer: fine)"
const REDUCED_MOTION = "(prefers-reduced-motion: reduce)"

/** Decorative light colours — design tokens only, never a literal. */
const GLOW_COLOR = {
  primary: "var(--primary)",
  "chart-1": "var(--chart-1)",
  "chart-2": "var(--chart-2)",
  "chart-3": "var(--chart-3)",

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/glow-card.json

Prompt

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-y on 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 getBoundingClientRect happens 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 CSSopacity: 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. A disabled card 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".

On This Page