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-stepper

Examples

Default

Open alone

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.

PropTypeDefaultNotes
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.
disabledbooleanfalse
format((index: number, count: number) => ReactNode)—How the value reads when nothing names it — a quantity reads `x3`, not "3 / 99".
loopbooleantrueWrap from the last back to the first. On by default, as a game's selector does.
optionsReactNode[]—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

Source

components/ds/option-stepper.tsxShow
'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 };