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.

Installation

Install the component using the shadcn CLI:

npx shadcn@latest add ChinmayNoob/fern-ui/hoverbox-effect

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