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.

Album cover
Album cover
Album cover

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

PropTypeDefaultDescription
childrenReactNoderequiredContent to display and flip
perspectivestring"3000px"CSS perspective value for 3D effect
transitionDurationnumber500Animation duration in milliseconds
grayscalebooleantrueApply 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: hidden for 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