跳至正文
组件

Steps步骤条

多步流程的进度与回退导航,支持横排、竖排、错误状态和随容器宽度切换的紧凑形态。

正在加载…

安装

pnpm dlx shadcn@latest add https://yiui.dev/r/steps.json
会连带安装 shadcn 组件 button。已存在的文件会询问是否覆盖。

用法

components/checkout-steps.tsx
"use client"

import { useState } from "react"
import { Steps } from "@/components/ui/steps/steps"

export function CheckoutSteps() {
  const [value, setValue] = useState(0)

  return (
    <Steps
      items={[
        { title: "填写信息" },
        { title: "选择支付" },
        { title: "下单成功" },
      ]}
      value={value}
      onValueChange={setValue}
    />
  )
}

value 从 0 开始。之前的步骤为 finish,当前为 process,之后为 wait;items[].status 可以单独覆盖状态。默认只能点回当前索引之前、状态为 finish 且未禁用的步骤;clickable="none" 关闭回退。

配合表单逐步校验

步骤条只显示进度、发出回退请求。前进按钮先校验当前页,再更新 value;最后一步提交由业务表单处理。

components/verified-checkout.tsx
"use client"

import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Steps } from "@/components/ui/steps/steps"

export function VerifiedCheckout() {
  const [value, setValue] = useState(0)
  const items = [
    { title: "填写信息" },
    { title: "选择支付" },
    { title: "下单成功" },
  ]

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault()
        // 浏览器先校验当前渲染的字段,通过后才触发 onSubmit。
        if (value < items.length - 1) setValue(value + 1)
      }}
    >
      <Steps items={items} value={value} onValueChange={setValue} />
      {value === 0 && (
        <label>
          收件人
          <input name="recipient" autoComplete="shipping name" required />
        </label>
      )}
      {value === 1 && (
        <label>
          支付方式
          <select name="payment" required defaultValue="">
            <option value="" disabled>
              请选择支付方式
            </option>
            <option value="card">银行卡</option>
          </select>
        </label>
      )}
      {value < items.length - 1 ? (
        <Button type="submit" size="default">
          下一步
        </Button>
      ) : (
        <p role="status">下单成功</p>
      )}
    </form>
  )
}

使用表单库时,前进逻辑可换成 if (await form.trigger(currentFields)) setValue(value + 1)。受控模式下,onValueChange 也可以先检查业务条件,再决定是否更新;不更新就停留在原步骤。

示例

竖排与错误状态

orientation="vertical" 将描述靠右显示,适合打码信息回显。status="error" 显示感叹号和错误色,并为读屏补上「出错」。

正在加载…

随容器宽度切换

orientation="auto" 在组件容器窄于 28rem 时显示 compact,宽度足够时恢复横排。拖动滑块观察切换;手机上的可用空间较小时会一直保持 compact。也可以用 variant="compact" 固定为紧凑形态。

正在加载…

API

根元素为 div;className、ref 和其他 HTML 属性都作用于它。空数组显示「第 0 步 / 共 0 步」摘要(compact 时),不产生可点步骤。越界值按有效索引夹取,非有限数按 0 显示;不会因此触发回调。

类型

type StepStatus = "wait" | "process" | "finish" | "error"

type StepItem = {
  title: React.ReactNode
  description?: React.ReactNode
  status?: StepStatus
  disabled?: boolean
}

入口还导出 StepsProps、StepsLabels 和 stepsVariants。disabled 只禁止该步回退,不改变其状态。

labels

type StepsLabels = {
  compactText: string // "第 {current} 步 / 共 {total} 步"
  list: string // "流程步骤"
  srWait: string // "未开始"
  srProcess: string // "进行中"
  srFinish: string // "已完成"
  srError: string // "出错"
  srDisabled: string // "不可返回"
}

通过 labels 部分覆盖;compactText 的 {current} 为从 1 开始的序号,{total} 为总步骤数。

data-slot

元素data-slot
根元素steps
每一步steps-item
圆形序号 / 状态图标steps-indicator
标题steps-title
描述steps-description
圆点间连线steps-separator
紧凑形态容器steps-compact
紧凑形态的进度分段steps-progress

根元素提供 data-orientation、data-variant、data-size;步骤提供 data-state 和 data-disabled。

可访问性

  • 两种形态都使用 ol > li,当前索引的步骤带 aria-current="step"。容器查询隐藏的形态不进入无障碍树。
  • 只有允许回退的步骤渲染为 button type="button",支持 Tab 聚焦、Enter / 空格激活和可见焦点环;其他步骤是普通文本。
  • compact 每个分段都有完整的标题、描述、状态,当前摘要使用 aria-live="polite" 播报变化。
  • 完成用勾号、错误用感叹号区分,读屏也有对应状态文字;不只靠颜色。
  • 触屏下可点区域至少 44px 高;减少动态效果时关闭按钮过渡。

注意事项

  • 这是流程导航,不是输入字段;不提交表单值,也不响应表单 reset。受控表单应自行重置步骤索引。
  • title 和 description 允许换行;建议使用文本或非交互内容,避免在回退按钮里嵌套链接、按钮等控件。
  • item.status 只改变视觉状态,当前索引仍由 value 决定。前面的步骤被标成 error 后也不能通过步骤条回退,修复入口由业务页面提供。
  • auto 使用 CSS 容器查询,无需测量浏览器尺寸,服务端渲染时内容即可见。
  • 本示例表单只展示校验流程。实际业务应将字段值保存在外部状态或表单库中,保证切换步骤后仍能恢复。

源码

@ui/steps/steps.tsx
"use client"

import { cva } from "class-variance-authority"
import { Check } from "lucide-react"

import { buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"

import { DEFAULT_LABELS } from "./config"
import type { StepStatus, StepsProps } from "./types"

const stepsVariants = cva("@container/steps w-full min-w-0", {
  variants: {
    size: {
      sm: "text-xs [--steps-dot:1.25rem]",
      default: "text-sm [--steps-dot:1.5rem]",
    },
    variant: { default: "", compact: "" },
  },
  defaultVariants: { size: "default", variant: "default" },
})

/** 多步流程的进度与回退导航;前进与业务校验由调用方处理。 */
function Steps({
  items,
  value: valueProp,
  defaultValue = 0,
  onValueChange,
  orientation = "horizontal",
  variant = "default",
  clickable = "completed",
  size = "default",
  labels: labelsProp,
  className,
  ...props
}: StepsProps) {
  const labels = { ...DEFAULT_LABELS, ...labelsProp }
  const [rawValue, setValue] = useControllableState({
    prop: valueProp,
    defaultProp: defaultValue,
    onChange: onValueChange,
  })
  const value = Number.isFinite(rawValue)
    ? Math.min(Math.max(Math.trunc(rawValue), 0), Math.max(items.length - 1, 0))
    : 0
  const statuses = items.map(
    (item, index): StepStatus =>
      item.status ??
      (index < value ? "finish" : index === value ? "process" : "wait"),
  )
  const statusText = {
    wait: labels.srWait,
    process: labels.srProcess,
    finish: labels.srFinish,
    error: labels.srError,
  }
  const vertical = orientation === "vertical"
  const compact = variant === "compact"
  const auto = orientation === "auto" && !compact
  const summary = labels.compactText
    .replaceAll("{current}", String(items.length ? value + 1 : 0))
    .replaceAll("{total}", String(items.length))

  return (
    <div
      {...props}
      data-slot="steps"
      data-orientation={orientation}
      data-variant={variant}
      data-size={size}
      className={cn(stepsVariants({ size, variant }), className)}
    >
      <div
        data-slot="steps-compact"
        className={cn(
          "min-w-0",
          compact
            ? "block"
            : auto
              ? "block @min-[28rem]/steps:hidden"
              : "hidden",
        )}
      >
        <p
          className="mb-2 flex flex-wrap items-baseline gap-x-1.5 gap-y-1"
          aria-live="polite"
          aria-atomic="true"
        >
          <span className="text-muted-foreground tabular-nums">{summary}</span>
          {items.length > 0 && (
            <>
              <span aria-hidden="true" className="text-muted-foreground">
                ·
              </span>
              <span
                data-slot="steps-title"
                className={cn(
                  "min-w-0 font-medium wrap-anywhere",
                  statuses[value] === "error" && "text-destructive",
                )}
              >
                {statuses[value] === "error" && (
                  <span aria-hidden="true">! </span>
                )}
                {items[value].title}
              </span>
              <span className="sr-only">{statusText[statuses[value]]}</span>
            </>
          )}
        </p>
        <ol aria-label={labels.list} className="flex list-none gap-1.5 p-0">
          {items.map((item, index) => {
            const status = statuses[index]
            const canGoBack =
              clickable === "completed" &&
              index < value &&
              status === "finish" &&
              !item.disabled
            const content = (
              <>
                <span className="sr-only">
                  {index + 1}. {item.title} {item.description} —{" "}
                  {statusText[status]}
                  {item.disabled ? ` · ${labels.srDisabled}` : ""}
                </span>
                <span
                  data-slot="steps-progress"
                  aria-hidden="true"
                  className={cn(
                    "block h-1 w-full rounded-full",
                    status === "error"
                      ? "bg-destructive"
                      : status === "finish" || status === "process"
                        ? "bg-primary"
                        : "bg-muted",
                  )}
                />
              </>
            )
            return (
              <li
                key={index}
                data-slot="steps-item"
                data-state={status}
                data-disabled={item.disabled || undefined}
                aria-current={index === value ? "step" : undefined}
                className="min-w-0 flex-1"
              >
                {canGoBack ? (
                  <button
                    type="button"
                    onClick={() => setValue(index)}
                    className={cn(
                      buttonVariants({ variant: "ghost", size: "lg" }),
                      "w-full px-0 motion-reduce:transition-none",
                    )}
                  >
                    {content}
                  </button>
                ) : (
                  <span className="flex min-h-9 items-center pointer-coarse:min-h-11">
                    {content}
                  </span>
                )}
              </li>
            )
          })}
        </ol>
      </div>
      <ol
        aria-label={labels.list}
        className={cn(
          "m-0 list-none p-0",
          vertical ? "flex-col" : "items-start",
          compact ? "hidden" : auto ? "hidden @min-[28rem]/steps:flex" : "flex",
        )}
      >
        {items.map((item, index) => {
          const status = statuses[index]
          const canGoBack =
            clickable === "completed" &&
            index < value &&
            status === "finish" &&
            !item.disabled
          const content = (
            <>
              <span
                data-slot="steps-indicator"
                aria-hidden="true"
                className={cn(
                  "relative z-10 flex size-(--steps-dot) shrink-0 items-center justify-center rounded-full border bg-background font-mono text-xs tabular-nums",
                  status === "finish" &&
                    "border-primary bg-primary text-primary-foreground",
                  status === "process" &&
                    "border-2 border-primary bg-background text-primary",
                  status === "wait" && "border-edge text-muted-foreground",
                  status === "error" && "border-destructive text-destructive",
                )}
              >
                {status === "finish" ? (
                  <Check className="size-3" strokeWidth={3} />
                ) : status === "error" ? (
                  "!"
                ) : (
                  index + 1
                )}
              </span>
              <span
                data-slot="steps-title"
                className={cn(
                  "min-w-0 wrap-anywhere whitespace-normal",
                  vertical && "flex-1 text-left",
                  status === "process" && "font-medium text-primary",
                  status === "wait" && "text-muted-foreground",
                  status === "error" && "text-destructive",
                )}
              >
                {item.title}
              </span>
              {item.description != null && (
                <span
                  data-slot="steps-description"
                  className={cn(
                    "min-w-0 text-xs font-normal wrap-anywhere whitespace-normal text-muted-foreground",
                    vertical && "ml-auto max-w-1/2 text-right",
                  )}
                >
                  {item.description}
                </span>
              )}
              <span className="sr-only">
                {statusText[status]}
                {item.disabled ? ` · ${labels.srDisabled}` : ""}
              </span>
            </>
          )
          const contentClass = cn(
            "relative flex w-full min-w-0 gap-2 rounded-lg px-1.5 py-1.5 text-inherit font-normal",
            vertical
              ? "min-h-9 flex-row items-center pointer-coarse:min-h-11"
              : "flex-col items-center text-center",
          )
          return (
            <li
              key={index}
              data-slot="steps-item"
              data-state={status}
              data-disabled={item.disabled || undefined}
              aria-current={index === value ? "step" : undefined}
              className={cn("relative min-w-0", !vertical && "flex-1")}
            >
              {index < items.length - 1 && (
                <span
                  data-slot="steps-separator"
                  aria-hidden="true"
                  className={cn(
                    "pointer-events-none absolute",
                    status === "finish" ? "bg-primary" : "bg-edge",
                    vertical
                      ? "top-[calc(.375rem+var(--steps-dot))] bottom-[calc(-.375rem)] left-[calc(.375rem+var(--steps-dot)/2)] w-px"
                      : "top-[calc(.375rem+var(--steps-dot)/2)] left-[calc(50%+var(--steps-dot)/2+.375rem)] h-px w-[calc(100%-var(--steps-dot)-.75rem)]",
                  )}
                />
              )}
              {canGoBack ? (
                <button
                  type="button"
                  onClick={() => setValue(index)}
                  // 导航内容允许多行,覆盖固定高度;触屏点击区仍至少 44px。
                  className={cn(
                    buttonVariants({ variant: "ghost", size: "lg" }),
                    contentClass,
                    "h-auto justify-start whitespace-normal motion-reduce:transition-none pointer-coarse:h-auto pointer-coarse:min-h-11",
                  )}
                >
                  {content}
                </button>
              ) : (
                <div className={contentClass}>{content}</div>
              )}
            </li>
          )
        })}
      </ol>
    </div>
  )
}

export { Steps, stepsVariants }
export type { StepItem, StepStatus, StepsProps } from "./types"
export type { StepsLabels } from "./config"
@ui/steps/types.ts
import type * as React from "react"

import type { StepsLabels } from "./config"

type StepStatus = "wait" | "process" | "finish" | "error"

type StepItem = {
  /** 步骤标题,长标题自动换行 */
  title: React.ReactNode
  /** 补充信息,竖排时靠右显示 */
  description?: React.ReactNode
  /** 覆盖按当前索引推导的状态 */
  status?: StepStatus
  /** 禁止回到这一步;不影响其状态显示。@default false */
  disabled?: boolean
}

type StepsProps = Omit<
  React.ComponentProps<"div">,
  "children" | "defaultValue"
> & {
  /** 按流程顺序排列的步骤 */
  items: StepItem[]
  /** 当前步骤索引,从 0 开始;受控时传入 */
  value?: number
  /** 非受控初始索引。@default 0 */
  defaultValue?: number
  /** 用户请求回到某一步;受控时由调用方校验后更新 value */
  onValueChange?: (value: number) => void
  /** auto 在容器窄于 28rem 时切换 compact。@default "horizontal" */
  orientation?: "horizontal" | "vertical" | "auto"
  /** compact 显示当前步摘要和分段进度。@default "default" */
  variant?: "default" | "compact"
  /** completed 只允许回到当前索引之前且状态为 finish 的步骤。@default "completed" */
  clickable?: "completed" | "none"
  /** 圆点与文字尺寸;竖排行最小高 36px,触屏 44px。@default "default" */
  size?: "sm" | "default"
  /** 覆盖中文摘要、状态与列表名称 */
  labels?: Partial<StepsLabels>
}

export type { StepItem, StepStatus, StepsProps }
@ui/steps/config.ts
const DEFAULT_LABELS = {
  /** 支持 {current} 和 {total} 占位符 */
  compactText: "第 {current} 步 / 共 {total} 步",
  list: "流程步骤",
  srWait: "未开始",
  srProcess: "进行中",
  srFinish: "已完成",
  srError: "出错",
  srDisabled: "不可返回",
}

type StepsLabels = typeof DEFAULT_LABELS

export { DEFAULT_LABELS }
export type { StepsLabels }
hooks/use-controllable-state.ts
"use client"

import * as React from "react"

type UseControllableStateParams<T> = {
  /** 受控值;传入后组件不再维护内部状态 */
  prop?: T
  /** 非受控时的初始值 */
  defaultProp: T
  /** 值变化时回调(受控与非受控都会触发) */
  onChange?: (value: T) => void
}

/**
 * 同时支持受控与非受控的状态 hook。
 * 约定见 component-conventions 技能第 4 节:open/defaultOpen/onOpenChange 等成对 props 都用它实现。
 */
function useControllableState<T>({
  prop,
  defaultProp,
  onChange,
}: UseControllableStateParams<T>) {
  const [uncontrolled, setUncontrolled] = React.useState(defaultProp)
  const isControlled = prop !== undefined
  const value = isControlled ? prop : uncontrolled

  // 用 ref 保存最新的回调和值,保证 setValue 引用稳定
  const onChangeRef = React.useRef(onChange)
  const valueRef = React.useRef(value)
  React.useEffect(() => {
    onChangeRef.current = onChange
    valueRef.current = value
  })

  const setValue = React.useCallback(
    (next: React.SetStateAction<T>) => {
      const resolved =
        typeof next === "function"
          ? (next as (prev: T) => T)(valueRef.current)
          : next
      if (Object.is(resolved, valueRef.current)) return
      valueRef.current = resolved
      if (!isControlled) setUncontrolled(resolved)
      onChangeRef.current?.(resolved)
    },
    [isControlled],
  )

  return [value, setValue] as const
}

export { useControllableState }