backtop —— 맨 위로
긴 페이지를 끝까지 내린 뒤 사용자가 맨 위로 가려면 마우스 휠을 미친 듯이 굴려야 합니다. backtop은 오른쪽 아래의 떠 있는 버튼으로, 일정 거리를 스크롤해야 나타나고 누르면 부드럽게 맨 위로 돌아갑니다. 논리 자체는 복잡하지 않지만 스크롤 API의 호환성 함정이 유난히 많습니다 — 스크롤 뿌리가 누구인지, 부드러운 스크롤을 어떻게 강등하는지, 스크롤 끝을 어떻게 탐지하는지를 core/backtop이 모두 거둡니다.
언제 사용할까
섹션 제목: “언제 사용할까”- 각 단말이
<LhBackTop>을 구현할 때: 긴 목록, 긴 문서, 백엔드 데이터 표의 맨 위 이동 입구. - 페이지 높이가 한 화면을 명확히 넘을 때(보통 2~3화면 이상)에만 등장시킬 가치가 있습니다.
- 사용하면 안 되는 곳: 짧은 페이지(한 화면도 안 되면 버튼이 영원히 나오지 않으면서 코드만 차지); 애초에 휠/제스처로 빠르게 맨 위로 갈 수 있는 모바일 네이티브 페이지(모바일 시스템 제스처가 이미 커버함, h5 내장 페이지는 상황에 따라).
가져오기
섹션 제목: “가져오기”import { LH_BACKTOP_VISIBILITY_HEIGHT_DEFAULT, LH_BACKTOP_SCROLLEND_DELAY, getBackTopClassNames, resolveScrollTarget, getScrollTop, backToTop, isBackTopVisible, supportsScrollEnd,} from '@lh-ui/share/core/backtop'설계 의도
섹션 제목: “설계 의도”스크롤 뿌리의 우선순위: document.scrollingElement가 document.documentElement보다 우선합니다. 이것은 결벽이 아닙니다 — 일부 페이지(body { overflow }를 쓴 곳)에서 진짜로 스크롤하는 대상은 body이고, documentElement.scrollTop이 항상 0이라 가시성 판정이 완전히 실패합니다. scrollingElement가 이 혼란을 통일하려고 W3C가 만든 표준 속성입니다.
getScrollTop이 instanceof Window가 아니라 instanceof HTMLElement를 쓰는 이유: 주석은 ’realm 간 안전’이라고 적혀 있습니다 — 페이지에 iframe이 있으면 iframe이 넘기는 Window 객체는 현재 realm의 Window 생성자와 같지 않아 x instanceof Window가 잘못해 false가 됩니다. HTMLElement로 판정하면(요소에는 이 문제가 없음) 피해 갑니다. 아주 세부적이지만 실재하는 함정입니다.
부드러운 스크롤의 2단계 강등: ① prefers-reduced-motion이 켜져 있으면 auto(애니메이션 없음)로 강등 — 전정 기능 장애 사용자는 지속적 스크롤에 어지러움을 느끼며 이것은 접근성의 필수 요구입니다; ② 대상 요소에 scrollTo 메서드가 없으면(오래된 jsdom 같은 환경) scrollTop = 0을 직접 씁니다.
supportsScrollEnd의 특성 탐지: scrollend 이벤트는 Chromium 114+ / Safari 18.2+에만 있습니다 — 이것을 쓰면 스크롤이 진짜로 멈췄을 때 가시 상태를 정확히 새로고침할 수 있습니다(스크롤 도중 scrollTop을 자주 읽는 것에는 성능 비용이 있습니다). 지원하지 않을 때는 호출 쪽이 scroll 이벤트 + LH_BACKTOP_SCROLLEND_DELAY(150ms) 디바운스로 되돌아갑니다.
가시성은 순수 함수: isBackTopVisible(scrollTop, height)은 수치만 비교합니다 — UI 계층이 직접 스크롤을 감시하고 scrollTop을 저장하며, core은 어떠한 반응형 상태도 가지지 않습니다. 이 덕분에 단위 테스트가 수치 경계를 곧바로 단언할 수 있습니다.
함수 표
섹션 제목: “함수 표”| 함수 / 상수 | 시그니처 | 반환 | 역할 |
|---|---|---|---|
getBackTopClassNames |
(state: { visible }) => string[] |
클래스명 | 가시 상태가 표시/숨김 애니메이션 구동 |
resolveScrollTarget |
(target?: string | HTMLElement) => HTMLElement | Window |
스크롤 대상 | 선택자가 빗나가면 window로 폴백 |
getScrollTop |
(target) => number |
스크롤 거리 | 컨테이너/뷰포트 자동 적응 |
backToTop |
(target, smooth) => void |
없음 | 부드럽게 맨 위로(강등 포함) |
isBackTopVisible |
(scrollTop, visibilityHeight) => boolean |
가시 여부 | 임계값 판정 |
supportsScrollEnd |
(target) => boolean |
지원 여부 | 특성 탐지 |
상수: LH_BACKTOP_VISIBILITY_HEIGHT_DEFAULT = 200, LH_BACKTOP_SCROLLEND_DELAY = 150.
타입 정의
섹션 제목: “타입 정의”export interface LhBackTopState { visible: boolean // 현재 가시 여부(표시/숨김 클래스명 구동) target?: string | HTMLElement visibilityHeight?: number smooth?: boolean // 부드러운 스크롤 여부(기본 true)}접근성
섹션 제목: “접근성”// 버튼의 접근성 속성은 UI 계층에서 조합:const aria = { type: 'button', 'aria-label': '맨 위로', // 아이콘 버튼은 글자가 없으므로 라벨 필수}type="button": 맨 위 버튼이<form>안에 있으면 기본type="submit"이 폼 제출을 촉발합니다 — 가장 흔한 작은 버그입니다.- aria-label 필수: 순수 아이콘 버튼(위쪽 화살표)은 스크린 리더에게 이름이 없으며,
aria-label="맨 위로"가 있어야 읽어 줄 수 있습니다. - aria-hidden과 가시성 동기화: 보이지 않을 때 접근성 트리에서 제거(
aria-hidden="true"또는v-if로 언마운트)하여 스크린 리더가 보이지 않는 버튼으로 이동하지 않게 합니다. - 키보드 도달: 버튼은 네이티브
<button>그대로라 Tab 포커스 + Enter/Space로 촉발되어 자연스럽게 지원됩니다.<div @click>으로 만들지 마세요. - prefers-reduced-motion의 이중 의미: 접근성 요구(전정 안전)이면서 동시에 저사양 기기에서 부드러운 스크롤의 뚝뚝 끊김을 막습니다.
backToTop내부가 이미 처리하므로 UI 계층은 판단할 필요가 없습니다.
사용 예
섹션 제목: “사용 예”import { resolveScrollTarget, getScrollTop, isBackTopVisible, backToTop, supportsScrollEnd, LH_BACKTOP_VISIBILITY_HEIGHT_DEFAULT, LH_BACKTOP_SCROLLEND_DELAY,} from '@lh-ui/share/core/backtop'
isBackTopVisible(200, 200) // true(>= 임계값)isBackTopVisible(199, 200) // falseisBackTopVisible(0, 200) // false
supportsScrollEnd(window) // 최신 브라우저 truesupportsScrollEnd(someEl) // Chromium 114+ / Safari 18.2+만 true// 감시 전략: scrollend를 지원하면 멈췄을 때만 판정, 그렇지 않으면 scroll + 디바운스const target = resolveScrollTarget(props.target)let visible = ref(false)
function refresh() { visible.value = isBackTopVisible( getScrollTop(target), props.visibilityHeight ?? LH_BACKTOP_VISIBILITY_HEIGHT_DEFAULT, )}
if (supportsScrollEnd(target)) { target.addEventListener('scrollend', refresh)} else { let timer: ReturnType<typeof setTimeout> | undefined target.addEventListener('scroll', () => { clearTimeout(timer) timer = setTimeout(refresh, LH_BACKTOP_SCROLLEND_DELAY) })}
function onClick() { backToTop(target, props.smooth ?? true)}<!-- Vue3 --><template> <button type="button" class="lh-backtop" :class="getBackTopClassNames({ visible })" aria-label="맨 위로" :aria-hidden="!visible" @click="backToTop(target, true)" > <LhIcon name="arrow-up" /> </button></template>// React: useEffect로 스크롤 감시 등록/해제(target과 visibilityHeight에 의존);// smooth는 props가 제어, reduced-motion은 core 내부가 처리// uniapp: <button open-type?>는 사용 불가; <view> + @tap 사용;// 미니 프로그램 단말의 페이지 스크롤은 onPageScroll(uniapp 내장), 요소 스크롤은 scroll-view의 scroll 이벤트 사용스타일과 토큰
섹션 제목: “스타일과 토큰”| 클래스명 | 의미 |
|---|---|
.lh-backtop |
버튼 컨테이너(fixed 오른쪽 아래) |
.lh-backtop--visible |
가시(페이드인/위로 미끄러지는 등장 애니메이션 구동) |
기본 숨김은 opacity: 0; visibility: hidden(display 대신 visibility를 써서 전환 애니메이션을 보존); 둥근 버튼 --lh-backtop-size(예: 40px); 배경색 --lh-bg-color + 그림자 --lh-box-shadow-base; z-index --lh-z-index-backtop.
주의사항
섹션 제목: “주의사항”- 스크롤 감시는 해제해야: 컴포넌트가 파괴될 때
removeEventListener하지 않으면 제거된 DOM을 참조해서 메모리 누출이 생깁니다. Vue의onUnmounted/ React의 useEffect 정리 함수를 쓰세요. window감시의 특수성:scroll이벤트는 window에서 수동적이고 고빈도이므로 반드시 디바운스하거나 scrollend를 써야 합니다. 매번getScrollTop을 읽으면 매우 긴 페이지에서 끊김이 생깁니다.- 대상 요소가 직접 스크롤되는 장면: 표 내부가 스크롤되는(페이지 스크롤이 아님) 경우
target을 표 컨테이너로 지정해야 합니다 — 그렇지 않으면 버튼의 가시성이 페이지 스크롤을 따라가고 실제로 맨 위로 보낼 컨테이너와 무관해집니다. - 부드러운 스크롤이 CSS로 덮어씌워짐: 페이지에
html { scroll-behavior: smooth }가 있으면scrollTo({ behavior: 'auto' })가 CSS에 덮어씌워집니다 —backToTop의 reduced-motion 강등이 실패합니다. 엄격한 강등이 필요할 때 UI 계층이 해당 CSS를 잠시 제거해야 합니다. - iOS Safari의 스크롤 관성: iOS 스크롤에는 관성이 있어 scrollend는 관성이 끝나야 촉발됩니다; 디바운스 폴백의 150ms는 빠르게 쓸어 넘길 때 가시 상태를 너무 일찍 새로고침할 수 있습니다(버튼이 잠깐 반짝임). 이것은 알려진 절충입니다.
- 가시 임계값 200의 의미: 약 한 화면 높이의 절반(뷰포트 높이에 따라 다름). 문서류 페이지는 작게(100), 도구류 페이지는 크게(500) 조정하면 됩니다 —
visibilityHeight프로퍼티가 그것을 위한 것입니다. - 버튼이 내용을 가리지 않게: fixed 위치 + 오른쪽 아래이며 모바일에서는 하단 내비게이션 바를 피해야 합니다(안전 영역 높이 확보).
@lh-ui/share/core/backtop → isBackTopVisible( )(填好参数后点运行)Copyright © 2024-2026 FengMo WenXue