본문 바로가기
빌드노트 검색

빌드노트

fixed 버튼이 화면 대신 부모를 따라 움직인다

조상에 translateZ(0)을 넣자 fixed 버튼의 화면 좌표가 스크롤을 따라 바뀌었다. 같은 버튼의 실측값으로 위치 기준이 바뀐 이유를 확인하고, transform 제거와 조상 밖으로 옮기는 수정의 선택 조건을 설명한다.

position: fixed로 띄운 도움말 버튼이 조상에 transform을 추가한 뒤 페이지 스크롤을 따라 움직인다면, 버튼의 위치 기준이 바뀌었는지 확인한다. transform이 none이 아닌 조상은 fixed 자식의 containing block을 만들 수 있다. 그 조상이 기준이라면 불필요한 transform을 제거하거나 버튼을 그 조상 밖으로 옮겨야 화면 기준으로 돌아온다.

containing block은 top, left 같은 위치값의 기준이 되는 상자다. fixed는 보통 뷰포트를 기준으로 하지만, CSS Positioned Layout 3 작업 초안의 포함 블록 규칙 (새 탭에서 열림)은 fixed의 기준을 만드는 가장 가까운 조상이 있으면 그 상자를 사용한다고 설명한다. 단순히 position: relative인 부모를 찾는 것과는 다르다.

움직이지 않는 transform도 기준을 바꿨다

이를 구별하려고 외부 스타일을 불러오지 않는 새 HTML에서 같은 버튼을 비교했다. 실제 서비스의 장애 기록은 아니다. 다음 내용을 새 fixed.html에 저장하고 데스크톱 브라우저로 열면 비교 조건을 만들 수 있다. 외부 라이브러리나 서버는 필요 없다.

<!doctype html>
<meta charset="utf-8">
<title>fixed containing block: isolated measurement</title>
<style>
  body { margin: 0; min-height: 2400px; }
  #shell {
    margin: 160px 0 0 40px; width: 600px; height: 480px;
    border: 4px solid #777; position: relative; transform: none;
  }
  #help { position: fixed; top: 20px; left: 20px; margin: 0; }
</style>
<div id="shell"><button id="help" type="button">도움말</button></div>

개발자 도구의 Console에서 아래 코드를 실행하면 버튼의 화면 위쪽 좌표와 가까운 순서의 조상 스타일을 읽을 수 있다. 실제 페이지에서는 #help를 해당 버튼의 선택자로 바꾼다. 일치하는 요소가 없으면 선택자를 확인하라는 오류가 난다.

(() => {
  const button = document.querySelector('#help');
  if (!button) throw new Error('#help 선택자를 확인하세요.');
  const ancestors = [];
  for (let el = button.parentElement; el; el = el.parentElement) {
    const css = getComputedStyle(el);
    ancestors.push({ element: el.id || el.tagName,
      transform: css.transform, perspective: css.perspective,
      filter: css.filter, willChange: css.willChange, contain: css.contain });
  }
  return { position: getComputedStyle(button).position,
    pageY: scrollY, top: button.getBoundingClientRect().top, ancestors };
})()

먼저 transform: none으로 측정하고, Elements에서 #shell의 선언만 transform: translateZ(0)으로 바꿔 비교한다. 각 조건에서 window.scrollTo(0, 0)으로 맨 위로 돌아간 뒤 측정한다. 이어서 window.scrollTo(0, 300)을 실행하고, 스크롤이 반영된 다음 Console 입력에서 같은 측정식을 다시 실행한다. pageY가 실제로 300인지도 함께 본다.

2026년 10월 21일 Windows의 headless Chrome 156.0.8078.47에서 1280×720 화면으로 실행한 결과는 다음과 같았다. 단위는 CSS px이며, 여기서 top은 CSS 선언값이 아니라 화면 위쪽에서 잰 좌표다.

  • none: pageY가 0에서 300으로 바뀌어도 버튼의 top은 20 → 20이었다.
  • translateZ(0): 같은 스크롤에서 버튼의 top은 184 → -116이었다. 두 시점 모두 position은 fixed였다.

음수인 -116은 버튼의 위쪽이 화면 위로 벗어났다는 뜻이다. 두 조건에서 조상 상자의 위쪽은 160 → -140으로 움직였다. transform이 있을 때 버튼은 조상보다 계속 24px 아래였다. 조상의 위 테두리 4px와 버튼의 top: 20px를 더한 간격이다. CSS Transforms 1 후보 권고안의 렌더링 규칙 (새 탭에서 열림)은 CSS 박스에 적용된 none 이외의 transform이 fixed 자손의 기준을 만들고, 그 기준으로 조상의 padding box를 사용한다고 설명한다. 이 예제에서는 테두리 바로 안쪽이 그 기준의 위쪽이다.

translateZ(0)은 눈에 보이는 이동량이 0이어도 none과 같은 선언이 아니다. 이 브라우저의 조상 스타일 출력은 matrix(1, 0, 0, 1, 0, 0)이었다. 원래 모양을 유지하는 행렬도 none은 아니므로, “상자가 움직이지 않았으니 fixed에도 영향이 없다”고 판단하면 놓친다. 반면 none 비교에서도 조상의 position: relative는 유지됐고 버튼은 화면에 남았다.

추가로 버튼에 z-index: 999999를 줬지만 184 → -116은 그대로였다. getBoundingClientRect() (새 탭에서 열림)의 top은 뷰포트 기준 좌표다. 좌표 자체가 페이지와 함께 바뀌는 이 경우에는 겹치는 순서를 높여도 위치 기준이 돌아오지 않았다. top은 계속 20인데 버튼만 보이지 않는다면 다른 요소의 가림이나 잘림을 따로 살펴봐야 한다.

조상 스타일을 없앨지, 버튼을 옮길지

자기 화면에서는 먼저 측정 결과의 position이 실제로 fixed인지 확인한다. 조상 목록에 none이 아닌 transform이 있으면 가장 가까운 후보부터 개발자 도구에서 잠시 none으로 바꾸고 스크롤을 반복한다. 이 예제에서도 transform을 제거하자 같은 버튼의 top은 다시 20 → 20이었다. 후보가 여러 개면 하나씩 바꾸고 다시 측정한다. 불필요하게 남은 선언이라면 해당 조상의 transform을 제거하는 쪽이 작은 수정이다.

transform이 화면 전환이나 패널 이동에 필요하다면 버튼의 렌더링 위치를 바꾸는 쪽을 검토한다. 위 합성 문서에서는 #shell의 translateZ(0)을 유지한 채 다음 한 줄로 같은 버튼을 body의 직접 자식으로 옮겼다.

document.body.append(document.querySelector('#help'))

페이지가 이미 300px 내려간 상태에서도 버튼의 top은 20으로 돌아왔다. 다시 0과 300을 비교해도 20 → 20이었다. transform은 형제로 남은 #shell에 계속 적용됐다. 실제 앱에서는 이 결과를 확인한 뒤, 화면에 붙어야 하는 버튼을 변형되는 콘텐츠와 형제인 위치에 렌더링하도록 구조를 고른다. 프레임워크를 쓴다면 위 한 줄은 합성 문서에서의 확인용으로 쓰고, 실제 수정은 템플릿이나 포털의 렌더링 위치에 반영한다. 옮긴 위치에서는 기존 조상 선택자에 의존한 스타일이나 이벤트 위임이 달라질 수 있으므로 버튼의 표시와 클릭도 확인해야 한다.

body 바로 아래로 옮기면 언제나 해결된다는 뜻은 아니다. 새 위치에서도 body와 html을 포함한 조상 경로를 확인한다. 조상표는 원인 후보를 읽는 도구이며 포함 블록을 자동 판정하는 코드는 아니다. transform이 전부 none인데 증상이 남으면 perspective, filter, will-change, contain의 값과 적용 조건도 살펴본다. MDN의 fixed 설명 (새 탭에서 열림)은 transform 외의 조건과 will-change: transform도 설명한다. 따라서 transform을 지우는 대신 will-change: transform으로 바꾸는 것을 같은 해결책으로 취급하면 안 된다.

이 관찰은 sticky의 스크롤 영역이나 상자의 폭이 아니라 fixed 위치값의 기준에 관한 것이다. 직접 확인한 범위는 일반 블록 조상 하나와 같은 fixed 버튼을 둔 새 문서, 페이지 세로 스크롤, Chrome 한 종류다. Firefox·Safari, 실제 모바일 기기의 주소창 변화, 내부 스크롤 컨테이너, 다른 속성이나 중첩 transform 조합은 실행하지 않았다. 이번 관찰로 판단한 것은 조상의 transform을 제거하거나 그 조상 밖으로 버튼을 옮겼을 때 뷰포트 기준 좌표가 유지되는지까지다.

← 전체 글 보기