
PlayTalk에 혼자 하는 스도쿠를 붙이면서 '도움' 기능을 넣었습니다. 버튼을 누르면 빈칸 하나가 정답으로 채워지는 건데, 그냥 값이 툭 나타나면 심심하잖아요. 그래서 숫자가 버튼에서 그 칸까지 날아가는 애니메이션을 넣었습니다. 외부 라이브러리 없이 CSS transition만으로요.
구현은 단순합니다.
- 버튼과 목적지 칸의 화면 좌표를 getBoundingClientRect()로 잽니다.
- 그 좌표에 position: fixed로 숫자 하나를 띄웁니다.
- 다음 프레임에 목적지 좌표로 left/top을 바꿔 transition을 태웁니다.
- 460ms 뒤 도착하면 오버레이를 지우고 보드에 실제 값을 확정합니다.
const FLY_MS = 460;
function FlyingNumber({ value, from, to, onArrive }) {
const [pos, setPos] = useState(from);
useEffect(() => {
const raf = requestAnimationFrame(() => setPos(to));
const t = setTimeout(onArrive, FLY_MS);
return () => { cancelAnimationFrame(raf); clearTimeout(t); };
}, []);
return (
<div style={{
position: 'fixed', left: pos.x, top: pos.y, zIndex: 40, pointerEvents: 'none',
transition: `left ${FLY_MS}ms cubic-bezier(.2,.7,.3,1), top ${FLY_MS}ms ...`,
}}>{value}</div>
);
}
모바일에서 테스트했습니다. 잘 됩니다. 숫자가 예쁘게 날아가요.
PC에서 열었더니 아무것도 안 보였습니다.
값은 채워지는데 애니메이션만 없다
이상한 건 기능 자체는 멀쩡했다는 겁니다. 버튼을 누르면 남은 횟수가 줄고, 460ms 뒤에 칸에 숫자가 정확히 들어갔어요. 오직 날아가는 과정만 안 보였습니다.
처음엔 zIndex: 40이 부족한가 싶어 올려봤습니다. 그대로였어요. 개발자 도구로 잡아보니 요소는 DOM에 분명히 있고, left/top도 계산한 좌표 그대로 들어가 있고, transition도 걸려 있었습니다. 그냥 화면 밖에 있었어요.
범인: 조상의 transform
원인은 스도쿠 코드가 아니라 PlayTalk 공통 레이아웃에 있었습니다.
PlayTalk은 게임 플레이 중일 때 PC에서 카드를 화면 가운데로 옮깁니다. 그 방식이 이거였어요.
cardPlayingDesktop: {
top: '50%',
left: '50%',
right: 'auto',
bottom: 'auto',
transform: 'translate(-50%, -50%)', // ← 이 줄
},
가운데 정렬의 국룰 같은 코드죠. 그런데 CSS 명세에는 이런 규칙이 있습니다.
transform(그리고 filter, perspective, will-change, contain 등)이 none이 아닌 요소는 자손의 position: fixed에 대해 containing block이 된다.
즉 position: fixed가 더 이상 뷰포트 기준이 아니게 됩니다. transform이 걸린 그 조상 박스가 새 기준이 돼요.
제 코드는 getBoundingClientRect()로 뷰포트 기준 좌표를 재서 fixed에 넣고 있었습니다. 그런데 그 fixed의 기준이 카드 박스로 바뀌어 있었으니, 좌표가 카드 좌상단 기준으로 재해석된 거죠. 화면 중앙쯤에 있는 카드에서 다시 오른쪽 아래로 그만큼 밀려나니 카드 밖으로 벗어나 잘려 보이지 않았습니다.
모바일에서 멀쩡했던 이유도 이걸로 설명됩니다. 모바일은 카드가 전체화면이라 cardPlayingDesktop이 적용되지 않아 transform이 없었거든요. PC에서만 재현되는 버그의 정체가 이거였습니다.
해결: 포털로 body에 렌더
방법은 두 가지였습니다.
(A) 카드의 transform을 없앤다. translate(-50%, -50%) 대신 flex 정렬로 가운데를 맞추면 됩니다. 근데 이 카드는 전체 서비스가 공유하는 레이아웃이고, 스도쿠 하나 때문에 모든 게임의 플레이 화면 정렬 방식을 건드리는 건 위험 대비 이득이 없었습니다.
(B) 날아가는 숫자를 그 조상 밖으로 꺼낸다. React의 createPortal을 쓰면 컴포넌트 트리는 그대로 두고 DOM 위치만 document.body 밑으로 보낼 수 있습니다. body 밑에는 transform이 없으니 fixed가 원래대로 뷰포트를 기준으로 삼습니다.
B로 갔습니다.
import { createPortal } from 'react-dom';
// document.body로 포털 렌더 — 플레이 중 데스크톱 카드(cardPlayingDesktop의 transform)가
// position:fixed 자손의 기준을 카드 박스로 바꿔 잘려 보이던 문제 회피. 좌표는 뷰포트 기준 유지.
return createPortal(
<div style={{ position: 'fixed', left: pos.x, top: pos.y, ... }}>{value}</div>,
document.body
);
한 줄 바꾸니 PC에서도 그대로 날아갔습니다. 좌표 계산은 손댈 게 없었어요 — 애초에 getBoundingClientRect()가 주는 뷰포트 기준 좌표가 맞았고, 그걸 해석하는 기준만 어긋나 있었으니까요.
이런 걸 어떻게 미리 알아채나
솔직히 저는 이번에 시간을 꽤 썼습니다. 다음에 같은 데서 헤매지 않으려고 정리해 둔 신호들입니다.
- fixed 요소가 "값은 맞는데 위치만 이상"하면 조상에 transform이 있는지부터 본다. 개발자 도구에서 요소를 잡고 부모를 거슬러 올라가며 Computed의 transform이 none이 아닌 놈을 찾으면 됩니다.
- transform 말고도 같은 효과를 내는 속성이 있다. filter, backdrop-filter, perspective, will-change: transform, contain: paint. 특히 will-change는 "성능 최적화니까 넣어두면 좋겠지" 하고 별생각 없이 넣었다가 이 문제를 만드는 경우가 많습니다.
- 모바일에서만/PC에서만 재현되는 시각 버그는 반응형 분기에서 갈린 스타일부터 의심한다. 이번 건도 원인은 스도쿠가 아니라 isDesktopLayout 분기에 있었습니다.
- 오버레이(모달·토스트·툴팁·플로팅 애니메이션)는 애초에 포털로 body에 붙이는 게 안전하다. 지금 당장 조상에 transform이 없어도, 반년 뒤 누가 카드에 애니메이션 하나 넣으면서 조용히 깨질 수 있으니까요.
마지막 항목이 진짜 교훈인 것 같습니다. 이번 버그의 원인이 된 translate(-50%, -50%)은 스도쿠보다 훨씬 전에 쓰인 코드였고, 당시엔 아무 문제가 없었어요. 나중에 fixed 오버레이가 그 안으로 들어오면서 터진 거죠. 오버레이를 처음부터 트리 밖에 두면 이 종류의 시한폭탄이 안 생깁니다.
마치며
PlayTalk의 스도쿠는 난이도 3단계에 도움 기능(쉬움 12회 / 보통 9회 / 어려움 6회)이 있고, 이 글의 날아가는 숫자가 그 도움 버튼을 눌렀을 때 나옵니다. 외부 애니메이션 라이브러리 없이 CSS transition + requestAnimationFrame 한 번이면 되는 효과라, 라이브러리를 붙이기 전에 한 번 직접 해보시는 것도 추천합니다.
👉 PlayTalk 에서 직접 해보실 수 있습니다.