For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Number Field

A formatted number typed or stepped, with minus and plus buttons at the end of an Input.

Installation

$ pnpm dlx shadcn@latest add @cobalt/number-field

Usage

import { NumberField } from "@/components/ui/number-field";
 
<NumberField label="Seats" defaultValue={5} min={1} max={20} />;

The field formats and parses the value for the reader's locale. Pass Base UI's format for currency, percent and unit styles, and keep the value inside min and max — the steppers switch off at either limit:

<NumberField
  label="Hourly rate"
  defaultValue={49}
  min={0}
  step={5}
  format={{ style: "currency", currency: "USD" }}
/>

size="sm" gives the 32px field instead of the 36px one. hideSteppers takes the buttons away while the keys keep working. label and hint are wired to the input, required adds the asterisk, and tooltip shows an info icon next to the label. aria-invalid fills the field with the invalid surface and turns the hint red.

Keyboard

  • ArrowUp and ArrowDown step the value; hold Alt for the small step and Shift for the large one.
  • Home and End jump to min and max when they are set.
  • The scroll wheel steps the value while the field has focus.
  • Holding a stepper repeats it. The steppers stay out of the tab order because the keys do the same job.

Source

"use client";

import { NumberField as NumberFieldPrimitive } from "@base-ui/react/number-field";
import { RiInformationLine, RiSubtractLine, RiAddLine } from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";

import { Label } from "@/components/ui/label";

type IconComponent = React.ComponentType<{
  className?: string;
  "aria-hidden"?: boolean | "true" | "false";
}>;

// The shell is Input's: same 10px corners, 36/32px heights, surface, inset
// ring and 150ms transition. Hover lifts the ring one step and focus takes it
// to the active step, except while disabled or invalid.
const shellClassName = cn(
  "relative flex w-full items-center rounded-[10px] bg-[var(--surface-strong,var(--muted))] ring-2 ring-transparent ring-inset outline-none",
  "transition-[background-color,box-shadow,color] duration-150 ease-[ease] motion-reduce:transition-none",
  "hover:not-focus-within:not-has-[input:disabled]:not-has-[input[aria-invalid=true]]:ring-[var(--border-hover,var(--border))]",
  // cobalt-allow-focus-within -- text input (number input)
  "focus-within:not-has-[input:disabled]:not-has-[input[aria-invalid=true]]:ring-[var(--foreground-subtle,var(--ring))]",
  "data-disabled:bg-[var(--input-disabled-surface,var(--muted))]",
  "data-[invalid=true]:bg-[var(--input-invalid-surface,color-mix(in_srgb,var(--destructive)_15%,transparent))]"
);

// 24px (20px with `sm`) steppers. The resting colour is the source's
// foreground/icon/secondary (neutral-500 in both modes), hover fills with
// background/tertiary/hover and lifts the glyph to text/primary, and a
// disabled stepper at either limit keeps the disabled input text colour.
// React Aria's data-pressed has no Base UI counterpart, so :active stands in
// for it.
const stepperClassName = cn(
  "flex shrink-0 cursor-pointer items-center justify-center rounded-[6px] outline-none",
  "text-[color:var(--foreground-secondary,var(--muted-foreground))]",
  "transition-colors duration-150 ease-out motion-reduce:transition-none",
  "[&:hover:not([data-disabled])]:bg-[var(--border-strong,color-mix(in_oklch,var(--foreground)_12%,transparent))] [&:hover:not([data-disabled])]:text-foreground",
  "[&:active:not([data-disabled])]:bg-[var(--border-strong,color-mix(in_oklch,var(--foreground)_12%,transparent))]",
  "focus-visible:ring-2 focus-visible:ring-ring",
  "data-disabled:cursor-not-allowed data-disabled:text-[color:var(--input-disabled-foreground,var(--muted-foreground))]"
);

const startPadding = {
  icon: { default: "ps-1", sm: "ps-0.5" },
  plain: { default: "ps-2", sm: "ps-1.5" },
};

export interface NumberFieldProps extends Omit<
  NumberFieldPrimitive.Root.Props,
  "children" | "className" | "inputRef"
> {
  /** Label rendered above the field and wired to the input with `htmlFor`. */
  label?: React.ReactNode;
  /** Caption rendered below the field and wired with `aria-describedby`. */
  hint?: React.ReactNode;
  /** Show an info icon next to the label. */
  tooltip?: boolean | string;
  placeholder?: string;
  /** 36px (`default`) or 32px (`sm`). */
  size?: "default" | "sm";
  leadingIcon?: IconComponent;
  /** Hide the minus and plus buttons; the keys still step the value. */
  hideSteppers?: boolean;
  className?: string;
  fieldClassName?: string;
  /** Ref to the visible input. */
  inputRef?: React.Ref<HTMLInputElement>;
}

function NumberField({
  label,
  hint,
  tooltip,
  leadingIcon: Leading,
  hideSteppers = false,
  size = "default",
  allowWheelScrub = true,
  required = false,
  className,
  fieldClassName,
  id,
  placeholder,
  inputRef,
  "aria-label": ariaLabel,
  "aria-invalid": ariaInvalid,
  "aria-describedby": ariaDescribedBy,
  ...rootProps
}: NumberFieldProps) {
  const generatedId = React.useId();
  const inputId = id ?? generatedId;
  const hintId = React.useId();
  const isInvalid = ariaInvalid === true || ariaInvalid === "true";
  const stepperSize = size === "sm" ? "size-5" : "size-6";

  return (
    <NumberFieldPrimitive.Root
      {...rootProps}
      id={inputId}
      required={required}
      allowWheelScrub={allowWheelScrub}
      data-slot="number-field"
      className={cn(
        "group/number-field flex w-full flex-col items-start gap-1",
        className
      )}
    >
      {label ? (
        <Label
          data-slot="number-field-label"
          htmlFor={inputId}
          className="gap-0.5 leading-5"
        >
          {label}
          {required && (
            <span aria-hidden="true" className="text-destructive">
              *
            </span>
          )}
          {tooltip ? (
            <RiInformationLine
              aria-hidden={true}
              className="size-4 shrink-0 text-[color:var(--border-strong,var(--foreground-subtle,var(--muted-foreground)))]"
            />
          ) : null}
        </Label>
      ) : null}
      <NumberFieldPrimitive.Group
        data-slot="number-field-control"
        data-invalid={isInvalid ? "true" : undefined}
        className={cn(
          shellClassName,
          size === "default" ? "h-9 pe-2 py-2" : "h-8 pe-1.5 py-2",
          // Input pads 8px all round; with a leading icon the icon carries 4px of it.
          startPadding[Leading ? "icon" : "plain"][size],
          fieldClassName
        )}
      >
        <div className="flex w-full min-w-0 items-center gap-2">
          <div className="flex min-w-0 flex-1 items-center gap-0.5">
            {Leading ? (
              <Leading
                aria-hidden={true}
                className="ms-1 size-5 shrink-0 text-[color:var(--foreground-subtle,var(--muted-foreground))]"
              />
            ) : null}
            <NumberFieldPrimitive.Input
              ref={inputRef}
              data-slot="number-field-input"
              placeholder={placeholder}
              aria-label={
                ariaLabel ??
                (!label && typeof placeholder === "string"
                  ? placeholder
                  : undefined)
              }
              aria-invalid={ariaInvalid}
              aria-describedby={
                [ariaDescribedBy, hint ? hintId : undefined]
                  .filter(Boolean)
                  .join(" ") || undefined
              }
              className={cn(
                "m-0 min-w-0 flex-1 border-0 bg-transparent p-0 ps-1 text-start font-sans text-sm leading-5 font-normal text-foreground tabular-nums outline-none",
                "placeholder:text-[color:var(--foreground-subtle,var(--muted-foreground))] focus:placeholder:text-foreground",
                "disabled:cursor-not-allowed disabled:text-[color:var(--input-disabled-foreground,var(--muted-foreground))] disabled:placeholder:text-[color:var(--input-disabled-foreground,var(--muted-foreground))]",
                "aria-invalid:placeholder:text-[color:var(--input-invalid-placeholder,var(--destructive))]"
              )}
            />
          </div>
          {hideSteppers ? null : (
            // -me-1 pulls the pair into the shell's 8px padding, leaving 4px.
            <span className="-my-1 -me-1 flex shrink-0 items-center gap-0.5">
              <NumberFieldPrimitive.Decrement
                className={cn(stepperClassName, stepperSize)}
              >
                <RiSubtractLine aria-hidden={true} className="size-4" />
              </NumberFieldPrimitive.Decrement>
              <NumberFieldPrimitive.Increment
                className={cn(stepperClassName, stepperSize)}
              >
                <RiAddLine aria-hidden={true} className="size-4" />
              </NumberFieldPrimitive.Increment>
            </span>
          )}
        </div>
      </NumberFieldPrimitive.Group>
      {hint ? (
        <p
          id={hintId}
          data-slot="number-field-hint"
          className={cn(
            "pt-px text-[12px] leading-4 font-medium tracking-[0.15px]",
            isInvalid ? "text-destructive" : "text-muted-foreground"
          )}
        >
          {hint}
        </p>
      ) : null}
    </NumberFieldPrimitive.Root>
  );
}

export { NumberField };