부모가 기준이 되는 순간을 막아라: position: fixed·sticky의 진짜 기준
fixed는 “뷰포트 기준”이 깨지는 순간이 있고, sticky는 “스크롤 컨테이너 기준”이 잡히는 순간이 있다. 둘 다 기준(컨테이닝 블록/스크롤 컨테이너)부터 확인하면 대부분의 삽질이 사라진다.
한 문장 결론: fixed는 “뷰포트 기준”이 깨지는 순간이 있고, sticky는 “스크롤 컨테이너 기준”이 잡히는 순간이 있다. 둘 다 기준(컨테이닝 블록/스크롤 컨테이너)부터 확인하면 대부분의 삽질이 사라진다.
UI에서 헤더/툴바/플로팅 버튼은 자주 fixed나 sticky로 구현한다.
그런데 특정 페이지에서만 “갑자기 부모 기준으로 붙는다”, “스크롤해도 안 따라온다”, “z-index가 먹지 않는다” 같은 문제가 튀어나온다.
포인트는 단순하다. “어디를 기준으로 위치를 계산하느냐”가 바뀌는 순간이 있기 때문이다.
배경/문제
흔히 만나는 증상
position: fixed인데 뷰포트가 아니라 부모 박스 기준으로 움직임position: sticky인데 스크롤해도 고정되지 않음z-index를 올려도 레이어가 기대대로 안 올라감
이런 증상은 대부분 컨테이닝 블록(containing block: 위치 계산 기준이 되는 박스) 또는 스크롤 컨테이너(scroll container: 스크롤이 실제로 발생하는 박스)를 잘못 잡아서 생긴다.
핵심 개념
1) fixed의 기준: “대부분 뷰포트, 가끔 조상”
position: fixed는 기본적으로 뷰포트(viewport: 브라우저에서 실제로 보이는 화면 영역)를 기준으로 top/right/bottom/left를 계산한다.
다만 특정 CSS 속성이 조상(ancestor)에 걸리면, 그 조상이 fixed의 컨테이닝 블록이 될 수 있다. 이때부터 fixed가 “부모 기준처럼” 보인다.
아래 다이어그램을 보면, 기준이 어떻게 갈리는지 한 번에 정리된다.
→ 기대 결과/무엇이 달라졌는지: fixed가 “왜 부모 기준처럼 보이는지”를 기준 변경 관점에서 바로 진단할 수 있다.
대표적으로 기준을 바꾸는 조상 조건(환경에 따라 달라질 수 있음):
- transform / perspective가 none이 아님
- will-change가 transform 또는 perspective를 예고함
- filter가 적용되는 경우(브라우저별로 차이가 있을 수 있음)
- contain: paint 같은 containment 설정
참고(공식/권장 문서): MDN - position, MDN - containing block, MDN - transform, MDN - will-change, MDN - contain, MDN - filter
2) sticky의 기준: “스크롤이 누구의 책임이냐”
position: sticky는 규칙이 단순한 편이지만, 전제가 많다.
핵심은 딱 하나: sticky는 스크롤과 함께 작동한다는 점이다.
- 스크롤이 문서(뷰포트)에서 일어나면 → sticky도 뷰포트 스크롤 기준으로 동작
- 스크롤이 특정 컨테이너(overflow로 스크롤 생성)에서 일어나면 → sticky도 그 컨테이너 기준으로 동작
→ 기대 결과/무엇이 달라졌는지: sticky가 “따라오다 멈추는 이유”가 컨테이너 경계 때문임을 구조로 이해할 수 있다.
참고(공식/권장 문서): MDN - position: sticky
해결 접근
접근 1) fixed가 부모 기준처럼 보일 때의 해결
- 조상에 기준을 바꾸는 속성(transform/filter/contain/will-change)이 있는지 확인한다.
- 왜: 조상이 컨테이닝 블록이 되면
fixed가 “그 안에서만” 고정된 것처럼 보인다. - 기대 결과: “부모 기준으로 붙는 현상” 원인을 즉시 특정.
- 왜: 조상이 컨테이닝 블록이 되면
- 레이아웃 전체 래퍼(wrapper)에
transform: translateZ(0)같은 최적화 트릭을 걸었다면,fixed가 필요한 레이어만 분리한다.- 대안 A:
fixed요소를 래퍼 밖(문서 루트)에 배치 - 대안 B: Portal(포탈)로
document.body아래로 렌더링
- 대안 A:
접근 2) sticky가 안 붙을 때의 해결
- 스크롤이 어디에서 일어나는지(문서 vs 특정 컨테이너) 먼저 확정한다.
- 왜: sticky는 “스크롤 컨테이너”에 종속된다.
- 기대 결과: “왜 페이지에서는 되는데 모달/패널에서는 안 되는지”가 정리된다.
sticky에는 보통top(또는bottom)이 필요하다.- 왜: 임계점이 없으면 “언제부터 붙을지”를 결정할 수 없다.
- 기대 결과: sticky 전환 타이밍이 예측 가능해진다.
비교: fixed vs sticky vs absolute
fixed: 뷰포트(또는 특정 조상)를 기준으로 항상 고정sticky: 스크롤 컨테이너에서 임계점 이후에만 고정, 컨테이너 경계에서 멈춤absolute: 컨테이닝 블록 기준으로 그냥 배치(스크롤 따라 자연 이동)
추가 대안(비교용): sticky가 브라우저/레이아웃 조건 때문에 까다로우면, Intersection Observer로 “붙는 순간”을 감지해 클래스 토글하는 방식도 선택지다.
참고: MDN - Intersection Observer
구현(코드)
예제 1) sticky 헤더 (Next.js에서 재현 가능한 형태)
app/sticky-demo/page.tsx
export default function StickyDemoPage() {
return (
<main style={{ padding: 24 }}>
<section style={{ maxWidth: 720, margin: "0 auto" }}>
<header style={{ position: "sticky", top: 0, zIndex: 10, background: "white", borderBottom: "1px solid #eee" }}>
<div style={{ padding: "12px 0" }}>
<strong>Sticky Header</strong>
<div style={{ fontSize: 12, opacity: 0.7 }}>스크롤 임계점 이후 top=0에 고정</div>
</div>
</header>
<article style={{ paddingTop: 16 }}>
{Array.from({ length: 60 }).map((_, i) => (
<p key={i} style={{ lineHeight: 1.8 }}>
콘텐츠 라인 {i + 1}
</p>
))}
</article>
</section>
</main>
);
}→ 기대 결과/무엇이 달라졌는지: 스크롤을 내리면 헤더가 상단에 붙고, 콘텐츠는 아래로 계속 흐른다.
포인트
- top: 0이 sticky 전환 임계점이다.
- background를 주지 않으면 아래 콘텐츠가 비쳐 가독성이 떨어질 수 있다.
- zIndex는 “어떤 쌓임 맥락 안에 있느냐”에 따라 체감이 달라질 수 있다. 참고: MDN - stacking context
예제 2) “부모가 기준이 되는 fixed”를 Portal로 분리
아래 패턴은 레이아웃 래퍼에 transform 등이 걸린 상태에서도, fixed UI(예: 토스트/전역 퀵메뉴)를 문서 루트로 빼는 방식이다.
app/components/BodyPortal.tsx
"use client";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
type Props = {
children: React.ReactNode;
};
export function BodyPortal({ children }: Props) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
return createPortal(children, document.body);
}→ 기대 결과/무엇이 달라졌는지: document 접근이 필요한 Portal이 클라이언트에서만 안전하게 동작한다(서버 렌더링 단계에서 터지지 않음).
참고: Next.js Docs, React Docs - Portals
app/fixed-demo/page.tsx
import { BodyPortal } from "../components/BodyPortal";
export default function FixedDemoPage() {
return (
<main>
{/* 일부러 "문제 상황"을 만들기 위해 래퍼에 transform을 적용 */}
<div style={{ transform: "translateZ(0)", minHeight: "200vh", padding: 24 }}>
<h1>Fixed Demo</h1>
<p>아래 버튼은 Portal로 body에 렌더링되어 기준이 흔들리지 않는다.</p>
</div>
<BodyPortal>
<button
style={{
position: "fixed",
right: 16,
bottom: 16,
padding: "12px 14px",
borderRadius: 12,
border: "1px solid #ddd",
background: "white",
}}
>
Floating Action
</button>
</BodyPortal>
</main>
);
}→ 기대 결과/무엇이 달라졌는지: 래퍼에 transform이 있어도 버튼은 뷰포트 기준으로 우하단에 고정된다.
검증 방법(체크리스트)
fixed가 부모 기준처럼 보이면: 조상에 transform/perspective/filter/contain/will-change가 있는지 확인sticky가 안 붙으면: 스크롤이 문서에서 나는지, 특정 컨테이너(overflow)에서 나는지 확인sticky에 top(또는 bottom)이 있는지 확인z-index가 기대대로 안 먹으면: 부모가 쌓임 맥락을 새로 만들었는지 확인scroll-margin-top 같은 보정 고려
참고: MDN - scroll-margin흔한 실수/FAQ
Q1. position: sticky가 “그냥 아무 일도 안 해요”
top이 없으면 sticky 전환 임계점이 없어서 “붙는 순간”이 생기지 않는다.- 스크롤이 문서가 아니라 컨테이너에서 일어나는데, sticky 요소가 그 컨테이너 규칙을 못 타고 있을 수도 있다. 참고: MDN - position: sticky
Q2. fixed인데 특정 페이지에서만 부모 기준으로 붙어요
- 그 페이지의 조상에
transform같은 속성이 들어가면서 컨테이닝 블록이 바뀌었을 가능성이 크다. - 전역 플로팅 UI라면 Portal로
body아래에 분리하는 방식이 안전하다. 참고: React Docs - Portals
Q3. z-index를 올려도 위로 안 올라가요
- 숫자만 올린다고 해결되지 않는다. 쌓임 맥락이 갈라졌을 수 있다. 참고: MDN - stacking context
요약(3~5줄)
fixed는 기본적으로 뷰포트 기준이지만, 조상 CSS에 의해 기준(컨테이닝 블록)이 바뀔 수 있다.sticky는 스크롤이 어디서 발생하는지(스크롤 컨테이너)가 전부다.- 문제가 생기면 “기준”부터 확인하고, 전역 플로팅 UI는 Portal로 분리하면 안정적이다.
z-index는 숫자보다 쌓임 맥락을 먼저 본다.
결론
position 문제는 “속성 자체가 어려워서”가 아니라, 기준이 바뀌는 순간을 놓쳐서 터진다.
fixed는 컨테이닝 블록, sticky는 스크롤 컨테이너. 이 두 축으로 보면 대부분의 케이스가 정리된다.