Number Input

An input component for entering numeric values, with built-in increment and decrement buttons.

import { NumberInput } from "~/components/number-input";

export default function NumberInputBasicDemo() {
  return <NumberInput label="Quantity" defaultValue="1" min={1} max={10} />;
}

Installation

CLI

Run the following command to add the component to your project:

npx @ark-preset/cli@latest add number-input

Manual

Create the recipe file at src/components/recipes/number-input.ts:

import { tv, type VariantProps } from "tailwind-variants";

export const numberInputVariants = tv({
  slots: {
    root: "inline-flex flex-col gap-2",
    control:
      "inline-flex items-center rounded-md border border-input bg-background text-sm ring-offset-background focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
    input:
      "flex h-8 w-20 flex-1 appearance-none text-start text-base md:text-sm font-medium px-2 focus-visible:outline-none",
    incrementTrigger:
      "flex items-center justify-center h-4 w-6 rounded-r-md text-sm hover:bg-accent not-disabled:cursor-pointer disabled:opacity-50",
    decrementTrigger:
      "flex items-center justify-center h-4 w-6 rounded-r-md text-sm hover:bg-accent not-disabled:cursor-pointer disabled:opacity-50",
    triggerGroup: "flex flex-col items-center",
    scrubber: "flex h-1 w-0 grow bg-transparent",
    valueText: "text-base md:text-sm font-mono text-foreground",
  },
  variants: {
    disabled: {
      true: {
        control: "opacity-50 pointer-events-none",
        input: "opacity-50",
        incrementTrigger: "opacity-50",
        decrementTrigger: "opacity-50",
      },
    },
    invalid: {
      true: {
        control: "border-destructive focus-within:ring-destructive",
        input: "text-destructive",
        incrementTrigger: "text-destructive",
        decrementTrigger: "text-destructive",
      },
    },
  },
  defaultVariants: {
    disabled: false,
    invalid: false,
  },
});

export type NumberInputVariants = VariantProps<typeof numberInputVariants>;

Create the component directory and files.

src/components/number-input/number-input.base.tsx:

import { NumberInput as ArkNumberInput } from "@ark-ui/solid/number-input";
import { splitProps, type Component } from "solid-js";
import { numberInputVariants, labelVariants } from "../recipes/number-input";
import { HTMLProps } from "@ark-ui/solid";

const styles = numberInputVariants();

const NumberInputRoot: Component<ArkNumberInput.RootProps> = (props) => {
  const [local, others] = splitProps(props, ["class"]);
  return <ArkNumberInput.Root class={styles.root({ class: local.class })} {...others} />;
};

const NumberInputLabel: Component<ArkNumberInput.LabelProps> = (props) => {
  const [local, others] = splitProps(props, ["class"]);
  return <ArkNumberInput.Label class={labelVariants({ class: local.class })} {...others} />;
};

const NumberInputControl: Component<ArkNumberInput.ControlProps> = (props) => {
  const [local, others] = splitProps(props, ["class"]);
  return <ArkNumberInput.Control class={styles.control({ class: local.class })} {...others} />;
};

const NumberInputInput: Component<ArkNumberInput.InputProps> = (props) => {
  const [local, others] = splitProps(props, ["class"]);
  return <ArkNumberInput.Input class={styles.input({ class: local.class })} {...others} />;
};

const NumberInputIncrementTrigger: Component<ArkNumberInput.IncrementTriggerProps> = (props) => {
  const [local, others] = splitProps(props, ["class"]);
  return (
    <ArkNumberInput.IncrementTrigger
      class={styles.incrementTrigger({ class: local.class })}
      {...others}
    />
  );
};

const NumberInputDecrementTrigger: Component<ArkNumberInput.DecrementTriggerProps> = (props) => {
  const [local, others] = splitProps(props, ["class"]);
  return (
    <ArkNumberInput.DecrementTrigger
      class={styles.decrementTrigger({ class: local.class })}
      {...others}
    />
  );
};

const NumberInputScrubber: Component<ArkNumberInput.ScrubberProps> = (props) => {
  const [local, others] = splitProps(props, ["class"]);
  return <ArkNumberInput.Scrubber class={styles.scrubber({ class: local.class })} {...others} />;
};

const NumberInputRootProvider = ArkNumberInput.RootProvider;

const NumberInputTriggerGroup: Component<HTMLProps<"div">> = (props) => {
  const [local, others] = splitProps(props, ["class"]);
  return <div class={styles.triggerGroup({ class: local.class })} {...others} />;
};

const NumberInputValueText: Component<ArkNumberInput.ValueTextProps> = (props) => {
  const [local, others] = splitProps(props, ["class"]);
  return <ArkNumberInput.ValueText class={styles.valueText({ class: local.class })} {...others} />;
};

export const NumberInput = {
  Root: NumberInputRoot,
  Label: NumberInputLabel,
  Control: NumberInputControl,
  Input: NumberInputInput,
  IncrementTrigger: NumberInputIncrementTrigger,
  DecrementTrigger: NumberInputDecrementTrigger,
  Scrubber: NumberInputScrubber,
  RootProvider: NumberInputRootProvider,
  TriggerGroup: NumberInputTriggerGroup,
  ValueText: NumberInputValueText,
};

src/components/number-input/index.tsx:

import { NumberInput as ArkNumberInput } from "@ark-ui/solid/number-input";
import { splitProps, type Component } from "solid-js";
import { NumberInput as NumberInputBase } from "./number-input.base";

const NumberInputControl = () => (
  <>
    <NumberInputBase.Control>
      <NumberInputBase.Input />
      <NumberInputBase.TriggerGroup>
        <NumberInputBase.IncrementTrigger>
          <svg
            xmlns="http://www.w3.org/2000/svg"
            width="16"
            height="16"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            stroke-width="2"
            stroke-linecap="round"
            stroke-linejoin="round"
          >
            <path d="m18 15-6-6-6 6" />
          </svg>
        </NumberInputBase.IncrementTrigger>
        <NumberInputBase.DecrementTrigger>
          <svg
            xmlns="http://www.w3.org/2000/svg"
            width="16"
            height="16"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            stroke-width="2"
            stroke-linecap="round"
            stroke-linejoin="round"
          >
            <path d="m6 9 6 6 6-6" />
          </svg>
        </NumberInputBase.DecrementTrigger>
      </NumberInputBase.TriggerGroup>
    </NumberInputBase.Control>
    <NumberInputBase.Scrubber>
      <svg
        xmlns="http://www.w3.org/2000/svg"
        width="16"
        height="16"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        stroke-width="2"
        stroke-linecap="round"
        stroke-linejoin="round"
      >
        <circle cx="9" cy="12" r="1" />
        <circle cx="15" cy="12" r="1" />
        <circle cx="9" cy="5" r="1" />
        <circle cx="15" cy="5" r="1" />
        <circle cx="9" cy="19" r="1" />
        <circle cx="15" cy="19" r="1" />
      </svg>
    </NumberInputBase.Scrubber>
  </>
);

type NumberInputProps = Omit<ArkNumberInput.RootProps, "children"> & {
  label?: string;
  class?: string;
  error?: boolean;
};

const NumberInput: Component<NumberInputProps> = (props) => {
  const [local, others] = splitProps(props, ["class", "label", "disabled", "error"]);
  return (
    <NumberInputBase.Root class={local.class} disabled={local.disabled} {...others}>
      {local.label && <NumberInputBase.Label>{local.label}</NumberInputBase.Label>}
      <NumberInputControl />
    </NumberInputBase.Root>
  );
};

export { NumberInput, NumberInputBase };

export { numberInputVariants, type NumberInputVariants } from "../recipes/number-input";

Note: Make sure your project has the Tailwind CSS theme variables set up (--background, --foreground, --ring, --border, etc.) or override the utility classes to match your design system.

Note: Make sure your project has the Tailwind CSS theme variables set up (--foreground, --input, --border, etc.) or override the utility classes to match your design system.

Usage

Basic Usage

A basic number input with increment and decrement controls.

import { NumberInput } from "~/components/number-input";

export default function NumberInputBasicDemo() {
  return <NumberInput label="Quantity" defaultValue="1" min={1} max={10} />;
}

Error State

Use the error prop to show an error state.

import { NumberInput } from "~/components/number-input";

export default function NumberInputErrorDemo() {
  return <NumberInput label="Amount" defaultValue="0" error />;
}

Disabled

Use the disabled prop to disable the input.

import { NumberInput } from "~/components/number-input";

export default function NumberInputDisabledDemo() {
  return <NumberInput defaultValue="50" disabled />;
}

API Reference

See the Ark UI Number Input documentation.