
2025/12/03 23:51
Building a Toast Component
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
Summary
Sonner(2023年リリース)は、多くのハイプロファイルプロジェクトや shadcn/ui フレームワークで定番のトーストライブラリとなっています。週あたり 7 百万件以上 の npm ダウンロードを記録し、Cursor、X、Vercel などの企業でも採用されています。
主な強み:
- シンプルな API:
からsonner
をインポートしてどこでも呼び出せます。フックやコンテキストは不要です。プロミスベースのヘルパー(toast
)により、ローディング・成功・エラー状態を扱えます。toast.promise - 開発者体験: インタラクティブなドキュメントとすぐに使える例が採用障壁を下げています。
- 視覚的魅力 & UX:
- スムーズなスタッキングアニメーション は CSS トランジション(キー フレームではなく)で構築され、割り込み可能な動きを実現します。
- スワイプ解除 はモバイル/デスクトップの両方で機能し、速度閾値 0.11 または距離で判定します。
- ホバー拡張: トースト領域にカーソルを合わせるとすべてのトーストが展開されます。
の<Toaster />
プロパティで制御できます。expand - 小さな改善点: ドキュメントが非表示になった際はタイマーを一時停止、ホバーギャップは疑似要素で維持、ドラッグ中のポインタキャプチャを保持し、摩擦でスワイプ動作を滑らかにします。
内部的には Sonner は Observer パターン(
ToastState と <Toaster /> が setToasts を通じて購読)を使用して状態管理を効率化しています。
このライブラリの成功は、開発者フレンドリーな API、洗練されたアニメーション、および競合他社と差別化する思慮深い UX ディテールに起因します。今後の計画としては、インタラクティブドキュメントを拡充し、プロミスベースのヘルパーを増やすとともにアクセシビリティ機能を洗練させることが挙げられます。
本文
2023 年に立ち上げた Toast ライブラリ「Sonner」
2023 年、私は Sonner と呼ばれる Toast ライブラリを作ることにしました。現在では npm から週に 700 万件以上がダウンロードされており、Cursor、X、Vercel などの企業で採用されています。また、shadcn/ui のデフォルト Toast コンポーネントでもあります。
開発時点では「Toast 市場」は既に混雑していました。そこで Sonner を際立たせるポイントは何だったのでしょうか? なぜ人々は確立された代替品よりも Sonner を選ぶのですか?
名前
機能を表す名前(react-toast、react-snackbar、react-notifications など)は安っぽく感じられます。私は「もっとユニークでエレガントなもの」を求めていました。
フランス語で通知に関係する単語を調べた結果、「Sonner」(サンネール)という言葉がぴったりでした。
sonner /sɔ.ne/
動詞(自動詞) – 古フランス語 soner から、ラテン語 sonāre に由来
- 鳴る、音を立てる 2. 鐘を鳴らす
Sonner la cloche(鐘を鳴らす)
Sonner à la porte(ドアベルを鳴らす)
発見性と明確さは犠牲にしますが、エレガントで違和感のない名前だと感じました。
アニメーション
Sonner が即座に注目された理由は、そのスタッキングアニメーションです。企業では既に使われていた技術ですが、オープンソース化されていませんでした。この動きが自然であることを確認できたとき、すぐに採用しました。
最初は CSS キーフレームを使用していましたが、中断不可だったため、複数 Toast を追加すると古いものが新しい位置へジャンプし、スムーズな遷移になりませんでした。
解決策
CSS トランジション(中断可能)と小さな
useEffect フックを組み合わせて、初回レンダリング後に mounted を設定します。
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%); } </li>
後に
@starting-style CSS アットルールでさらに簡潔化できます。
Toast のスタッキング
スタッキング効果を作るには、各 Toast のインデックスに応じてギャップを掛けた値を Y 座標にします。Toast は絶対位置で配置され、深さ感のため少し縮小されています。
[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)); }
Toast の高さが異なる場合は、スタックモードで前面 Toast と同じ高さに揃えます。
スワイプでの解除
もう一つの特徴は、モバイルやデスクトップでも便利なスワイプ解除です。速度ベース(モーメンタム)で動作し、短い距離でも素早くフリックすると解除できます。
const onMove = (event) => { const yPosition = event.clientY - pointerStartRef.current.y; toastRef.current.style.setProperty("--swipe-amount", `${yPosition}px`); }; const timeTaken = new Date().getTime() - dragStartTime.current.getTime(); const velocity = Math.abs(swipeAmount) / timeTaken; if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > 0.11) { removeToast(toast); }
Toast の展開
スタックモードでは、Toast エリアにカーソルを合わせるとすべての Toast が拡大します。
const toastsHeightBefore = React.useMemo(() => { return heights.reduce((prev, curr, reducerIndex) => { if (reducerIndex >= heightIndex) return prev; return prev + curr.height; }, 0); }, [heights, heightIndex]); const offset = React.useMemo( () => heightIndex * GAP + toastsHeightBefore, [heightIndex, toastsHeightBefore], );
<Toaster /> に expand プロパティを渡すことで、デフォルトで有効にできます。
開発者体験
コンポーネントは意図的にシンプルです。フックやコンテキストは不要で、
<Toaster /> を一度挿入し、どこからでも toast() を呼び出せます。
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> ); }
import { toast } from "sonner"; toast("My toast");
Promise API は高く評価されています。
toast.promise(promise, { loading: 'Loading...', success: 'Success!', error: 'Error!' });
react-hot-toast に触発されつつ、状態管理にはオブザーバーパターンを採用しています。
小さなディテールが大きく違う
-
タブが隠れたときに一時停止:
フックでタイマーを止めます。useIsDocumentHiddenexport 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; }; -
一貫したホバー状態:
擬似要素で Toast 間の隙間を埋めます。::after -
ドラッグ中のポインタキャプチャ:ポインタが範囲外に出ても Toast が反応し続けます。
-
上方向ドラッグ時の摩擦:Toast は減速して自然に停止します。
これら見えない部分が組み合わさり、滑らかで直感的な体験を実現しています。
Sonner の成功理由
- 開発者体験 – フックやコンテキスト不要。
+<Toaster />
だけで完結。toast() - ビジュアルの魅力 – 美しいデフォルトと洗練されたアニメーションが、混雑した市場で際立ちます。
Sonner のようなコンポーネントを作りたい方は、私のアニメーションコース「animations.dev」もぜひご覧ください。