
세로 스크롤이 필요한 사이드 네비게이션 내부에서
아이콘 클릭 시 팝업(알림 패널, 프로필 오버레이 등)을 띄워야 하는 경우가 있다.
이때 팝업을 position: absolute로 배치하면,
특정 환경에서 팝업이 잘리거나 보이지 않는 문제가 발생할 수 있다.
본 글은 스크롤 UX는 유지하면서도,
팝업이 항상 아이콘 옆에 안정적으로 고정되도록 만드는 해결 과정을
구조와 좌표 계산 관점에서 코드 중심으로 정리한 기록이다.
함수명, 변수명, 컴포넌트명은 모두 예시용 가명으로 작성되어 있다.
초기 구현을 다음과 같은 구조로 시작하였다.
구현 자체는 자연스럽지만, 사이드바에 스크롤이 생긴 이후부터 특정 위치에서 팝업이 잘리거나 보이지 않는 문제가 발생한다.
import React, {useState} from 'react';
import styled from 'styled-components';
export function SideNavWithInlinePopovers() {
return (
<SideNavScrollContainer>
<NotificationIconWithPopover />
</SideNavScrollContainer>
);
}
function NotificationIconWithPopover() {
const [isOpen, setIsOpen] = useState(false);
return (
<IconAnchor>
<IconButton onClick={() => setIsOpen(v => !v)}>🔔</IconButton>
{isOpen && <NotificationPopoverPanel>알림 패널</NotificationPopoverPanel>}
</IconAnchor>
);
}
const SideNavScrollContainer = styled.aside`
height: 100dvh;
overflow-y: scroll;
`;
const IconAnchor = styled.div`
position: relative;
`;
const IconButton = styled.button``;
const NotificationPopoverPanel = styled.div`
position: absolute;
left: 60px;
bottom: 0px;
width: 360px;
height: 500px;
background: #fff;
border-radius: 12px;
`;
원인은 팝업이 overflow-y: scroll이 적용된 요소의 DOM 계층 내부에 존재한다는 점이었다.
스크롤 컨테이너는 자식 요소가 부모 영역을 벗어날 경우 클리핑을 발생시킬 수 있다. 따라서 팝업이 부모 박스 바깥으로 튀어나오면 z-index와 무관하게 잘릴 수 있다.
이 때문에 다음 방식들은 근본적인 해결책이 되지 않는다.
문제의 본질은 레이어 우선순위가 아니라 DOM 구조다.
해결 목표는 다음과 같다.
이를 위해 컨테이너를 두 겹으로 분리한다.
Outer
InnerScroll
[좌표 재계산 필요성]
구조를 분리하면 팝업은 더 이상 아이콘의 부모(relative)를 기준으로 배치할 수 없게 된다.
따라서 다음 절차로 좌표를 계산한다.
import React, {useCallback, useEffect, useRef, useState} from 'react';
import styled from 'styled-components';
/** SideNav 바깥(클리핑 없음) 기준 absolute 좌표 */
type PopoverPosition = {left: number; top: number};
export function SideNavLayout() {
const sideNavOuterRef = useRef<HTMLDivElement>(null);
const sideNavScrollRef = useRef<HTMLDivElement>(null);
const notificationIconRef = useRef<HTMLDivElement>(null);
const profileIconRef = useRef<HTMLDivElement>(null);
const [isNotificationOpen, setIsNotificationOpen] = useState(false);
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
const [notificationPos, setNotificationPos] = useState<PopoverPosition | null>(null);
const [profilePos, setProfilePos] = useState<PopoverPosition | null>(null);
const refreshPopoverPositions = useCallback(() => {
if (!sideNavOuterRef.current) return;
if (isNotificationOpen) {
setNotificationPos(
computePopoverPosition({
outerEl: sideNavOuterRef.current,
anchorEl: notificationIconRef.current,
panelHeight: 500,
offsetLeft: 60,
}),
);
}
if (isProfileMenuOpen) {
setProfilePos(
computePopoverPosition({
outerEl: sideNavOuterRef.current,
anchorEl: profileIconRef.current,
panelHeight: 645,
offsetLeft: 60,
}),
);
}
}, [isNotificationOpen, isProfileMenuOpen]);
useEffect(() => {
refreshPopoverPositions();
}, [isNotificationOpen, isProfileMenuOpen, refreshPopoverPositions]);
useEffect(() => {
const scroller = sideNavScrollRef.current;
if (!scroller) return;
const onScroll = () => refreshPopoverPositions();
scroller.addEventListener('scroll', onScroll, {passive: true});
window.addEventListener('resize', refreshPopoverPositions);
return () => {
scroller.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', refreshPopoverPositions);
};
}, [refreshPopoverPositions]);
return (
<SideNavOuter ref={sideNavOuterRef}>
<SideNavInnerScroll ref={sideNavScrollRef}>
<SideNavTopSection>...</SideNavTopSection>
<SideNavBottomSection>
<IconAnchor ref={notificationIconRef}>
<SideNavIconButton
onClick={() => {
setIsProfileMenuOpen(false);
setIsNotificationOpen(v => !v);
}}
>
🔔
</SideNavIconButton>
</IconAnchor>
<IconAnchor ref={profileIconRef}>
<SideNavIconButton
onClick={() => {
setIsNotificationOpen(false);
setIsProfileMenuOpen(v => !v);
}}
>
🙂
</SideNavIconButton>
</IconAnchor>
</SideNavBottomSection>
</SideNavInnerScroll>
<NotificationPopover
isOpen={isNotificationOpen}
position={notificationPos}
onClose={() => setIsNotificationOpen(false)}
/>
<ProfileMenuPopover
isOpen={isProfileMenuOpen}
position={profilePos}
onClose={() => setIsProfileMenuOpen(false)}
/>
</SideNavOuter>
);
}
function computePopoverPosition({
outerEl,
anchorEl,
panelHeight,
offsetLeft,
}: {
outerEl: HTMLElement;
anchorEl: HTMLElement | null;
panelHeight: number;
offsetLeft: number;
}): PopoverPosition | null {
if (!anchorEl) return null;
const anchorRect = anchorEl.getBoundingClientRect();
const outerRect = outerEl.getBoundingClientRect();
const left = anchorRect.left - outerRect.left + offsetLeft;
const top = anchorRect.bottom - outerRect.top - panelHeight;
return {left, top};
}
이 문제는 CSS의 문제가 아니라 레이아웃 구조 문제다. 스크롤 컨테이너와 팝업 기준을 분리하고 ref 기반 좌표를 스크롤/리사이즈 시점마다 재계산하면 팝업은 항상 아이콘 옆에 고정된다.
이 패턴은 사이드 네비게이션뿐 아니라 스크롤 영역 내부에서 팝업·툴팁·오버레이를 다뤄야 하는 다양한 UI에 재사용할 수 있을 것 같다.