sticky 요소의 stuck 상태 별 동적 스타일 적용하기
ResizeObserver와 getBoundingClientRect()로 sticky 요소의 상태에 따라 스타일을 적용하는 방법.
근래에, 전에 개발했던 피쳐의 확장 및 개선을 위한 개발을 진행하며 기존에 개발해두었던 컴포넌트들도 더 멋있는 디자인을 입힐 필요가 생겼다. 특히 페이지 하단의 참여 완료를 위한 플로팅 위젯 형태의 컴포넌트는 디자이너님이 신경 써서 시안을 뽑아주신 만큼 멋드러졌고, 쉽지 않은 스타일 구현이 필요했다. 특히 플로팅 위젯은 sticky 형태로, 네비게이션처럼 사용자의 화면을 따라다니며 사용자가 미션을 수행하면서 추가로 스크롤을 하지 않아도 퀘스트에 바로 참여까지 완료할 수 있도록 해주는 인터랙티브한 형태가 필요했다.

다만, 여태 컴포넌트가 상단에 존재하고, 하단 스크롤에 따라오는 형태는 여럿 구현해본 경험이 있지만 반대로 컴포넌트가 하단에 존재해서 위에서 따라다니는 형태는 구현해본 경험이 없었다. 단순한 sticky로만 구현하는 것이 아닌 컴포넌트의 동적인 위치 변화를 정확하게 감지하고 이에 따라 스타일을 변경하는, 간만에 깊은 생각이 필요한 스타일링을 할 기회가 되었다.
우선, 회사에서는 Nuxt/Vue 프레임워크를 이용해 프론트엔드 애플리케이션의 개발을 진행하고 있다. 초기에는 이 플로팅 위젯의 ref를 이용해서 offsetTop의 값을 얻어서 기록하고, 이전의 값과 현재의 값을 비교해서 sticky 요소의 stuck 상태 별로 스타일을 적용하도록 로직을 구현했었다. 스크롤 이벤트에 부착함과 더불어 스로틀링도 적용하여 최대한 성능적인 부분도 놓치지 않게끔 노력했다.
const handleScroll = () => {
if (floatingBarRef.value) {
const { offsetTop } = floatingBarRef.value
isSticky.value = offsetTop === floatingBarOffsetTop.value
floatingBarOffsetTop.value = offsetTop
}
}
// 등록과 해제에 동일한 함수 참조 사용
const throttledHandleScroll = useThrottleFn(handleScroll, 30)
onMounted(() => {
window.addEventListener('scroll', throttledHandleScroll)
handleScroll() // 초기 상태 지정
})
onBeforeUnmount(() => {
window.removeEventListener('scroll', throttledHandleScroll)
})스크롤하며 확인했을 때는 의도한 대로 동작하는 것처럼 보였다. 하지만 스크롤 이벤트만으로는 미션 컴포넌트의 펼침·접힘이나 브라우저 리사이즈에 따른 변화를 반영할 수 없었다. 우선 콘텐츠 크기가 달라질 때도 다시 측정할 수 있는 방법이 필요했다.
그래서 고려한 것은 ResizeObserver API였다. 아마 IntersectionObserver API를 사용해본 경험이 있다면 익숙할 것이다. 이 ResizeObserver API는 브라우저의 크기 변화를 감지하는 resize 이벤트와는 조금 다른 부분이 있다. 바로 브라우저의 크기가 아닌, 특정 요소의 크기 변화를 감지할 수 있다는 것이다.
미션을 펼치고 접으면 페이지의 크기도 달라지니, 이때 위젯의 위치를 다시 계산하면 되겠다는 생각이 들었다. 바로 페이지를 감싸는 요소에 식별자를 추가하고 옵저버를 연결했다.
const throttledHandleScroll = useThrottleFn(handleScroll, 30)
onMounted(() => {
observer.value = new ResizeObserver(throttledHandleScroll)
const page = document.getElementById(PAGE_ID)
if (page) observer.value.observe(page)
window.addEventListener('scroll', throttledHandleScroll)
handleScroll()
})
onBeforeUnmount(() => {
window.removeEventListener('scroll', throttledHandleScroll)
observer.value?.disconnect()
})얼추 동작하는 느낌은 들었으나, 정확도가 문제였다. 접혀 있던 미션을 펼쳐서 사이즈가 늘어났는데도 sticky 여부를 검사하는 로직이 제대로 동작하지 않는 것 같았다. 또한, 브라우저를 가로로 리사이징하면서도 offsetTop을 계산하고 저장하는 로직이 영향을 받아 정상적으로 동작하지 않았다.
다시 생각해 보니, 크기가 바뀔 때도 계산하도록 연결했을 뿐 이전의 offsetTop과 비교하는 방식은 그대로였다. 이전보다 위치가 바뀌었는지를 보는 것보다, 지금 위젯이 화면의 어디에 있는지를 확인하는 편이 낫겠다는 생각이 들었다.
고민 속에서 getBoundingClientRect() API를 발견했다. 요소의 크기뿐 아니라 뷰포트를 기준으로 어디에 있는지도 알 수 있었다. 그렇다면 위젯이 화면 하단에 붙어 있을 때, top 값에 위젯의 높이와 하단에서 띄운 간격을 더하면 화면 높이와 같아지지 않을까?
이 값을 비교하면 이전 위치를 따로 저장하지 않고도 상태를 구분할 수 있을 것 같았다. 하단 간격은 1rem을 사용하고 있어서, getComputedStyle로 루트 요소의 폰트 크기를 읽어 계산에 더했다.
const getRootElementFontSize = () =>
parseFloat(getComputedStyle(document.documentElement).fontSize)
const handleScroll = () => {
const element = floatingBarRef.value
if (!element) return
const { height, top } = element.getBoundingClientRect()
isSticky.value =
height + top + getRootElementFontSize() !== window.innerHeight
}
const throttledHandleScroll = useThrottleFn(handleScroll, 30)
onMounted(() => {
observer.value = new ResizeObserver(throttledHandleScroll)
const page = document.getElementById(PAGE_ID)
if (page) observer.value.observe(page)
window.addEventListener('scroll', throttledHandleScroll)
handleScroll()
})
onBeforeUnmount(() => {
window.removeEventListener('scroll', throttledHandleScroll)
observer.value?.disconnect()
})이 로직에서는 플로팅 위젯의 위치를 뷰포트와 비교하며 얻어내어 계산하고 있으므로, 접힌 미션 컴포넌트의 펼침, 브라우저의 가로 리사이즈 및 CSS 브레이크포인트를 넘나드는 동작에도 예상한대로 동작하는 것을 확인할 수 있었다.
여기서 한 발 더 나아가서, 측정 요청을 처리하는 방식도 개선해볼 수 있다. 기존에는 스로틀링을 적용하여 30ms 간격으로 실행 빈도를 제한했지만, requestAnimationFrame을 이용하면 하드웨어의 화면 갱신 주기에 맞춰 측정을 예약할 수 있다. 스크롤 이벤트와 ResizeObserver에서 동일한 함수를 호출하도록 연결하고, 이미 예약된 측정이 있다면 추가 예약을 생략하는 방식이다. 이렇게 하면 사용자마다 다른 하드웨어에 따른 주사 빈도에 맞춰 더 자연스러운 애니메이션 인터랙션을 보여줄 수도 있다.
let frameId: number | null = null
const scheduleMeasure = () => {
if (frameId !== null) return
frameId = requestAnimationFrame(() => {
frameId = null
handleScroll()
})
}
onMounted(() => {
observer.value = new ResizeObserver(scheduleMeasure)
const page = document.getElementById(PAGE_ID)
if (page) observer.value.observe(page)
window.addEventListener('scroll', scheduleMeasure)
handleScroll()
})
onBeforeUnmount(() => {
window.removeEventListener('scroll', scheduleMeasure)
observer.value?.disconnect()
if (frameId !== null) cancelAnimationFrame(frameId)
})기존의 스로틀 함수 대신 scheduleMeasure를 연결하고, 언마운트할 때는 이벤트와 옵저버를 해제하면서 남아 있는 rAF 예약도 취소하면 된다. 다만 rAF는 보통 화면 주사율에 맞춰 호출되며, 30ms 스로틀보다 계산이 더 자주 실행될 수 있으므로, 호출 수 감소나 성능 향상이 보장되는 것은 아니다.
단순 스타일 구현과 API의 활용 수준에 가깝지만, 원하는 기획과 디자인을 구현해주었다는 뿌듯함을 얻은 건 기본이고, 스타일링에 대한 오만함을 내려두고, 목표와 현재의 상황을 기반으로 쌓아올린 지식 체계를 통해 가설을 세워 분석해나가는 사고가 정말 중요하다는 생각이 들었다. 앞으로도 항상 겸손하며 더욱 지식 체계를 넓혀, 기획자와 디자이너가 원하는 서비스의 모습이 어떤 것이라도 성능 친화적이고 깨끗한 코드로 구현해줄 수 있는 능력을 기르고자 한다.
References
원문: Medium