Skip to content
Dethink Components

ComponentsNumberInput

NumberInput

Collect a number with optional minimum, maximum, and step values.

On this page

Install component source from the public registry, or use the local workspace package. Follow the setup guide first. The npm package is not published yet.

View registry files

Implemented with React and browser APIs, without an additional behavior or rendering library.

Shared foundation: React, clsx for conditional classes, tailwind-merge for resolving utility-class conflicts. Styles use Tailwind CSS and Dethink CSS variables.

Based on this component’s source and registry composition. Libraries used only in showcase examples are excluded.

Import the component into your page or component file.

Usage
import { NumberInput } from "@dethink/components";

Try the examples, then open the code to use them in your app.

Bounded quantity

type=number gives native min/max/step behavior — arrow keys and spinners step within bounds.

Between 1 and 500 seats.

Show sourceexamples/number-input/basic.tsx
examples/number-input/basic.tsx
"use client";

import {
  Field,
  FieldControl,
  FieldDescription,
  FieldLabel,
  NumberInput,
} from "@dethink/components";

export function NumberInputBasic() {
  return (
    <div className="mx-auto max-w-xs">
      <Field id="ni-seats">
        <FieldLabel>Seats</FieldLabel>
        <FieldControl asChild>
          <NumberInput
            name="seats"
            type="number"
            min={1}
            max={500}
            step={1}
            defaultValue={5}
          />
        </FieldControl>
        <FieldDescription>Between 1 and 500 seats.</FieldDescription>
      </Field>
    </div>
  );
}

Keypad modes and states

numberMode switches the mobile keypad between decimal and digits-only; disabled and invalid states flow through Field.

Show sourceexamples/number-input/modes.tsx
examples/number-input/modes.tsx
"use client";

import {
  Field,
  FieldControl,
  FieldLabel,
  NumberInput,
} from "@dethink/components";

export function NumberInputModes() {
  return (
    <div className="mx-auto grid max-w-md gap-5 sm:grid-cols-2">
      <Field id="ni-amount">
        <FieldLabel>Amount (decimal keypad)</FieldLabel>
        <FieldControl asChild>
          <NumberInput numberMode="decimal" placeholder="19.99" />
        </FieldControl>
      </Field>
      <Field id="ni-code">
        <FieldLabel>Verification code (numeric keypad)</FieldLabel>
        <FieldControl asChild>
          <NumberInput
            numberMode="numeric"
            placeholder="123456"
            maxLength={6}
          />
        </FieldControl>
      </Field>
      <Field id="ni-disabled" disabled>
        <FieldLabel>Disabled</FieldLabel>
        <FieldControl asChild>
          <NumberInput type="number" defaultValue={42} />
        </FieldControl>
      </Field>
      <Field id="ni-invalid" invalid>
        <FieldLabel>Invalid</FieldLabel>
        <FieldControl asChild>
          <NumberInput type="number" defaultValue={-3} min={0} />
        </FieldControl>
      </Field>
    </div>
  );
}

Examples that combine components for common tasks.

Budget allocator

The 100% invariant lives across all three inputs, so every field flips invalid together, a stacked bar visualizes the split, and a live region explains the group-level error.

Budget fully allocated.

Show sourceexamples/number-input/recipe-allocator.tsx
examples/number-input/recipe-allocator.tsx
"use client";

import { useState } from "react";
import {
  Field,
  FieldControl,
  FieldLabel,
  NumberInput,
} from "@dethink/components";

const channels = [
  { id: "search", label: "Search ads" },
  { id: "social", label: "Social" },
  { id: "content", label: "Content" },
] as const;

type Allocation = Record<(typeof channels)[number]["id"], number>;

/**
 * Cross-field validation: each input is fine on its own — the invariant
 * lives across all three, so every field flips invalid together and a live
 * region explains the group-level error.
 */
export function NumberInputRecipeAllocator() {
  const [allocation, setAllocation] = useState<Allocation>({
    search: 50,
    social: 30,
    content: 20,
  });
  const total = channels.reduce(
    (sum, channel) => sum + (allocation[channel.id] || 0),
    0,
  );
  const balanced = total === 100;

  return (
    <div className="mx-auto max-w-sm space-y-4">
      <div className="grid grid-cols-3 gap-3">
        {channels.map((channel) => (
          <Field
            key={channel.id}
            id={`alloc-${channel.id}`}
            invalid={!balanced}
          >
            <FieldLabel className="text-sm">{channel.label}</FieldLabel>
            <FieldControl asChild>
              <NumberInput
                type="number"
                min={0}
                max={100}
                value={allocation[channel.id]}
                onChange={(event) =>
                  setAllocation((current) => ({
                    ...current,
                    [channel.id]: Number(event.target.value) || 0,
                  }))
                }
              />
            </FieldControl>
          </Field>
        ))}
      </div>
      <div className="space-y-1.5">
        <div
          aria-hidden="true"
          className="bg-muted flex h-2 overflow-hidden rounded-full"
        >
          {channels.map((channel, index) => (
            <div
              key={channel.id}
              className={
                index === 0
                  ? "bg-primary"
                  : index === 1
                    ? "bg-info"
                    : "bg-success"
              }
              style={{ width: `${Math.min(allocation[channel.id], 100)}%` }}
            />
          ))}
        </div>
        <p
          aria-live="polite"
          className={`text-sm ${balanced ? "text-muted-foreground" : "text-destructive font-medium"}`}
        >
          {balanced
            ? "Budget fully allocated."
            : `Allocations must total 100% — currently ${total}%.`}
        </p>
      </div>
    </div>
  );
}

NumberInput renders a real input element, so all native numeric attributes apply.

NumberInput props
PropWhat it doesDefault
numberMode"decimal" | "numeric"Sets inputMode so mobile keyboards show the right keypad — decimals or digits only."decimal"
type"text" | "number"Use "number" for native min/max/step semantics and arrow-key stepping; "text" keeps free-form entry with a numeric keypad."text"
controlSize"sm" | "md" | "lg"Height and typography scale aligned with Input."md"
invalidbooleanShows an error style and marks the field as invalid for screen readers.false
…native input propsInputHTMLAttributesRenders a real input: min, max, step, name, disabled, and form behavior are native.Not set