콘텐츠로 이동

affix 고정 —— 레이아웃

페이지가 스크롤될 때 “고정”하고 싶은 요소가 있습니다: 상단 내비게이션, 하단 작업 바, 사이드 목차입니다. affix는 요소가 뷰포트를 벗어나면 fixed 위치 지정으로 전환하고, 다시 돌아오면 원래대로 되돌립니다. 어려운 부분은 전환 자체가 아니라 판정 시점과 자리 채우기를 통한 무너짐 방지입니다——core은 판정과 스타일만 담당하고, 감시와 자리 채우기는 UI 계층의 책임입니다.

  • 각 단말에서 <LhAffix> 구현: 페이지 상단 내비게이션 상단 고정, 폼 하단의 작업 바 하단 고정, 긴 문서의 사이드 목차를 뷰포트에 고정.
  • 요소가 스크롤 도중에도 계속 보여야 하는 상황.
  • 사용하면 안 되는 경우: 요소가 원래부터 뷰포트 안에 있는 짧은 페이지(고정 불필요); 전체 화면 마스크가 필요한 상황(loading의 fullscreen 사용); 사이드바 전체를 고정하는 레이아웃(CSS position: sticky를 직접 쓰는 편이 더 간단합니다——affix의 가치는 “스크롤이 임계값에 도달했을 때만 고정”한다는 조건부 동작에 있습니다).
import {
getAffixClassNames,
isAffixFixed,
getAffixFixedStyle,
} from '@lh-ui/share/core/affix'
import type { LhAffixState } from '@lh-ui/share/core/affix'

top과 bottom은 상호 배타적인 두 가지 모드입니다: offsetTop > 0일 때는 “윗변이 뷰포트 상단 임계값을 넘었는지”를 판정하고, offsetBottom > 0일 때는 “아랫변이 뷰포트 하단 임계값을 넘었는지”를 판정합니다. 두 offset은 동시에 적용될 수 없습니다——isAffixFixed는 if (offsetBottom > 0)일 때 우선적으로 하단 모드로 진입합니다. 의미상 요소가 고정될 수 있는 가장자리는 한쪽뿐입니다.

하단 판정에는 반드시 뷰포트 높이가 필요합니다: rect.bottom > viewportHeight - offsetBottom——자리 채우기 요소의 아랫변이 “뷰포트 하단 - 임계값”을 넘어서야 비로소 고정으로 간주합니다. 이 임계값은 동적입니다(viewportHeight는 창 크기에 따라 변합니다). 그러므로 UI 계층은 매 콜백마다 현재 뷰포트 높이를 전달해야 하며, 캐시하면 안 됩니다.

판정 함수와 감시 메커니즘의 분리: isAffixFixed가 받는 것은 { top, bottom } 사각형 객체이며, 그 출처는 관여하지 않습니다——IntersectionObserver 콜백에서 entry의 boundingClientRect를 가져오거나, resize/scroll 이벤트 안에서 getBoundingClientRect()로 폴백하거나, 두 경로가 동일한 판정 로직을 공유합니다.

getAffixFixedStyle의 top: 'auto' / bottom: 'auto': 고정 시 다른 축을 명시적으로 auto로 설정해, 요소가 top과 bottom을 동시에 갖는 것을 막습니다(fixed 위치 지정에서 양쪽에 동시에 값이 있으면 요소 높이가 늘어납니다).

스타일 직접 수정이 아닌 fixed 클래스명: lh-affix--fixed가 fixed 위치 지정의 세부 사항(z-index, 그림자, 트랜지션 애니메이션)을 SCSS가 결정하고, core의 getAffixFixedStyle는 top/bottom의 구체적 픽셀값만 계산합니다——위치 지정 온오프와 오프셋량을 분리합니다.

함수 시그니처 반환 역할
getAffixClassNames (state: LhAffixState) => string[] 클래스명 fixed 상태
isAffixFixed (rect: { top, bottom }, offsetTop, offsetBottom, viewportHeight) => boolean 고정 여부 뷰포트 이탈 판정
getAffixFixedStyle (offsetTop, offsetBottom) => Record<string, string> 인라인 스타일 fixed 위치 오프셋
export interface LhAffixState {
fixed: boolean // 현재 고정 상태인지(클래스명을 구동)
}

affix는 순수하게 시각적인 위치 지정이며 콘텐츠의 의미를 바꾸지 않고, 전용 aria 속성도 없습니다. 관련된 두 가지 실천 방침:

  • 고정 상태가 되어도 DOM 순서를 바꾸지 마세요: 요소가 fixed가 되어도 원래 DOM 위치에 그대로 있으며, 스크린 리더는 소스 코드 순서대로 읽어 줍니다. 시각적인 이유로 요소를 body 말단으로 이동(teleport)하면 음성 순서가 튑니다——affix의 구현은 위치만 바꾸고 DOM은 움직이지 않아야 합니다.
  • 고정 요소의 가시성: 고정 상태의 요소는 항상 뷰포트 안에 있으며, aria-hidden이 스크롤에 따라 변해서는 안 됩니다(원래부터 계속 보이기 때문입니다).
  • 포커스 트랩 위험: 하단에 고정된 “저장/제출” 작업 바는 긴 폼에서 편리하지만, fixed 요소가 페이지 하단 콘텐츠를 가릴 수 있습니다(버튼이 마지막 줄을 항상 덮음)——UI 계층은 body나 콘텐츠 컨테이너에 padding-bottom을 주어 공간을 확보해야 합니다. 이는 레이아웃 문제이지 접근성 문제는 아니지만, 조작성에 영향을 줍니다.
import { isAffixFixed, getAffixFixedStyle } from '@lh-ui/share/core/affix'
// 상단 모드: 윗변이 임계값 80px을 넘음
isAffixFixed({ top: 50, bottom: 200 }, 80, 0, 900) // false(50 < 80이어야 고정, 50은 넘지 못함)
isAffixFixed({ top: 100, bottom: 300 }, 80, 0, 900) // true(100 > 80)
// 판정 방향 주의: top < offsetTop이 참 —— 요소의 윗변이 임계선 **보다 위**에(말려 올라감)
// 하단 모드: 아랫변이 "뷰포트 하단 - 임계값"을 넘음
isAffixFixed({ top: 700, bottom: 950 }, 0, 60, 900) // true(950 > 900-60=840)
isAffixFixed({ top: 500, bottom: 700 }, 0, 60, 900) // false(700 < 840, 아직 넘지 못함)
// 두 offset을 모두 전달: 하단이 우선
isAffixFixed({ top: 100, bottom: 950 }, 80, 60, 900) // true(하단 분기로 진입)
getAffixFixedStyle(80, 0) // { position: 'fixed', top: '80px', bottom: 'auto' }
getAffixFixedStyle(0, 60) // { position: 'fixed', bottom: '60px', top: 'auto' }
getAffixFixedStyle(0, 0) // { position: 'fixed', top: '0px', bottom: 'auto' }(기본 상단 고정)
// UI 계층: IntersectionObserver 주요 경로 + 자리 채우기로 무너짐 방지
const placeholder = document.createElement('div') // 고정 시 원래 자리를 채워 뒤따른 콘텐츠의 도약 방지
let observer: IntersectionObserver | undefined
function observe() {
observer = new IntersectionObserver(
(entries) => {
const rect = entries[0].boundingClientRect
const fixed = isAffixFixed(
{ top: rect.top, bottom: rect.bottom },
props.offsetTop ?? 0,
props.offsetBottom ?? 0,
window.innerHeight,
)
if (fixed !== state.fixed) {
state.fixed = fixed
if (fixed) targetEl.before(placeholder) // 자리 채우기
else placeholder.remove() // 자리 채우기 제거
}
},
{ threshold: [0, 1] },
)
observer.observe(targetEl)
}
<!-- Vue3 -->
<template>
<LhAffix :offset-top="64">
<nav class="page-nav">…</nav>
</LhAffix>
</template>
// React: useEffect로 IntersectionObserver를 등록. SSR 시에는 건너뛰고(IntersectionObserver 없음),
// 클라이언트 하이드레이션 이후에 마운트
// uniapp: 미니프로그램 환경에서 IntersectionObserver는 wx.createIntersectionObserver;
// h5 환경에서는 브라우저 네이티브 API 사용. core의 판정 함수는 양쪽이 공유
클래스명 의미
.lh-affix 래퍼 컨테이너(원래 자리)
.lh-affix--fixed 고정 상태(position: fixed + z-index + 옵션 그림자/트랜지션)

z-index --lh-z-index-affix; 고정 상태의 그림자 --lh-box-shadow-affix(옵션, 상단 고정 시 아래쪽에 얇은 그림자로 층위를 구분).

  • 자리 채우기 요소는 필수입니다: 요소가 fixed가 되면 문서 흐름을 벗어나고, 뒤따르는 콘텐츠가 전체적으로 위로 밀려 빈자리를 채웁니다——페이지가 격렬하게 도약합니다. UI 계층은 같은 크기의 자리 채우기 요소를 놓아야 합니다(위 예시). 자리 채우기의 높이는 요소가 고정되기 전 높이와 같아야 합니다.
  • 하단 모드의 뷰포트 높이는 실시간: isAffixFixed의 viewportHeight 매개변수는 호출할 때마다 현재값을 전달해야 하고, 창 resize 시에 재판정해야 합니다(affix 상태가 고정에서 비고정으로 바뀔 수 있습니다).
  • offsetTop / offsetBottom 중 하나만: 동시에 전달하면 하단이 우선되어 상단 임계값이 무효가 됩니다. API상 둘 다 전달은 가능하지만, 의미상으로는 하나만 설정해야 합니다.
  • 고정 상태의 너비 무너짐: 요소가 fixed가 되면 더 이상 부모 컨테이너의 너비 제약을 받지 않습니다(block 요소는 fixed가 되면 콘텐츠 너비로 줄어듭니다). SCSS 측에서는 보통 --fixed 안에서 원래 너비를 기록해 재사용하거나, UI 계층이 너비를 인라인 스타일에 적습니다.
  • 중첩 스크롤 컨테이너: 페이지 안에 window가 아닌 국소적으로 스크롤되는 컨테이너가 있다면, IntersectionObserver의 root를 그 컨테이너로 설정해야 합니다——isAffixFixed의 뷰포트 높이 매개변수도 컨테이너의 clientHeight로 바꿔야 합니다. core은 이를 구분하지 않고, 호출자가 일관성을 보장합니다.
  • IntersectionObserver의 호환성: 타깃 단말은 모두 지원합니다. 아주 오래된 환경을 지원하려면 scroll 이벤트 + getBoundingClientRect()로 폴백(판정 함수는 사각형 객체만 받으므로 직접 재사용 가능).
  • affix와 sticky의 선택: CSS position: sticky는 대부분의 “상단 고정” 수요를 커버하며 JS가 필요 없습니다. affix의 추가적인 가치는 임계값 제어(가장자리에 닿자마자 고정하는 것이 아니라 64px을 넘으면 고정)와 하단 모드(sticky는 스크롤 방향의 가까운 가장자리에만 붙습니다)에 있습니다. 단순한 상황에는 affix를 쓰지 마세요.
@lh-ui/share/core/affix → isAffixFixed( )
返回值
(填好参数后点运行)

Copyright © 2024-2026 FengMo WenXue