Game UI
Option stepper
Cycle a set with ‹ ›: a shirt’s model, a texture, a quantity.
stablev0.5.0 Chromium 103 ui-scale safe
Install
Copies the file into
components/ds/, with its registry dependencies and npm packages. See Installation for the one-time setup.bunx shadcn@3 add @fastrp/option-stepperExamples
Default
API
<OptionStepper />Cycling through a set — a shirt's variation, a texture, a colour — with ‹ › on the right of a menu row. ← → step it while the row has the cursor; the value rolls in from the side it came from.
| Prop | Type | Default | Notes |
|---|---|---|---|
| count* | number | — | How many there are; the row reads "3 / 12" unless `options` names them. |
| label* | ReactNode | — | |
| onChange* | (index: number) => void | — | |
| value* | number | — | The current index, from 0. |
| disabled | boolean | false | |
| format | ((index: number, count: number) => ReactNode) | — | How the value reads when nothing names it — a quantity reads `x3`, not "3 / 99". |
| loop | boolean | true | Wrap from the last back to the first. On by default, as a game's selector does. |
| options | ReactNode[] | — | Names for the options, shown instead of the number: "Kırmızı", "Lacivert". |
Dependencies
npm
- @fastrp/design-tokens
- lucide-react@^0.469.0
- motion@^12.40.0
Registry
- @fastrp/menu-list
- @fastrp/ui-sounds
- @fastrp/utils
Source
components/ds/option-stepper.tsxShowHide
'use client';
import { DURATION_S, EASE } from '@fastrp/design-tokens/motion';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { AnimatePresence, motion, useReducedMotion } from 'motion/react';
import * as React from 'react';
import { focusOnHover, menuRowClassName } from '@/components/ds/menu-list';
import { useUiSound } from '@/components/ds/ui-sounds';
import { cn } from '@/lib/utils';
// @fast-ds [email protected]
export type OptionStepperProps = Omit<React.ComponentProps<'div'>, 'onChange'> & {
label: React.ReactNode;
/** The current index, from 0. */
value: number;
/** How many there are; the row reads "3 / 12" unless `options` names them. */
count: number;
/** Names for the options, shown instead of the number: "Kırmızı", "Lacivert". */
options?: React.ReactNode[];
/** How the value reads when nothing names it — a quantity reads `x3`, not "3 / 99". */
format?: (index: number, count: number) => React.ReactNode;
onChange: (index: number) => void;
/** Wrap from the last back to the first. On by default, as a game's selector does. */
loop?: boolean;
disabled?: boolean;
};
/**
* Cycling through a set — a shirt's variation, a texture, a colour — with ‹ › on the right of a
* menu row. ← → step it while the row has the cursor; the value rolls in from the side it came
* from.
*/
function OptionStepper({
label,
value,
count,
options,
format,
onChange,
loop = true,
disabled = false,
className,
onPointerEnter,
...props
}: OptionStepperProps) {
const sound = useUiSound();
const reduceMotion = useReducedMotion();
const [direction, setDirection] = React.useState<1 | -1>(1);
const step = (delta: 1 | -1) => {
if (disabled || count <= 0) return;
const next = loop
? (value + delta + count) % count
: Math.min(count - 1, Math.max(0, value + delta));
if (next === value) {
sound('error');
return;
}
setDirection(delta);
sound('adjust');
onChange(next);
};
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'ArrowLeft') {
event.preventDefault();
step(-1);
} else if (event.key === 'ArrowRight') {
event.preventDefault();
step(1);
}
};
const atStart = !loop && value <= 0;
const atEnd = !loop && value >= count - 1;
const arrow =
'flex size-8 items-center justify-center rounded-menu transition-[background-color,transform] duration-fast hover:bg-foreground/15 active:scale-90 group-focus/row:hover:bg-background/15 disabled:opacity-30 [&_svg]:size-5';
return (
<div
data-slot="option-stepper"
data-menu-row
data-sfx-hover="navigate"
role="spinbutton"
tabIndex={disabled ? -1 : 0}
aria-label={typeof label === 'string' ? label : undefined}
aria-valuenow={value + 1}
aria-valuemin={1}
aria-valuemax={count}
aria-valuetext={typeof options?.[value] === 'string' ? (options[value] as string) : undefined}
aria-disabled={disabled || undefined}
onKeyDown={onKeyDown}
onPointerEnter={(event) => {
onPointerEnter?.(event);
focusOnHover(event);
}}
className={cn(menuRowClassName, disabled && 'opacity-40', className)}
{...props}
>
<span className="min-w-0 flex-1 truncate">{label}</span>
<span className="flex shrink-0 items-center gap-1">
<button
type="button"
tabIndex={-1}
aria-label="Önceki"
disabled={disabled || atStart}
onClick={() => step(-1)}
className={arrow}
>
<ChevronLeft />
</button>
<span className="relative min-w-[4.5rem] overflow-hidden text-center tabular-nums">
<AnimatePresence mode="popLayout" initial={false} custom={direction}>
<motion.span
key={value}
custom={direction}
className="block"
variants={{
enter: (d: number) => ({ x: reduceMotion ? 0 : `${d * 60}%`, opacity: 0 }),
center: { x: '0%', opacity: 1 },
exit: (d: number) => ({ x: reduceMotion ? 0 : `${-d * 60}%`, opacity: 0 }),
}}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: DURATION_S.fast, ease: EASE }}
>
{options?.[value] ?? (format ? format(value, count) : `${value + 1} / ${count}`)}
</motion.span>
</AnimatePresence>
</span>
<button
type="button"
tabIndex={-1}
aria-label="Sonraki"
disabled={disabled || atEnd}
onClick={() => step(1)}
className={arrow}
>
<ChevronRight />
</button>
</span>
</div>
);
}
export { OptionStepper };