覆蓋層與回饋

Toast Stack

sonner 式堆疊收合通知:新通知從邊緣滑入,未 hover 時收合為一疊卡片,滑入視口展開完整列表並暫停自動關閉倒數。

把這個效果裝進你的專案

在專案資料夾執行指令,CLI 會加入原始碼與依賴套件,再依下方使用範例引用元件。

npx webberui@latest add toast-stack

還沒有專案,或安裝遇到問題?看快速開始 →

Toast Stack 是用 React、Motion(motion/react)與 Tailwind CSS 做的 toast 通知(吐司訊息、通知堆疊)元件:多則通知收合成一疊,滑鼠移上時展開成完整列表並暫停自動關閉倒數。用 ToastProvider 包住應用,在任何子元件裡呼叫 useToast() 的 toast() 就能發送,提供 default、success、error 三種樣式。

Playground

即時調整 props、程式碼片段同步更新——直接試出你要的樣子再複製。

5000
5
編輯中的呼叫預設屬性
<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型別預設值說明
childrenReact.ReactNode—應用內容,useToast() 需在此範圍內呼叫
position'bottom-right' | 'bottom-left' | 'top-right' | 'top-left''bottom-right'視口停靠的角落
durationnumber5000自動關閉的毫秒數;設為 0 以下或 Infinity 則不自動關閉
maxnumber5同時最多保留的通知數,超過時移除最舊的一張

useToast()

回傳 { toast, dismiss }:

方法簽名說明
toast(options: ToastOptions) => string發送一則通知,回傳該通知的 id
dismiss(id: string) => void依 id 手動關閉某則通知

ToastOptions

欄位型別預設值說明
titlestring—標題
descriptionstring—說明文字,顯示在標題下方
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",不會打斷使用者目前的操作,也不會搶走焦點。系統開啟「減少動態效果」時,滑入與堆疊位移改為淡入淡出。