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:
parent
78a178bfdf
commit
c8d8316cea
2 changed files with 176 additions and 0 deletions
175
packages/ui/src/components/ds/TiltCard.tsx
Normal file
175
packages/ui/src/components/ds/TiltCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ export { InstrumentPanel } from './InstrumentPanel'
|
|||
export { Led } from './Led'
|
||||
export { PhysicalButton } from './PhysicalButton'
|
||||
export { MetalCard } from './MetalCard'
|
||||
export { TiltCard } from './TiltCard'
|
||||
export { PhosphorText } from './PhosphorText'
|
||||
export { MetalDial } from './MetalDial'
|
||||
export { ScreenPanel } from './ScreenPanel'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue