Skip to content
UI Kit
Accent color

Number input

A number field after the spin button pattern, in four looks. It steps, keeps to its limits, repeats while held and reads the number format of the language.

One of 7 components in Pickers and selections

4 variants

Variants

Stepper

Buttons stacked at the end, two wide buttons on phones. Arrows step, Shift or Page keys take ten steps, Home and End jump to the limits. The total follows every change.

Open in Studio

Split

Minus and plus on both sides, for counts you tap rather than type. Limits depend on each other, the bar shows the beds taken.

Open in Studio

Cart quantity

Compact for cart lines. At the minimum the minus turns into a bin, a removed line leaves an undo in its place, and the subtotal counts along.

Open in Studio

Scrub

The design tool field. Drag the letter or the name sideways to change the value, Shift for big steps, Alt for fine ones, or type. A hairline shows where the value sits in its range, the preview follows live.

Open in Studio

Details

What Number input brings along, read from the registry and the source.

Variants
4
Version
1.0.0 · since 1.1.0
File
components/ui/number-input.tsx
Brings along
utilskiticoninput
npm packages
None
Languages
English and German, 4 texts of its own

Props

The NumberInputProps interface from components/ui/number-input.tsx, with the comments from the code. Also takes every prop of FieldOwnProps.

  • variantoptional
    NumberInputVariant

    steppersplitquantityscrub

  • valueoptional
    number
  • defaultValueoptional
    number
  • onValueChangeoptional
    (value: number) => void
  • minoptional
    number
  • maxoptional
    number
  • stepoptional
    number
  • bigStepoptional
    number

    Step for Page Up, Page Down and Shift with an arrow. Default 10 steps.

  • decimalsoptional
    number

    Digits after the decimal mark. Default from the step.

  • prefixoptional
    string

    Quiet text before the value, e.g. "$"

  • unitoptional
    string

    Quiet text after the value, e.g. "px", "€" or "kg"

  • groupingoptional
    boolean

    Group thousands (1,280). Default true.

  • handleoptional
    string

    scrub: a short letter at the start that you drag, e.g. "W". Without handle and handleIcon the label is the handle, inside the box.

  • handleIconoptional
    IconName

    scrub: an icon at the start that you drag, instead of the letter

  • onRemoveoptional
    () => void

    quantity: at the minimum the minus turns into a remove button

  • sizeoptional
    ControlSize
  • nameoptional
    string
  • idoptional
    string
  • disabledoptional
    boolean
  • requiredoptional
    boolean
  • invalidoptional
    boolean
  • aria-labeloptional
    string

    Accessible name when there is no visible label

Built-in texts

Number input writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 4 texts
increaseEnglish IncreaseGerman Erhöhen
decreaseEnglish DecreaseGerman Verringern
removeEnglish RemoveGerman Entfernen
scrubEnglish Drag sideways to changeGerman Seitlich ziehen zum Ändern

Accessibility

Read from the source of the component.

  • Number input handles the keys Escape, Enter and Space itself.
  • Sets aria-controls, aria-describedby, aria-invalid, aria-label, aria-valuemax, aria-valuemin, aria-valuenow and aria-valuetext, so screen readers announce purpose and state.
  • Uses the role spinbutton.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 3 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 440 lines
components/ui/number-input.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useLocale, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { CONTROL_HEIGHT, controlBox, useControl, useShake, withField, type ControlSize, type FieldOwnProps } from '@/components/ui/input';

/*
 * A number field after the spin button pattern (WAI-ARIA APG): the field
 * holds the value, arrows step, Page Up and Page Down take big steps, Home
 * and End jump to the limits. Typed text is read in the format of the
 * language (1.280,5 or 1,280.5) and snapped to the step on Enter or blur.
 * Four looks: buttons stacked at the end, buttons on both sides, a compact
 * cart quantity, and a design tool field you drag sideways.
 */

const TEXTS = {
  en: { increase: 'Increase', decrease: 'Decrease', remove: 'Remove', scrub: 'Drag sideways to change' },
  de: { increase: 'Erhöhen', decrease: 'Verringern', remove: 'Entfernen', scrub: 'Seitlich ziehen zum Ändern' },
};

export type NumberInputVariant = 'stepper' | 'split' | 'quantity' | 'scrub';

export interface NumberInputProps extends FieldOwnProps {
  variant?: NumberInputVariant;
  value?: number;
  defaultValue?: number;
  onValueChange?: (value: number) => void;
  min?: number;
  max?: number;
  step?: number;
  /** Step for Page Up, Page Down and Shift with an arrow. Default 10 steps. */
  bigStep?: number;
  /** Digits after the decimal mark. Default from the step. */
  decimals?: number;
  /** Quiet text before the value, e.g. "$" */
  prefix?: string;
  /** Quiet text after the value, e.g. "px", "€" or "kg" */
  unit?: string;
  /** Group thousands (1,280). Default true. */
  grouping?: boolean;
  /** scrub: a short letter at the start that you drag, e.g. "W". Without handle and handleIcon the label is the handle, inside the box. */
  handle?: string;
  /** scrub: an icon at the start that you drag, instead of the letter */
  handleIcon?: IconName;
  /** quantity: at the minimum the minus turns into a remove button */
  onRemove?: () => void;
  size?: ControlSize;
  name?: string;
  id?: string;
  disabled?: boolean;
  required?: boolean;
  invalid?: boolean;
  /** Accessible name when there is no visible label */
  'aria-label'?: string;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const decimalsOf = (n: number) => {
  const s = String(n);
  return s.includes('.') ? s.split('.')[1].length : 0;
};

/** Reads typed text in the format of the language. Returns null when it is not a number. */
export function parseNumber(text: string, locale: 'en' | 'de'): number | null {
  let s = text.replace(/[\s  ]/g, '').replace(/[^\d.,+-]/g, '');
  if (!s || s === '-' || s === '+') return null;
  if (locale === 'de') {
    if (s.includes(',')) s = s.replace(/\./g, '').replace(',', '.');
    else if (/\.\d{3}(\.|$)/.test(s)) s = s.replace(/\./g, '');
  } else s = s.replace(/,/g, '');
  const n = Number(s);
  return Number.isFinite(n) ? n : null;
}

/** Repeats an action while a button is held: once at once, then faster and faster */
function useHold(action: () => void) {
  const timer = useRef<number | undefined>(undefined);
  const latest = useRef(action);
  latest.current = action;
  const stop = () => window.clearTimeout(timer.current);
  useEffect(() => stop, []);
  const start = (e: React.PointerEvent) => {
    if (e.button !== 0) return;
    e.preventDefault();
    latest.current();
    let delay = 380;
    const tick = () => {
      latest.current();
      delay = Math.max(40, delay * 0.82);
      timer.current = window.setTimeout(tick, delay);
    };
    timer.current = window.setTimeout(tick, delay);
  };
  return { onPointerDown: start, onPointerUp: stop, onPointerLeave: stop, onPointerCancel: stop, onContextMenu: (e: React.MouseEvent) => e.preventDefault() };
}

/** The value slides in from the side it came from: up when it grows, down when it shrinks */
function nudge(el: HTMLElement | null, dir: number) {
  if (!el || !dir || prefersReducedMotion() || typeof el.animate !== 'function') return;
  el.animate([{ transform: `translateY(${dir > 0 ? 7 : -7}px)`, opacity: 0.25 }, { transform: 'translateY(0)', opacity: 1 }], { duration: 260, easing: 'cubic-bezier(0.2,0.7,0.2,1)' });
}

/** A short sideways bump when a limit is reached */
function bump(el: HTMLElement | null) {
  if (!el || prefersReducedMotion() || typeof el.animate !== 'function') return;
  el.animate([{ transform: 'translateX(0)' }, { transform: 'translateX(3px)' }, { transform: 'translateX(-2px)' }, { transform: 'translateX(0)' }], { duration: 240, easing: 'ease-out' });
}

const STEP_BTN =
  'relative grid shrink-0 place-items-center text-muted-foreground outline-none transition-[background-color,color,scale] duration-200 ease-[var(--ease-kit)] hover:bg-accent hover:text-foreground active:scale-90 focus-visible:z-10 focus-visible:bg-accent focus-visible:text-foreground focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] disabled:pointer-events-none disabled:opacity-35 motion-reduce:transition-none';

function NumberBox(props: Omit<NumberInputProps, keyof FieldOwnProps> & { inlineLabel?: React.ReactNode }) {
  const {
    variant = 'stepper', value: valueProp, defaultValue, onValueChange, min = -Infinity, max = Infinity, step = 1, bigStep, decimals, prefix, unit,
    grouping = true, handle, handleIcon, onRemove, size = 'md', name, id, disabled, required, invalid, labels, className, inlineLabel,
  } = props;
  const t = useTexts(TEXTS, labels);
  const locale = useLocale();
  const c = useControl({ id, invalid, disabled, required });
  const digits = decimals ?? decimalsOf(step);
  const big = bigStep ?? step * 10;
  const [inner, setInner] = useState(() => defaultValue ?? (Number.isFinite(min) ? min : 0));
  const value = valueProp ?? inner;
  const [draft, setDraft] = useState<string | null>(null);
  const boxRef = useRef<HTMLDivElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const valueRef = useRef<HTMLSpanElement>(null);
  const hintId = useId();
  useShake(boxRef, c.invalid);

  const fmt = (n: number, group = grouping) => n.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US', { minimumFractionDigits: digits, maximumFractionDigits: digits, useGrouping: group });
  const snap = (n: number) => {
    const base = Number.isFinite(min) ? min : 0;
    const snapped = round(base + Math.round((n - base) / step) * step, digits);
    return Math.min(max, Math.max(min, snapped));
  };

  const commit = (next: number, from: 'step' | 'type' = 'step') => {
    // Steps land on the grid of the step, typed values are kept as typed (only limits and digits apply)
    const v = from === 'type' ? Math.min(max, Math.max(min, round(next, digits))) : snap(next);
    if (v === value) {
      if (from === 'step' && next !== value) bump(boxRef.current);
      return;
    }
    if (from === 'step') nudge(variant === 'stepper' || variant === 'scrub' ? inputRef.current : valueRef.current ?? inputRef.current, v - value);
    setInner(v);
    onValueChange?.(v);
  };
  const by = (delta: number) => commit(value + delta);

  const plus = useHold(() => by(step));
  const minus = useHold(() => (onRemove && value <= min ? onRemove() : by(-step)));
  const atMin = value <= min;
  const atMax = value >= max;
  const removeMode = variant === 'quantity' && Boolean(onRemove) && atMin;

  const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    const k = e.key;
    const map: Record<string, () => void> = {
      ArrowUp: () => by(e.shiftKey ? big : step),
      ArrowDown: () => by(-(e.shiftKey ? big : step)),
      PageUp: () => by(big),
      PageDown: () => by(-big),
      Home: () => Number.isFinite(min) && commit(min),
      End: () => Number.isFinite(max) && commit(max),
    };
    if (map[k]) {
      e.preventDefault();
      if (draft !== null) {
        const typed = parseNumber(draft, locale);
        setDraft(null);
        if (typed !== null && snap(typed) !== value) return commit(typed, 'type');
      }
      map[k]();
    } else if (k === 'Enter') {
      finish();
    } else if (k === 'Escape' && draft !== null) {
      e.preventDefault();
      setDraft(null);
    }
  };
  const finish = () => {
    if (draft === null) return;
    const typed = parseNumber(draft, locale);
    setDraft(null);
    if (typed !== null) commit(typed, 'type');
  };

  /* ---------- scrub: drag the handle sideways ---------- */
  const drag = useRef<{ x: number; start: number; moved: boolean } | null>(null);
  const [dragging, setDragging] = useState(false);
  const scrub = {
    onPointerDown: (e: React.PointerEvent<HTMLSpanElement>) => {
      if (c.disabled || e.button !== 0) return;
      e.preventDefault();
      e.currentTarget.setPointerCapture(e.pointerId);
      drag.current = { x: e.clientX, start: value, moved: false };
      setDragging(true);
    },
    onPointerMove: (e: React.PointerEvent<HTMLSpanElement>) => {
      const d = drag.current;
      if (!d) return;
      const dx = e.clientX - d.x;
      if (Math.abs(dx) > 2) d.moved = true;
      // One step every 4 px, Shift ten times faster, Alt ten times finer
      const factor = e.shiftKey ? 10 : e.altKey ? 0.1 : 1;
      const next = snap(d.start + Math.round(dx / 4) * step * factor);
      if (next !== value) {
        setInner(next);
        onValueChange?.(next);
      }
    },
    onPointerUp: () => {
      const d = drag.current;
      drag.current = null;
      setDragging(false);
      if (d && !d.moved) inputRef.current?.focus();
    },
  };
  useEffect(() => {
    if (!dragging) return;
    const prev = document.body.style.cursor;
    document.body.style.cursor = 'ew-resize';
    return () => void (document.body.style.cursor = prev);
  }, [dragging]);

  const shown = draft ?? fmt(value);
  // "50%" in English, "50 %" in German, degrees always close
  const tight = unit === '°' || (unit === '%' && locale === 'en');
  const valueText = `${prefix ?? ''}${fmt(value)}${unit ? `${tight ? '' : ' '}${unit}` : ''}`;
  const input = (
    <input
      ref={inputRef}
      id={c.id}
      type="text"
      inputMode={digits > 0 || min < 0 ? 'decimal' : 'numeric'}
      role="spinbutton"
      aria-valuenow={value}
      aria-valuemin={Number.isFinite(min) ? min : undefined}
      aria-valuemax={Number.isFinite(max) ? max : undefined}
      aria-valuetext={valueText}
      aria-label={props['aria-label']}
      aria-invalid={c['aria-invalid']}
      aria-describedby={cn(c['aria-describedby'], variant === 'scrub' && hintId) || undefined}
      disabled={c.disabled}
      required={c.required}
      autoComplete="off"
      spellCheck={false}
      value={shown}
      onChange={(e) => setDraft(e.target.value)}
      onFocus={(e) => {
        setDraft(fmt(value, false));
        const el = e.currentTarget;
        requestAnimationFrame(() => el.select());
      }}
      onBlur={finish}
      onKeyDown={onKeyDown}
      // In the centered looks the field is as wide as its text, so a unit sits right next to the number
      style={variant === 'split' || variant === 'quantity' ? { width: `${Math.max(shown.length, 1) + 0.6}ch` } : undefined}
      className={cn(
        'h-full min-w-0 bg-transparent tabular-nums text-inherit outline-none placeholder:text-subtle-foreground',
        // Between two buttons the box ring alone does not say which part has focus, so the number gets its own line
        variant === 'split' || variant === 'quantity' ? 'max-w-full flex-none rounded-none text-center font-medium focus-visible:shadow-[inset_0_-2px_0_var(--primary)]' : 'w-0 flex-1 text-start',
      )}
    />
  );
  const hidden = name ? <input type="hidden" name={name} value={value} /> : null;
  const affix = (text: string | undefined, side: 'start' | 'end') =>
    text ? <span aria-hidden className={cn('shrink-0 select-none text-subtle-foreground', side === 'start' ? 'pe-1' : tight ? 'ps-px' : 'ps-1')}>{text}</span> : null;

  /* ---------- split: minus | value | plus ---------- */
  if (variant === 'split' || variant === 'quantity') {
    const q = variant === 'quantity';
    const btn = q ? 'size-11 sm:size-9 rounded-[7px]' : cn('h-full aspect-square', size === 'lg' ? 'w-12' : 'w-11');
    return (
      <div
        ref={boxRef}
        data-invalid={c.invalid || undefined}
        data-disabled={c.disabled || undefined}
        className={cn(
          'group/num relative flex items-center',
          q ? 'h-11 w-[8.5rem] shrink-0 gap-0.5 rounded-lg p-0 text-base sm:h-9 sm:w-[7.25rem] sm:text-sm' : cn('w-full', CONTROL_HEIGHT[size]),
          controlBox,
          q && 'overflow-hidden',
          className,
        )}
      >
        <button
          type="button"
          aria-label={removeMode ? t.remove : t.decrease}
          aria-controls={c.id}
          disabled={c.disabled || (atMin && !removeMode)}
          {...minus}
          onKeyDown={(e) => {
            if (e.key === 'Enter' || e.key === ' ') {
              e.preventDefault();
              if (removeMode) onRemove?.();
              else by(-step);
            }
          }}
          className={cn(STEP_BTN, btn, !q && 'rounded-s-[inherit] border-e border-border', removeMode && 'hover:text-destructive')}
        >
          {/* The minus turns into a bin at the minimum, with a short turn */}
          <span key={removeMode ? 'rm' : 'minus'} className="grid place-items-center transition-[rotate,scale,opacity] duration-300 ease-[var(--ease-kit)] starting:-rotate-90 starting:scale-50 starting:opacity-0 motion-reduce:transition-none">
            <Icon name={removeMode ? 'trash' : 'minus'} size={q ? 15 : 17} />
          </span>
        </button>
        <span ref={valueRef} onClick={() => inputRef.current?.focus()} className="flex h-full min-w-0 flex-1 cursor-text items-center justify-center px-1">
          {affix(prefix, 'start')}
          {input}
          {affix(unit, 'end')}
        </span>
        <button
          type="button"
          aria-label={t.increase}
          aria-controls={c.id}
          disabled={c.disabled || atMax}
          {...plus}
          onKeyDown={(e) => {
            if (e.key === 'Enter' || e.key === ' ') {
              e.preventDefault();
              by(step);
            }
          }}
          className={cn(STEP_BTN, btn, !q && 'rounded-e-[inherit] border-s border-border')}
        >
          <Icon name="plus" size={q ? 15 : 17} />
        </button>
        {hidden}
      </div>
    );
  }

  /* ---------- scrub: a design tool field ---------- */
  if (variant === 'scrub') {
    const ranged = Number.isFinite(min) && Number.isFinite(max);
    const p = ranged ? (value - min) / (max - min) : 0;
    return (
      <div
        ref={boxRef}
        data-invalid={c.invalid || undefined}
        data-disabled={c.disabled || undefined}
        data-dragging={dragging || undefined}
        className={cn('group/num relative flex h-11 w-full items-center overflow-hidden rounded-lg text-base sm:h-9 sm:rounded-md sm:text-[13px]', controlBox, dragging && 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))]', className)}
      >
        {inlineLabel ? (
          // A word as the handle: it is the label of the field too
          <label
            htmlFor={c.id}
            title={t.scrub}
            {...scrub}
            className={cn(
              'flex h-full shrink-0 cursor-ew-resize touch-none select-none items-center ps-3 pe-2 text-[13px] text-muted-foreground transition-colors duration-200 hover:text-foreground sm:ps-2.5 sm:text-[12.5px]',
              dragging && 'text-primary',
              c.disabled && 'pointer-events-none',
            )}
          >
            {inlineLabel}
          </label>
        ) : (
          <span
            aria-hidden
            title={t.scrub}
            {...scrub}
            className={cn(
              'grid h-full w-11 shrink-0 cursor-ew-resize touch-none select-none place-items-center text-[12px] font-medium text-subtle-foreground transition-colors duration-200 hover:text-foreground sm:w-8',
              dragging && 'text-primary',
              c.disabled && 'pointer-events-none',
            )}
          >
            {handleIcon ? <Icon name={handleIcon} size={15} /> : handle}
          </span>
        )}
        <span className={cn('flex h-full min-w-0 flex-1 items-center pe-2.5', inlineLabel && '[&>input]:text-end')}>
          {affix(prefix, 'start')}
          {input}
          {affix(unit, 'end')}
        </span>
        {/* Where the value sits in its range, a hairline along the bottom edge */}
        {ranged && (
          <span aria-hidden className="pointer-events-none absolute inset-x-0 bottom-0 h-[2px] overflow-hidden">
            <span
              className={cn('block h-full origin-left bg-primary transition-[scale,opacity] duration-200 ease-[var(--ease-kit)] motion-reduce:transition-none', dragging ? 'opacity-100' : 'opacity-0 group-hover/num:opacity-60 group-focus-within/num:opacity-60')}
              style={{ scale: `${round(p, 4)} 1` }}
            />
          </span>
        )}
        <span id={hintId} className="sr-only">{t.scrub}</span>
        {hidden}
      </div>
    );
  }

  /* ---------- stepper: buttons stacked at the end ---------- */
  return (
    <div
      ref={boxRef}
      data-invalid={c.invalid || undefined}
      data-disabled={c.disabled || undefined}
      className={cn('group/num relative flex w-full items-stretch overflow-hidden', CONTROL_HEIGHT[size], controlBox, className)}
    >
      <span className={cn('flex min-w-0 flex-1 items-center', size === 'sm' ? 'px-3' : size === 'lg' ? 'px-4' : 'px-3.5')}>
        {affix(prefix, 'start')}
        {input}
        {affix(unit, 'end')}
      </span>
      {/* Phones get two wide buttons side by side, larger screens a slim column */}
      <span className="flex shrink-0 border-s border-border sm:w-8 sm:flex-col">
        <button type="button" tabIndex={-1} aria-label={t.increase} aria-controls={c.id} disabled={c.disabled || atMax} {...plus} className={cn(STEP_BTN, 'order-2 w-11 border-s border-border sm:order-1 sm:w-full sm:flex-1 sm:border-s-0 sm:border-b')}>
          <Icon name="plus" size={15} className="sm:hidden" />
          <Icon name="chevron-up" size={13} strokeWidth={2} className="max-sm:hidden" />
        </button>
        <button type="button" tabIndex={-1} aria-label={t.decrease} aria-controls={c.id} disabled={c.disabled || atMin} {...minus} className={cn(STEP_BTN, 'order-1 w-11 sm:order-2 sm:w-full sm:flex-1')}>
          <Icon name="minus" size={15} className="sm:hidden" />
          <Icon name="chevron-down" size={13} strokeWidth={2} className="max-sm:hidden" />
        </button>
      </span>
      {hidden}
    </div>
  );
}

/**
 * A number field with steps, limits and the number format of the language.
 *   <NumberInput label="Copies" min={1} max={500} defaultValue={50} unit="pcs" />
 *   <NumberInput variant="split" label="Adults" min={1} max={8} defaultValue={2} />
 *   <NumberInput variant="quantity" defaultValue={1} min={1} onRemove={() => remove(id)} aria-label="Quantity" />
 *   <NumberInput variant="scrub" handle="W" unit="px" min={0} max={1440} defaultValue={320} aria-label="Width" />
 */
export function NumberInput({ label, hint, error, optional, labelAction, className, ...rest }: NumberInputProps) {
  // scrub without a letter or icon: the label itself is the handle, inside the box
  if (rest.variant === 'scrub' && label && !rest.handle && !rest.handleIcon) {
    const box = <NumberBox {...rest} inlineLabel={label} invalid={rest.invalid ?? (error ? true : undefined)} className={hint || error ? undefined : className} />;
    return withField({ hint, error, id: rest.id, required: rest.required, disabled: rest.disabled, className }, box);
  }
  const wrapped = Boolean(label || hint || error);
  return withField({ label, hint, error, optional, labelAction, id: rest.id, required: rest.required, disabled: rest.disabled, className }, <NumberBox {...rest} invalid={rest.invalid ?? (error ? true : undefined)} className={wrapped ? undefined : className} />);
}

Frequent questions

How do I install Number input?

With aiw add @ai-website/number-input. The command puts components/ui/number-input.tsx into your project, the code is yours from then on. It brings along icon and input. It needs no further npm packages.

Which variants does Number input have?

4 variants, Stepper, Split, Cart quantity and Scrub. You pick one with the variant prop, for example variant="quantity".

Can I translate the texts of Number input?

Yes. Number input carries 4 texts of its own in English and German, such as “Increase”, “Decrease” and “Remove”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Number input?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Number input with aiw_components and install it with aiw_add @ai-website/number-input. Name the variant in your prompt, for example variant="split", and the assistant writes your content straight into the props.

Can I adjust Number input without code?

Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching NumberInput code to copy.

Often searched

Jump to

Copied to the clipboard