Toast Stack
sonner 式堆疊收合通知:新通知從邊緣滑入,未 hover 時收合為一疊卡片,滑入視口展開完整列表並暫停自動關閉倒數。
把這個效果裝進你的專案
在專案資料夾執行指令,CLI 會加入原始碼與依賴套件,再依下方使用範例引用元件。
npx webberui@latest add toast-stackToast Stack 是用 React、Motion(motion/react)與 Tailwind CSS 做的 toast 通知(吐司訊息、通知堆疊)元件:多則通知收合成一疊,滑鼠移上時展開成完整列表並暫停自動關閉倒數。用 ToastProvider 包住應用,在任何子元件裡呼叫 useToast() 的 toast() 就能發送,提供 default、success、error 三種樣式。
Playground
即時調整 props、程式碼片段同步更新——直接試出你要的樣子再複製。
<ToastProvider/>
完整範例包含預覽的排版與資料。先安裝元件,再把範例檔貼入你的專案。
適用情境
- 表單儲存、設定變更後的成功或失敗回饋
- 檔案上傳、匯出報表這類背景作業完成時的通知
- 複製連結、加入購物車等輕量操作的確認訊息
- 後台批次處理時,連續出現多則結果也不會塞滿畫面
- 不適合:刪除後需要提供「復原」按鈕時,改用 Undo Snackbar;這個元件的通知只有標題、說明與關閉鈕
安裝
npx webberui@latest add https://webberui.com/r/toast-stack.json或在 components.json 設定 registries 後,改用 @webberui/toast-stack 安裝。
使用
在應用最外層(或需要通知的範圍外層)包一次 ToastProvider,內部任何元件即可透過 useToast() 發送通知:
import { ToastProvider, useToast } from "@/components/ui/toast-stack";
function App() {
return (
<ToastProvider position="bottom-right" duration={5000} max={5}>
<SaveButton />
</ToastProvider>
);
}
function SaveButton() {
const { toast } = useToast();
return (
<button
onClick={() =>
toast({
title: "變更已儲存",
description: "設定已同步到所有裝置。",
variant: "success",
})
}
>
儲存
</button>
);
}toast() 會回傳該通知的 id,搭配 dismiss(id) 可以在流程完成時提前關閉:
const { toast, dismiss } = useToast();
const id = toast({ title: "上傳中…" });
// 完成後手動關閉
dismiss(id);視口由 ToastProvider 內建,經 createPortal 渲染到 document.body,不需要另外掛 viewport 元件。
範例
搭配 Next.js App Router
ToastProvider 要包在用戶端元件裡,建立一個 providers.tsx 再放進 root layout。
// app/providers.tsx
"use client";
import { ToastProvider } from "@/components/ui/toast-stack";
export function Providers({ children }: { children: React.ReactNode }) {
return <ToastProvider position="top-right">{children}</ToastProvider>;
}// app/layout.tsx
import { Providers } from "./providers";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-Hant">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}非同步儲存:處理中、成功、失敗
先發一則處理中的通知,請求結束後用 dismiss 關掉它,再依結果發送 success 或 error。
"use client";
import { useToast } from "@/components/ui/toast-stack";
export function SaveProfileButton({ data }: { data: FormData }) {
const { toast, dismiss } = useToast();
async function save() {
const pending = toast({ title: "儲存中…" });
try {
const res = await fetch("/api/profile", { method: "POST", body: data });
if (!res.ok) throw new Error(String(res.status));
toast({ title: "個人資料已更新", variant: "success" });
} catch {
toast({
title: "儲存失敗",
description: "請檢查網路連線後再試一次。",
variant: "error",
});
} finally {
dismiss(pending);
}
}
return <button onClick={save}>儲存</button>;
}不自動關閉的通知
duration 設為 0 時,通知會一直留著,直到使用者按關閉或程式呼叫 dismiss。這個設定作用在整個 Provider。
<ToastProvider duration={0} max={3}>
{children}
</ToastProvider>Props
ToastProvider
| Prop | 型別 | 預設值 | 說明 |
|---|---|---|---|
children | React.ReactNode | — | 應用內容,useToast() 需在此範圍內呼叫 |
position | 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' | 'bottom-right' | 視口停靠的角落 |
duration | number | 5000 | 自動關閉的毫秒數;設為 0 以下或 Infinity 則不自動關閉 |
max | number | 5 | 同時最多保留的通知數,超過時移除最舊的一張 |
useToast()
回傳 { toast, dismiss }:
| 方法 | 簽名 | 說明 |
|---|---|---|
toast | (options: ToastOptions) => string | 發送一則通知,回傳該通知的 id |
dismiss | (id: string) => void | 依 id 手動關閉某則通知 |
ToastOptions
| 欄位 | 型別 | 預設值 | 說明 |
|---|---|---|---|
title | string | — | 標題 |
description | string | — | 說明文字,顯示在標題下方 |
variant | 'default' | 'success' | 'error' | 'default' | 變體,決定左緣色帶與圖示(Info / CheckCircle / XCircle) |
細節
- 收合堆疊:未 hover 時只有最前面(最新)的一張留在文件流,其餘卡片絕對定位疊在它後面,依距離遞減
scale(每層 -0.05)並往停靠邊外側位移 14px,露出一小條邊;第 4 張起完全隱藏(透明度 0 且不接收 pointer events)。 - hover 展開:滑鼠移入視口(或鍵盤焦點進入任何一張通知)時展開成完整列表,後面的卡片從絕對定位切回文件流,由 Motion 的 layout FLIP 以 spring 過渡到各自的位置;移出後反向收攏回堆疊。
- 進退場:新通知從停靠的上下邊緣滑入(
y: ±120%+ 淡入),其餘卡片以 layout 動畫讓位;退場時往左右邊緣滑出,AnimatePresence的popLayout模式讓剩下的卡片同步收攏補位。 - 倒數暫停:hover 期間清除所有
setTimeout,離開視口時為每張通知重排一次完整倒數(重設而非續跑);被dismiss、被max擠掉或 Provider 卸載時,對應的 timer 一律回收。 - 卡片間距:展開時的間距做成每張卡片自身的 padding 而不是容器的
gap,滑鼠在卡片之間移動時 hover 狀態不會中斷,堆疊不會展開收合來回抖動。 - 視口實作:視口是一條撐滿螢幕直向走廊的
pointer-events-none容器(事件由卡片冒泡上來),容器的螢幕位置固定,popLayout退場時的絕對定位量測才不會因容器高度變化而跳動。
可及性
- 視口帶
aria-live="polite",每張通知帶role="status",新通知會由螢幕閱讀器以不打斷目前操作的方式朗讀 - 每張通知都有帶
aria-label的關閉鈕;鍵盤焦點進入視口時等同 hover——堆疊展開、倒數暫停,焦點離開後恢復 - 使用者系統開啟「減少動態效果」時,滑入滑出與堆疊位移全部歸零,退化為短暫的淡入淡出,不產生任何位移感
- 通知不會搶焦點,發送後焦點停留在原本的操作元素上
常見問題
可以在 Next.js App Router 使用嗎?
可以。檔案開頭已有 "use client";ToastProvider 放進 layout 的方式見上方範例。通知視口在掛載後才透過 portal 渲染到 document.body,伺服器端輸出為空。
可以為單一通知設定不同的停留時間嗎?
不行。duration 是 ToastProvider 的設定,套用到所有通知。需要個別控制時,可以把 duration 設為 0,再自己用 setTimeout 呼叫 dismiss(id)。
可以在 React 元件以外呼叫 toast() 嗎?
不行。toast 來自 useToast(),必須在 ToastProvider 範圍內的元件裡呼叫,否則會丟出錯誤。
同時發很多則會怎樣?
超過 max(預設 5)時會移除最舊的一則;收合狀態最多露出 3 張,其餘隱藏,hover 展開才看得到。
螢幕閱讀器會讀出通知嗎?
會。視口帶 aria-live="polite",每則通知帶 role="status",不會打斷使用者目前的操作,也不會搶走焦點。系統開啟「減少動態效果」時,滑入與堆疊位移改為淡入淡出。