Dynamic Island Toast
iOS 靈動島式通知:待命膠囊液態展開為通知卡片,多則通知在後方堆疊露邊,支援 loading → success 就地更新。
載入預覽⋯
Playground
即時調整 props、程式碼片段同步更新——直接試出你要的樣子再複製。
4000
4
16
<DynamicIslandToast />
安裝
npx shadcn@latest add https://webberui.com/r/dynamic-island-toast.json或在 components.json 設定 registries 後,改用 @webberui/dynamic-island-toast 安裝。
安裝依賴後,從 registry JSON(/r/dynamic-island-toast.json 的 files[0].content)複製 dynamic-island-toast.tsx 原始碼到你的 components/ui/ 目錄:
npm install motion lucide-react clsx tailwind-merge使用
在應用最外層(或需要通知的範圍外層)包一次 DynamicIslandToastProvider,內部任何元件即可透過 useDynamicIslandToast() 推送通知:
import {
DynamicIslandToastProvider,
useDynamicIslandToast,
} from "@/components/ui/dynamic-island-toast";
function App() {
return (
<DynamicIslandToastProvider duration={4000} max={4}>
<SaveButton />
</DynamicIslandToastProvider>
);
}
function SaveButton() {
const { notify } = useDynamicIslandToast();
return (
<button
onClick={() =>
notify({
title: "部署完成",
description: "已上線至正式環境",
variant: "success",
})
}
>
部署
</button>
);
}notify() 會回傳該通知的 id,搭配 update() 可以把同一則 loading 就地換成結果,dismiss() 則提前關閉:
const { notify, update, dismiss } = useDynamicIslandToast();
const id = notify({ title: "同步中…", variant: "loading" });
// 流程結束:同一顆島身直接形變成 success,不會另開一則
update(id, { title: "同步完成", variant: "success" });
// 或手動關閉
dismiss(id);島身由 DynamicIslandToastProvider 內建,預設經 createPortal 以 fixed 掛到 document.body;傳入 container(需 position: relative)則改以 absolute 掛在該容器內,適合侷限在展示區塊。
Props
DynamicIslandToastProvider
| Prop | 型別 | 預設值 | 說明 |
|---|---|---|---|
children | React.ReactNode | — | 應用內容,useDynamicIslandToast() 需在此範圍內呼叫 |
duration | number | 4000 | 自動關閉的毫秒數;設為 0 以下或 Infinity 則不自動關閉 |
max | number | 4 | 同時最多保留的通知數,超過時擠掉最舊的一則 |
align | 'start' | 'center' | 'end' | 'center' | 島身的水平對齊 |
offset | number | 16 | 距容器頂端的位移(px) |
persistent | boolean | true | 沒有通知時是否保留待命膠囊(帶呼吸光點) |
container | HTMLElement | null | — | 渲染目標容器;提供時以 absolute 掛入(容器需 relative),未提供則 fixed 掛到 document.body |
useDynamicIslandToast()
回傳 { notify, update, dismiss }:
| 方法 | 簽名 | 說明 |
|---|---|---|
notify | (options: IslandToastOptions) => string | 推送一則通知,回傳其 id |
update | (id: string, options: Partial<IslandToastOptions>) => void | 依 id 更新既有通知並重排倒數 |
dismiss | (id: string) => void | 依 id 手動關閉某則通知 |
IslandToastOptions
| 欄位 | 型別 | 預設值 | 說明 |
|---|---|---|---|
title | string | — | 標題(主要訊息,單行截斷) |
description | string | — | 說明文字,顯示在標題下方 |
variant | 'default' | 'success' | 'error' | 'loading' | 'default' | 前導圖示(Bell / CheckCircle2 / XCircle / 旋轉 Loader2) |
icon | React.ReactNode | — | 自訂前導圖示,覆蓋 variant 的預設圖示 |
duration | number | 繼承 Provider | 覆寫此則的自動關閉毫秒數 |
細節
- 液態形變:待命膠囊與通知卡片是同一顆
motion.div,透過 Motion 的layout讓島身以 spring 就內容尺寸伸縮,內容以AnimatePresence(popLayout)交叉淡入,營造「一團黑塊流動變形」的靈動島質感。 - 堆疊景深:前景固定為最新那則;較舊的通知在島身後方以絕對定位往下露出一小條邊(每層
translateY 7px、scale −0.05、透明度遞減),最多再露 2 層,其餘僅計入而不顯示。 - loading 生命週期:
variant: "loading"預設常駐不倒數(顯示旋轉圖示),直到update()換成結果或dismiss();update()讓同一顆島身直接形變,避免另開新通知造成閃爍。 - 倒數暫停:滑鼠移入或鍵盤焦點進入島身時清除所有
setTimeout;離開後為每則非 loading 通知重排一次完整倒數(重設而非續跑)。被dismiss、被max擠掉或 Provider 卸載時,對應的 timer 一律回收。 - 就地掛載:預設
fixed貼齊視窗頂端置中;傳入container則改absolute掛進指定容器,便於在文件流中的區塊內展示。
可及性
- 島身容器帶
aria-live="polite"、每則通知帶role="status",新通知與update()的內容變更都會由螢幕閱讀器以不打斷目前操作的方式朗讀 - 每則通知都有帶
aria-label的關閉鈕;鍵盤焦點進入島身時等同 hover——暫停倒數,焦點離開後恢復 - 待命膠囊的呼吸光點與堆疊邊皆
aria-hidden,不會被輔助科技朗讀 - 使用者系統開啟「減少動態效果」時,形變、堆疊位移與呼吸光點全部歸零,退化為短暫的淡入淡出,不產生任何位移感
- 通知不會搶焦點,推送後焦點停留在原本的操作元素上