콘텐츠로 이동

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) // false
isBackTopVisible(0, 200) // false
supportsScrollEnd(window) // 최신 브라우저 true
supportsScrollEnd(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