effects#3d#perspective
3D Card Tilt
Cards that tilt in 3D space following cursor position with realistic glare effects
Implementation Guide
# 3D Card Tilt Effect
> Cards that tilt in 3D space following cursor position, with subtle lighting/glare effects that shift. Creates depth perception without WebGL.
## Quick Start
```tsx
import ThreeDCardTilt from '@/components/effects/3d-card-tilt';
export default function Features() {
return (
<div className="h-screen">
<ThreeDCardTilt maxTilt={15} glare={true} />
</div>
);
}
```
## Props
| Prop | Type | Default | Description |
| ------------ | ------- | ------- | -------------------------------------------- |
| maxTilt | number | 15 | Maximum tilt angle in degrees (5 - 25) |
| perspective | number | 1000 | CSS perspective value in pixels (500 - 2000) |
| scale | number | 1.05 | Scale multiplier on hover (1.0 - 1.2) |
| glare | boolean | true | Enable glare/shine overlay effect |
| glareOpacity | number | 0.3 | Maximum opacity of glare effect (0.1 - 0.5) |
## Full Implementation
```tsx
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
interface TiltCardProps {
maxTilt?: number;
perspective?: number;
scale?: number;
glare?: boolean;
glareOpacity?: number;
children: React.ReactNode;
className?: string;
}
interface TiltState {
rotateX: number;
rotateY: number;
glareX: number;
glareY: number;
}
function TiltCard({
maxTilt = 15,
perspective = 1000,
scale = 1.05,
glare = true,
glareOpacity = 0.3,
children,
className = '',
}: TiltCardProps) {
const cardRef = useRef<HTMLDivElement>(null);
const [tilt, setTilt] = useState<TiltState>({
rotateX: 0,
rotateY: 0,
glareX: 50,
glareY: 50,
});
const [isHovering, setIsHovering] = useState(false);
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
setPrefersReducedMotion(mediaQuery.matches);
const handleChange = (event: MediaQueryListEvent) => {
setPrefersReducedMotion(event.matches);
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);
const handleMouseMove = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
if (!cardRef.current || prefersReducedMotion) return;
const rect = cardRef.current.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
// Calculate mouse position relative to card center
const mouseX = event.clientX - centerX;
const mouseY = event.clientY - centerY;
// Map to rotation values
const rotateY = (mouseX / (rect.width / 2)) * maxTilt;
const rotateX = -(mouseY / (rect.height / 2)) * maxTilt;
// Calculate glare position (follows cursor)
const glareX = ((event.clientX - rect.left) / rect.width) * 100;
const glareY = ((event.clientY - rect.top) / rect.height) * 100;
setTilt({ rotateX, rotateY, glareX, glareY });
},
[maxTilt, prefersReducedMotion]
);
const handleMouseEnter = useCallback(() => {
setIsHovering(true);
}, []);
const handleMouseLeave = useCallback(() => {
setIsHovering(false);
setTilt({ rotateX: 0, rotateY: 0, glareX: 50, glareY: 50 });
}, []);
const cardStyle: React.CSSProperties = {
perspective: `${perspective}px`,
};
const innerStyle: React.CSSProperties = {
transform: prefersReducedMotion
? `scale(${isHovering ? scale : 1})`
: `rotateX(${tilt.rotateX}deg) rotateY(${tilt.rotateY}deg) scale(${isHovering ? scale : 1})`,
transformStyle: 'preserve-3d',
transition: 'transform 0.15s ease-out',
};
const glareStyle: React.CSSProperties = {
position: 'absolute',
inset: 0,
background: `radial-gradient(circle at ${tilt.glareX}% ${tilt.glareY}%, rgba(255, 255, 255, ${isHovering ? glareOpacity : 0}) 0%, transparent 60%)`,
pointerEvents: 'none',
transition: 'opacity 0.3s ease-out',
opacity: isHovering ? 1 : 0,
};
return (
<div style={cardStyle} className={className}>
<div
ref={cardRef}
style={innerStyle}
onMouseMove={handleMouseMove}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className="relative"
>
{children}
{glare && !prefersReducedMotion && <div style={glareStyle} />}
</div>
</div>
);
}
// Example usage with demo cards
export default function ThreeDCardTilt({
maxTilt = 15,
perspective = 1000,
scale = 1.05,
glare = true,
glareOpacity = 0.3,
}: ThreeDCardTiltProps) {
return (
<div className="flex h-full w-full items-center justify-center">
<TiltCard
maxTilt={maxTilt}
perspective={perspective}
scale={scale}
glare={glare}
glareOpacity={glareOpacity}
>
<div className="border border-zinc-700 bg-zinc-800 p-6">
<h3 className="font-semibold text-white">Your Card Content</h3>
<p className="text-zinc-400">Hover to see the 3D tilt effect</p>
</div>
</TiltCard>
</div>
);
}
```
## Customization
### Subtle Professional Effect
For a more subtle, professional feel:
```tsx
<ThreeDCardTilt maxTilt={8} scale={1.02} glareOpacity={0.15} />
```
### Dramatic 3D Effect
For a more dramatic, playful effect:
```tsx
<ThreeDCardTilt maxTilt={25} perspective={800} scale={1.1} glareOpacity={0.4} />
```
### No Glare (Clean Look)
For a clean tilt without the glare overlay:
```tsx
<ThreeDCardTilt glare={false} maxTilt={12} />
```
### Custom Card Content
Use the `TiltCard` component directly for custom content:
```tsx
import { TiltCard } from '@/components/effects/3d-card-tilt';
function ProductCard({ product }) {
return (
<TiltCard maxTilt={10} glare={true}>
<div className="rounded-lg bg-white p-4 shadow-lg">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>{product.price}</p>
</div>
</TiltCard>
);
}
```
### Adjusting Transition Speed
Modify the transition timing for different feels:
```tsx
// In the innerStyle object:
const innerStyle: React.CSSProperties = {
transform: `rotateX(${tilt.rotateX}deg) rotateY(${tilt.rotateY}deg)`,
transformStyle: 'preserve-3d',
transition: 'transform 0.3s cubic-bezier(0.03, 0.98, 0.52, 0.99)', // Smoother, slower
};
```
## Performance Considerations
- **GPU Acceleration**: The effect uses CSS transforms which are GPU-accelerated
- **No requestAnimationFrame**: Uses React state with mouse events for simplicity
- **Throttling**: Consider throttling `handleMouseMove` if experiencing performance issues on many cards
- **Mobile**: Touch events are not handled by default - the effect is primarily for desktop hover interactions
- **Many Cards**: For grids with 10+ cards, consider reducing `maxTilt` and disabling `glare`
### Throttled Version for Performance
```tsx
import { useCallback, useRef } from 'react';
function useThrottledCallback<T extends (...args: any[]) => void>(callback: T, delay: number): T {
const lastRun = useRef(Date.now());
return useCallback(
((...args) => {
if (Date.now() - lastRun.current >= delay) {
callback(...args);
lastRun.current = Date.now();
}
}) as T,
[callback, delay]
);
}
// Use in component:
const throttledMouseMove = useThrottledCallback(handleMouseMove, 16); // ~60fps
```
## Accessibility
- **Reduced Motion**: Fully respects `prefers-reduced-motion` media query
- When enabled: Tilt and glare effects are disabled
- Scale effect remains as a subtle hover indicator
- **Keyboard Navigation**: Cards remain fully interactive via keyboard
- **Screen Readers**: Effect is purely visual; ensure card content is properly structured
- **Focus States**: Add visible focus indicators for keyboard users:
```tsx
<TiltCard className="focus-within:ring-2 focus-within:ring-blue-500">
<button>Interactive content</button>
</TiltCard>
```
## Browser Support
- **Modern Browsers**: Full support in Chrome, Firefox, Safari, Edge
- **CSS Transform 3D**: Required for the perspective effect
- **CSS Custom Properties**: Used for dynamic styling
- **Pointer Events**: Required for glare positioning
| Browser | Version | Support |
| ------- | ------- | ------- |
| Chrome | 36+ | Full |
| Firefox | 16+ | Full |
| Safari | 9+ | Full |
| Edge | 12+ | Full |
| IE | - | None |