← Files Parallax CardARCHIVED FILE

skills/parallax-card/assets/react/useCardMotion.js

8.54 KB · Oct 3, 2026 · 06:38 UTC

↓ Download file

import { useCallback, useEffect, useRef, useState } from 'react'

const clamp = (value, min, max) => Math.min(max, Math.max(min, value))
const normalizeDepth = (value) =>
  Number.isFinite(Number(value)) ? clamp(Number(value), 0, 100) / 100 : 0
const emptyPosition = () => ({ x: 0, y: 0, lift: 0 })

/**
 * Drives CSS variables directly without rendering on every animation frame.
 * --rx/--ry: card rotation in degrees; --px/--py: depth-scaled position (-1..1);
 * --mx/--my: light position in percent; --lift: depth-scaled engagement (0..1).
 * Use fixed layer distances with --px/--py; depth is already applied here.
 */
export default function useCardMotion({
  depth = 70,
  autoRotate = false,
  enabled = true,
} = {}) {
  const cardRef = useRef(null)
  const engineRef = useRef(null)
  const optionsRef = useRef({ depth, autoRotate, enabled })
  const [reducedMotion, setReducedMotion] = useState(() =>
    typeof window !== 'undefined'
      ? window.matchMedia('(prefers-reduced-motion: reduce)').matches
      : false,
  )

  useEffect(() => {
    const media = window.matchMedia('(prefers-reduced-motion: reduce)')
    let prefersReducedMotion = media.matches
    let frameId = 0
    let lastTime = 0
    let disposed = false
    let hovered = false
    let keyboardActive = false
    let touchPointer = null
    let captureNode = null
    let orbitStartedAt = performance.now()
    let current = emptyPosition()
    let target = emptyPosition()

    const strength = () =>
      optionsRef.current.enabled && !prefersReducedMotion
        ? normalizeDepth(optionsRef.current.depth)
        : 0

    function writePosition() {
      const element = cardRef.current
      if (!element) return
      const amount = strength()
      const x = current.x * amount
      const y = current.y * amount
      element.style.setProperty('--rx', `${(-y * 14).toFixed(3)}deg`)
      element.style.setProperty('--ry', `${(x * 14).toFixed(3)}deg`)
      element.style.setProperty('--px', x.toFixed(4))
      element.style.setProperty('--py', y.toFixed(4))
      element.style.setProperty('--mx', `${(50 + x * 50).toFixed(2)}%`)
      element.style.setProperty('--my', `${(50 + y * 50).toFixed(2)}%`)
      element.style.setProperty('--lift', (current.lift * amount).toFixed(4))
    }

    function cancelFrame() {
      if (frameId) cancelAnimationFrame(frameId)
      frameId = 0
      lastTime = 0
    }

    function schedule() {
      if (!disposed && !document.hidden && !frameId) {
        frameId = requestAnimationFrame(animate)
      }
    }

    function animate(timestamp) {
      frameId = 0
      if (disposed) return
      if (!strength()) {
        current = emptyPosition()
        target = emptyPosition()
        writePosition()
        lastTime = 0
        return
      }

      const orbit = optionsRef.current.autoRotate && !hovered && !keyboardActive
      if (orbit) {
        const elapsed = timestamp - orbitStartedAt
        target = {
          x: Math.sin(elapsed / 4200) * 0.42,
          y: Math.sin(elapsed / 5700) * 0.3,
          lift: 0.55,
        }
      }

      const delta = lastTime ? clamp(timestamp - lastTime, 0, 64) : 1000 / 60
      const smoothing = 1 - Math.exp(-delta / 85)
      lastTime = timestamp
      let remaining = 0
      for (const axis of ['x', 'y', 'lift']) {
        current[axis] += (target[axis] - current[axis]) * smoothing
        const distance = Math.abs(target[axis] - current[axis])
        if (distance < 0.0005) current[axis] = target[axis]
        else remaining = Math.max(remaining, distance)
      }
      writePosition()
      if (orbit || remaining > 0) schedule()
      else lastTime = 0
    }

    function releaseCapture() {
      if (touchPointer !== null && captureNode) {
        try {
          if (captureNode.hasPointerCapture(touchPointer)) {
            captureNode.releasePointerCapture(touchPointer)
          }
        } catch {
          // The browser may already have cancelled a touch to begin scrolling.
        }
      }
      touchPointer = null
      captureNode = null
    }

    function rest() {
      hovered = false
      keyboardActive = false
      target = emptyPosition()
      schedule()
    }

    function pointerMove(event) {
      if (!strength()) return
      if (event.pointerType === 'touch' && event.pointerId !== touchPointer) return
      const element = cardRef.current
      if (!element) return
      const bounds = element.getBoundingClientRect()
      if (!bounds.width || !bounds.height) return
      hovered = true
      keyboardActive = false
      target = {
        x: clamp(((event.clientX - bounds.left) / bounds.width) * 2 - 1, -1, 1),
        y: clamp(((event.clientY - bounds.top) / bounds.height) * 2 - 1, -1, 1),
        lift: 1,
      }
      schedule()
    }

    function pointerDown(event) {
      if (!strength() || event.isPrimary === false) return
      if (event.pointerType === 'touch') {
        touchPointer = event.pointerId
        captureNode = event.currentTarget
        try {
          captureNode.setPointerCapture(touchPointer)
        } catch {
          // Pointer capture is optional; native touch scrolling stays enabled.
        }
      }
      pointerMove(event)
    }

    function pointerUp(event) {
      if (event.pointerId === touchPointer) {
        releaseCapture()
        rest()
      }
    }

    function pointerLeave(event) {
      if (event.pointerType === 'touch' && touchPointer !== null) return
      rest()
    }

    function pointerCancel(event) {
      if (event.pointerId === touchPointer) releaseCapture()
      rest()
    }

    function reset() {
      releaseCapture()
      hovered = false
      keyboardActive = false
      current = emptyPosition()
      target = emptyPosition()
      orbitStartedAt = performance.now()
      cancelFrame()
      writePosition()
      if (strength() && optionsRef.current.autoRotate) schedule()
    }

    function keyDown(event) {
      if (['Escape', 'Home', '0'].includes(event.key)) {
        event.preventDefault()
        reset()
        return
      }
      if (!strength() || !event.key.startsWith('Arrow')) return
      const directions = {
        ArrowLeft: [-0.2, 0],
        ArrowRight: [0.2, 0],
        ArrowUp: [0, -0.2],
        ArrowDown: [0, 0.2],
      }
      const direction = directions[event.key]
      if (!direction) return
      event.preventDefault()
      keyboardActive = true
      hovered = false
      target = {
        x: clamp(target.x + direction[0], -1, 1),
        y: clamp(target.y + direction[1], -1, 1),
        lift: 1,
      }
      schedule()
    }

    function refresh() {
      if (!strength()) {
        reset()
        return
      }
      if (!hovered && !keyboardActive && !optionsRef.current.autoRotate) {
        target = emptyPosition()
      }
      schedule()
    }

    function motionPreferenceChanged(event) {
      prefersReducedMotion = event.matches
      setReducedMotion(event.matches)
      refresh()
    }

    function visibilityChanged() {
      if (document.hidden) cancelFrame()
      else refresh()
    }

    engineRef.current = {
      pointerMove,
      pointerDown,
      pointerUp,
      pointerLeave,
      pointerCancel,
      keyDown,
      rest,
      reset,
      refresh,
    }
    media.addEventListener('change', motionPreferenceChanged)
    document.addEventListener('visibilitychange', visibilityChanged)
    writePosition()
    refresh()

    return () => {
      disposed = true
      cancelFrame()
      releaseCapture()
      media.removeEventListener('change', motionPreferenceChanged)
      document.removeEventListener('visibilitychange', visibilityChanged)
      engineRef.current = null
    }
  }, [])

  useEffect(() => {
    optionsRef.current = { depth, autoRotate, enabled }
    engineRef.current?.refresh()
  }, [depth, autoRotate, enabled])

  const onPointerMove = useCallback((event) => engineRef.current?.pointerMove(event), [])
  const onPointerDown = useCallback((event) => engineRef.current?.pointerDown(event), [])
  const onPointerUp = useCallback((event) => engineRef.current?.pointerUp(event), [])
  const onPointerLeave = useCallback((event) => engineRef.current?.pointerLeave(event), [])
  const onPointerCancel = useCallback((event) => engineRef.current?.pointerCancel(event), [])
  const onKeyDown = useCallback((event) => engineRef.current?.keyDown(event), [])
  const onBlur = useCallback(() => engineRef.current?.rest(), [])
  const reset = useCallback(() => engineRef.current?.reset(), [])

  return {
    cardRef,
    bind: {
      onPointerMove,
      onPointerDown,
      onPointerUp,
      onPointerLeave,
      onPointerCancel,
      onKeyDown,
      onBlur,
    },
    reset,
    reducedMotion,
  }
}

SHA-256: d3a04787367dff378df209671985d9ff0329aaedee732e28866d13ff10c9a83d