feat(ui): TiltCard — 3D tilt + 커서 추종 스페큘러 하이라이트 DS 컴포넌트

네이티브 pointer 이벤트 + CSS 변수 (리렌더 0 / 60fps). perspective() 단일 요소 틸트, pointerleave 시 변수 중립 리셋 후 복귀 트랜지션. prefers-reduced-motion 완전 존중. MetalCard 표면 레시피(동일 토큰) 재사용.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Yun Chan 2026-07-22 00:51:30 +09:00
parent 78a178bfdf
commit c8d8316cea
2 changed files with 176 additions and 0 deletions

View file

@ -0,0 +1,175 @@
'use client'
// packages/ui/src/components/ds/TiltCard.tsx
// v2 "Midnight Glass": 3D 틸트 + 커서 추종 빛 반사(specular highlight) 카드.
// MetalCard의 글래스 표면(동일 토큰)에 마우스 추적 3D 기울임 + 커서 위치에서
// 빛이 반사되는 듯한 스페큘러 하이라이트를 더한다. 라이브러리 없이 네이티브
// pointer 이벤트 + CSS 변수로 구현(리렌더 0). prefers-reduced-motion 완전 존중.
//
// 구조: 루트 Box(transform/perspective/표면/blur/링/시인/overflow) +
// ::before(앰비언트 시인) + ::after(그라디언트 헤어라인 링) +
// 자식 span.d3-tilt-spotlight(커서 추종 스페큘러, z1) + 자식 콘텐츠(z2).
import { useCallback, useEffect, useRef } from 'react'
import { Box, type BoxProps } from '@mui/material'
import { d3roPalette, d3roShadow, d3roRadius } from '../../theme'
interface TiltCardProps extends Omit<BoxProps, 'component'> {
/** 최대 기울기 각도(deg). 히어로 8, 작은 타일 6 권장. */
maxTilt?: number
/** 커서 추종 스페큘러 하이라이트(빛 반사) 표시 여부. */
spotlight?: boolean
/** 카드 콘텐츠 */
children?: React.ReactNode
}
/**
* 3D + .
* React state를 ref의 CSS 60fps .
* prefers-reduced-motion / .
*/
function useTiltCard(maxTilt: number) {
const ref = useRef<HTMLDivElement>(null)
const reduceRef = useRef(false)
// OS 설정 변경(런타임 토글)에도 즉시 반응하도록 구독.
useEffect(() => {
if (typeof window === 'undefined' || !('matchMedia' in window)) return
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
reduceRef.current = mq.matches
const handler = (e: MediaQueryListEvent): void => {
reduceRef.current = e.matches
}
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [])
const onPointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>): void => {
if (reduceRef.current) return
const el = ref.current
if (!el) return
const rect = el.getBoundingClientRect()
const px = (e.clientX - rect.left) / rect.width // 0..1
const py = (e.clientY - rect.top) / rect.height // 0..1
// rotateY: 좌우 / rotateX: 상하(반전 → 커서 쪽으로 기울임)
el.style.setProperty('--d3-tilt-ry', `${((px - 0.5) * 2 * maxTilt).toFixed(2)}deg`)
el.style.setProperty('--d3-tilt-rx', `${(-(py - 0.5) * 2 * maxTilt).toFixed(2)}deg`)
el.style.setProperty('--d3-tilt-mx', `${(px * 100).toFixed(1)}%`)
el.style.setProperty('--d3-tilt-my', `${(py * 100).toFixed(1)}%`)
el.setAttribute('data-tilting', 'true')
},
[maxTilt],
)
const onPointerLeave = useCallback((): void => {
const el = ref.current
if (!el) return
// 변수를 중립으로 리셋한 뒤 data 속성 제거 → 복귀 트랜지션 동작.
// (리셋하지 않으면 마지막 각도에 고정됨)
el.style.setProperty('--d3-tilt-rx', '0deg')
el.style.setProperty('--d3-tilt-ry', '0deg')
el.style.setProperty('--d3-tilt-mx', '50%')
el.style.setProperty('--d3-tilt-my', '50%')
el.removeAttribute('data-tilting')
}, [])
return { ref, onPointerMove, onPointerLeave }
}
export function TiltCard({
children,
maxTilt = 8,
spotlight = true,
onPointerMove,
onPointerLeave,
sx,
...boxProps
}: TiltCardProps): React.ReactElement {
const tilt = useTiltCard(maxTilt)
return (
<Box
ref={tilt.ref}
{...boxProps}
onPointerMove={(e) => {
onPointerMove?.(e)
tilt.onPointerMove(e)
}}
onPointerLeave={(e) => {
onPointerLeave?.(e)
tilt.onPointerLeave()
}}
sx={{
position: 'relative',
bgcolor: d3roPalette.glass.surface,
backdropFilter: `blur(${d3roPalette.glass.blur})`,
borderRadius: d3roRadius.card,
boxShadow: d3roShadow.glowCard,
p: 3,
overflow: 'hidden',
// perspective() 단일 요소 틸트. 항상 non-none → 안정적 스태킹 컨텍스트.
transform:
'perspective(900px) rotateX(var(--d3-tilt-rx,0deg)) rotateY(var(--d3-tilt-ry,0deg))',
transition: 'transform 0.5s cubic-bezier(0.16,1,0.3,1), box-shadow 0.25s ease',
// 앰비언트 시인 (정적) — MetalCard와 동일
'&::before': {
content: '""',
position: 'absolute',
inset: 0,
borderRadius: 'inherit',
background: d3roPalette.glass.sheen,
pointerEvents: 'none',
},
// 그라디언트 헤어라인 링 (mask 링 기법) — MetalCard와 동일
'&::after': {
content: '""',
position: 'absolute',
inset: 0,
borderRadius: 'inherit',
padding: '1px',
background: d3roPalette.glass.borderGradient,
WebkitMask: 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
WebkitMaskComposite: 'xor',
maskComposite: 'exclude',
pointerEvents: 'none',
},
'&:hover': { boxShadow: d3roShadow.glowCardHover },
// 트래킹 중: transform 트랜지션 제거(즉시 반응) + 합성 레이어 승격
'&[data-tilting="true"]': {
transition: 'box-shadow 0.25s ease',
willChange: 'transform',
},
// 스페큘러 reveal (Emotion이 plain class를 리라이트하지 않으므로 리터럴 className 사용)
'&:hover .d3-tilt-spotlight': { opacity: 1 },
// reduced-motion: 틸트·트랜지션·추종 모두 정지
'@media (prefers-reduced-motion: reduce)': {
transition: 'none',
transform: 'none',
'& .d3-tilt-spotlight': { transition: 'none' },
},
...sx,
}}
>
{spotlight && (
<Box
component="span"
className="d3-tilt-spotlight"
aria-hidden
sx={{
position: 'absolute',
inset: 0,
borderRadius: 'inherit',
pointerEvents: 'none',
zIndex: 1,
opacity: 0,
transition: 'opacity 0.25s ease',
mixBlendMode: 'screen',
background: `radial-gradient(circle 200px at var(--d3-tilt-mx,50%) var(--d3-tilt-my,50%), ${d3roPalette.glass.specular}, transparent 70%)`,
}}
/>
)}
<Box sx={{ position: 'relative', zIndex: 2 }}>{children}</Box>
</Box>
)
}

View file

@ -6,6 +6,7 @@ export { InstrumentPanel } from './InstrumentPanel'
export { Led } from './Led' export { Led } from './Led'
export { PhysicalButton } from './PhysicalButton' export { PhysicalButton } from './PhysicalButton'
export { MetalCard } from './MetalCard' export { MetalCard } from './MetalCard'
export { TiltCard } from './TiltCard'
export { PhosphorText } from './PhosphorText' export { PhosphorText } from './PhosphorText'
export { MetalDial } from './MetalDial' export { MetalDial } from './MetalDial'
export { ScreenPanel } from './ScreenPanel' export { ScreenPanel } from './ScreenPanel'