Css Box

PreviousNext

A ui component.

Docs
fancyui

Preview

Loading preview…
fancy/blocks/css-box.tsx
"use client"

import {
  forwardRef,
  ReactNode,
  useCallback,
  useEffect,
  useImperativeHandle,
  useRef,
} from "react"
import { motion, useMotionValue, useSpring, useTransform } from "motion/react"

import { cn } from "@/lib/utils"

interface FaceProps {
  transform: string
  className?: string
  showBackface?: boolean
  children?: ReactNode
  style?: React.CSSProperties
}

const CubeFace = ({
  transform,
  className,
  showBackface,
  children,
  style,
}: FaceProps) => (
  <div
    className={cn(
      "absolute",
      showBackface ? "backface-visible" : "backface-hidden",
      className
    )}
    style={{ transform, ...style }}
  >
    {children}
  </div>
)

interface CubeFaces {
  front?: ReactNode
  back?: ReactNode
  right?: ReactNode
  left?: ReactNode
  top?: ReactNode
  bottom?: ReactNode
}

export interface CSSBoxRef {
  showFront: () => void
  showBack: () => void
  showLeft: () => void
  showRight: () => void
  showTop: () => void
  showBottom: () => void
  rotateTo: (x: number, y: number) => void
  getCurrentRotation: () => { x: number; y: number }
}

interface CSSBoxProps extends React.HTMLProps<HTMLDivElement> {
  width: number
  height: number
  depth: number
  className?: string
  perspective?: number
  stiffness?: number
  damping?: number
  showBackface?: boolean
  faces?: CubeFaces
  draggable?: boolean
}

const CSSBox = forwardRef<CSSBoxRef, CSSBoxProps>(
  (
    {
      width,
      height,
      depth,
      className,
      perspective = 600,
      stiffness = 100,
      damping = 30,
      showBackface = false,
      faces = {},
      draggable = true,
      ...props
    },
    ref
  ) => {
    const isDragging = useRef(false)
    const startPosition = useRef({ x: 0, y: 0 })
    const startRotation = useRef({ x: 0, y: 0 })

    const baseRotateX = useMotionValue(0)
    const baseRotateY = useMotionValue(0)

    const springRotateX = useSpring(baseRotateX, {
      stiffness,
      damping,
      ...(isDragging.current ? { stiffness: stiffness / 2 } : {}),
    })
    const springRotateY = useSpring(baseRotateY, {
      stiffness,
      damping,
      ...(isDragging.current ? { stiffness: stiffness / 2 } : {}),
    })

    const currentRotation = useRef({ x: 0, y: 0 })

    useImperativeHandle(
      ref,
      () => ({
        showFront: () => {
          baseRotateX.set(0)
          baseRotateY.set(0)
        },
        showBack: () => {
          baseRotateX.set(0)
          baseRotateY.set(180)
        },
        showLeft: () => {
          baseRotateX.set(0)
          baseRotateY.set(-90)
        },
        showRight: () => {
          baseRotateX.set(0)
          baseRotateY.set(90)
        },
        showTop: () => {
          baseRotateX.set(-90)
          baseRotateY.set(0)
        },
        showBottom: () => {
          baseRotateX.set(90)
          baseRotateY.set(0)
        },
        rotateTo: (x: number, y: number) => {
          baseRotateX.set(x)
          baseRotateY.set(y)
        },

        getCurrentRotation: () => currentRotation.current,
      }),
      []
    )

    const transform = useTransform(
      [springRotateX, springRotateY],
      ([x, y]) =>
        `translateZ(-${depth / 2}px) rotateX(${x}deg) rotateY(${y}deg)`
    )
    const handleStart = useCallback(
      (e: React.MouseEvent | React.TouchEvent) => {
        if (!draggable) return
        isDragging.current = true
        const point = 'touches' in e ? e.touches[0] : e
        startPosition.current = { x: point.clientX, y: point.clientY }
        startRotation.current = {
          x: baseRotateX.get(),
          y: baseRotateY.get(),
        }
      },
      [draggable]
    )

    const handleMove = useCallback((e: MouseEvent | TouchEvent) => {
      if (!isDragging.current) return
      const point = 'touches' in e ? e.touches[0] : e
      const deltaX = point.clientX - startPosition.current.x
      const deltaY = point.clientY - startPosition.current.y
      baseRotateX.set(startRotation.current.x - deltaY / 2)
      baseRotateY.set(startRotation.current.y + deltaX / 2)
    }, [])

    const handleEnd = useCallback(() => {
      isDragging.current = false
    }, [])

    useEffect(() => {
      if (draggable) {
        window.addEventListener("mousemove", handleMove)
        window.addEventListener("mouseup", handleEnd)
        window.addEventListener("touchmove", handleMove)
        window.addEventListener("touchend", handleEnd)
        return () => {
          window.removeEventListener("mousemove", handleMove)
          window.removeEventListener("mouseup", handleEnd)
          window.removeEventListener("touchmove", handleMove)
          window.removeEventListener("touchend", handleEnd)
        }
      }
    }, [draggable, handleMove, handleEnd])

    useEffect(() => {
      const unsubscribeX = baseRotateX.on("change", (v) => {
        currentRotation.current.x = v
      })
      const unsubscribeY = baseRotateY.on("change", (v) => {
        currentRotation.current.y = v
      })
      return () => {
        unsubscribeX()
        unsubscribeY()
      }
    }, [])

    return (
      <div
        className={cn(draggable && "cursor-move", className)}
        style={{
          width,
          height,
          perspective: `${perspective}px`,
        }}
        onMouseDown={handleStart}
        onTouchStart={handleStart}
        {...props}
      >
        <motion.div
          className="relative w-full h-full [transform-style:preserve-3d]"
          style={{ transform }}
        >
          {/* Front and Back */}
          <CubeFace
            transform={`rotateY(0deg) translateZ(${depth / 2}px)`}
            style={{ width, height }}
            showBackface={showBackface}
          >
            {faces.front}
          </CubeFace>

          <CubeFace
            transform={`rotateY(180deg) translateZ(${depth / 2}px)`}
            style={{ width, height }}
            showBackface={showBackface}
          >
            {faces.back}
          </CubeFace>

          {/* Right and Left */}
          <CubeFace
            transform={`rotateY(90deg) translateZ(${width / 2}px)`}
            style={{
              width: depth,
              height,
              left: (width - depth) / 2,
            }}
            showBackface={showBackface}
          >
            {faces.right}
          </CubeFace>

          <CubeFace
            transform={`rotateY(-90deg) translateZ(${width / 2}px)`}
            style={{
              width: depth,
              height,
              left: (width - depth) / 2,
            }}
            showBackface={showBackface}
          >
            {faces.left}
          </CubeFace>

          {/* Top and Bottom */}
          <CubeFace
            transform={`rotateX(90deg) translateZ(${height / 2}px)`}
            style={{
              width,
              height: depth,
              top: (height - depth) / 2,
            }}
            showBackface={showBackface}
          >
            {faces.top}
          </CubeFace>

          <CubeFace
            transform={`rotateX(-90deg) translateZ(${height / 2}px)`}
            style={{
              width,
              height: depth,
              top: (height - depth) / 2,
            }}
            showBackface={showBackface}
          >
            {faces.bottom}
          </CubeFace>
        </motion.div>
      </div>
    )
  }
)

CSSBox.displayName = "CSSBox"

export default CSSBox

Installation

npx shadcn@latest add @fancy/css-box

Usage

import { CssBox } from "@/components/ui/css-box"
<CssBox />