Skip to content

FreeIn reviewv1.0.0

Holo Card

A holographic card: it leans to the pointer, a glare follows, and a band of spectrum foil slides with the angle.
npx shadcn@latest add @vibebox/holo-card

Controls

14
1
<HoloCard />

About

A card in a 900 px perspective, rotated about two axes by the pointer's position over it, with its content lifted 30 px off its face so the lean gives parallax. Two layers sit on top: a foil of repeating spectrum bands (and a fine dot pattern) whose background position follows the pointer under color-dodge, so it reads as light bending in a film, and a radial glare under soft-light. All three effects read the same two properties, --hx and --hy, so one event updates everything.

Use it when

  • A membership, loyalty, gift or access card on a page.
  • A pricing or tier card that should feel precious.
  • A badge or certificate.

Skip it when

  • The card carries long text.
  • The audience is mostly on desktop without pointers: the lean needs touch or a mouse.

Install

  1. 1Add the VibeBox registry to components.json:
    {
      "registries": {
        "@vibebox": "https://motion.vibeboxph.com/r/{name}.json"
      }
    }
  2. 2Add the component with the shadcn CLI:
    npx shadcn@latest add @vibebox/holo-card
  3. 3Use it:
    import { HoloCard } from "@/components/vibebox/holo-card";
    
    <HoloCard className="w-full max-w-[460px]" kicker="Gold tier" title="Casa Alon" detail="Member since 2024" footer="Priority booking" />

Code

2 files
components/vibebox/holo-card/holo-card.client.tsx
/**
 * VibeBox Motion · Holo Card
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
"use client";

import { useRef, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { cn } from "@/lib/utils";

export type HoloCardProps = {
  /** A small label at the top (a tier, a type). */
  kicker?: string;
  /** The main line (a name, a value). */
  title?: string;
  /** The line under it (a number, a date). */
  detail?: string;
  /** The foot line at the left. */
  footer?: string;
  /** Anything else, laid over the card's face. */
  children?: ReactNode;
  /** The card's base colour and its text colour (any CSS colours). */
  colors?: { base?: string; ink?: string };
  /** How far it leans, in degrees (default 14). */
  tilt?: number;
  /** How strong the holographic foil is, 0 to 1.5. */
  foil?: number;
  /** Accessible name. */
  label?: string;
  className?: string;
};

/*
 * A card of foil. The pointer is a light: the card leans toward it, a glare follows it, and a
 * band of spectrum slides across the foil the way a hologram does as the angle changes. The
 * lean, the glare and the foil are three custom properties set from the pointer, so the card
 * costs one style update per move and nothing while it is still.
 */
export function HoloCard({ kicker = "Member", title = "Alon Reyes", detail = "0000 0482 1019 6624", footer = "Valid thru 12/28", children, colors, tilt = 14, foil = 1, label, className }: HoloCardProps) {
  const ref = useRef<HTMLDivElement>(null);
  const set = (x: number, y: number, active: number) => {
    const el = ref.current;
    if (!el) return;
    el.style.setProperty("--hx", String(x));
    el.style.setProperty("--hy", String(y));
    el.style.setProperty("--ha", String(active));
  };
  const onMove = (event: ReactPointerEvent<HTMLDivElement>) => {
    if (event.pointerType === "touch" && event.buttons === 0) return;
    const rect = event.currentTarget.getBoundingClientRect();
    set((event.clientX - rect.left) / rect.width, (event.clientY - rect.top) / rect.height, 1);
  };
  const base = colors?.base ?? "#0e1424";
  const ink = colors?.ink ?? "#f5f6fa";
  const style = {
    "--hx": 0.5,
    "--hy": 0.5,
    "--ha": 0,
    "--tilt": `${tilt}deg`,
    "--foil": foil,
    "--base": base,
    "--ink": ink,
  } as CSSProperties;

  return (
    <div className={cn("[perspective:900px]", className)} data-vb="holo-card" style={style}>
      <div
        ref={ref}
        role="img"
        aria-label={label ?? `${kicker}: ${title}`}
        onPointerMove={onMove}
        onPointerDown={onMove}
        onPointerLeave={() => set(0.5, 0.5, 0)}
        onPointerUp={(event) => event.pointerType === "touch" && set(0.5, 0.5, 0)}
        className="relative aspect-[1.586/1] w-full touch-pan-y overflow-hidden rounded-[18px] [transform-style:preserve-3d] [transition:transform_220ms_var(--ease-expo-out)]"
        style={{
          background: "var(--base)",
          color: "var(--ink)",
          transform: "rotateY(calc((var(--hx) - 0.5) * var(--tilt) * 2 * var(--ha))) rotateX(calc((0.5 - var(--hy)) * var(--tilt) * 2 * var(--ha)))",
          boxShadow: "0 30px 60px -28px rgb(0 0 0 / 0.65), 0 0 0 1px color-mix(in oklab, var(--ink) 10%, transparent) inset",
        }}
      >
        {/* The foil: spectrum bands that slide with the angle, masked to a pattern of fine rings. */}
        <span
          aria-hidden="true"
          className="pointer-events-none absolute inset-0 [mix-blend-mode:color-dodge]"
          style={{
            opacity: "calc(0.18 + 0.4 * var(--foil) * (0.35 + 0.65 * var(--ha)))",
            backgroundImage:
              "repeating-linear-gradient(115deg, var(--spectrum-1) 0%, var(--spectrum-3) 8%, var(--spectrum-4) 16%, var(--spectrum-6) 24%, var(--spectrum-7) 32%, var(--spectrum-1) 40%), radial-gradient(circle at 20% 20%, rgb(255 255 255 / 0.5) 0 1px, transparent 2px)",
            backgroundSize: "300% 300%, 14px 14px",
            backgroundPosition: "calc(var(--hx) * 100%) calc(var(--hy) * 100%), 0 0",
          }}
        />
        {/* The glare: a soft white that follows the pointer. */}
        <span
          aria-hidden="true"
          className="pointer-events-none absolute inset-0"
          style={{
            opacity: "calc(0.12 + 0.5 * var(--ha))",
            background: "radial-gradient(circle at calc(var(--hx) * 100%) calc(var(--hy) * 100%), rgb(255 255 255 / 0.7), transparent 52%)",
            mixBlendMode: "soft-light",
          }}
        />
        <div className="relative flex size-full flex-col justify-between p-[7%] [transform:translateZ(30px)]">
          <div className="flex items-start justify-between">
            <span className="font-mono text-[clamp(0.6rem,1.3vw,0.78rem)] tracking-[0.22em] uppercase opacity-80">{kicker}</span>
            <span aria-hidden="true" className="size-[13%] rounded-full" style={{ background: "conic-gradient(from 210deg, var(--spectrum-1), var(--spectrum-4), var(--spectrum-7), var(--spectrum-1))" }} />
          </div>
          <div className="flex flex-col gap-[0.35em]">
            <span className="text-[clamp(1.1rem,3.2vw,1.9rem)] leading-none font-semibold tracking-tight">{title}</span>
            <span className="font-mono text-[clamp(0.62rem,1.5vw,0.9rem)] tracking-[0.14em] opacity-80">{detail}</span>
          </div>
          <div className="flex items-end justify-between">
            <span className="font-mono text-[clamp(0.55rem,1.2vw,0.72rem)] tracking-[0.16em] uppercase opacity-70">{footer}</span>
            {children}
          </div>
        </div>
      </div>
    </div>
  );
}
components/vibebox/holo-card/index.tsx
/**
 * VibeBox Motion · Holo Card
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
export { HoloCard, type HoloCardProps } from "./holo-card.client";

Props

PropTypeDefaultDescription
kickerstring"Member"A small label at the top.
titlestring"Alon Reyes"The main line (a name, a value).
detailstring—The line under it (a number, a date).
footerstring—The foot line at the left.
childrenReactNode—Anything else, laid at the bottom right of the face.
colors{ base?: string; ink?: string }—The card's base colour and its text colour.
tiltnumber14How far it leans, in degrees.
foilnumber1How strong the holographic foil is, 0 to 1.5.
labelstring—Accessible name.
classNamestring—Merged with cn(); give it a width (the card is 1.586:1).

Accessibility and motion

Keyboard
Not focusable; the card is an illustration. Put real actions beside it.
Screen readers
An image named by label (by default its kicker and title).
Touch
A press and drag leans the card; vertical scrolling is never blocked.
Reduced motion
The card does not lean and the foil rests at its middle angle.

Edge cases and limits

  • color-dodge is bright on light base colours: use dark bases.
  • Safari clips mix-blend-mode inside rounded overflow: hidden boxes sometimes; the card is isolated to avoid it.
  • The card is 1.586:1; give it a width, not a height.
  • A cream envelope with a red wax seal, its flap open and a card rising out

    An envelope that opens on a press: the flap swings back, the card rises out and comes forward.

    3D · CSS

  • A fan of five colourful cards with the middle one lifted

    A hand of cards: lift one on hover, bring it forward on tap, arrow keys between them. Pure CSS 3D.

    3D · CSS

  • A cube of coloured cards turning, with the next face coming to the front

    Content on the faces of a turning prism: arrows, keys and drag, a face at a time. Pure CSS 3D.

    3D · CSS

↑ ↓ to move↵ to openesc to close