Number Input

A control for entering a number, with increment/decrement buttons and keyboard/scroll support.
Installation
Expand

npx @left4code/cosmic-ui-cli@latest add number-input
 
Expand

This writes the component and everything it imports, then installs the packages it needs. Run npx @left4code/cosmic-ui-cli@latest initfirst if you haven't already (it detects Vue automatically).

Install the following dependencies:

pnpm
npm
yarn
bun
yarn add @zag-js/number-input @zag-js/vue

Built directly on the Zag.js state machine. NumberInputControl renders the decrement button, the input, and the increment button together - it's the one part that needs all three wired to the same machine. The bordered box shown here is a placeholder; swap it for the Frame primitive later. Vue SFCs can't export multiple components from one file, so NumberInput is a folder instead of a single file — copy all of it into your project.

components/ui/number-input/context.ts
Expand

import type { InjectionKey, ComputedRef } from "vue";
import type * as numberInputMachine from "@zag-js/number-input";

type NumberInputApi = ComputedRef<ReturnType<typeof numberInputMachine.connect>>;

const NumberInputApiKey: InjectionKey<NumberInputApi> = Symbol("number-input-api");

export { NumberInputApiKey };
export type { NumberInputApi };
 
Expand
components/ui/number-input/NumberInputRoot.vue
Expand

<script setup lang="ts">
import { computed, provide, useAttrs, useId } from "vue";
import { useMachine, normalizeProps } from "@zag-js/vue";
import * as numberInputMachine from "@zag-js/number-input";
import { twMerge } from "tailwind-merge";
import { NumberInputApiKey } from "./context";

defineOptions({ inheritAttrs: false });
const rawAttrs = useAttrs() as Partial<numberInputMachine.Props> & { class?: string };
const id = useId();

const machineProps = computed(() => {
  const { class: _class, ...rest } = rawAttrs;
  return { id, ...rest };
});
const service = useMachine(numberInputMachine.machine, machineProps);
const api = computed(() => numberInputMachine.connect(service, normalizeProps));

provide(NumberInputApiKey, api);
</script>

<template>
  <div v-bind="api.getRootProps()" :class="twMerge(['flex flex-col gap-2', rawAttrs.class])">
    <slot />
  </div>
</template>
 
Expand
components/ui/number-input/NumberInputLabel.vue
Expand

<script setup lang="ts">
import { inject } from "vue";
import { NumberInputApiKey } from "./context";

defineProps<{ class?: string }>();

const injectedApi = inject(NumberInputApiKey);
if (!injectedApi)
  throw new Error("NumberInput parts must be used within <NumberInputRoot>");
const api = injectedApi;
</script>

<template>
  <label v-bind="api.getLabelProps()" :class="$props.class">
    <slot />
  </label>
</template>
 
Expand
components/ui/number-input/NumberInputControl.vue
Expand

<script setup lang="ts">
import { inject } from "vue";
import { Minus, Plus } from "@lucide/vue";
import { twMerge } from "tailwind-merge";
import { NumberInputApiKey } from "./context";

const { class: className } = defineProps<{ class?: string }>();

const injectedApi = inject(NumberInputApiKey);
if (!injectedApi)
  throw new Error("NumberInput parts must be used within <NumberInputRoot>");
const api = injectedApi;
</script>

<template>
  <div
    v-bind="api.getControlProps()"
    :class="
      twMerge(['flex items-center border border-primary/30 bg-primary/10 w-fit', className])
    "
  >
    <button
      v-bind="api.getDecrementTriggerProps()"
      class="px-2.5 py-2 hover:bg-primary/10 disabled:opacity-30 disabled:pointer-events-none"
    >
      <Minus class="size-3.5" />
    </button>
    <input v-bind="api.getInputProps()" class="w-16 text-center outline-none bg-transparent py-2" />
    <button
      v-bind="api.getIncrementTriggerProps()"
      class="px-2.5 py-2 hover:bg-primary/10 disabled:opacity-30 disabled:pointer-events-none"
    >
      <Plus class="size-3.5" />
    </button>
  </div>
</template>
 
Expand
components/ui/number-input/index.ts
Expand

export { default as NumberInputRoot } from "./NumberInputRoot.vue";
export { default as NumberInputLabel } from "./NumberInputLabel.vue";
export { default as NumberInputControl } from "./NumberInputControl.vue";
 
Expand

Update the import paths to match your project setup.

Usage
Expand

import {
  NumberInputRoot,
  NumberInputLabel,
  NumberInputControl,
} from "@/components/ui/number-input";
 
Expand
Expand

<NumberInputRoot defaultValue="1" :min="0" :max="10">
  <NumberInputLabel>Quantity</NumberInputLabel>
  <NumberInputControl />
</NumberInputRoot>
 
Expand
Powered by synthetic caffeine · Deployed by Left4code · Signal traceable on GitHub.