Sonner
웹 앱에서 사용자에게 짧은 피드백을 줄 때 가장 흔히 쓰는 UI가 토스트 알림이다. "저장되었습니다", "오류가 발생했습니다" 같은 메시지를 화면 구석에 잠깐 띄웠다가 사라지게 하는 것이다.
그런데 토스트를 직접 구현하려면 생각보다 신경 쓸 게 많다. 여러 개의 토스트가 동시에 떠 있을 때 스택 관리, 자동 닫힘 타이머, 스와이프로 닫기, 접근성(aria-live), 애니메이션 등을 모두 처리해야 한다. 기존에 많이 쓰던 react-toastify 같은 라이브러리는 이런 기능을 잘 제공하지만, 번들 크기가 크고 기본 스타일이 과하게 들어가서 커스터마이징이 번거롭다.
Sonner는 Emil Kowalski가 만든 React 토스트 컴포넌트로, react-hot-toast에서 영감을 받은 간결한 API에 세련된 기본 스타일과 부드러운 애니메이션을 제공한다. "opinionated"를 표방하는데, 위치·애니메이션·스택 동작 등에 대해 합리적인 기본값을 미리 정해놓고, 필요한 부분만 오버라이드하는 방식이다.
기본 구조
Sonner는 <Toaster />와 toast() 두 가지로 구성된다. <Toaster />는 토스트가 렌더링될 컨테이너이고, toast()는 어디서든 호출할 수 있는 명령형 함수다.
import { Toaster, toast } from 'sonner';
function App() {
return (
<div>
<Toaster />
<button onClick={() => toast('저장되었습니다')}>
저장
</button>
</div>
);
}
이 구조의 핵심은 선언적 컨테이너 + 명령형 호출의 분리다. <Toaster />는 앱 루트(예: layout.tsx)에 한 번만 넣으면 되고, toast()는 컴포넌트 트리 어디서든 import해서 호출할 수 있다. Context나 Provider로 감쌀 필요가 없다.
내부적으로 Sonner는 모듈 레벨 상태를 사용한다. toast()를 호출하면 내부 상태 배열에 토스트가 추가되고, <Toaster />가 이를 구독해서 렌더링한다. React Context가 아니라 외부 스토어 패턴(Zustand의 useSyncExternalStore와 유사한 접근)을 쓰기 때문에 Provider 없이도 동작하는 것이다.
토스트 타입
기본 문자열 외에 미리 정의된 타입들이 있다. 각 타입마다 아이콘과 스타일이 다르게 적용된다.
// 기본 (아이콘 없음)
toast('이벤트가 생성되었습니다');
// 성공 (체크 아이콘)
toast.success('저장되었습니다');
// 에러 (X 아이콘)
toast.error('오류가 발생했습니다');
// 로딩 (스피너)
toast.loading('처리 중...');
// 정보
toast.info('새 업데이트가 있습니다');
// 경고
toast.warning('저장 공간이 부족합니다');
richColors 옵션을 <Toaster />에 설정하면 각 타입별로 배경색이 달라진다. success는 초록, error는 빨강 등으로 시각적 구분이 명확해진다.
<Toaster richColors />
Promise 토스트
비동기 작업의 상태를 자동으로 추적하는 toast.promise()가 특히 유용하다. loading → success/error 상태 전환을 수동으로 관리할 필요가 없다.
const saveData = async () => {
const response = await fetch('/api/save', { method: 'POST' });
if (!response.ok) throw new Error('저장 실패');
return response.json();
};
toast.promise(saveData(), {
loading: '저장 중...',
success: (data) => `${data.name}이(가) 저장되었습니다`,
error: (err) => `오류: ${err.message}`,
});
Promise가 resolve되면 loading 토스트가 success로 자연스럽게 전환되고, reject되면 error로 전환된다. success와 error에 함수를 넘기면 결과값이나 에러 객체를 받아서 동적 메시지를 만들 수 있다.
기존에는 이런 패턴을 구현하려면 토스트 ID를 저장해두고, Promise 결과에 따라 수동으로 업데이트해야 했다.
// toast.promise 없이 수동으로 하면 이렇게 됨
const id = toast.loading('저장 중...');
try {
await saveData();
toast.success('저장 완료', { id }); // 기존 토스트를 교체
} catch {
toast.error('실패', { id });
}
toast.promise()는 이 보일러플레이트를 없앤다.
Action과 Cancel 버튼
토스트에 버튼을 추가해서 사용자가 즉시 행동을 취할 수 있게 할 수 있다. "실행 취소" 패턴에 유용하다.
toast('일정이 삭제되었습니다', {
action: {
label: '실행 취소',
onClick: () => restoreEvent(),
},
});
cancel 옵션은 보조 버튼을 렌더링한다. action은 주요 동작(primary 스타일), cancel은 취소 동작(secondary 스타일)이다.
toast('변경사항을 적용하시겠습니까?', {
action: {
label: '적용',
onClick: () => applyChanges(),
},
cancel: {
label: '취소',
onClick: () => console.log('취소됨'),
},
});
버튼 클릭 시 기본적으로 토스트가 닫히는데, onClick 콜백 안에서 event.preventDefault()를 호출하면 닫히지 않게 할 수 있다.
JSX를 직접 넘길 수도 있다.
toast('알림', {
action: <Button onClick={() => handleAction()}>확인</Button>,
});
토스트 제어
ID로 업데이트하기
toast()가 반환하는 ID를 사용해서 기존 토스트를 업데이트하거나 닫을 수 있다.
const toastId = toast.loading('업로드 중...');
// 나중에 업데이트
toast.success('업로드 완료!', { id: toastId });
// 또는 닫기
toast.dismiss(toastId);
같은 ID를 전달하면 새 토스트를 만드는 게 아니라 기존 토스트의 내용을 교체한다. 파일 업로드 진행률 표시 같은 곳에서 유용하다.
전체 닫기
toast.dismiss(); // 인자 없이 호출하면 모든 토스트를 닫음
닫힘 이벤트
toast('메시지', {
onDismiss: (t) => console.log(`토스트 ${t.id} 수동 닫힘`),
onAutoClose: (t) => console.log(`토스트 ${t.id} 자동 닫힘`),
});
onDismiss는 사용자가 스와이프하거나 닫기 버튼을 눌러서 닫았을 때, onAutoClose는 duration이 끝나서 자동으로 닫혔을 때 호출된다. 분석 로깅이나 다음 동작 트리거에 활용할 수 있다.
Toaster 설정
<Toaster />에서 전역 설정을 조정한다.
위치
<Toaster position="top-center" />
// 가능한 값: top-left, top-center, top-right,
// bottom-left, bottom-center, bottom-right
위치에 따라 스와이프 방향도 자동으로 바뀐다. top 위치면 위로 스와이프, bottom 위치면 아래로 스와이프해서 닫을 수 있다.
스택 확장
기본적으로 토스트가 여러 개 쌓이면 살짝 겹쳐서 보인다(최대 3개). 호버하면 확장되어 전부 보인다. 처음부터 확장된 상태로 보여주고 싶다면:
<Toaster expand visibleToasts={5} />
visibleToasts로 보이는 토스트 수를 제어한다. 기본값은 3이다.
테마
<Toaster theme="dark" />
// 가능한 값: light, dark, system
next-themes와 연동하면 앱 테마에 맞춰 자동 전환된다.
'use client';
import { Toaster as SonnerToaster, type ToasterProps } from 'sonner';
import { useTheme } from 'next-themes';
export function Toaster() {
const { resolvedTheme } = useTheme();
return <SonnerToaster theme={resolvedTheme as ToasterProps['theme']} />;
}
여러 Toaster 인스턴스
서로 다른 위치에 토스트를 렌더링해야 하는 경우 ID로 구분한다.
<Toaster id="global" position="top-right" />
<Toaster id="editor" position="bottom-left" />
// 특정 Toaster를 타겟
toast('전역 알림', { toasterId: 'global' });
toast('에디터 알림', { toasterId: 'editor' });
오프셋
// 모든 방향 동일
<Toaster offset={16} />
// 방향별 개별 설정
<Toaster offset={{ bottom: '24px', right: '16px', left: '16px' }} />
// 모바일 별도 설정 (화면 너비 600px 미만)
<Toaster mobileOffset={{ bottom: '16px' }} />
닫기 버튼
<Toaster closeButton />
모든 토스트에 X 버튼이 추가된다. 개별 토스트에서도 설정 가능하다.
커스텀 토스트
JSX 토스트
문자열 대신 JSX를 넘기면 Sonner의 기본 스타일 안에서 커스텀 내용을 렌더링한다.
toast(
<div>
<h3>새 메시지</h3>
<p>김철수님이 메시지를 보냈습니다.</p>
</div>
);
Headless 토스트
Sonner의 스타일을 완전히 벗어나서 직접 UI를 그리고 싶다면 toast.custom()을 사용한다.
toast.custom((t) => (
<div className="my-custom-toast">
<span>커스텀 알림입니다</span>
<button onClick={() => toast.dismiss(t)}>닫기</button>
</div>
));
콜백이 토스트 ID를 인자로 받기 때문에 toast.dismiss(t)로 프로그래밍적으로 닫을 수 있다. 기본 애니메이션과 스택 관리는 그대로 동작하되, 렌더링만 직접 제어하는 것이다.
duration 제어
토스트가 자동으로 닫히기까지의 시간을 밀리초 단위로 설정한다.
// 개별 토스트
toast('알림', { duration: 10000 }); // 10초
// 전역 기본값
<Toaster toastOptions={{ duration: 5000 }} />
// 무한 (자동으로 닫히지 않음)
toast('중요한 알림', { duration: Infinity });
기본값은 4000ms(4초)다. Infinity를 설정하면 사용자가 직접 닫기 전까지 사라지지 않는다.
다른 토스트 라이브러리와 비교
react-toastify
가장 오래되고 인기 있는 토스트 라이브러리다. 기능이 매우 많고 옵션이 세밀하지만, 번들 크기가 크다(~30KB gzipped, CSS 포함). 기본 스타일이 강하게 적용되어 있어서 디자인 시스템에 맞추려면 CSS 오버라이드를 많이 해야 한다. 2013년부터 시작된 라이브러리라서 레거시적인 API 설계가 남아있기도 하다.
react-hot-toast
Sonner의 API 설계에 직접적인 영감을 준 라이브러리다. toast() 함수 기반의 간결한 API, toast.promise() 패턴 등이 유사하다. 다만 react-hot-toast는 스타일링이 최소화되어 있어서 직접 디자인을 많이 해야 하고, 스택 애니메이션이 Sonner만큼 세련되지는 않다.
Sonner의 차별점
Sonner는 이 두 라이브러리의 장점을 취합한 느낌이다. react-hot-toast의 간결한 API를 기반으로 하되, 기본 스타일과 애니메이션의 완성도가 높다. 별도 CSS를 import할 필요 없이 바로 쓸 수 있고, 스택 겹침 효과와 호버 시 확장 등 세부 인터랙션이 잘 다듬어져 있다. shadcn/ui에서 기본 토스트 컴포넌트로 채택하면서 사실상 React 생태계의 표준이 되어가고 있다.
| 항목 | react-toastify | react-hot-toast | sonner |
|---|---|---|---|
| 번들 크기 | ~30KB | ~5KB | ~7KB |
| API 스타일 | 옵션 중심 | 함수 중심 | 함수 중심 |
| 기본 스타일 | 과함 | 최소 | 적절 |
| 스택 애니메이션 | 보통 | 보통 | 우수 |
| Provider 필요 | 아니오 | 아니오 | 아니오 |
| shadcn/ui 채택 | 아니오 | 아니오 | 예 |
shadcn/ui에서의 사용
shadcn/ui는 Sonner를 공식 토스트 컴포넌트로 사용한다. npx shadcn-ui@latest add sonner 명령으로 추가하면 테마 연동과 기본 스타일이 적용된 래퍼 컴포넌트가 생성된다.
// components/ui/sonner.tsx (shadcn/ui가 생성하는 파일)
'use client';
import { useTheme } from 'next-themes';
import { Toaster as Sonner } from 'sonner';
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme();
return (
<Sonner
theme={theme as ToasterProps['theme']}
className="toaster group"
toastOptions={{
classNames: {
toast:
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: 'group-[.toast]:text-muted-foreground',
actionButton:
'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton:
'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
},
}}
{...props}
/>
);
};
export { Toaster };
toastOptions.classNames를 통해 토스트의 각 부분에 Tailwind 클래스를 적용한다. group CSS 셀렉터를 활용해서 토스트 컨테이너의 상태에 따라 내부 요소의 스타일을 제어하는 패턴이 특징적이다.
접근성
Sonner는 기본적으로 aria-live 영역을 사용해서 스크린 리더에 토스트 내용을 전달한다. containerAriaLabel 옵션으로 라벨을 커스터마이즈할 수 있다.
<Toaster containerAriaLabel="알림" />
키보드 사용자를 위해 Alt + T (Mac에서는 ⌥ + T) 핫키로 토스트 영역에 포커스를 이동시킬 수 있다. hotkey prop으로 변경 가능하다.
주의할 점
SSR 환경에서의 hydration
<Toaster />는 클라이언트에서만 동작한다. Next.js App Router에서는 서버 컴포넌트인 layout.tsx에 직접 넣을 수 있지만, 테마 연동 래퍼를 만드는 경우 'use client' 지시어가 필요하다. 레이아웃에 넣는 것 자체는 문제가 없는데, Sonner가 내부적으로 클라이언트 전용 코드를 가지고 있어서 자동으로 클라이언트에서만 렌더링된다.
duration과 Promise의 관계
toast.promise()는 Promise가 완료될 때까지 loading 상태를 유지하므로 duration이 적용되지 않는다. Promise가 resolve/reject된 후에 success/error 토스트가 뜨고, 그 토스트에 duration이 적용된다.
스타일 오버라이드
Sonner의 기본 CSS는 인라인 스타일과 CSS 변수를 혼합 사용한다. Tailwind로 완전히 오버라이드하려면 toastOptions.classNames를 활용하는 게 가장 깔끔하다.
<Toaster
toastOptions={{
classNames: {
toast: 'bg-white border border-gray-200',
title: 'text-gray-900 font-medium',
description: 'text-gray-500',
},
}}
/>
className(단수)은 토스트 컨테이너 전체에 적용되고, classNames(복수)는 개별 부분에 세밀하게 적용된다. 둘 다 동시에 사용할 수 있다.
정리
<Toaster />+toast()구조로 Provider 없이 어디서든 토스트 호출 가능toast.promise()로 비동기 작업의 loading→success/error 전환을 자동 처리- shadcn/ui 기본 채택으로 React 생태계 표준에 가까워졌고,
toastOptions.classNames로 Tailwind 커스터마이징이 깔끔함