数字步进器
加一时数字向上滑入,减一时向下滑入,让变化的方向既能看到也能感受到。
人数
2
用 React 和 CSS 制作一个两侧带减号和加号按钮的数量步进器。在 state 中保存数值和最近一次变化的方向。数字放在一个以数值为 key 的 span 中,让 React 在每次变化时重新挂载它,并设置 data-dir="up" 或 "down"。挂载时播放一个 280ms 的 keyframe,缓动 cubic-bezier(0.16, 1, 0.3, 1):向上时从 translateY(60%)、opacity 0 开始,向下时从 translateY(-60%) 开始,结束于 translateY(0)、opacity 1。数字框设置 overflow: hidden 和 tabular-nums。数值限制在 1 到 9 之间,到达上下限时禁用对应按钮。数字外层设置 aria-live="polite",播报新的数值。在 prefers-reduced-motion 下去掉滑动。标签为“人数”。
源代码
'use client'
import { useState } from 'react'
import s from './demo.module.css'
export default function NumberStepper({ labels }: { labels: Record<string, string> }) {
const [state, setState] = useState({ n: 2, dir: 'up' })
const step = (d: 1 | -1) => setState(({ n }) => ({ n: n + d, dir: d > 0 ? 'up' : 'down' }))
return (
<div className={s.wrap}>
<span className={s.label}>{labels.label}</span>
<div className={s.stepper}>
<button type="button" className={s.btn} aria-label={labels.minus} disabled={state.n <= 1} onClick={() => step(-1)}>
−
</button>
<span className={s.value} aria-live="polite">
<span key={state.n} className={s.num} data-dir={state.dir}>
{state.n}
</span>
</span>
<button type="button" className={s.btn} aria-label={labels.plus} disabled={state.n >= 9} onClick={() => step(1)}>
+
</button>
</div>
</div>
)
}
.wrap {
display: grid;
justify-items: center;
gap: 12px;
}
.label {
font: 500 var(--text-sm) / 1 var(--font-body);
color: var(--stage-muted);
}
.stepper {
display: flex;
align-items: center;
gap: 4px;
padding: 4px;
border: 1px solid var(--stage-rule);
border-radius: 999px;
background: var(--stage-surface);
}
.btn {
width: 44px;
height: 44px;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--stage-ink);
font: 400 var(--text-2xl) / 1 var(--font-body);
cursor: pointer;
transition: background-color var(--dur-micro);
}
.btn:hover:not(:disabled) { background: var(--stage-bg); }
.btn:disabled { color: var(--stage-rule); cursor: not-allowed; }
.btn:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
.value {
display: grid;
place-items: center;
width: 48px;
height: 44px;
overflow: hidden;
}
.num {
font: 600 var(--text-2xl) / 1 var(--font-display);
font-variant-numeric: tabular-nums;
color: var(--stage-ink);
animation: in-up 280ms var(--ease-out);
}
.num[data-dir='down'] { animation-name: in-down; }
@keyframes in-up {
from { transform: translateY(60%); opacity: 0; }
}
@keyframes in-down {
from { transform: translateY(-60%); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.num { animation: none; }
}