Number Stepper
Spinbutton with −/+ controls whose digits roll in the direction of change, press-and-hold repeat that accelerates, direct typing, arrow/Page/Home/End keys, min/max/step with a shake when pushed past a bound, and Intl.NumberFormat formatting; controlled/uncontrolled.
Preview
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple
* HIG) on a coarse pointer, without changing what it looks like or the space
* it takes in layout. An invisible `::after` box, centered on the control and
* `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button
* reaches 6px past each edge, a wide 40px button only grows vertically, and a
* control already 44px or larger is left as it is. Hit-testing a pseudo
* element lands on its owner, so handlers, focus and `:active` all behave.
*
* Mouse and trackpad rendering is untouched — nothing here applies outside
* `pointer: coarse`. Requirements on the element it goes on:
* - It must be the containing block: add `relative` (and keep it mergeable, so
* a consumer's `absolute` still wins through `cn`).
* - It must not clip itself (`overflow-hidden`), or the overhang is clipped
* away along with its hit area.
* - It must render children: `<input>` and other void elements have no
* `::after`. Put it on the wrapper or label instead.
* - It must not already use `::after` for something visible.
*
* Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the
* later one wins that strip, which still ends well short of the earlier
* button's visual center. Below a ~32px pitch the later neighbour starts
* eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.
*
* The overhang is real geometry: inside a scroll container it counts as
* scrollable overflow, so a control flush against a scroller's edge can add a
* few pixels of scroll on touch screens. Give such a scroller a little padding
* on that side.
*/
export const TOUCH_TARGET_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter
* than ~32px apart in a row (pagination dots, dense swatches), where growing
* sideways would hand most of each control to its neighbour. Same rules.
*/
export const TOUCH_TARGET_Y_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/motion/number-stepper
import { Minus, Plus } from "lucide-react";
import {
AnimatePresence,
animate,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import {
type KeyboardEvent,
type MouseEvent,
type PointerEvent,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease";
import { TOUCH_GESTURE_CLASS, TOUCH_TARGET_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
export interface NumberStepperProps {
/** Controlled value. */
value?: number;
/** Uncontrolled initial value. Default `min` when finite, else 0. */
defaultValue?: number;
onChange?: (value: number) => void;
/** Default 0. */
min?: number;
/** Default Infinity. */
max?: number;
/** Default 1. */
step?: number;
/** PageUp/PageDown stride. Default `step * 10`. */
largeStep?: number;
/** Display formatting, e.g. `{ style: "currency", currency: "USD" }`. */
formatOptions?: Intl.NumberFormatOptions;
/** Formatting locale. Fixed default so server and client render the same text. */
locale?: string;
/** Visible label above the control. */
label?: string;
"aria-label"?: string;
disabled?: boolean;
className?: string;
}
// Press-and-hold: first repeat after HOLD_DELAY, then each interval shrinks by
// HOLD_ACCEL down to HOLD_MIN — slow enough to stop on a target, fast enough
// to cross large ranges.
const HOLD_DELAY = 400;
const HOLD_START = 140;
const HOLD_ACCEL = 0.85;
const HOLD_MIN = 30;
const SHAKE = { x: [0, -5, 5, -3, 3, 0] };
const decimalsOf = (n: number) => {
if (!Number.isFinite(n)) return 0;
const s = String(n);
const e = s.indexOf("e-");
if (e >= 0) return Number(s.slice(e + 2));
const dot = s.indexOf(".");
return dot < 0 ? 0 : s.length - dot - 1;
};
export function NumberStepper({
value: valueProp,
defaultValue,
onChange,
min = 0,
max = Number.POSITIVE_INFINITY,
step = 1,
largeStep,
formatOptions,
locale = "en-US",
label,
"aria-label": ariaLabel,
disabled = false,
className,
}: NumberStepperProps) {
const uid = useId();
const inputId = `${uid}-input`;
const reduce = useReducedMotion() ?? false;
const lo = min;
const hi = max >= min ? max : min;
const stride = step > 0 ? step : 1;
const big = largeStep && largeStep > 0 ? largeStep : stride * 10;
const decimals = Math.max(decimalsOf(stride), decimalsOf(lo));
const clampRound = (n: number) =>
Number(Math.min(hi, Math.max(lo, n)).toFixed(decimals));
const controlled = valueProp !== undefined;
const [internal, setInternal] = useState(() =>
clampRound(defaultValue ?? (Number.isFinite(lo) ? lo : 0)),
);
const value = clampRound(controlled ? valueProp : internal);
const [draft, setDraft] = useState<string | null>(null);
const [direction, setDirection] = useState<1 | -1>(1);
const fieldRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Hold-to-repeat runs on timers, so it reads the latest value from here.
const valueRef = useRef(value);
const holdTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
valueRef.current = value;
}, [value]);
useEffect(
() => () => {
if (holdTimer.current) clearTimeout(holdTimer.current);
},
[],
);
const formatter = useMemo(
() => new Intl.NumberFormat(locale, formatOptions),
[locale, formatOptions],
);
const formatted = formatter.format(value);
const percent = formatOptions?.style === "percent";
const shake = () => {
if (reduce || !fieldRef.current) return;
animate(fieldRef.current, SHAKE, { duration: 0.32, ease: EASE_OUT });
};
const commit = (next: number) => {
const current = valueRef.current;
if (next === current) return;
setDirection(next > current ? 1 : -1);
valueRef.current = next;
if (!controlled) setInternal(next);
onChange?.(next);
};
/** Steps from `from`; returns false (and shakes) when pinned at a bound. */
const stepFrom = (from: number, delta: number) => {
const next = clampRound(from + delta);
if (next === from) {
shake();
return false;
}
commit(next);
return true;
};
const parse = (raw: string) => {
const cleaned = raw.replace(/[^0-9.-]/g, "");
if (!cleaned || cleaned === "-" || cleaned === ".") return Number.NaN;
const n = Number(cleaned);
return percent ? n / 100 : n;
};
/** Applies typed text; returns the value it settled on. */
const commitDraft = () => {
if (draft === null) return valueRef.current;
setDraft(null);
const parsed = parse(draft);
if (Number.isNaN(parsed)) {
shake();
return valueRef.current;
}
const clean = clampRound(parsed);
if (clean !== Number(parsed.toFixed(decimals))) shake();
commit(clean);
return clean;
};
const stopHold = () => {
if (holdTimer.current) clearTimeout(holdTimer.current);
holdTimer.current = null;
};
const startHold = (delta: number) => {
stopHold();
if (!stepFrom(commitDraft(), delta)) return;
let interval = HOLD_START;
const tick = () => {
if (!stepFrom(valueRef.current, delta)) {
holdTimer.current = null;
return;
}
interval = Math.max(HOLD_MIN, interval * HOLD_ACCEL);
holdTimer.current = setTimeout(tick, interval);
};
holdTimer.current = setTimeout(tick, HOLD_DELAY);
};
const onInputKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (disabled) return;
const deltas: Record<string, number> = {
ArrowUp: stride,
ArrowDown: -stride,
PageUp: big,
PageDown: -big,
};
if (e.key in deltas) {
e.preventDefault();
stepFrom(commitDraft(), deltas[e.key]);
inputRef.current?.select();
return;
}
if (e.key === "Home" && Number.isFinite(lo)) {
e.preventDefault();
setDraft(null);
if (valueRef.current === lo) shake();
commit(lo);
} else if (e.key === "End" && Number.isFinite(hi)) {
e.preventDefault();
setDraft(null);
if (valueRef.current === hi) shake();
commit(hi);
} else if (e.key === "Enter") {
e.preventDefault();
commitDraft();
inputRef.current?.select();
} else if (e.key === "Escape") {
setDraft(null);
}
};
const atMin = value <= lo;
const atMax = value >= hi;
const editing = draft !== null;
const integerOnly = decimals === 0 && !percent;
const button = (dir: 1 | -1) => {
const pinned = dir > 0 ? atMax : atMin;
const Icon = dir > 0 ? Plus : Minus;
return (
<motion.button
type="button"
tabIndex={-1}
disabled={disabled}
// aria-disabled, not disabled, at a bound: the button must still
// receive the press so it can shake back at the user.
aria-disabled={pinned || undefined}
aria-label={dir > 0 ? "Increase" : "Decrease"}
aria-controls={inputId}
whileTap={reduce || pinned ? undefined : { scale: 0.86 }}
transition={SPRING_PRESS}
onPointerDown={(e: PointerEvent<HTMLButtonElement>) => {
if (disabled || e.button !== 0) return;
startHold(dir * stride);
}}
onPointerUp={stopHold}
onPointerLeave={stopHold}
onPointerCancel={stopHold}
onContextMenu={(e: MouseEvent) => e.preventDefault()}
onClick={(e: MouseEvent<HTMLButtonElement>) => {
// Pointer presses already stepped on pointerdown; this is the
// keyboard / assistive-tech path.
if (e.detail === 0 && !disabled) stepFrom(commitDraft(), dir * stride);
}}
className={cn(
"relative grid size-9 shrink-0 place-items-center rounded-full text-foreground outline-none transition-colors duration-150",
"hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed",
pinned && "text-muted-foreground/50 hover:bg-transparent",
TOUCH_GESTURE_CLASS,
TOUCH_TARGET_CLASS,
)}
>
<Icon className="size-4" aria-hidden />
</motion.button>
);
};
return (
<div className={cn("inline-flex flex-col gap-1.5", className)}>
{label ? (
<label
htmlFor={inputId}
className="px-1 text-sm font-medium text-foreground"
>
{label}
</label>
) : null}
<div
ref={fieldRef}
className={cn(
"flex h-12 items-center gap-1 rounded-full border border-border bg-background p-1 transition-colors duration-200",
"focus-within:border-foreground/40 focus-within:ring-2 focus-within:ring-ring/40",
disabled && "opacity-60",
)}
>
{button(-1)}
<div className="relative flex h-full min-w-16 flex-1 items-center justify-center">
<input
ref={inputRef}
id={inputId}
type="text"
role="spinbutton"
inputMode={integerOnly && lo >= 0 ? "numeric" : "decimal"}
autoComplete="off"
spellCheck={false}
disabled={disabled}
aria-label={label ? undefined : (ariaLabel ?? "Value")}
aria-valuenow={value}
aria-valuemin={Number.isFinite(lo) ? lo : undefined}
aria-valuemax={Number.isFinite(hi) ? hi : undefined}
aria-valuetext={formatted}
value={draft ?? formatted}
onFocus={(e) => e.currentTarget.select()}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => commitDraft()}
onKeyDown={onInputKeyDown}
size={Math.max(formatted.length, 2)}
className={cn(
"h-full w-full bg-transparent text-center text-lg font-semibold tabular-nums outline-none selection:bg-ring/30 disabled:cursor-not-allowed",
// Until the user types, the rolling digits on top do the talking.
editing ? "text-foreground" : "text-transparent caret-foreground",
)}
/>
{editing ? null : (
<span className="pointer-events-none absolute inset-0 flex items-center justify-center text-lg font-semibold text-foreground">
<RollingText
text={formatted}
direction={direction}
reduce={reduce}
/>
</span>
)}
</div>
{button(1)}
</div>
</div>
);
}
/* -------------------------------------------------------------------------- */
/* Rolling text */
/* -------------------------------------------------------------------------- */
// NumberTicker rolls each digit along a fixed 0–9 column, so 9 → 0 on an
// increment travels backwards. A stepper needs the roll to follow intent —
// up rolls up, down rolls down — hence this direction-aware slot.
const roll: Variants = {
enter: (dir: number) => ({ y: dir > 0 ? "70%" : "-70%", opacity: 0 }),
center: { y: "0%", opacity: 1 },
exit: (dir: number) => ({
y: dir > 0 ? "-70%" : "70%",
opacity: 0,
transition: { duration: 0.14, ease: EASE_OUT },
}),
};
const fade: Variants = {
enter: { opacity: 0 },
center: { opacity: 1 },
exit: { opacity: 0, transition: { duration: 0.1 } },
};
function RollingText({
text,
direction,
reduce,
}: {
text: string;
direction: 1 | -1;
reduce: boolean;
}) {
// Rendered without entrance on the first paint (and on the server), so the
// number is visible before hydration.
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const chars = text.split("");
// Key by place value from the right, so a digit keeps its slot as the
// number grows or shrinks on the left.
const glyphs = chars.map((char, i) => ({ char, place: chars.length - 1 - i }));
return (
<span aria-hidden className="inline-flex tabular-nums">
{glyphs.map(({ char, place }) => (
<motion.span
key={place}
layout={reduce ? false : "position"}
transition={{ layout: SPRING_LAYOUT }}
className="relative inline-flex h-[1.4em] items-center justify-center overflow-hidden"
>
<span className="invisible">{char}</span>
<AnimatePresence initial={false} custom={direction}>
<motion.span
key={char}
custom={direction}
variants={reduce ? fade : roll}
initial={mounted ? "enter" : false}
animate="center"
exit="exit"
transition={reduce ? { duration: 0.12 } : SPRING_SWAP}
className="absolute inset-0 flex items-center justify-center"
>
{char}
</motion.span>
</AnimatePresence>
</motion.span>
))}
</span>
);
}
"use client";
import { animate, useInView, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface AnimatedNumberProps {
value: number;
duration?: number;
format?: (n: number) => string;
className?: string;
startOnView?: boolean;
}
export function AnimatedNumber({
value,
duration = 1.2,
format = (n) => Math.round(n).toLocaleString("en-US"),
className,
startOnView = true,
}: AnimatedNumberProps) {
const ref = useRef<HTMLSpanElement>(null);
const inView = useInView(ref, { once: true, amount: 0.6 });
const reduce = useReducedMotion();
const [display, setDisplay] = useState(0);
const fromRef = useRef(0);
useEffect(() => {
if (startOnView && !inView) return;
if (reduce) {
fromRef.current = value;
setDisplay(value);
return;
}
const controls = animate(fromRef.current, value, {
duration,
ease: EASE_OUT,
onUpdate: (v) => setDisplay(v),
});
fromRef.current = value;
return () => controls.stop();
}, [value, duration, inView, startOnView, reduce]);
return (
<span ref={ref} className={cn("tabular-nums", className)}>
{format(display)}
</span>
);
}
@karaui not found? Add it to components.json
{
"registries": {
"@karaui": "https://karaui.kamisama-v0.my.id/r/{name}.json"
}
}@karaui is in shadcn's public registry directory, so the latest CLI needs no setup. On an older CLI, merge this into the existing file once and every @karaui/… install resolves.
API Reference
value?numberControlled value.
—defaultValue?numberUncontrolled initial value. Default `min` when finite, else 0.
—onChange?((value: number) => void)—min?numberDefault 0.
0max?numberDefault Infinity.
Number.POSITIVE_INFINITYstep?numberDefault 1.
1largeStep?numberPageUp/PageDown stride. Default `step * 10`.
—formatOptions?NumberFormatOptionsDisplay formatting, e.g. `{ style: "currency", currency: "USD" }`.
—locale?stringFormatting locale. Fixed default so server and client render the same text.
en-USlabel?stringVisible label above the control.
—aria-label?string—disabled?booleanfalseclassName?string—Updated