"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Steps } from "@/@ui/steps/steps"
const items = [
{ title: "填写信息", description: "收货地址" },
{ title: "选择支付", description: "确认订单" },
{ title: "下单成功", description: "等待发货" },
]
export default function StepsDemo() {
const [value, setValue] = React.useState(1)
return (
<div className="w-full max-w-xl rounded-xl border border-edge bg-background p-4 shadow-xs">
<div className="flex items-baseline justify-between gap-2 pb-5">
<p className="text-sm font-medium">订单结算</p>
<p className="font-mono text-[11px] text-muted-foreground tabular-nums">
{value + 1} / {items.length}
</p>
</div>
<Steps items={items} value={value} onValueChange={setValue} />
<div className="mt-5 flex items-center justify-between gap-3">
<Button
size="default"
variant="outline"
disabled={value === 0}
onClick={() => setValue(value - 1)}
>
上一步
</Button>
<Button
size="default"
disabled={value === items.length - 1}
onClick={() => setValue(value + 1)}
>
下一步
</Button>
</div>
<p className="mt-3 text-xs text-muted-foreground">
点已完成的步骤,可以返回修改。
</p>
</div>
)
}
安装
pnpm dlx shadcn@latest add https://yiui.dev/r/steps.json用法
"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;最后一步提交由业务表单处理。
"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" 显示感叹号和错误色,并为读屏补上「出错」。
import { Steps } from "@/@ui/steps/steps"
export default function StepsVerticalDemo() {
return (
<div className="w-full max-w-xl rounded-xl border border-edge bg-background p-4 shadow-xs">
<div className="flex items-baseline justify-between gap-2 pb-3">
<p className="text-sm font-medium">实名认证</p>
<p className="font-mono text-[11px] text-muted-foreground">3 / 4</p>
</div>
<Steps
orientation="vertical"
defaultValue={2}
items={[
{ title: "验证手机号", description: "138 **** 8000" },
{ title: "填写身份信息", description: "张 * · 1101 **** 001X" },
{ title: "上传证件", description: "反面不清晰", status: "error" },
{ title: "人脸核验", description: "等待上传完成" },
]}
/>
<p className="mt-3 text-xs text-muted-foreground">
请重新拍摄证件反面,确保文字清晰、边角完整。
</p>
</div>
)
}
随容器宽度切换
orientation="auto" 在组件容器窄于 28rem 时显示 compact,宽度足够时恢复横排。拖动滑块观察切换;手机上的可用空间较小时会一直保持 compact。也可以用 variant="compact" 固定为紧凑形态。
"use client"
import * as React from "react"
import { Steps } from "@/@ui/steps/steps"
import { ValueSlider } from "@/@ui/value-slider/value-slider"
export default function StepsCompactDemo() {
const [width, setWidth] = React.useState(100)
return (
<div className="w-full max-w-xl rounded-xl border border-edge bg-background p-4 shadow-xs">
<div className="flex items-baseline justify-between gap-2 pb-3">
<p className="text-sm font-medium">随容器收起</p>
<p className="font-mono text-[11px] text-muted-foreground">
auto / 28rem
</p>
</div>
<ValueSlider
label="容器宽度"
min={45}
max={100}
value={width}
onValueChange={(next) => setWidth(next as number)}
formatValue={(value) => `${value}%`}
className="mb-4"
/>
<div
className="min-w-0 rounded-lg border border-dashed border-edge p-3"
style={{ width: `${width}%` }}
>
<Steps
orientation="auto"
defaultValue={1}
items={[
{ title: "填写信息", description: "收货地址" },
{ title: "选择支付", description: "确认订单" },
{ title: "下单成功", description: "等待发货" },
]}
/>
</div>
<p className="mt-3 text-xs text-muted-foreground">
拖动滑块缩小容器;可用宽度不足 28rem 时显示摘要与分段进度。
</p>
</div>
)
}
API
按流程顺序排列的步骤
类型StepItem[]当前步骤索引,从 0 开始;受控时传入
类型number | undefined非受控初始索引。@default 0
类型number | undefined用户请求回到某一步;受控时由调用方校验后更新 value
类型((value: number) => void) | undefinedauto 在容器窄于 28rem 时切换 compact。@default "horizontal"
类型"auto" | "horizontal" | "vertical" | undefinedcompact 显示当前步摘要和分段进度。@default "default"
类型"compact" | "default" | undefinedcompleted 只允许回到当前索引之前且状态为 finish 的步骤。@default "completed"
类型"completed" | "none" | undefined圆点与文字尺寸;竖排行最小高 36px,触屏 44px。@default "default"
类型"default" | "sm" | undefined覆盖中文摘要、状态与列表名称
类型Partial<{ compactText: string; list: string; srWait: string; srProcess: string; srFinish: string; srError: string; srDisabled: string; }> | undefined根元素为 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 容器查询,无需测量浏览器尺寸,服务端渲染时内容即可见。- 本示例表单只展示校验流程。实际业务应将字段值保存在外部状态或表单库中,保证切换步骤后仍能恢复。
源码
"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"
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 }
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 }
"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 }