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
Install
1. Register the namespace (once per project):
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
npx shadcn add @bottega/cyber-tilt-cardUsage
<CyberTiltCard />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| title | string | CYBER CARD | Large headline rendered in a gradient gradient clip (two lines, newline = <br>). |
| subtitle | string | INTERACTIVE | Small label line below the title, e.g. "INTERACTIVE 3D EFFECT". |
| subtitleHighlight | string | 3D EFFECT | Highlighted span inside the subtitle (rendered with --primary gradient). |
| promptText | string | HOVER ME | Prompt text shown at rest before any hover (default "HOVER ME"). |
| className | string | Extra class names merged onto the root wrapper. |
Source
"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