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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/holo-foil-card.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "HoloFoilCard" component (no runtime
dependencies beyond React and the shared cn() utility).
Contract
- Export a forwardRef<HTMLDivElement, HoloFoilCardProps> extending
React.HTMLAttributes<HTMLDivElement>; spread the remaining props onto the
root so it stays a drop-in card container.
- Props: density = 24 (grating lines per 100px, clamped 6–60),
intensity = "medium" ("subtle" | "medium" | "bold"), tilt = true,
disabled = false, plus className / style / children.
- children are arbitrary and are rendered as direct children of the root: the
component owns the surface material, never the content or the layout.
- intensity is one tier object with three numbers — the always-on sheen
(`rest`), what hover/focus adds (`gain`) and the specular scale (`spec`) —
so the three layers can never drift out of step with each other.
Behavior
- Three decorative layers stack under the content, each aimed by the same two
CSS custom properties (--foil-x, --foil-y as percentages of the box):
1. an iridescent sheet: one linear-gradient sweep through all five chart
tokens, painted at 260% of the card, so moving its background-position
drags a different part of the spectrum across the surface;
2. a foil grating: a repeating-linear-gradient of two hues whose pitch is
100 / density px, phase-shifted by the pointer and masked by a radial
gradient anchored at the pointer, so the fine lines only exist where the
light hits them;
3. a specular highlight: a small radial blow-out at the pointer.
- A fourth span is not decoration but the cap the other three are measured
against: a card-coloured contrast guard (bg-card/75), painted over all of
them and under the content. Decorative gradients sit directly behind
arbitrary children, so the composite needs a ceiling in the component rather
than a sentence in the docs. Measured on the repo tokens (oklch → linear sRGB
→ Y, composited in sRGB) at the worst pixel on the card — the most extreme
chart band with a grating line and the specular core stacked on it, at every
tier while lit: without the guard card-foreground falls to 2.5:1 and
muted-foreground to 1.0:1; with it they are ≥ 10.9:1 and ≥ 3.3:1, and
muted-foreground clears 4.5:1 behind one more bg-card/75 nameplate.
- Both gradient layers are oversized (260% and 200%) with
background-repeat: no-repeat. A percentage background-position only moves an
image that is larger than its box, and a tiled repeating gradient seams
wherever the pitch and the card width disagree.
- A fourth and fifth var (--foil-tx, --foil-ty, -1..1 from the centre) drive
the optional tilt: perspective(900px) rotateX(ty * lit * 6deg)
rotateY(tx * lit * -6deg). Signs are picked so the edge under the pointer
lifts toward the viewer.
- --foil-lit is 0 or 1 and multiplies into every opacity and both rotations,
so one variable write both lights and levels the card. Layer opacity is
calc(rest + lit * gain); when lit is 0 the rotations collapse to identity.
- Wiring lives in one effect and writes only to the DOM: pointermove stores
clientX/clientY and schedules a single requestAnimationFrame, and that frame
does the one getBoundingClientRect read and the four setProperty writes. A
zero-sized rect bails out instead of writing NaN into every gradient.
- pointerenter/pointerleave/pointercancel toggle --foil-lit; leaving keeps the
last angle and fades from there, because resetting the position would swing
the whole spectrum across the card on the way out.
- A non-touch pointermove is itself proof of hover, not something to gate on a
pointerenter that already happened: a card that mounts under a stationary
cursor (scroll reveal, route change, the effect wiring itself up when the
visitor turns reduced motion off) never gets that event, and would otherwise
ignore every move until the cursor physically left and came back.
- Touch pointers (event.pointerType === "touch") are ignored: a finger cannot
hover, and it would light the card and leave it lit.
- focusin/focusout light the same foil from a fixed angle so keyboard users get
the effect too; focusout that lands on another element inside the card is
not a real exit (check event.relatedTarget with root.contains).
- Clicking a child inside the card focuses it, so the pointer can leave a card
that is still lit by focus. When that happens, re-park the four vars at the
canonical focus pose: the alternative is a card frozen at whatever 6° lean
the cursor last had, fully lit, with no pointer anywhere near it.
- Environment gating with useSyncExternalStore over "(pointer: coarse)" and
"(prefers-reduced-motion: reduce)", server snapshot false so SSR and the
first client render agree. If either matches — or disabled is set — no
listener is attached at all and the layers stay parked at their resting pose:
still a foil card, just a printed one. Nothing about the content depends on
the effect having run.
- Cleanup cancels any pending frame, removes all six listeners, and deletes the
custom properties and both data attributes it wrote. React never sets those, so
deleting them is exactly what returns the card to the printed pose when the
visitor flips reduced motion on mid-session.
- All listeners are passive; the component never calls preventDefault, so a
drag that starts on the card scrolls the page like anywhere else.
Rendering & styling
- Root: relative isolate rounded-xl border bg-card text-card-foreground
shadow-sm. `isolate` matters — the layers use -z-10 so they paint above the
card's own background and below every child without wrapping the content in
an extra div. All four spans share that one -z-10, so tree order is what puts
the guard on top of the foil: it is the last of them.
- Semantic tokens only: var(--chart-1..5) for the iridescence, mixed through
color-mix(in oklab, …, transparent); var(--foreground) inside the mask (a
mask reads alpha, so the hue never reaches the screen). The specular needs a
colour lighter than the card in both themes and no single token is, so set
[--foil-shine:var(--background)] dark:[--foil-shine:var(--foreground)] and
mix from that.
- Every layer is aria-hidden, pointer-events-none and rounded-[inherit], so a
caller-overridden radius carries through and nothing decorative is reachable.
- Transitions: opacity fades 500ms both ways; the transform runs 500ms by
default and 0ms while data-foil-track="true", so the tilt tracks the cursor
1:1. Track is "a pointer is on the card", which is not the same thing as
data-foil-lit ("this card is lit", which focus also sets) — every move of the
light that no cursor is driving has to ease rather than snap. Both carry
motion-reduce:transition-none.
- Keep focus-visible:ring-2 ring-ring on the root so a card the consumer makes
focusable still shows a ring, and skip will-change — permanently promoting
the card is what makes text look fuzzy under a 3D transform.
- Merge the consumer className through cn(); no live region, because the sheen
only duplicates the hover/focus state assistive tech already reports.
Customization levers
- density: 10–16 reads as coarse "brushed" foil, 24 is card stock, 45+ is silk.
It is a pitch, not a count — the look is stable at any card width.
- intensity: "subtle" for a card that carries body copy, "bold" for a hero
collectible. Only the three numbers in the tier table change; add a tier
rather than sprinkling opacities through the layers.
- tilt={false} keeps the iridescence and drops the lean — the right call inside
a dense grid where every card leaning at once reads as noise.
- Palette: swap which chart tokens the sheet sweeps (two tokens instead of five
gives a duotone "oil slick"), or point the grating at one token for a mono
brushed-metal look.
- Angle: the sheet's 115deg and the grating's 102deg are deliberately a few
degrees apart so the two layers beat against each other; matching them makes
the surface look flat.
- Geometry: override rounded / border / padding through className — every layer
inherits the radius, so nothing else needs updating.
- Content: children are yours, and the guard already earns them
text-card-foreground at any tier. Small text-muted-foreground copy is the one
case the guard cannot fix on its own — that token has only 4.96:1 on a bare
white card, so no visible tint of any kind carries it — so give those a
bg-card/75 nameplate, which is what takes them back over 4.5:1.Concepts
- Iridescent sheet — one gradient sweep through all five chart tokens painted at 260% of the card; the pointer scrolls it, so the hue under the cursor is a function of the angle you appear to be holding the card at.
- Diffraction grating — the fine foil pattern is a repeating gradient whose pitch is
100 / densitypx. Because it is a pitch and not a count, a 320px tile and a 900px hero show the same material rather than the same number of lines. - Mask, not paint — the grating is masked by a radial gradient anchored at the pointer, so the lines only exist where the light is. A mask reads alpha, which is why the token inside it never reaches the screen.
- One lit variable —
--foil-litis 0 or 1 and multiplies into every layer opacity and both rotations, so a single write lights the card and levelling it is free; the content and its children never re-render. - Contrast guard — a fourth span,
bg-card/75, paints over all three foil layers and under the content, so the loudest pixel the material can produce is capped by the component rather than by a warning in these docs. Measured on the tokens (oklch → linear sRGB → Y, composited in sRGB) at the worst case — the most extreme chart band, a grating line and the specular core on the same pixel, at every tier while lit —text-card-foregroundgoes from 2.5:1 to ≥ 10.9:1 andtext-muted-foregroundfrom 1.0:1 to ≥ 3.3:1, in both themes. Small muted copy still wants abg-card/75nameplate on top of that, which is what carries it past 4.5:1; that token has only 4.96:1 on a bare white card, so no visible tint of any kind can carry it alone. - Printed-foil fallback — coarse pointer, reduced motion or
disabledall land in the same place: no listeners, layers parked at the resting pose. The card is still foil, it just stops moving, and nothing readable was ever hidden behind the effect. - Theme-honest specular — a highlight has to be lighter than the card in both themes and no single token is, so light mode borrows
--backgroundand dark mode borrows--foreground: the pale end of either palette, still zero literals.
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.
Elastic Drag Card
A card you can grab and fling — it chases the pointer on a spring, tilts with its own drag velocity, and springs home or docks into a snap point on release.