저는 2023년에 토스트 라이브러리인 Sonner를 만들었습니다. 현재 주간 npm 다운로드가 800만 회를 넘어섰고, Cursor, X, Vercel 같은 회사에서 사용되고 있습니다. 또한 shadcn/ui의 기본 토스트 컴포넌트이기도 합니다.
Sonner를 만들 당시, 토스트 "시장"은 이미 포화 상태였습니다. 그렇다면 무엇이 Sonner를 돋보이게 했을까요? 왜 사람들은 검증된 대안 대신 Sonner를 선택했을까요?
이름부터 시작해봅시다.
네이밍
제 생각에 기능에 기반해서 이름을 짓는 건 너무 평범합니다. react-toast, react-snackbar, react-notifications 같은 이름은 모두 지루하고 일반적으로 느껴집니다. 더 독특하고 우아한 것을 원했습니다.
알림과 관련된 프랑스어 단어들을 조사했고, "울리다"라는 뜻의 "Sonner"를 선택했습니다.
sonner
/sɔ.ne/ 동사 [자동사]
- 소리 나다
- 울리다
Sonner la cloche (종을 울리다)
Sonner a la porte (초인종을 누르다)
발견 가능성과 명확성을 포기했지만, 저에게는 우아하게 느껴집니다. 또한 차별화된 느낌을 주는 이름이기도 한데, 남들 사이에서 돋보이길 원한다면 이는 중요한 요소입니다.
애니메이션
Sonner가 즉각적으로 인기를 끈 이유는 스택 애니메이션 덕분이라고 생각합니다. 몇몇 회사에서 이전에 구현했지만 오픈 소스로 공개된 적은 없었습니다.
사람들이 저의 작은 컴포넌트에 빠져들게 만든 가장 큰 요인입니다. 애니메이션을 보면 그냥 딱 맞는 느낌이 들었거든요. 예를 들어 Theo의 반응을 보세요.
라이브러리를 소개할 때 이 모션을 강조해야 한다는 걸 알았기 때문에, 스택 애니메이션에 초점을 맞춘 몇 가지 다른 발표 영상을 실험했습니다. 2023년에 최종적으로 만든 영상입니다.
코드 측면에서, 처음에는 애니메이션에 CSS 키프레임을 사용했지만 중단이 불가능했습니다. 아래에서 토스트를 빠르게 추가해보세요. 토스트를 더 추가하면, 이전 토스트들이 부드럽게 전환되지 않고 새 위치로 점프합니다.
이는 키프레임의 단점 중 하나입니다. 애니메이션이 실행되는 동안 끝 위치를 부드럽게 변경할 수 없습니다. 반면 CSS 트랜지션은 첫 번째 트랜지션이 끝나기 전에도 중단하고 재설정할 수 있으므로 키프레임 대신 사용했습니다.
진입 키프레임을 모방하기 위해, useEffect 훅을 사용해서 초기 렌더링 후 mounted를 true로 설정합니다. 이렇게 하면 토스트가 translateY(100%)에서 시작해서 translateY(0)으로 트랜지션됩니다. 스타일은 data 속성을 통해 적용됩니다.
React.useEffect(() => {
setMounted(true);
}, []);
//...
<li data-mounted={mounted}>
.sonner-toast {
transition: transform 400ms ease;
}
[data-mounted="true"] {
transform: translateY(0);
}
[data-mounted="false"] {
transform: translateY(100%);
}
이제 CSS의 @starting-style 규칙으로도 해결할 수 있어서 구현이 훨씬 단순해질 것입니다. 곧 이를 사용하도록 코드를 업데이트할 수도 있습니다.
토스트 스택 쌓기
스택 효과를 만들기 위해, 토스트 사이의 간격에 토스트의 index를 곱해서 y 위치를 계산합니다. 각 토스트는 스택을 단순화하기 위해 position: absolute를 사용합니다. 또한 깊이감을 주기 위해 0.05 * index만큼 스케일을 줄입니다.
CSS 구현:
[data-sonner-toast][data-expanded="false"][data-front="false"] {
--scale: var(--toasts-before) * 0.05 + 1;
--y: translateY(calc(var(--lift-amount) * var(--toasts-before)))
scale(calc((-1 * var(--toasts-before) * 0.05) + 1));
}
이것은 토스트의 높이가 다를 때까지는 균일하게 튀어나오지 않게 잘 작동합니다. 해결책은 스택 모드일 때 모든 토스트를 맨 앞 토스트의 높이에 맞추는 것입니다.
스와이프
Sonner의 또 다른 기능은 스와이프 제스처입니다. 이것은 특히 알림을 스와이프해서 해제하는 데 익숙한 기기에서 유용합니다.
토스트를 아래로 스와이프해서 해제할 수 있습니다. 이것은 translateY 값을 담당하는 변수를 업데이트하는 토스트의 간단한 이벤트 리스너일 뿐입니다.
// 이것은 코드의 단순화된 버전입니다
const onMove = (event) => {
const yPosition = event.clientY - pointerStartRef.current.y;
toastRef.current.style.setProperty("--swipe-amount", `${yPosition}px`);
};
스와이프는 모멘텀 기반입니다. 즉, 해제하기 위해 토스트를 특정 임계값 이상으로 드래그할 필요가 없습니다. 스와이프가 충분히 빠르면, 속도가 충분히 높기 때문에 거리가 짧아도 토스트가 제거됩니다.
드래그가 시작된 이후 얼마나 시간이 지났는지 확인하고, 절대 드래그 거리를 경과 시간으로 나눠서 속도를 구합니다. 스와이프 양이 임계값보다 크거나 속도가 이 경우 0.11보다 높으면 토스트를 제거합니다.
const timeTaken = new Date().getTime() - dragStartTime.current.getTime();
const velocity = Math.abs(swipeAmount) / timeTaken;
// 0.11은 여러 번의 시행착오를 통해 결정된 숫자입니다
if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > 0.11) {
removeToast(toast);
}
토스트 확장하기
토스트가 스택 모드일 때, 토스트 영역 위에 호버하면 모든 토스트를 확장해서 볼 수 있습니다.
각 토스트의 확장된 위치는 이전 토스트들의 높이와 그 사이의 간격을 더해서 계산합니다. 이 값이 사용자가 토스트 영역 위에 호버할 때 새로운 translateY 값이 됩니다.
const toastsHeightBefore = React.useMemo(() => {
return heights.reduce((prev, curr, reducerIndex) => {
// 현재 토스트까지의 오프셋 계산
if (reducerIndex >= heightIndex) {
return prev;
}
return prev + curr.height;
}, 0);
}, [heights, heightIndex]);
// 이 값을 CSS 변수로 사용합니다, "--offset": ${offset}px
const offset = React.useMemo(
() => heightIndex * GAP + toastsHeightBefore,
[heightIndex, toastsHeightBefore],
);
모든 토스트가 항상 보이도록 하려면 이 확장 모드를 기본 동작으로 사용할 수도 있습니다. <Toaster /> 컴포넌트에 expand prop을 추가하면 간단히 활성화할 수 있습니다.
개발자 경험
개발자 경험도 잘 챙기는 것이 매우 중요했습니다. 컴포넌트가 사용하기 쉽지 않으면, 사람들은 시도해보기도 전에 포기할 것입니다. 개발자 경험이 핵심입니다.
그래서 저는 Sonner를 위한 완전한 커스텀 문서 사이트를 만들었습니다. 이 문서 사이트에서 바로 사용할 수 있는 코드 스니펫이 포함된 많은 인터렉티브 예제를 찾을 수 있습니다.

이를 통해 사람들이 자신의 프로젝트에서 사용하기 전에도 제품을 만져보고, 가지고 놀고, 익숙해지고, 작동 방식을 이해할 수 있습니다.
좋은 문서와 명확한 지침은 컴포넌트뿐만 아니라 모든 제품의 사용 장벽을 크게 낮출 수 있습니다. 이것은 종종 간과되고 사후 작업 정도로만 여겨지는데, 그러면 안 됩니다.
하지만 개발자 경험의 기술적 세부 사항도 다뤄봅시다.
리액트의 Context를 사용하지 않기 위해, 옵서버 패턴으로 상태를 관리합니다. <Toaster /> 컴포넌트에서 observable 객체를 구독합니다. toast() 함수가 호출될 때마다 <Toaster /> 컴포넌트(구독자)가 알림을 받고 상태를 업데이트합니다. 그런 다음 Array.map()을 사용해서 모든 토스트를 렌더링할 수 있습니다.
function Toaster() {
const [toasts, setToasts] = React.useState([]);
React.useEffect(() => {
return ToastState.subscribe((toast) => {
setToasts((toasts) => [...toasts, toast]);
});
}, []);
// ...
return (
<ol>
{toasts.map((toast, index) => (
<Toast key={toast.id} toast={toast} />
))}
</ol>
);
}
새 토스트를 만들려면 toast를 임포트하고 호출하면 됩니다. 훅이나 컨텍스트가 필요 없고, 어디서든 할 수 있는 간단한 함수 호출입니다.
import { toast } from "sonner";
// ...
toast("My toast");
사람들은 종종 Sonner의 promise API를 칭찬합니다. 단순히 promise를 전달하고, 3가지 상태(loading, success, error)에서 토스트가 무엇을 표시해야 하는지 지정하면 됩니다. 대부분의 엔지니어들은 꽤 직관적이라고 생각합니다.
이 API 설계는 react-hot-toast에서 영감을 받았습니다. 상태 관리는 다르지만, 토스트를 렌더링하는 방식은 그 라이브러리와 매우 유사합니다. 단순하고 아주 좋기 때문입니다. Timo가 훌륭한 일을 해냈습니다.
작지만 중요한 디테일들
더 작고 눈에 띄기 어려운 것들도 여전히 중요합니다. 이것들이 Sonner가 그렇게 느껴지는 이유를 만듭니다. 몇 가지를 소개합니다.
기본적으로 토스트는 호버하지 않으면 4초 후에 사라집니다. 하지만 토스트가 트리거되고 사용자가 다른 탭으로 전환하면 어떻게 될까요? 4초가 지나고 토스트는 절대 보이지 않을 것입니다.
그래서 문서(탭)가 숨겨져 있는지 확인하는 useIsDocumentHidden 훅이 있고, 그렇다면 타이머를 일시 정지합니다. 경험을 개선하는 작은 디테일입니다.
이 코드는 상대적으로 간단하고 document.hidden 프로퍼티를 활용합니다.
export const useIsDocumentHidden = () => {
const [isDocumentHidden, setIsDocumentHidden] = React.useState(
document.hidden,
);
React.useEffect(() => {
function handleVisibilityChange() {
setIsDocumentHidden(document.hidden);
}
document.addEventListener("visibilitychange", handleVisibilityChange);
return () =>
document.removeEventListener("visibilitychange", handleVisibilityChange);
}, []);
return isDocumentHidden;
};
// ...
const isDocumentHidden = useIsDocumentHidden();
if (isDocumentHidden) {
pauseTimer();
}
이것은 직접 구현해본 적이 없다면 아마 눈치채지 못할 것들 중 하나이고, 괜찮습니다. 그것이 예상되는 동작입니다. 비활성 탭은 말 그대로... 비활성이므로, 탭이 사용되지 않는 동안 토스트가 멈춰야 한다는 것이 당연하게 느껴집니다.
또 다른 흥미로운 것은 올바른 호버 상태를 유지하는 것입니다.
호버 상태는 토스트 중 하나 위에 호버하고 있는지에 따라 달라지지만, 어떤 토스트에도 속하지 않는 토스트 사이의 간격도 있습니다. 그 영역 위에 호버하면 토스트가 호버 상태를 잃게 됩니다.
이를 해결하기 위해, 일관된 호버 상태를 유지하도록 이 간격을 채우는 :after 가상 요소를 추가합니다.
또 하나, 토스트를 드래그하는 동안 포인터가 밖으로 나가면 어떻게 될까요? 더 이상 토스트 위에 호버하지 않기 때문에 드래그 이벤트가 중단됩니다. 따라서 올바른 포인터 캡처를 유지해야 합니다.
드래그를 시작하면, 토스트가 모든 미래의 포인터 이벤트를 캡처하도록 설정합니다. 이렇게 하면 드래그하는 동안 마우스나 엄지가 토스트 밖으로 이동해도, 토스트가 포인터 이벤트의 대상으로 유지됩니다. 결과적으로, 포인터가 토스트 밖에 있어도 드래그가 가능하게 되어 더 나은 사용자 경험을 제공합니다.
위 영상에서 볼 수 있는 또 다른 것은 마찰입니다. 토스트를 위쪽으로 드래그하는 것을 그냥 허용하지 않는 대신, 여전히 드래그할 수 있지만 점점 느려지다가 결국 멈춥니다. 토스트를 즉시 멈추는 것보다 더 자연스럽습니다.
이러한 디테일들이 모두 사용자에게 인식되는 것은 아니지만, 괜찮습니다. 이러한 디테일들이 쌓입니다. 함께 모여서 딱 맞는 느낌의 컴포넌트를 만들어냅니다.
"보이지 않는 모든 디테일이 합쳐져서 놀라운 것을 만들어냅니다. 마치 천 개의 겨우 들릴 듯한 목소리가 모두 화음을 이루며 노래하는 것처럼." — 폴 그레이엄, 해커와 화가
사용자가 세부적인 기능을 덜 인식할수록 더 좋습니다. 그만큼 더 직관적인 사용성을 제공하고 있다는 뜻입니다. 작동 방식에 대해 생각할 필요가 없고 당면한 작업에 집중할 수 있습니다. 비록 의식하진 못할지라도 오히려 사용자는 그런 경험을 긍정적으로 느낍니다.
Sonner가 성공한 이유
두 가지 주요 이유가 있습니다.
첫째는 개발자 경험이 좋다는 것입니다. 훅도 컨텍스트도 필요 없고, <Toaster />를 한 번 삽입하고 toast()를 호출해서 토스트를 만듭니다. 그게 전부입니다.
둘째는 보기 좋다는 것입니다. 좋은 기본값과 좋은 애니메이션이 있습니다. 이것이 진정한 차별화 요소입니다. 사람들은 단순히 아름다운 것을 좋아합니다. 아름다움은 소프트웨어에서 일반적으로 충분히 활용되지 못하는데, 이를 차별화 수단으로 사용할 수 있습니다.
'Web Frontend Developer' 카테고리의 다른 글
| 프론트엔드 테스트는 꼭 필요할까? (1) | 2026.01.02 |
|---|---|
| [번역] package.json을 관리하는 방법 (1) | 2025.12.10 |
| [번역] HTTP 캐싱 완벽 가이드 (1) | 2025.11.20 |
| [요즘IT] 프론트엔드 개발자가 써본 "피그마 MCP"의 가능성과 한계 (0) | 2025.11.05 |
| [번역] 웹어셈블리(Wasm) 3.0 (0) | 2025.10.23 |