From c8d8316ceaa93c8a52b614d206fd4f23b5f951e1 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Wed, 22 Jul 2026 00:51:30 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui):=20TiltCard=20=E2=80=94=203D=20tilt=20?= =?UTF-8?q?+=20=EC=BB=A4=EC=84=9C=20=EC=B6=94=EC=A2=85=20=EC=8A=A4?= =?UTF-8?q?=ED=8E=98=ED=81=98=EB=9F=AC=20=ED=95=98=EC=9D=B4=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20DS=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 네이티브 pointer 이벤트 + CSS 변수 (리렌더 0 / 60fps). perspective() 단일 요소 틸트, pointerleave 시 변수 중립 리셋 후 복귀 트랜지션. prefers-reduced-motion 완전 존중. MetalCard 표면 레시피(동일 토큰) 재사용. Co-Authored-By: Claude --- packages/ui/src/components/ds/TiltCard.tsx | 175 +++++++++++++++++++++ packages/ui/src/components/ds/index.ts | 1 + 2 files changed, 176 insertions(+) create mode 100644 packages/ui/src/components/ds/TiltCard.tsx diff --git a/packages/ui/src/components/ds/TiltCard.tsx b/packages/ui/src/components/ds/TiltCard.tsx new file mode 100644 index 0000000..9347512 --- /dev/null +++ b/packages/ui/src/components/ds/TiltCard.tsx @@ -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 { + /** 최대 기울기 각도(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(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): 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 ( + { + 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 && ( + + )} + {children} + + ) +} diff --git a/packages/ui/src/components/ds/index.ts b/packages/ui/src/components/ds/index.ts index 8e2d052..b388f76 100644 --- a/packages/ui/src/components/ds/index.ts +++ b/packages/ui/src/components/ds/index.ts @@ -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'