junyeokk
Blog
React Ecosystem·2025. 11. 15

Sonner

웹 앱에서 사용자에게 짧은 피드백을 줄 때 가장 흔히 쓰는 UI가 토스트 알림이다. "저장되었습니다", "오류가 발생했습니다" 같은 메시지를 화면 구석에 잠깐 띄웠다가 사라지게 하는 것이다.

그런데 토스트를 직접 구현하려면 생각보다 신경 쓸 게 많다. 여러 개의 토스트가 동시에 떠 있을 때 스택 관리, 자동 닫힘 타이머, 스와이프로 닫기, 접근성(aria-live), 애니메이션 등을 모두 처리해야 한다. 기존에 많이 쓰던 react-toastify 같은 라이브러리는 이런 기능을 잘 제공하지만, 번들 크기가 크고 기본 스타일이 과하게 들어가서 커스터마이징이 번거롭다.

Sonner는 Emil Kowalski가 만든 React 토스트 컴포넌트로, react-hot-toast에서 영감을 받은 간결한 API에 세련된 기본 스타일과 부드러운 애니메이션을 제공한다. "opinionated"를 표방하는데, 위치·애니메이션·스택 동작 등에 대해 합리적인 기본값을 미리 정해놓고, 필요한 부분만 오버라이드하는 방식이다.


기본 구조

Sonner는 <Toaster />toast() 두 가지로 구성된다. <Toaster />는 토스트가 렌더링될 컨테이너이고, toast()는 어디서든 호출할 수 있는 명령형 함수다.

tsx
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 없이도 동작하는 것이다.


토스트 타입

기본 문자열 외에 미리 정의된 타입들이 있다. 각 타입마다 아이콘과 스타일이 다르게 적용된다.

tsx
// 기본 (아이콘 없음)
toast('이벤트가 생성되었습니다');

// 성공 (체크 아이콘)
toast.success('저장되었습니다');

// 에러 (X 아이콘)
toast.error('오류가 발생했습니다');

// 로딩 (스피너)
toast.loading('처리 중...');

// 정보
toast.info('새 업데이트가 있습니다');

// 경고
toast.warning('저장 공간이 부족합니다');

richColors 옵션을 <Toaster />에 설정하면 각 타입별로 배경색이 달라진다. success는 초록, error는 빨강 등으로 시각적 구분이 명확해진다.

tsx
<Toaster richColors />

Promise 토스트

비동기 작업의 상태를 자동으로 추적하는 toast.promise()가 특히 유용하다. loading → success/error 상태 전환을 수동으로 관리할 필요가 없다.

tsx
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로 전환된다. successerror에 함수를 넘기면 결과값이나 에러 객체를 받아서 동적 메시지를 만들 수 있다.

기존에는 이런 패턴을 구현하려면 토스트 ID를 저장해두고, Promise 결과에 따라 수동으로 업데이트해야 했다.

tsx
// toast.promise 없이 수동으로 하면 이렇게 됨
const id = toast.loading('저장 중...');
try {
  await saveData();
  toast.success('저장 완료', { id }); // 기존 토스트를 교체
} catch {
  toast.error('실패', { id });
}

toast.promise()는 이 보일러플레이트를 없앤다.


Action과 Cancel 버튼

토스트에 버튼을 추가해서 사용자가 즉시 행동을 취할 수 있게 할 수 있다. "실행 취소" 패턴에 유용하다.

tsx
toast('일정이 삭제되었습니다', {
  action: {
    label: '실행 취소',
    onClick: () => restoreEvent(),
  },
});

cancel 옵션은 보조 버튼을 렌더링한다. action은 주요 동작(primary 스타일), cancel은 취소 동작(secondary 스타일)이다.

tsx
toast('변경사항을 적용하시겠습니까?', {
  action: {
    label: '적용',
    onClick: () => applyChanges(),
  },
  cancel: {
    label: '취소',
    onClick: () => console.log('취소됨'),
  },
});

버튼 클릭 시 기본적으로 토스트가 닫히는데, onClick 콜백 안에서 event.preventDefault()를 호출하면 닫히지 않게 할 수 있다.

JSX를 직접 넘길 수도 있다.

tsx
toast('알림', {
  action: <Button onClick={() => handleAction()}>확인</Button>,
});

토스트 제어

ID로 업데이트하기

toast()가 반환하는 ID를 사용해서 기존 토스트를 업데이트하거나 닫을 수 있다.

tsx
const toastId = toast.loading('업로드 중...');

// 나중에 업데이트
toast.success('업로드 완료!', { id: toastId });

// 또는 닫기
toast.dismiss(toastId);

같은 ID를 전달하면 새 토스트를 만드는 게 아니라 기존 토스트의 내용을 교체한다. 파일 업로드 진행률 표시 같은 곳에서 유용하다.

전체 닫기

tsx
toast.dismiss(); // 인자 없이 호출하면 모든 토스트를 닫음

닫힘 이벤트

tsx
toast('메시지', {
  onDismiss: (t) => console.log(`토스트 ${t.id} 수동 닫힘`),
  onAutoClose: (t) => console.log(`토스트 ${t.id} 자동 닫힘`),
});

onDismiss는 사용자가 스와이프하거나 닫기 버튼을 눌러서 닫았을 때, onAutoClose는 duration이 끝나서 자동으로 닫혔을 때 호출된다. 분석 로깅이나 다음 동작 트리거에 활용할 수 있다.


Toaster 설정

<Toaster />에서 전역 설정을 조정한다.

위치

tsx
<Toaster position="top-center" />
// 가능한 값: top-left, top-center, top-right,
//           bottom-left, bottom-center, bottom-right

위치에 따라 스와이프 방향도 자동으로 바뀐다. top 위치면 위로 스와이프, bottom 위치면 아래로 스와이프해서 닫을 수 있다.

스택 확장

기본적으로 토스트가 여러 개 쌓이면 살짝 겹쳐서 보인다(최대 3개). 호버하면 확장되어 전부 보인다. 처음부터 확장된 상태로 보여주고 싶다면:

tsx
<Toaster expand visibleToasts={5} />

visibleToasts로 보이는 토스트 수를 제어한다. 기본값은 3이다.

테마

tsx
<Toaster theme="dark" />
// 가능한 값: light, dark, system

next-themes와 연동하면 앱 테마에 맞춰 자동 전환된다.

tsx
'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로 구분한다.

tsx
<Toaster id="global" position="top-right" />
<Toaster id="editor" position="bottom-left" />

// 특정 Toaster를 타겟
toast('전역 알림', { toasterId: 'global' });
toast('에디터 알림', { toasterId: 'editor' });

오프셋

tsx
// 모든 방향 동일
<Toaster offset={16} />

// 방향별 개별 설정
<Toaster offset={{ bottom: '24px', right: '16px', left: '16px' }} />

// 모바일 별도 설정 (화면 너비 600px 미만)
<Toaster mobileOffset={{ bottom: '16px' }} />

닫기 버튼

tsx
<Toaster closeButton />

모든 토스트에 X 버튼이 추가된다. 개별 토스트에서도 설정 가능하다.


커스텀 토스트

JSX 토스트

문자열 대신 JSX를 넘기면 Sonner의 기본 스타일 안에서 커스텀 내용을 렌더링한다.

tsx
toast(
  <div>
    <h3>새 메시지</h3>
    <p>김철수님이 메시지를 보냈습니다.</p>
  </div>
);

Headless 토스트

Sonner의 스타일을 완전히 벗어나서 직접 UI를 그리고 싶다면 toast.custom()을 사용한다.

tsx
toast.custom((t) => (
  <div className="my-custom-toast">
    <span>커스텀 알림입니다</span>
    <button onClick={() => toast.dismiss(t)}>닫기</button>
  </div>
));

콜백이 토스트 ID를 인자로 받기 때문에 toast.dismiss(t)로 프로그래밍적으로 닫을 수 있다. 기본 애니메이션과 스택 관리는 그대로 동작하되, 렌더링만 직접 제어하는 것이다.


duration 제어

토스트가 자동으로 닫히기까지의 시간을 밀리초 단위로 설정한다.

tsx
// 개별 토스트
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-toastifyreact-hot-toastsonner
번들 크기~30KB~5KB~7KB
API 스타일옵션 중심함수 중심함수 중심
기본 스타일과함최소적절
스택 애니메이션보통보통우수
Provider 필요아니오아니오아니오
shadcn/ui 채택아니오아니오

shadcn/ui에서의 사용

shadcn/ui는 Sonner를 공식 토스트 컴포넌트로 사용한다. npx shadcn-ui@latest add sonner 명령으로 추가하면 테마 연동과 기본 스타일이 적용된 래퍼 컴포넌트가 생성된다.

tsx
// 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 옵션으로 라벨을 커스터마이즈할 수 있다.

tsx
<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를 활용하는 게 가장 깔끔하다.

tsx
<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 커스터마이징이 깔끔함

관련 문서