Components / Hover Box Effect
Hover Box Effect
A 3D flip effect that rotates content on the X-axis when hovered. Features grayscale front face, smooth transitions, and respects reduced motion preferences.
Preview
Hover over the image to see the 3D flip effect.
Usage
tsx
import HoverBoxEffect from "@/components/hoverbox-effect/hovebox-effect"
export function Example() {
return (
<HoverBoxEffect>
<div className="h-[300px] w-[300px] rounded-xl overflow-hidden">
<img
src="/images/photo.jpg"
alt="Example"
className="w-full h-full object-cover"
/>
</div>
</HoverBoxEffect>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | required | Content to display and flip |
| perspective | string | "3000px" | CSS perspective value for 3D effect |
| transitionDuration | number | 500 | Animation duration in milliseconds |
| grayscale | boolean | true | Apply grayscale filter to front face |
Features
- 3D flip animation on X-axis (rotates 90°)
- Auto-detects child dimensions using ResizeObserver
- Optional grayscale filter on front face
- Customizable perspective and transition duration
- Respects
prefers-reduced-motion - Works with any child content (images, cards, etc.)
- No external animation libraries required
How It Works
- Creates two copies of children: front and back faces
- Front face positioned at
translateZ(height/2) - Back face pre-rotated -90° and positioned at same Z
- On hover, container rotates 90° revealing back face
- Uses
backfaceVisibility: hiddenfor clean transitions
Notes
- Child element should have defined dimensions
- Hidden copy used to measure dimensions before render
- Animation disabled when user prefers reduced motion
- Lower perspective values create more dramatic 3D effect
Source Code
tsx
'use client'
import { useState, useLayoutEffect, useRef } from 'react'
interface HoverBoxEffectProps {
children: React.ReactNode
perspective?: string
transitionDuration?: number
grayscale?: boolean
}
const HoverBoxEffect = ({
children,
perspective = '3000px',
transitionDuration = 500,
grayscale = true
}: HoverBoxEffectProps) => {
const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
const [reduceMotion, setReduceMotion] = useState(false)
const [isHovered, setIsHovered] = useState(false)
const childRef = useRef<HTMLDivElement>(null)
useLayoutEffect(() => {
const updateDimensions = () => {
if (childRef.current) {
const { offsetWidth: width, offsetHeight: height } = childRef.current
setDimensions({ width, height })
}
}
const observer = new ResizeObserver(updateDimensions)
if (childRef.current) observer.observe(childRef.current)
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
setReduceMotion(mediaQuery.matches)
const motionHandler = (e: MediaQueryListEvent) => setReduceMotion(e.matches)
mediaQuery.addEventListener('change', motionHandler)
return () => {
observer.disconnect()
mediaQuery.removeEventListener('change', motionHandler)
}
}, [])
const frontTransform = `translateZ(${dimensions.height / 2}px)`
const backTransform = `rotateX(-90deg) translateZ(${dimensions.height / 2}px)`
const handleHover = (state: boolean) => {
setIsHovered(state)
}
return (
<>
<div style={{ visibility: 'hidden', position: 'absolute' }}>
<div ref={childRef}>{children}</div>
</div>
<div
style={{
perspective,
width: dimensions.width,
height: dimensions.height,
margin: '0 auto'
}}
>
<div
style={{
transformStyle: 'preserve-3d',
transition: reduceMotion
? 'none'
: `transform ${transitionDuration}ms ease-in-out`,
transform: isHovered ? 'rotateX(90deg)' : 'none',
height: '100%',
width: '100%',
position: 'relative'
}}
onMouseEnter={() => handleHover(true)}
onMouseLeave={() => handleHover(false)}
>
<div
style={{
transform: frontTransform,
backfaceVisibility: 'hidden',
filter: grayscale ? 'grayscale(1)' : 'none',
position: 'absolute',
width: '100%',
height: '100%'
}}
>
{children}
</div>
<div
style={{
transform: backTransform,
backfaceVisibility: 'hidden',
position: 'absolute',
width: '100%',
height: '100%'
}}
>
{children}
</div>
</div>
</div>
</>
)
}
export default HoverBoxEffect