Stateful Button
idle → loading → success / error 的狀態機按鈕,async onClick 自動切換,內容以 crossfade 淡切、寬度隨之形變。
把這個效果裝進你的專案
在專案資料夾執行指令,CLI 會加入原始碼與依賴套件,再依下方使用範例引用元件。
npx webberui@latest add stateful-buttonStateful Button 是用 React、Motion 與 Tailwind CSS 做的載入按鈕(loading button)元件:onClick 回傳 Promise 時自動切換載入中、成功與失敗狀態,適合儲存、送出表單這類非同步操作。
Playground
即時調整 props、程式碼片段同步更新——直接試出你要的樣子再複製。
<StatefulButton/>
完整範例包含預覽的排版與資料。先安裝元件,再把範例檔貼入你的專案。
適用情境
- 設定頁的「儲存變更」按鈕
- 聯絡表單、報名表單的送出
- 「寄送驗證信」「重新同步」這類呼叫 API 的單一動作
- 後台列表中的發布、封存等列操作
- 不適合:付款、刪除等需要使用者刻意確認的不可逆操作,可以先搭配 Slide to Unlock 或 Hold to Confirm 擋住誤觸
安裝
npx webberui@latest add https://webberui.com/r/stateful-button.json或在 components.json 設定 registries 後,改用 @webberui/stateful-button 安裝。
使用
import { StatefulButton } from "@/components/ui/stateful-button";
async function save() {
await fetch("/api/save", { method: "POST" });
}
<StatefulButton onClick={save} successChildren="已儲存" errorChildren="失敗">
儲存
</StatefulButton>;點擊時只要 onClick 回傳一個 Promise,按鈕便自動進入 loading;Promise resolve 後顯示 success、reject 後顯示 error,1.5 秒後自動回到 idle。
受控模式
傳入 state 即改為受控,內建的自動切換與重置會關閉,狀態完全由外部主導:
const [state, setState] = React.useState<"idle" | "loading" | "success" | "error">("idle");
<StatefulButton state={state} onClick={() => setState("loading")}>
送出
</StatefulButton>;量測寬度模式
傳入 morph="measure" 改用另一種過場:元件以隱藏的量測 span 搭配 ResizeObserver 算出下一個狀態的內容寬度(加上按鈕的 padding 與 border 後無條件進位),再以 spring(bounce: 0、duration: 0.26)動畫 width;文字與圖示改為上下交叉——新內容從下方 8px 滑入、舊內容往上 8px 淡出,0.18 秒。成功與失敗文字長短差很多時,寬度變化比預設的 layout 動畫更平順,內容也不會在形變過程中被縮放。
<StatefulButton
morph="measure"
onClick={send}
successChildren="驗證信已寄到信箱"
errorChildren="失敗"
>
寄送驗證信
</StatefulButton>量測模式下按鈕寬度由元件以行內樣式設定,w-full 這類寬度 class 不會生效;children 會多渲染一份隱藏副本用於量測,請避免放入帶狀態或帶 id 的元素。預設 morph="layout",行為與舊版完全相同。
範例
讓 HTTP 錯誤顯示失敗狀態
fetch 遇到 4xx、5xx 不會 reject,要自己檢查 res.ok 並丟出錯誤,按鈕才會切到 error。
async function saveProfile() {
const res = await fetch("/api/profile", { method: "PUT" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
}
<StatefulButton
onClick={saveProfile}
successChildren="已儲存"
errorChildren="儲存失敗,請重試"
>
儲存變更
</StatefulButton>搭配 Next.js Server Action
Server Action 呼叫後會回傳 Promise,直接在 onClick 裡回傳就能驅動狀態機。
"use client";
import { StatefulButton } from "@/components/ui/stateful-button";
import { publishPost } from "./actions"; // "use server" 檔案
export function PublishButton({ postId }: { postId: string }) {
return (
<StatefulButton onClick={() => publishPost(postId)} successChildren="已發布">
發布文章
</StatefulButton>
);
}受控:成功後停留在完成狀態
非受控時成功與失敗狀態會在 1.5 秒後回到 idle。想讓成功狀態停住,例如驗證信寄出後不再重送,就改用受控模式並搭配 disabled。
"use client";
import * as React from "react";
import { StatefulButton } from "@/components/ui/stateful-button";
type State = "idle" | "loading" | "success" | "error";
export function SendVerification() {
const [state, setState] = React.useState<State>("idle");
async function send() {
setState("loading");
try {
const res = await fetch("/api/verify-email", { method: "POST" });
setState(res.ok ? "success" : "error");
} catch {
setState("error");
}
}
return (
<StatefulButton
state={state}
onClick={send}
disabled={state === "loading" || state === "success"}
successChildren="驗證信已寄出"
errorChildren="寄送失敗,再試一次"
>
寄送驗證信
</StatefulButton>
);
}Props
| Prop | 型別 | 預設值 | 說明 |
|---|---|---|---|
state | "idle" | "loading" | "success" | "error" | — | 受控狀態;提供時由外部主導,關閉內建自動切換與重置 |
onClick | (e) => void | Promise<unknown> | — | 點擊處理器;非受控時回傳 Promise 會自動跑狀態機 |
children | React.ReactNode | — | idle/loading 顯示的內容 |
successChildren | React.ReactNode | children | 成功態內容 |
errorChildren | React.ReactNode | children | 錯誤態內容 |
disabled | boolean | false | 停用按鈕;非受控載入中會自動停用 |
morph | "layout" | "measure" | "layout" | 寬度形變方式:layout 沿用 layout 動畫與縮放淡切;measure 量測寬度後以 spring 動畫 width,內容上下交叉 |
其餘原生 <button> 屬性皆會透傳(onDrag / onDragStart / onDragEnd / onAnimationStart / onClick 除外)。
狀態機
idle:預設態,可點擊;按下時輕微縮放回饋loading:spinner 持續旋轉,非受控時自動鎖住按鈕避免重複送出success:綠底,勾勾以pathLength畫線進場error:紅底,叉叉依序畫出,整顆按鈕左右抖動一次- 內容切換以
AnimatePresence(popLayout)crossfade 淡切,按鈕寬度同步layout形變 morph="measure"時改由隱藏量測 span 算寬,width以 spring 動畫,內容上下交叉
可及性
aria-busy於載入態標記忙碌狀態- 內建
aria-live="polite"的狀態播報區,切換時朗讀「處理中/成功/錯誤」 - 使用者系統開啟「減少動態效果」時,停用旋轉、抖動與位移,勾勾/叉叉直接顯示,仍保留內容切換以維持語意;
morph="measure"時寬度 0 秒直接切換,內容只保留淡入淡出、不做上下位移
常見問題
可以在 Next.js App Router 使用嗎?
可以。元件檔案開頭已有 "use client",在 Server Component 頁面中照常 import 即可;onClick 是函式,需要從 client component 傳入。
放在表單裡為什麼不會送出?
按鈕預設 type="button"。要用它送出表單,請明確傳入 type="submit",其餘原生 button 屬性也都會透傳。
成功狀態停留的時間可以調整嗎?
非受控模式固定在 1.5 秒後回到 idle,沒有對應的 prop。需要不同的時間或停在某個狀態,請改用受控模式,用 state 自行切換。
會不會被重複點擊?
非受控模式在載入中會自動停用按鈕,避免重複送出。受控模式不會自動停用,請依 state 自行設定 disabled。
螢幕閱讀器會知道狀態改變嗎?
會。載入中按鈕帶 aria-busy,內建的 aria-live="polite" 區域會在切換時播報「處理中」「成功」「錯誤」。