티스토리 뷰
Dialog 태그 위로 토스트 보이도록 하기 (feat.TopLayer, createPortal)
YG - 96년생 , 강아지 있음, 개발자 희망 2023. 11. 9. 14:23보투게더 팀 블로그에서 작성한 글을 가져왔습니다.
Dialog 태그 위로 토스트 보이도록 하기 (feat.TopLayer, createPortal)
html dialog 태그로 만든 Drawer에서 에러가 났을 때 토스트가 보이지 않는 상황이 있었습니다. 이유는 dialog는 최상위 계층 (Top layer)으로 열리기 때문인데요. topLayer는 페이지의 다른 모든 콘텐츠 레
velog.io
문제가 되는 상황
html dialog 태그로 만든 Drawer에서 에러가 났을 때 토스트가 보이지 않는 상황이 있었습니다.
이유는 dialog는 최상위 계층 (Top layer)으로 열리기 때문인데요. topLayer는 페이지의 다른 모든 콘텐츠 레이어 위의 존재하는 레이어입니다. 그래서 토스트가 TopLayer에 가려져 보이지 않던 것이었습니다.
https://developer.mozilla.org/ko/docs/Glossary/Top_layer
예시 상황(codeSandBox)
https://codesandbox.io/p/sandbox/shadcn-ui-toast-dialog-issue-vp9dl6?file=%2FREADME.md


해결 방법
z-index를 10000으로 줘보기도 하고 position:fixed, absolute 등 다양한 방법을 시도했지만 전혀 통하지 않았습니다. 어떻게 해야 하나 고민을 하고 저와 비슷한 문제를 겪는 사람을 찾으러 구글 검색을 했습니다.
그리고 비슷한 문제를 겪는 사람들을 찾아서 힌트를 얻었습니다.
[bug]: Toasts shows behind a dialog · Issue #75 · shadcn-ui/ui
When Toasts are used with a Dialog component, the toast shows behind the dialog. Here is a reproduction Codesanbox
github.com
제가 선택한 해결 방법은 createPortal을 이용해서 다이어로그 내부에서 생성되도록 하는 것입니다. 어찌 생각하면 단순한데 Portal을 한 번도 이용해 보지 않아서 생각하기 어려웠습니다.
원하는 동작
- 평소에는 일반적으로 토스트가 생성된다.
- Dialog를 열었을 때는 Dialog 내부에 토스트가 생성된다.
- Dialog를 닫았을 때는 다시 일반적으로 토스트가 생성된다.
구현 방법
- 전역으로 관리하는 ToastProvider에 createPortal 코드를 이용합니다.
- html에 div를 새로 생성해 주었습니다. (생략해도 무방함)
- Dialog 컴포넌트 내부에서 토스트가 생길 곳을 지정할 엘리먼트 아이디를 타입으로 생성합니다.
- Dialog 안에 엘리먼트 아이디를 받아서 지정한 div 태그를 생성합니다.
- Dialog를 열고 닫을 때 토스트 생성할 엘리먼트 아이디를 useContext를 사용하여 전역으로 변경합니다.
5-1. Dialog를 열을 때 내부에 있는 id로 토스트 생성할 id를 바꿔줍니다.
5-2. Dialog를 닫을 때 일반적으로 토스트가 생성되는 id로 바꿔줍니다.
ToastProvider.tsx
...
export default function ToastProvider({ children }: PropsWithChildren) {
const [toastList, setToastList] = useState<ToastInfo[]>([]);
const [toastElementId, setToastElementId] = useState<ToastContentId>('toast-content');
const toastContentEl = document.getElementById(toastElementId);
const setElementId = useCallback((id: ToastContentId) => {
setToastElementId(id);
}, []);
...
return (
<ToastContext.Provider value={{ addMessage, setElementId }}>
{toastContentEl && createPortal(<ToastContainer toastList={toastList} />, toastContentEl)} // << 이 부분에서 createPortal을 이용해 생성하는 위치를 변경합니다.
{children}
</ToastContext.Provider>
);
}
types/toast.ts
포탈에 사용할 엘리먼트 아이디에 대한 타입
export type ToastContentId =
| 'toast-content'
| 'drawer-category-toast-content'
| 'drawer-alarm-toast-content';
export type DrawerToastContentId = Exclude<ToastContentId, 'toast-content'>;
Drawer.tsx
interface DrawerProps extends PropsWithChildren {
handleDrawerClose: () => void;
width: string;
placement: 'left' | 'right';
toastContentId: DrawerToastContentId; // 생성할 아이디를 받습니다.
}
const ARIA_MESSAGE =
'사용자 정보 및 카테고리 정보가 있는 사이드바가 열렸습니다. 사이드바 닫기 버튼을 누르거나 ESC를 누르면 닫을 수 있습니다.';
export default forwardRef(function Drawer(
{ handleDrawerClose, width, placement, toastContentId, children }: DrawerProps,
ref: ForwardedRef<HTMLDialogElement>
) {
const handleCloseClick = (event: MouseEvent<HTMLDialogElement>) => {
const modalBoundary = event.currentTarget.getBoundingClientRect();
if (
modalBoundary.left > event.clientX ||
modalBoundary.right < event.clientX ||
modalBoundary.top > event.clientY ||
modalBoundary.bottom < event.clientY
) {
handleDrawerClose();
}
};
const handleKeyDown = (event: KeyboardEvent<HTMLDialogElement>) => {
if (event.currentTarget.open && event.key === 'Escape') {
event.preventDefault();
handleDrawerClose();
}
};
return (
<S.Dialog
tabIndex={1}
aria-label={ARIA_MESSAGE}
aria-modal={true}
ref={ref}
$placement={placement}
$width={width}
onKeyDown={handleKeyDown}
onClose={handleCloseClick}
onClick={handleCloseClick}
>
<S.ToastWrapper id={toastContentId} $placement={placement} /> // << 생성할 아이디를 가진 div를 선언해 줍니다.
<S.CloseButton onClick={handleDrawerClose}>사이드바 닫기버튼</S.CloseButton>
{children}
</S.Dialog>
);
});
useDrawer.tsx
import { useContext, useEffect, useRef } from 'react';
import { DrawerToastContentId } from '@type/toast';
import { ToastContext } from './context/toast';
export const useDrawer = (placement: 'left' | 'right', toastElementId: DrawerToastContentId) => {
const drawerRef = useRef<HTMLDialogElement>(null);
const { setElementId } = useContext(ToastContext);
const openDrawer = () => {
if (!drawerRef.current) return;
setElementId(toastElementId); // << 열을 때 토스트 생성 위치를 Dialog 내부로 바꿔줍니다.
drawerRef.current.showModal();
drawerRef.current.style.transform = 'translateX(0)';
};
const closeDrawer = () => {
if (!drawerRef.current) return;
drawerRef.current.style.transform =
placement === 'left' ? 'translateX(-100%)' : 'translateX(100%)';
setElementId('toast-content'); // << 닫을 때 평소에 사용되는 토스트 생성 위치로 변경합니다.
setTimeout(() => {
if (!drawerRef.current) return;
drawerRef.current.close();
}, 300);
};
useEffect(() => {
if (!drawerRef.current) return;
drawerRef.current.style.transform =
placement === 'left' ? 'translateX(-100%)' : 'translateX(100%)';
}, [placement]);
return { drawerRef, openDrawer, closeDrawer };
};
해결된 상황


참고자료
https://github.com/shadcn-ui/ui/issues/75 (힌트를 얻은 사이트)
https://react-ko.dev/reference/react-dom/createPortal (리엑트 공식문서)
https://developer.mozilla.org/ko/docs/Glossary/Top_layer
'자바스크립트' 카테고리의 다른 글
Gmail SMTP로 무료 이메일 인증 구현하기 (0) | 2025.03.16 |
---|---|
husky를 이용하여 push, commit 전 테스트 자동화 (1) | 2023.12.02 |
Fetch로 추상화한 유틸 함수를 Axios 패키지로 마이그레이션 해보기 (0) | 2023.11.06 |
사용자가 업로드 한 이미지 압축하여 서버로 보내기 (feat.Browser Image Compression, Upload Images Converter, webp) (0) | 2023.10.04 |
프로그레시브 웹 앱(PWA)이란, 인앱 설치를 묻는 화면 구현하기 (feat.beforeinstallprompt) (0) | 2023.08.29 |
- Total
- Today
- Yesterday
- 프리온보딩
- 스토리 북
- NextRequest
- env
- javascript
- jest
- C언어
- 위코드
- CLASS
- 북클럽
- NextApiRequest
- React
- 노마드코더
- 초보
- 윤성우 열혈C프로그래밍
- createPortal
- nextjs
- 프론트앤드
- TopLayer
- 노개북
- 아차산
- 우아한테크코스
- error
- Storybook
- WSL2
- electron
- 원티드
- import/order
- nodejs
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |