按鈕與切換

Stateful Button

idle → loading → success / error 的狀態機按鈕,async onClick 自動切換,內容以 crossfade 淡切、寬度隨之形變。

把這個效果裝進你的專案

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

npx webberui@latest add stateful-button

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

Stateful 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 會自動跑狀態機
childrenReact.ReactNode—idle/loading 顯示的內容
successChildrenReact.ReactNodechildren成功態內容
errorChildrenReact.ReactNodechildren錯誤態內容
disabledbooleanfalse停用按鈕;非受控載入中會自動停用
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" 區域會在切換時播報「處理中」「成功」「錯誤」。