bottega

sections

Cyber Tilt Card

A cyberpunk-styled card that reacts to cursor position with CSS-only 3D tilt — a 5×5 invisible tracker grid overlays the card and maps each zone to a discrete rotateX/rotateY pair (±10°/±20° on each axis), giving 25 tilt states with no pointer-math JS. On hover the 'HOVER ME' prompt fades out, a gradient-clipped title lifts in, ambient radial glow blobs appear, a glare sheen sweeps the surface, tiny particles float outward per their direction vector, and corner bracket decorations brighten. Persistent CSS animations run continuously: four cyber scan lines pulse across the card from left/right on staggered timers, and a gradient sweep scrolls top-to-bottom. All motion is gated by a mount-check + useReducedMotion hook so no layout shift occurs on SSR and reduced-motion users get a completely static version.

Ported from 00Kubi (MIT)

HOVER ME

CYBER\nCARD
INTERACTIVE3D EFFECT

Install

1. Register the namespace (once per project):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/cyber-tilt-card

Usage

Usagetsx
<CyberTiltCard />

Props

PropTypeDefaultDescription
titlestringCYBER CARDLarge headline rendered in a gradient gradient clip (two lines, newline = <br>).
subtitlestringINTERACTIVESmall label line below the title, e.g. "INTERACTIVE 3D EFFECT".
subtitleHighlightstring3D EFFECTHighlighted span inside the subtitle (rendered with --primary gradient).
promptTextstringHOVER MEPrompt text shown at rest before any hover (default "HOVER ME").
classNamestringExtra class names merged onto the root wrapper.

Source

cyber-tilt-card.tsxtsx
"use client";

import { useState, useEffect } from "react";
import { useReducedMotion } from "motion/react";
import styles from "./cyber-tilt-card.module.css";

/** 5 × 5 = 25 tracker zones; each maps (col, row) → a specific rotateX/Y. */
const TRACKER_COUNT = 25;

/** Rotation table: [rotateX, rotateY] per tracker index (0-based, row-major). */
const ROTATIONS: [number, number][] = [
  [20, -10], [20, -5], [20, 0], [20, 5], [20, 10],
  [10, -10], [10, -5], [10, 0], [10, 5], [10, 10],
  [0, -10],  [0, -5],  [0, 0], [0, 5],  [0, 10],
  [-10, -10], [-10, -5], [-10, 0], [-10, 5], [-10, 10],
  [-20, -10], [-20, -5], [-20, 0], [-20, 5], [-20, 10],
];

export type CyberTiltCardProps = {
  /** Large headline rendered in a gradient gradient clip (two lines, newline = <br>). */
  title?: string;
  /** Small label line below the title, e.g. "INTERACTIVE  3D EFFECT". */
  subtitle?: string;
  /** Highlighted span inside the subtitle (rendered with --primary gradient). */
  subtitleHighlight?: string;
  /** Prompt text shown at rest before any hover (default "HOVER ME"). */
  promptText?: string;
  /** Extra class names merged onto the root wrapper. */
  className?: string;
};

export function CyberTiltCard({
  title = "CYBER\nCARD",
  subtitle = "INTERACTIVE",
  subtitleHighlight = "3D EFFECT",
  promptText = "HOVER ME",
  className = "",
}: CyberTiltCardProps) {
  // Mount-gate + null-safe reduced-motion (contract).
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  const prefersReducedMotion = useReducedMotion();
  const reduce = prefersReducedMotion === null || prefersReducedMotion;
  const animate = mounted && !reduce;

  // Which tracker zone is active (-1 = none).
  const [activeZone, setActiveZone] = useState(-1);

  const cardStyle =
    animate && activeZone >= 0
      ? {
          transform: `rotateX(${ROTATIONS[activeZone][0]}deg) rotateY(${ROTATIONS[activeZone][1]}deg) rotateZ(0deg)`,
          transition: "125ms ease-in-out",
        }
      : { transform: "rotateX(0deg) rotateY(0deg) rotateZ(0deg)", transition: "700ms" };

  const titleLines = title.split("\n");

  return (
    <div
      className={[styles.container, className].filter(Boolean).join(" ")}
      data-animate={animate}
    >
      {/* 5×5 tracker grid — sits above the card, intercepts hover zone */}
      <div className={styles.canvas} aria-hidden="true">
        {Array.from({ length: TRACKER_COUNT }, (_, i) => (
          <div
            key={i}
            className={styles.tracker}
            onMouseEnter={() => animate && setActiveZone(i)}
            onMouseLeave={() => setActiveZone(-1)}
          />
        ))}
      </div>

      {/* The card itself */}
      <div className={styles.card} style={cardStyle}>
        <div className={styles.cardContent}>
          {/* Glare sheen */}
          <div
            className={[styles.cardGlare, activeZone >= 0 && animate ? styles.cardGlareActive : ""].filter(Boolean).join(" ")}
            aria-hidden="true"
          />

          {/* Animated cyber lines */}
          <div className={styles.cyberLines} aria-hidden="true">
            <span className={styles.cyberLine1} />
            <span className={styles.cyberLine2} />
            <span className={styles.cyberLine3} />
            <span className={styles.cyberLine4} />
          </div>

          {/* Prompt (fades out on hover) */}
          <p
            className={[styles.prompt, activeZone >= 0 && animate ? styles.promptHidden : ""].filter(Boolean).join(" ")}
          >
            {promptText}
          </p>

          {/* Title — gradient clipped text */}
          <div
            className={[styles.title, activeZone >= 0 && animate ? styles.titleVisible : ""].filter(Boolean).join(" ")}
            aria-label={title.replace("\n", " ")}
          >
            {titleLines.map((line, i) => (
              <span key={i}>
                {line}
                {i < titleLines.length - 1 && <br />}
              </span>
            ))}
          </div>

          {/* Ambient glow blobs */}
          <div className={styles.glowingElements} aria-hidden="true">
            <div className={[styles.glow, styles.glow1, activeZone >= 0 && animate ? styles.glowVisible : ""].filter(Boolean).join(" ")} />
            <div className={[styles.glow, styles.glow2, activeZone >= 0 && animate ? styles.glowVisible : ""].filter(Boolean).join(" ")} />
            <div className={[styles.glow, styles.glow3, activeZone >= 0 && animate ? styles.glowVisible : ""].filter(Boolean).join(" ")} />
          </div>

          {/* Subtitle */}
          <div className={styles.subtitle}>
            <span>{subtitle}</span>
            {subtitleHighlight && (
              <span className={styles.highlight}>{subtitleHighlight}</span>
            )}
          </div>

          {/* Floating particles */}
          <div className={styles.cardParticles} aria-hidden="true">
            {Array.from({ length: 6 }, (_, i) => (
              <span
                key={i}
                className={[styles.particle, `${styles[`particle${i + 1}` as keyof typeof styles]}`, activeZone >= 0 && animate ? styles.particleActive : ""].filter(Boolean).join(" ")}
              />
            ))}
          </div>

          {/* Corner bracket decorations */}
          <div className={styles.cornerElements} aria-hidden="true">
            <span className={[styles.corner, styles.cornerTL, activeZone >= 0 && animate ? styles.cornerActive : ""].filter(Boolean).join(" ")} />
            <span className={[styles.corner, styles.cornerTR, activeZone >= 0 && animate ? styles.cornerActive : ""].filter(Boolean).join(" ")} />
            <span className={[styles.corner, styles.cornerBL, activeZone >= 0 && animate ? styles.cornerActive : ""].filter(Boolean).join(" ")} />
            <span className={[styles.corner, styles.cornerBR, activeZone >= 0 && animate ? styles.cornerActive : ""].filter(Boolean).join(" ")} />
          </div>

          {/* Scan line sweep */}
          <div className={[styles.scanLine, animate ? styles.scanLineAnimate : ""].filter(Boolean).join(" ")} aria-hidden="true" />
        </div>
      </div>
    </div>
  );
}

Dependencies

  • motion
  • @bottega/tokens