Vela UI Docsvenizia.ai

Input Number

A number input component for forms and user data entry with built-in styling and accessibility features.

npx shadcn@latest add @velaui/input-number
npx shadcn@latest add @velaui/input-number

Examples

  • Variants
  • Inline
  • Stacked
  • Plus
  • Minus
  • Disabled
  • Invalid

View code

Input Number · Variants

Installation

pnpm dlx velaui add @velaui/input-number
pnpm dlx velaui add @velaui/input-number

Code

input-number-variants.tsx
import { InputNumber } from "@/components/ui/input-number";
import { Label } from "@/components/ui/label";

export default function Example() {
  return (
    <div className="w-full max-w-xs space-y-2">
      <Label>Variants:</Label>
      <InputNumber id="outlined" variant={"outlined"} placeholder="Outlined" />
      <InputNumber id="fill" variant={"fill"} placeholder="Fill" />
      <InputNumber
        id="underline"
        variant={"underline"}
        placeholder="underline"
      />
    </div>
  );
}

View code

Input Number · Inline

Installation

pnpm dlx velaui add @velaui/input-number
pnpm dlx velaui add @velaui/input-number

Code

input-number-inline.tsx
import { InputNumber } from "@/components/ui/input-number";
import { Label } from "@/components/ui/label";
import { useId } from "react";

export default function Example() {
  const id = useId();

  return (
    <div className="w-full max-w-xs space-y-2">
      <Label htmlFor={id}>Input number with control centered:</Label>
      <InputNumber id={id} controls="center" defaultValue={1} min={0} />
    </div>
  );
}

View code

Input Number · Stacked

Installation

pnpm dlx velaui add @velaui/input-number
pnpm dlx velaui add @velaui/input-number

Code

input-number-stacked.tsx
import { InputNumber } from "@/components/ui/input-number";
import { Label } from "@/components/ui/label";
import { useId } from "react";

export default function Example() {
  const id = useId();
  return (
    <div className="w-full max-w-xs space-y-2">
      <Label htmlFor={id}>Input number with control arrows:</Label>
      <InputNumber
        id={id}
        controls="arrow"
        defaultValue={1}
        min={0}
        max={10}
      />
    </div>
  );
}

View code

Input Number · Plus

Installation

pnpm dlx velaui add @velaui/input-number
pnpm dlx velaui add @velaui/input-number

Code

input-number-plus.tsx
import { InputNumber } from "@/components/ui/input-number";
import { Label } from "@/components/ui/label";
import { useId } from "react";

export default function Example() {
  const id = useId()

  return (
    <div className="w-full max-w-xs space-y-2">
      <Label htmlFor={id}>Input number with default controls:</Label>
      <InputNumber id={id} controls="default" defaultValue={1} min={0} />
    </div>
  );
}

View code

Input Number · Minus

Installation

pnpm dlx velaui add @velaui/input-number
pnpm dlx velaui add @velaui/input-number

Code

input-number-minus.tsx
import { InputNumber } from "@/components/ui/input-number";
import { Label } from "@/components/ui/label";
import { useId } from "react";

export default function Example() {
  const id = useId();

  return (
    <div className="w-full max-w-xs space-y-2">
      <Label htmlFor={id}>Input with default controls:</Label>
      <InputNumber id={id} controls="default" defaultValue={1} min={0} />
    </div>
  );
}

View code

Input Number · Disabled

Installation

pnpm dlx velaui add @velaui/input-number
pnpm dlx velaui add @velaui/input-number

Code

input-number-disabled.tsx
import { InputNumber } from "@/components/ui/input-number";
import { Label } from "@/components/ui/label";
import { useId } from "react";

export default function Example() {
  const id = useId();

  return (
    <div className="w-full max-w-xs space-y-2">
      <Label htmlFor={id}>Disabled input number</Label>
      <InputNumber id={id} disabled controls={"arrow"} defaultValue={1} />
    </div>
  );
}

View code

Input Number · Invalid

Installation

pnpm dlx velaui add @velaui/input-number
pnpm dlx velaui add @velaui/input-number

Code

input-number-invalid.tsx
import { InputNumber } from "@/components/ui/input-number";
import { Label } from "@/components/ui/label";
import { useId } from "react";

export default function Example() {
  const id = useId();

  return (
    <div className="w-full max-w-xs space-y-2">
      <Label htmlFor={id}>Invalid input number</Label>
      <InputNumber id={id} aria-invalid controls="arrow" defaultValue={1}/>
    </div>
  );
}

Sizes

Usage

import { InputNumber } from "@/components/ui/input-number";
import { Label } from "@/components/ui/label";

export default function Example() {
  return (
    <div className="space-y-2 w-full">
      <Label>Sizes:</Label>
      <InputNumber id="small" size={"sm"} placeholder="Small" min={0} max={10} />
      <InputNumber id="medium" size={"md"} placeholder="Medium" min={0} max={10} />
      <InputNumber id="large" size={"lg"} placeholder="Large" min={0} max={10} />
    </div>
  );
}

Props

PropTypeDefault
controlsInputNumberControls"default"
size"sm" | "md" | "lg""sm"
variant"fill" | "underline" | "outlined""outlined"

Takes these and every prop of InputNumberProps.

PropTypeDefault
controlsInputNumberControls"default"
size"sm" | "md" | "lg""sm"
variant"underline" | "fill" | "outlined""outlined"

Takes these and every prop of InputNumberProps.

Dependencies

npm
class-variance-authority@0.7.1
Installs with it
ButtonInput4 icons

Source

components/ui/input-number.tsx
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { ChevronDown } from "@/components/icons/chevron-down";
import { ChevronUp } from "@/components/icons/chevron-up";
import { Dash } from "@/components/icons/dash";
import { Plus01 } from "@/components/icons/plus-01";

import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input, type InputProps } from "@/components/ui/input";

const inputNumberVariants = cva(
  "group/input-number relative flex w-full min-w-0 items-center text-sm leading-[1.4] transition-colors outline-none has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
  {
    variants: {
      variant: {
        outlined:
          "rounded-md border border-border-default bg-bg-main hover:border-border-hover hover:bg-input-hover has-[[data-slot=input-number-control]:focus-visible]:border-ring-neutral-outline has-[[data-slot=input-number-control]:focus-visible]:bg-input-default has-[[data-slot=input-number-control]:focus-visible]:ring-3 has-[[data-slot=input-number-control]:focus-visible]:ring-ring-neutral-shadow has-disabled:border-border-disabled has-disabled:bg-input-disabled has-[[data-slot][aria-invalid=true]]:border-negative-middle has-[[data-slot][aria-invalid=true]:focus-visible]:border-negative-middle has-[[data-slot][aria-invalid=true]:focus-visible]:ring-negative-middle/25",
        // Fill draws no border at rest, but hover/focus/invalid all draw a 1px
        // one — reserve it as transparent so entering a state never reflows.
        fill: "rounded-md border border-transparent bg-input-default hover:border-border-hover hover:bg-input-hover has-[[data-slot=input-number-control]:focus-visible]:border-ring-neutral-outline has-[[data-slot=input-number-control]:focus-visible]:bg-input-default has-[[data-slot=input-number-control]:focus-visible]:ring-3 has-[[data-slot=input-number-control]:focus-visible]:ring-ring-neutral-shadow has-disabled:border-transparent has-disabled:bg-input-disabled has-[[data-slot][aria-invalid=true]]:border-negative-middle has-[[data-slot][aria-invalid=true]:focus-visible]:border-negative-middle has-[[data-slot][aria-invalid=true]:focus-visible]:ring-negative-middle/25",
        // Underline carries no surface, no radius and no focus ring — the whole
        // state vocabulary lives on the bottom border.
        underline:
          "rounded-none border-0 border-b border-b-border-default bg-transparent hover:border-b-border-active has-[[data-slot=input-number-control]:focus-visible]:border-b-border-active has-disabled:border-b-border-default has-disabled:bg-transparent has-[[data-slot][aria-invalid=true]]:border-b-negative-middle has-[[data-slot][aria-invalid=true]:focus-visible]:border-b-negative-middle",
      },
      size: {
        sm: "h-8",
        md: "h-10",
        lg: "h-12",
      },
    },
    defaultVariants: {
      variant: "outlined",
      size: "sm",
    },
  },
);

function InputNumberGroup({
  className,
  variant = "outlined",
  size = "sm",
  ...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputNumberVariants>) {
  return (
    <div
      data-slot="input-number"
      data-variant={variant}
      data-size={size}
      role="group"
      className={cn(inputNumberVariants({ variant, size }), className)}
      {...props}
    />
  );
}

const inputNumberAddonVariants = cva(
  "flex cursor-text items-center justify-center gap-1.5 text-icon-muted select-none in-data-[variant=underline]:px-0 group-has-[:disabled]/input-number:text-icon-disabled [&>svg:not([class*='size-'])]:size-4",
  {
    variants: {
      align: {
        "inline-start": "order-first",
        "inline-end": "order-last",
      },
      size: {
        sm: "",
        md: "",
        lg: "",
      },
    },
    compoundVariants: [
      // Half a step below the control's own horizontal padding, so the glyph
      // sits optically flush with the text edge.
      { align: "inline-start", size: "sm", class: "pl-2" },
      { align: "inline-start", size: "md", class: "pl-2.5" },
      { align: "inline-start", size: "lg", class: "pl-3.5" },
      { align: "inline-end", size: "sm", class: "pr-2" },
      { align: "inline-end", size: "md", class: "pr-2.5" },
      { align: "inline-end", size: "lg", class: "pr-3.5" },
    ],
    defaultVariants: {
      align: "inline-end",
      size: "sm",
    },
  },
);

function InputNumberAddon({
  className,
  align = "inline-end",
  size = "sm",
  ...props
}: React.ComponentProps<"div"> &
  VariantProps<typeof inputNumberAddonVariants>) {
  return (
    <div
      role="group"
      data-slot="input-number-addon"
      data-align={align}
      className={cn(inputNumberAddonVariants({ align, size }), className)}
      onClick={(e) => {
        if ((e.target as HTMLElement).closest("button")) {
          return;
        }
        e.currentTarget.parentElement?.querySelector("input")?.focus();
      }}
      {...props}
    />
  );
}

function InputNumberButton({
  className,
  type = "button",
  variant = "ghost",
  onMouseDown,
  ...props
}: Omit<React.ComponentProps<typeof Button>, "size">) {
  return (
    <Button
      type={type}
      variant={variant}
      size="sm"
      data-slot="input-number-button"
      className={cn(
        "size-5 shrink-0 rounded-sm p-0 text-icon-muted shadow-none transition-none has-[>svg]:p-0 disabled:text-icon-disabled",
        className,
      )}
      // Keep focus on the input so its focus ring doesn't flicker on click.
      onMouseDown={(e) => {
        e.preventDefault();
        onMouseDown?.(e);
      }}
      {...props}
    />
  );
}

function InputNumberInput({ className, ...props }: InputProps) {
  return (
    <Input
      type="number"
      data-slot="input-number-control"
      className={cn(
        // The group owns the chrome — the control renders bare in every state.
        "flex-1 rounded-none border-0 border-b-0 bg-transparent shadow-none ring-0 hover:border-transparent hover:bg-transparent focus-visible:border-transparent focus-visible:bg-transparent focus-visible:ring-0 disabled:bg-transparent aria-invalid:border-transparent aria-invalid:ring-0 [appearance:textfield] [&::-webkit-inner-spin-button]:m-0 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:m-0 [&::-webkit-outer-spin-button]:appearance-none",
        className,
      )}
      {...props}
    />
  );
}

type InputNumberControls = "default" | "center" | "arrow";

export type InputNumberProps = Omit<React.ComponentProps<"input">, "size"> &
  VariantProps<typeof inputNumberVariants> & {
    controls?: InputNumberControls;
  };

function InputNumber({
  className,
  size = "sm",
  variant = "outlined",
  controls = "default",
  ref,
  disabled,
  ...props
}: InputNumberProps) {
  const inputRef = React.useRef<HTMLInputElement>(null);
  // Merge the forwarded ref (e.g. react-hook-form's `register` ref) with the
  // internal ref the step buttons need.
  const setRef = React.useCallback(
    (node: HTMLInputElement | null) => {
      inputRef.current = node;
      if (typeof ref === "function") ref(node);
      else if (ref) ref.current = node;
    },
    [ref],
  );

  // stepUp/stepDown mutate the DOM value directly, bypassing React's value
  // tracker — dispatch a native input event so React's onChange (and form
  // libraries) detect the change. Native step also clamps to min/max/step.
  const step = (direction: "up" | "down") => {
    const el = inputRef.current;
    if (!el || el.disabled) return;
    direction === "up" ? el.stepUp() : el.stepDown();
    el.dispatchEvent(new Event("input", { bubbles: true }));
    // Only focus on the first click — re-focusing an already-focused input
    // re-triggers the :focus-visible heuristic and flickers the ring.
    if (document.activeElement !== el) el.focus();
  };

  return (
    <InputNumberGroup size={size} variant={variant} className={className}>
      {controls === "center" && (
        <InputNumberAddon align="inline-start" size={size}>
          <InputNumberButton
            className="rounded-full"
            disabled={disabled}
            aria-label="Decrement"
            onClick={() => step("down")}
          >
            <Dash />
          </InputNumberButton>
        </InputNumberAddon>
      )}

      <InputNumberInput
        ref={setRef}
        disabled={disabled}
        size={size}
        variant={variant}
        className={cn(controls === "center" && "text-center")}
        {...props}
      />

      {controls === "default" && (
        <InputNumberAddon align="inline-end" size={size}>
          <InputNumberButton
            className="rounded-full"
            disabled={disabled}
            aria-label="Decrement"
            onClick={() => step("down")}
          >
            <Dash />
          </InputNumberButton>
          <InputNumberButton
            className="rounded-full"
            disabled={disabled}
            aria-label="Increment"
            onClick={() => step("up")}
          >
            <Plus01 />
          </InputNumberButton>
        </InputNumberAddon>
      )}

      {controls === "center" && (
        <InputNumberAddon align="inline-end" size={size}>
          <InputNumberButton
            className="rounded-full"
            disabled={disabled}
            aria-label="Increment"
            onClick={() => step("up")}
          >
            <Plus01 />
          </InputNumberButton>
        </InputNumberAddon>
      )}

      {controls === "arrow" && (
        <InputNumberAddon align="inline-end" size={size}>
          <div className="flex flex-col">
            <InputNumberButton
              disabled={disabled}
              aria-label="Increment"
              className="h-3.5 w-4 [&>svg]:size-3"
              onClick={() => step("up")}
            >
              <ChevronUp />
            </InputNumberButton>
            <InputNumberButton
              disabled={disabled}
              aria-label="Decrement"
              className="h-3.5 w-4 [&>svg]:size-3"
              onClick={() => step("down")}
            >
              <ChevronDown />
            </InputNumberButton>
          </div>
        </InputNumberAddon>
      )}
    </InputNumberGroup>
  );
}

export { InputNumber, inputNumberVariants };