liten

Number Input

A stepper split into minus, value, and plus by hairline seams, rolling the value in the direction it changed with press-and-hold auto-repeat.

A compact stepper in our depth aesthetic: a raised, top-lit panel divided into minus, value, and plus by hairline seams. The buttons lift into a chip on hover and press down on click. Change the value and the digits roll in the direction they moved, up enters from below, down from above, so the change reads instantly. Built with Motion.

Click a button, or focus the value and use the arrow keys (Home and End jump to the bounds). Press and hold a button to auto-repeat, it accelerates the longer you hold.

Installation

Complete the shared Setup first, then copy the component into components/ui/number-input.tsx.

Terminal
bun add motion
components/ui/number-input.tsx
'use client';import * as React from 'react';import { AnimatePresence, motion, useReducedMotion } from 'motion/react';import { Minus, Plus } from 'lucide-react';import { cn } from '@/lib/cn';const EASE_OUT = [0.23, 1, 0.32, 1] as const;const HOLD_DELAY = 320;const HOLD_START = 140;const HOLD_MIN = 36;const HOLD_RAMP = 0.82;const FOCUS =  'focus-visible:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:[outline-color:var(--ni-accent)] focus-visible:[outline-offset:-2px]';export type NumberInputProps = {  value?: number;  defaultValue?: number;  onChange?: (value: number) => void;  min?: number;  max?: number;  step?: number;  accent?: string;  disabled?: boolean;  format?: (value: number) => string;  'aria-label'?: string;  className?: string;};const clamp = (n: number, min: number, max: number) =>  Math.min(max, Math.max(min, n));function snap(n: number, step: number, min: number) {  const base = Number.isFinite(min) ? min : 0;  const snapped = Math.round((n - base) / step) * step + base;  const decimals = (String(step).split('.')[1] ?? '').length;  return decimals ? Number(snapped.toFixed(decimals)) : snapped;}export function NumberInput({  value: controlled,  defaultValue = 0,  onChange,  min = -Infinity,  max = Infinity,  step = 1,  accent = '#3ecf8e',  disabled = false,  format,  'aria-label': ariaLabel = 'Quantity',  className,}: NumberInputProps) {  const reduce = useReducedMotion();  const isControlled = controlled !== undefined;  const [uncontrolled, setUncontrolled] = React.useState(() =>    clamp(defaultValue, min, max),  );  const value = isControlled ? (controlled as number) : uncontrolled;  const [dir, setDir] = React.useState(0);  const atMin = value <= min;  const atMax = value >= max;  const commit = React.useCallback(    (next: number) => {      const clamped = clamp(snap(next, step, min), min, max);      if (clamped === value) return;      setDir(Math.sign(clamped - value));      if (!isControlled) setUncontrolled(clamped);      onChange?.(clamped);    },    [value, step, min, max, isControlled, onChange],  );  const commitRef = React.useRef(commit);  React.useEffect(() => {    commitRef.current = commit;  }, [commit]);  const step1 = (sign: 1 | -1) => commitRef.current(value + sign * step);  const holdTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);  const stopHold = React.useCallback(() => {    if (holdTimer.current) {      clearTimeout(holdTimer.current);      holdTimer.current = null;    }  }, []);  const startHold = (sign: 1 | -1) => {    step1(sign);    let interval = HOLD_START;    const tick = () => {      commitRef.current(valueRef.current + sign * step);      if (        (sign > 0 && valueRef.current >= max) ||        (sign < 0 && valueRef.current <= min)      ) {        stopHold();        return;      }      interval = Math.max(HOLD_MIN, interval * HOLD_RAMP);      holdTimer.current = setTimeout(tick, interval);    };    holdTimer.current = setTimeout(tick, HOLD_DELAY);  };  const valueRef = React.useRef(value);  React.useEffect(() => {    valueRef.current = value;  }, [value]);  React.useEffect(() => stopHold, [stopHold]);  const onKeyDown = (e: React.KeyboardEvent) => {    if (disabled) return;    if (e.key === 'ArrowUp') {      e.preventDefault();      step1(1);    } else if (e.key === 'ArrowDown') {      e.preventDefault();      step1(-1);    } else if (e.key === 'Home' && Number.isFinite(min)) {      e.preventDefault();      commit(min);    } else if (e.key === 'End' && Number.isFinite(max)) {      e.preventDefault();      commit(max);    }  };  const display = format ? format(value) : String(value);  return (    <div      style={{ '--ni-accent': accent } as React.CSSProperties}      className={cn(        'bloom-edge inline-flex h-11 select-none items-stretch overflow-hidden rounded-[14px]',        'bg-gradient-to-b from-white to-[#f4f4f5] text-neutral-900',        'dark:from-[#1d1d1d] dark:to-[#161716] dark:text-white',        'shadow-[0_1px_2px_0_rgba(0,0,0,0.05),0_5px_14px_-10px_rgba(0,0,0,0.1)]',        'dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.05),0_1px_2px_0_rgba(0,0,0,0.4),0_6px_16px_-10px_rgba(0,0,0,0.45)]',        disabled && 'opacity-50',        className,      )}    >      <StepButton        kind="minus"        disabled={disabled || atMin}        reduce={!!reduce}        onPress={() => startHold(-1)}        onRelease={stopHold}      />      <Divider />      <div        role="spinbutton"        tabIndex={disabled ? -1 : 0}        aria-label={ariaLabel}        aria-valuenow={value}        aria-valuemin={Number.isFinite(min) ? min : undefined}        aria-valuemax={Number.isFinite(max) ? max : undefined}        aria-valuetext={format ? display : undefined}        aria-disabled={disabled || undefined}        onKeyDown={onKeyDown}        className={cn(          'relative grid min-w-[3.5rem] place-items-center px-3 rounded-[6px]',          FOCUS,        )}      >        <div className="relative grid place-items-center overflow-hidden">          <span            aria-hidden            className="invisible px-0.5 text-[17px] font-semibold tabular-nums tracking-[-0.01em]"          >            {display}          </span>          <AnimatePresence mode="popLayout" initial={false} custom={dir}>            <motion.span              key={display}              aria-hidden              custom={dir}              initial={                reduce                  ? { opacity: 0 }                  : { opacity: 0, y: dir >= 0 ? '0.6em' : '-0.6em', filter: 'blur(4px)' }              }              animate={{ opacity: 1, y: 0, filter: 'blur(0px)' }}              exit={                reduce                  ? { opacity: 0, transition: { duration: 0.1 } }                  : {                      opacity: 0,                      y: dir >= 0 ? '-0.6em' : '0.6em',                      filter: 'blur(4px)',                      transition: { duration: 0.12, ease: EASE_OUT },                    }              }              transition={{ duration: 0.22, ease: EASE_OUT }}              className="absolute px-0.5 text-[17px] font-semibold tabular-nums tracking-[-0.01em]"            >              {display}            </motion.span>          </AnimatePresence>        </div>      </div>      <Divider />      <StepButton        kind="plus"        disabled={disabled || atMax}        reduce={!!reduce}        onPress={() => startHold(1)}        onRelease={stopHold}      />    </div>  );}function Divider() {  return (    <span      aria-hidden      className="my-2 w-px bg-gradient-to-b from-black/[0.04] via-black/[0.09] to-black/[0.04] dark:from-white/[0.03] dark:via-white/[0.1] dark:to-white/[0.03]"    />  );}function StepButton({  kind,  disabled,  reduce,  onPress,  onRelease,}: {  kind: 'minus' | 'plus';  disabled: boolean;  reduce: boolean;  onPress: () => void;  onRelease: () => void;}) {  const Icon = kind === 'plus' ? Plus : Minus;  const begin = (e: React.PointerEvent) => {    if (disabled) return;    e.preventDefault();    (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);    onPress();  };  const end = (e: React.PointerEvent) => {    try {      (e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId);    } catch {}    onRelease();  };  return (    <motion.button      type="button"      tabIndex={-1}      disabled={disabled}      aria-label={kind === 'plus' ? 'Increment' : 'Decrement'}      onPointerDown={begin}      onPointerUp={end}      onPointerLeave={end}      onPointerCancel={end}      whileTap={reduce || disabled ? undefined : { scale: 0.92 }}      className={cn(        'group relative grid w-11 shrink-0 place-items-center',        'text-neutral-500 dark:text-[#929292]',        'transition-colors duration-150',        !disabled && 'cursor-pointer hover:text-neutral-900 dark:hover:text-white',        disabled && 'cursor-not-allowed opacity-40',      )}    >      {!disabled && (        <span          aria-hidden          className={cn(            'pointer-events-none absolute inset-1 rounded-[9px] opacity-0',            'transition-opacity duration-150',            'bg-gradient-to-b from-white to-[#f0f0f1] dark:from-[#2a2a2a] dark:to-[#242424]',            'shadow-[0_0_0_1px_rgba(0,0,0,0.05),inset_0_1px_0_0_rgba(255,255,255,0.9),0_2px_5px_-1px_rgba(0,0,0,0.12)]',            'dark:shadow-[0_0_0_1px_rgba(255,255,255,0.06),inset_0_1px_0_0_rgba(255,255,255,0.06),0_3px_8px_-2px_rgba(0,0,0,0.6)]',            'group-hover:opacity-100 group-active:opacity-100',          )}        />      )}      <Icon className="relative size-4" strokeWidth={2.25} aria-hidden />    </motion.button>  );}

Usage

Example.tsx
import { NumberInput } from '@/components/ui/number-input';

export default function Example() {
  return <NumberInput defaultValue={3} min={0} max={99} />;
}

Examples

Give it a step and an accent. Holding a button flies through the range.

Pass value and onChange to control it, and format to render units or currency without changing the underlying number.

Props

PropTypeDefaultDescription
valuenumber-Controlled value. Pair with onChange.
defaultValuenumber0Uncontrolled starting value.
onChange(value: number) => void-Fired with the next clamped, step-snapped value.
minnumber-InfinitySmallest allowed value.
maxnumberInfinityLargest allowed value.
stepnumber1Increment per step.
accentstring"#3ecf8e"Focus outline and held-button tint.
disabledbooleanfalseDisables the whole control.
format(value: number) => string-Formats the displayed number (currency, units).
aria-labelstring"Quantity"Accessible label for the spinbutton.
classNamestring-Forwarded to the panel.
On this page0%