Skip to content
Star on GitHub
CollaborationNumber field
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises, from Astro sites to React apps and AI products.

Book a call

Number field

Type a number or step it with minus and plus, with units, limits and a word when a value is changed to fit. Free and MIT licensed, as HTML, a typed React component or an Astro component, in two styles with light and dark mode.

$npx designme add number-field
Run limitsFor each run of the release agent
Max steps
steps
Budget per run
$
Time limit
min

01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add number-field

React

$npx designme add number-field --react

Astro

$npx designme add number-field --astro

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro islands. The Astro version lands in src/components/designme too, with the same markup, CSS and script as the HTML version. See Astro.

shadcn CLI, React

$npx shadcn@latest add https://designme.dev/r/shadcn/number-field.json

shadcn CLI, Astro

$npx shadcn@latest add https://designme.dev/r/shadcn/astro/number-field.json

In a project set up for shadcn/ui, its CLI adds the same files to your components folder, under designme/, with the shared core. To give the components your shadcn or Tailwind v4 theme, add the theme bridge.

02

About

What it is for and what it does.

A field for a number you can type or step. Minus and plus sit at the ends of the field, a unit or a currency sign sits beside the value, and a hint under it gives the range. Press and hold minus or plus to keep going, faster the longer you hold; the arrow keys move a step, Shift or Page Up and Page Down ten. A typed value is rounded to the step and kept inside the range when you press Enter or leave the field, and the hint says what changed, like "$20.00 is the most" or "Rounded to 35 steps". The demo sets an agent's run limits.

  • For limits and quantities with a sensible range: an agent's steps, budget and time per run, retries, seats, a page size.
  • When people often move a value by a step or two, and sometimes type it outright.
  • Instead of a slider when the exact number matters more than a feel for where it sits in its range.
03

Usage

Import it in React and drive it from your own state, or place it in an Astro page.

React

import { NumberField } from '@/components/designme/number-field/NumberField';

React example

The demo above as a React component, 31 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React components: two more steps, a budget raised to the most it
// allows, then a minute less of time.
import { useRef, useState } from 'react';
import { NumberField, NumberFieldGroup } from './NumberField';
import { useStory } from '../lib/useScript';

export default function NumberFieldDemo() {
  const ref = useRef<HTMLDivElement>(null);
  const [steps, setSteps] = useState(25);
  const [budget, setBudget] = useState(2.5);
  const [time, setTime] = useState(10);
  useStory(ref, async ({ wait, press }) => {
    await wait(1500); press('[aria-label="More steps"]');
    await wait(500); press('[aria-label="More steps"]');
    await wait(2000); setBudget(20);
    await wait(2500); press('[aria-label="Less time"]');
  });
  return (
    <div ref={ref} style={{ width: '100%' }}>
      <NumberFieldGroup title="Run limits" description="For each run of the release agent"
        summary={`A run stops at ${steps} steps, $${budget.toFixed(2)} or ${time} min, whichever comes first.`}>
        <NumberField label="Max steps" min={5} max={100} step={5} value={steps} onValueChange={setSteps} unit="steps" buttonLabels={['Fewer steps', 'More steps']} />
        <NumberField label="Budget per run" min={0.25} max={20} step={0.25} decimals={2} prefix="$" value={budget} onValueChange={setBudget}
          valueText={v => `$${v.toFixed(2)}`} buttonLabels={['Lower budget', 'Higher budget']} />
        <NumberField label="Time limit" min={1} max={60} value={time} onValueChange={setTime} unit="min" valueText={v => `${v} minutes`} buttonLabels={['Less time', 'More time']} />
      </NumberFieldGroup>
    </div>
  );
}

Astro

In a page in src/pages. It plays the demo above; change its number-field.js to show your own data. Attributes like class go on its root element.

---
import NumberField from '../components/designme/number-field/NumberField.astro';
---
<NumberField />
<NumberField data-style="soft" class="my-class" />
04

Props

Everything NumberField takes.

PropTypeWhat it does
labelrequiredstringLike "Max steps"
minnumber
maxnumber
stepnumber
bigStepnumberSteps that Shift with an arrow, Page Up and Page Down move by (10)
valuenumber
defaultValuenumber
onValueChange(value: number) => void
decimalsnumberDecimal places the value shows with, like 2 for money (0)
prefixstringShown before the value, like "$"
unitstringShown after the value, like "min"
valueText(value: number) => stringThe value in words for screen readers, like "10 minutes". Defaults to the prefix, value and unit
hintstringThe line under the field. Defaults to the range, like "5 to 100, in steps of 5"
buttonLabels[string, string]What the minus and plus buttons are called, like ["Fewer steps", "More steps"]
classNamestring
05

Code

The full source, as the CLI installs it.

NumberField.tsxReact · 123 lines
'use client';
import { useEffect, useId, useRef, useState, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Swap } from '../lib/motion';
import '../designme.css';
import './number-field.css';

export interface NumberFieldProps {
  /** Like "Max steps" */
  label: string;
  min?: number;
  max?: number;
  step?: number;
  /** Steps that Shift with an arrow, Page Up and Page Down move by (10) */
  bigStep?: number;
  value?: number;
  defaultValue?: number;
  onValueChange?: (value: number) => void;
  /** Decimal places the value shows with, like 2 for money (0) */
  decimals?: number;
  /** Shown before the value, like "$" */
  prefix?: string;
  /** Shown after the value, like "min" */
  unit?: string;
  /** The value in words for screen readers, like "10 minutes". Defaults to the prefix, value and unit */
  valueText?: (value: number) => string;
  /** The line under the field. Defaults to the range, like "5 to 100, in steps of 5" */
  hint?: string;
  /** What the minus and plus buttons are called, like ["Fewer steps", "More steps"] */
  buttonLabels?: [string, string];
  className?: string;
}

const fix = (x: number) => +x.toFixed(4);

/** A number to type or step: minus and plus (press and hold to keep going), the arrow keys, Shift or Page Up/Down for ten
    steps. A typed value is rounded to the step and kept inside the range when you press Enter or leave, and the hint says so. */
export function NumberField({ label, min = 0, max = 100, step = 1, bigStep = 10, value, defaultValue = min, onValueChange, decimals = 0, prefix, unit, valueText, hint, buttonLabels = ['Decrease', 'Increase'], className }: NumberFieldProps) {
  const [v, setV] = useControllable(value, defaultValue, onValueChange);
  const shown = (x: number) => x.toFixed(decimals);
  const say = valueText ?? ((x: number) => `${prefix ?? ''}${shown(x)}${unit ? ' ' + unit : ''}`);
  const range = hint ?? `${shown(min)} to ${shown(max)}${step !== 1 ? `, in steps of ${step.toFixed(decimals)}` : ''}`;
  const [text, setText] = useState(shown(v));
  const [note, setNote] = useState('');
  const [bad, setBad] = useState(false);
  const [bump, setBump] = useState(0);
  const editing = useRef(false);
  useEffect(() => { if (!editing.current) setText(shown(v)); }, [v, decimals]); // eslint-disable-line react-hooks/exhaustive-deps
  const vRef = useRef(v); vRef.current = v;
  const set = (x: number, why?: string) => {
    const next = fix(Math.min(max, Math.max(min, x)));
    if (next !== vRef.current) { setV(next); vRef.current = next; setBump(b => b + 1); }
    setText(shown(next));
    setNote(why ?? (x > max ? `${say(max)} is the most` : x < min ? `${say(min)} is the least` : ''));
  };
  const stepBy = (n: number) => set(vRef.current + n * step);
  const commit = () => {
    editing.current = false; setBad(false);
    const raw = text.replace(/[^0-9.]/g, ''), x = parseFloat(raw);
    if (!raw || isNaN(x)) { setText(shown(vRef.current)); setNote(`Enter a number from ${shown(min)} to ${shown(max)}`); return; }
    const snapped = fix(Math.round((x - min) / step) * step + min);
    if (x > max || x < min) set(x);
    else set(snapped, Math.abs(snapped - x) > 1e-9 ? `Rounded to ${say(snapped)}` : '');
  };
  // press and hold minus or plus: one step at once, then more and more quickly until you let go
  const hold = useRef<ReturnType<typeof setTimeout>>(undefined);
  const stop = () => clearTimeout(hold.current);
  useEffect(() => stop, []);
  const press = (d: number) => {
    stepBy(d); stop();
    const rep = (ms: number) => { hold.current = setTimeout(() => { const at = vRef.current; if ((d < 0 && at <= min) || (d > 0 && at >= max)) return; stepBy(d); rep(Math.max(40, ms * .8)); }, ms); };
    rep(420);
  };
  const input = useRef<HTMLInputElement>(null), box = useRef<HTMLDivElement>(null);
  // a nudge of the value each time it changes
  useEffect(() => { const b = box.current; if (!b || !bump) return; b.classList.remove('dm-bump'); void b.offsetWidth; b.classList.add('dm-bump'); }, [bump]);
  const id = useId();
  const btn = (d: number, name: string): ReactNode => (
    <button type="button" className={cx('dm-ib', d < 0 ? 'dm-nmf-dn' : 'dm-nmf-up')} tabIndex={-1} aria-label={name} disabled={d < 0 ? v <= min : v >= max}
      onPointerDown={e => { if (e.button) return; e.preventDefault(); press(d); }} onPointerUp={stop} onPointerLeave={stop} onPointerCancel={stop}
      onClick={e => { if (e.detail === 0) stepBy(d); }}>
      <Icon name={d < 0 ? 'minus' : 'plus'} size="xs" />
    </button>
  );
  return (
    <div data-dm="" className={cx('dm-nmf-f', note && 'dm-noted', className)}>
      <span className="dm-nmf-l" id={id} onClick={() => input.current?.focus()}>{label}</span>
      <div ref={box} className="dm-nmf-in">
        {btn(-1, buttonLabels[0])}
        <span className="dm-nmf-v" onClick={e => { if (e.target !== input.current) input.current?.focus(); }}>
          {prefix && <span className="dm-nmf-u">{prefix}</span>}
          <input ref={input} className="dm-num" role="spinbutton" inputMode="decimal" autoComplete="off" spellCheck={false} aria-labelledby={id}
            aria-valuemin={min} aria-valuemax={max} aria-valuenow={v} aria-valuetext={say(v)} aria-invalid={bad || undefined} value={text}
            onChange={e => { editing.current = true; setText(e.target.value); setBad(!/^\$?\d*\.?\d*$/.test(e.target.value.trim())); }}
            onBlur={commit}
            onKeyDown={e => {
              if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { e.preventDefault(); editing.current = false; stepBy((e.key === 'ArrowUp' ? 1 : -1) * (e.shiftKey ? bigStep : 1)); }
              else if (e.key === 'PageUp' || e.key === 'PageDown') { e.preventDefault(); editing.current = false; stepBy(e.key === 'PageUp' ? bigStep : -bigStep); }
              else if (e.key === 'Enter') { e.preventDefault(); commit(); }
              else if (e.key === 'Escape') { editing.current = false; setBad(false); setText(shown(vRef.current)); }
            }} />
          {unit && <span className="dm-nmf-u">{unit}</span>}
        </span>
        {btn(1, buttonLabels[1])}
      </div>
      <Swap className="dm-nmf-hint dm-muted">{note || range}</Swap>
    </div>
  );
}

/** The panel the fields sit in on designme.dev: a title, a line under it, the fields, and an optional summary at the foot */
export function NumberFieldGroup({ title, description, summary, children, className }: { title?: string; description?: string; summary?: string; children: ReactNode; className?: string }) {
  return (
    <div data-dm="" className={cx('dm-nmf dm-panel', className)}>
      {title && <div className="dm-nmf-h"><b>{title}</b>{description && <span className="dm-muted">{description}</span>}</div>}
      {children}
      {summary != null && <p className="dm-nmf-sum dm-muted" aria-live="polite">{summary}</p>}
    </div>
  );
}
number-field.cssReact · 28 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Number field ---------- */
/* a number you can type or step: minus and plus at the ends of the field, a unit beside the value, and a hint under it that
   gives the range, or says what changed when a typed value was rounded to the step or kept inside the range */
.dm-nmf{width:100%;max-width:320px;margin:0 auto;padding:var(--dm-s-4);display:flex;flex-direction:column;gap:var(--dm-s-3)}
.dm-nmf-h{display:flex;flex-direction:column;font-size:13.5px;line-height:1.4}
.dm-nmf-h b{font-weight:500}
.dm-nmf-h .dm-muted{font-size:12px}
.dm-nmf-f{display:flex;flex-direction:column;gap:6px}
.dm-nmf-l{font-size:13px;color:var(--dm-fg2);cursor:default;align-self:flex-start}
.dm-nmf-in{display:flex;align-items:center;gap:2px;height:36px;padding:0 3px;border-radius:var(--dm-r-field);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line) 50%,transparent);transition:box-shadow var(--dm-t-fast)}
.dm-nmf-in:focus-within{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
.dm-nmf-in:has(input[aria-invalid]){box-shadow:inset 0 0 0 1px var(--dm-bad)}
.dm-nmf-in .dm-ib{width:28px;height:28px;flex:none;border-radius:calc(5px * var(--dm-rk));touch-action:manipulation}
/* the steppers carry the accent (owner, 4 Nov: the field read empty), as small squircles, not circles */
.dm-nmf-in .dm-ib:not(:disabled){background:var(--dm-accent-bg);color:var(--dm-accent-fg,var(--dm-accent))}
@media (hover:hover) and (pointer:fine){.dm-nmf-in .dm-ib:not(:disabled):hover{background:color-mix(in srgb,var(--dm-accent) 18%,transparent)}}
.dm-nmf-in .dm-ib:disabled{color:var(--dm-dis);background:none;cursor:not-allowed}
.dm-nmf-v{flex:1;min-width:0;align-self:stretch;display:flex;align-items:center;justify-content:center;gap:3px;cursor:text}
.dm-nmf-v input{field-sizing:content;min-width:1ch;max-width:100%;height:100%;border:0;outline:0;padding:0;background:none;font:inherit;font-size:14px;color:var(--dm-fg);text-align:center}
@supports not (field-sizing:content){.dm-nmf-v input{width:7ch}}
.dm-nmf-u{flex:none;font-size:13px;color:var(--dm-muted)}
.dm-nmf-in.dm-bump input{animation:dm-nmfbump .22s var(--dm-ease)}
@keyframes dm-nmfbump{40%{transform:translateY(-1px)}}
.dm-nmf-hint{font-size:12px;line-height:1.4}
.dm-nmf-f.dm-noted .dm-nmf-hint{color:var(--dm-fg2)}
.dm-nmf-sum{margin:0;padding-top:var(--dm-s-3);border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent);font-size:12px;line-height:1.5}
NumberField.astroAstro · 42 lines
---
// Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync.
// Number field: Type a number or step it with minus and plus, with units, limits and a word when a value is changed to fit.
// The markup, CSS and script are designme.dev's own, run by the shared core in ../core, which mounts the component when it scrolls
// into view and plays its demo. To show your own data, change number-field.js. The markup is kept exactly as it is (is:raw).
import type { HTMLAttributes } from 'astro/types';
import '../core/designme.css';
import './number-field.css';

export interface Props extends HTMLAttributes<'div'> {
  /** "soft" draws it in the Soft style. Leave it out for Blueprint, or for the style set on <html> (data-style="soft") */
  'data-style'?: 'soft';
}
const { class: className, ...rest } = Astro.props;
---
<div class:list={['card', className]} data-c="number-field" {...rest}>
  <div class="stage-in"><Fragment is:raw>
<div class="nmf panel">
  <div class="nmf-h"><b>Run limits</b><span class="muted">For each run of the release agent</span></div>
  <div class="nmf-f" data-k="steps">
    <span class="nmf-l">Max steps</span>
    <div class="nmf-in"><button class="ib nmf-dn" tabindex="-1" aria-label="Fewer steps"><svg class="i xs"><use href="#i-minus"/></svg></button><span class="nmf-v"><input class="num" role="spinbutton" inputmode="decimal" autocomplete="off" spellcheck="false" aria-label="Max steps"><span class="nmf-u">steps</span></span><button class="ib nmf-up" tabindex="-1" aria-label="More steps"><svg class="i xs"><use href="#i-plus"/></svg></button></div>
    <span class="nmf-hint muted"></span>
  </div>
  <div class="nmf-f" data-k="budget">
    <span class="nmf-l">Budget per run</span>
    <div class="nmf-in"><button class="ib nmf-dn" tabindex="-1" aria-label="Lower budget"><svg class="i xs"><use href="#i-minus"/></svg></button><span class="nmf-v"><span class="nmf-u">$</span><input class="num" role="spinbutton" inputmode="decimal" autocomplete="off" spellcheck="false" aria-label="Budget per run"></span><button class="ib nmf-up" tabindex="-1" aria-label="Higher budget"><svg class="i xs"><use href="#i-plus"/></svg></button></div>
    <span class="nmf-hint muted"></span>
  </div>
  <div class="nmf-f" data-k="time">
    <span class="nmf-l">Time limit</span>
    <div class="nmf-in"><button class="ib nmf-dn" tabindex="-1" aria-label="Less time"><svg class="i xs"><use href="#i-minus"/></svg></button><span class="nmf-v"><input class="num" role="spinbutton" inputmode="decimal" autocomplete="off" spellcheck="false" aria-label="Time limit"><span class="nmf-u">min</span></span><button class="ib nmf-up" tabindex="-1" aria-label="More time"><svg class="i xs"><use href="#i-plus"/></svg></button></div>
    <span class="nmf-hint muted"></span>
  </div>
  <p class="nmf-sum muted" aria-live="polite"></p>
</div>
  </Fragment></div>
</div>
<script>
  import './number-field.js';
</script>
number-field.htmlHTML · 24 lines
<div class="card" data-c="number-field">
  <div class="stage-in">
    <div class="nmf panel">
      <div class="nmf-h"><b>Run limits</b><span class="muted">For each run of the release agent</span></div>
      <div class="nmf-f" data-k="steps">
        <span class="nmf-l">Max steps</span>
        <div class="nmf-in"><button class="ib nmf-dn" tabindex="-1" aria-label="Fewer steps"><svg class="i xs"><use href="#i-minus"/></svg></button><span class="nmf-v"><input class="num" role="spinbutton" inputmode="decimal" autocomplete="off" spellcheck="false" aria-label="Max steps"><span class="nmf-u">steps</span></span><button class="ib nmf-up" tabindex="-1" aria-label="More steps"><svg class="i xs"><use href="#i-plus"/></svg></button></div>
        <span class="nmf-hint muted"></span>
      </div>
      <div class="nmf-f" data-k="budget">
        <span class="nmf-l">Budget per run</span>
        <div class="nmf-in"><button class="ib nmf-dn" tabindex="-1" aria-label="Lower budget"><svg class="i xs"><use href="#i-minus"/></svg></button><span class="nmf-v"><span class="nmf-u">$</span><input class="num" role="spinbutton" inputmode="decimal" autocomplete="off" spellcheck="false" aria-label="Budget per run"></span><button class="ib nmf-up" tabindex="-1" aria-label="Higher budget"><svg class="i xs"><use href="#i-plus"/></svg></button></div>
        <span class="nmf-hint muted"></span>
      </div>
      <div class="nmf-f" data-k="time">
        <span class="nmf-l">Time limit</span>
        <div class="nmf-in"><button class="ib nmf-dn" tabindex="-1" aria-label="Less time"><svg class="i xs"><use href="#i-minus"/></svg></button><span class="nmf-v"><input class="num" role="spinbutton" inputmode="decimal" autocomplete="off" spellcheck="false" aria-label="Time limit"><span class="nmf-u">min</span></span><button class="ib nmf-up" tabindex="-1" aria-label="More time"><svg class="i xs"><use href="#i-plus"/></svg></button></div>
        <span class="nmf-hint muted"></span>
      </div>
      <p class="nmf-sum muted" aria-live="polite"></p>
    </div>
  </div>
</div>
number-field.cssHTML · 27 lines
/* ---------- Number field ---------- */
/* a number you can type or step: minus and plus at the ends of the field, a unit beside the value, and a hint under it that
   gives the range, or says what changed when a typed value was rounded to the step or kept inside the range */
.nmf{width:100%;max-width:320px;margin:0 auto;padding:var(--dm-s-4);display:flex;flex-direction:column;gap:var(--dm-s-3)}
.nmf-h{display:flex;flex-direction:column;font-size:13.5px;line-height:1.4}
.nmf-h b{font-weight:500}
.nmf-h .muted{font-size:12px}
.nmf-f{display:flex;flex-direction:column;gap:6px}
.nmf-l{font-size:13px;color:var(--dm-fg2);cursor:default;align-self:flex-start}
.nmf-in{display:flex;align-items:center;gap:2px;height:36px;padding:0 3px;border-radius:var(--dm-r-field);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line) 50%,transparent);transition:box-shadow var(--dm-t-fast)}
.nmf-in:focus-within{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
.nmf-in:has(input[aria-invalid]){box-shadow:inset 0 0 0 1px var(--dm-bad)}
.nmf-in .ib{width:28px;height:28px;flex:none;border-radius:calc(5px * var(--dm-rk));touch-action:manipulation}
/* the steppers carry the accent (owner, 4 Nov: the field read empty), as small squircles, not circles */
.nmf-in .ib:not(:disabled){background:var(--dm-accent-bg);color:var(--dm-accent-fg,var(--dm-accent))}
@media (hover:hover) and (pointer:fine){.nmf-in .ib:not(:disabled):hover{background:color-mix(in srgb,var(--dm-accent) 18%,transparent)}}
.nmf-in .ib:disabled{color:var(--dm-dis);background:none;cursor:not-allowed}
.nmf-v{flex:1;min-width:0;align-self:stretch;display:flex;align-items:center;justify-content:center;gap:3px;cursor:text}
.nmf-v input{field-sizing:content;min-width:1ch;max-width:100%;height:100%;border:0;outline:0;padding:0;background:none;font:inherit;font-size:14px;color:var(--dm-fg);text-align:center}
@supports not (field-sizing:content){.nmf-v input{width:7ch}}
.nmf-u{flex:none;font-size:13px;color:var(--dm-muted)}
.nmf-in.bump input{animation:nmfbump .22s var(--dm-ease)}
@keyframes nmfbump{40%{transform:translateY(-1px)}}
.nmf-hint{font-size:12px;line-height:1.4}
.nmf-f.noted .nmf-hint{color:var(--dm-fg2)}
.nmf-sum{margin:0;padding-top:var(--dm-s-3);border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent);font-size:12px;line-height:1.5}
number-field.jsHTML · 77 lines
DM.def('number-field', (r, c) => {
  r.innerHTML = r.innerHTML.replace(/\{minus\}/g, I('minus','xs')).replace(/\{plus\}/g, I('plus','xs'));
  // each field: its range and step, how its value reads, and the words for its unit
  const F = {
    steps:{ min:5, max:100, step:5, v:25, dp:0, say:x => `${x} steps` },
    budget:{ min:.25, max:20, step:.25, v:2.5, dp:2, say:x => `$${x.toFixed(2)}` },
    time:{ min:1, max:60, step:1, v:10, dp:0, say:x => `${x} minutes` }
  };
  const sum = $('.nmf-sum', r);
  const fix = x => +x.toFixed(4);
  const line = () => `A run stops at ${F.steps.v} steps, $${F.budget.v.toFixed(2)} or ${F.time.v} min, whichever comes first.`;
  sum.textContent = line();
  $$('.nmf-f', r).forEach(f => {
    const o = F[f.dataset.k], inp = $('input', f), dn = $('.nmf-dn', f), up = $('.nmf-up', f), hint = $('.nmf-hint', f), box = $('.nmf-in', f);
    const base = `${o.dp ? o.min.toFixed(o.dp) : o.min} to ${o.dp ? o.max.toFixed(o.dp) : o.max}${o.step !== 1 ? `, in steps of ${o.dp ? o.step.toFixed(o.dp) : o.step}` : ''}`;
    let note = '';
    const tell = t => { if (t === note) return; note = t; swap(hint, t || base); f.classList.toggle('noted', !!t); };
    hint.textContent = base;
    const paint = () => {
      inp.value = o.v.toFixed(o.dp); inp.setAttribute('aria-valuenow', o.v); inp.setAttribute('aria-valuetext', o.say(o.v));
      dn.disabled = o.v <= o.min; up.disabled = o.v >= o.max; sum.textContent = line();
    };
    inp.setAttribute('aria-valuemin', o.min); inp.setAttribute('aria-valuemax', o.max);
    // a change from the buttons or the keys: one step, or ten with Shift or Page Up/Down; it stops at the ends and says so
    const set = (x, why) => {
      const was = o.v; o.v = fix(Math.min(o.max, Math.max(o.min, x)));
      if (o.v !== was){ box.classList.remove('bump'); void box.offsetWidth; box.classList.add('bump'); }
      tell(why || (x > o.max ? `${o.say(o.max)} is the most` : x < o.min ? `${o.say(o.min)} is the least` : ''));
      paint();
    };
    const stepBy = n => set(o.v + n * o.step);
    // what was typed is kept when you press Enter or leave the field: rounded to the step and kept inside the range, with a
    // word about what changed; something that isn't a number puts the last value back
    const commit = () => {
      const raw = inp.value.replace(/[^0-9.]/g, ''), x = parseFloat(raw);
      if (!raw || isNaN(x)){ inp.removeAttribute('aria-invalid'); tell(`Enter a number from ${base.split(',')[0]}`); paint(); return; }
      const snapped = fix(Math.round((x - o.min) / o.step) * o.step + o.min);
      if (x > o.max) set(x);
      else if (x < o.min) set(x);
      else set(snapped, Math.abs(snapped - x) > 1e-9 ? `Rounded to ${o.say(snapped)}` : '');
    };
    inp.addEventListener('input', () => { const ok = /^\$?\d*\.?\d*$/.test(inp.value.trim()); inp.toggleAttribute('aria-invalid', !ok); });
    inp.addEventListener('keydown', e => {
      const big = e.shiftKey ? 10 : 1;
      if (e.key === 'ArrowUp' || e.key === 'ArrowDown'){ e.preventDefault(); stepBy((e.key === 'ArrowUp' ? 1 : -1) * big); }
      else if (e.key === 'PageUp' || e.key === 'PageDown'){ e.preventDefault(); stepBy(e.key === 'PageUp' ? 10 : -10); }
      else if (e.key === 'Enter'){ e.preventDefault(); commit(); if (document.activeElement === inp) inp.select(); }
      else if (e.key === 'Escape'){ inp.removeAttribute('aria-invalid'); paint(); }
    });
    inp.addEventListener('blur', commit);
    // the label, and a press beside the value, put the cursor in the field
    $('.nmf-l', f).onclick = () => inp.focus();
    $('.nmf-v', f).onclick = e => { if (e.target !== inp) inp.focus(); };
    // press and hold − or +: one step at once, then more and more quickly until you let go
    [[dn, -1], [up, 1]].forEach(([b, d]) => {
      let t = 0;
      const stop = () => { clearTimeout(t); t = 0; };
      const rep = ms => { t = setTimeout(() => { if (b.disabled) return stop(); stepBy(d); rep(Math.max(40, ms * .8)); }, ms); c.t.push(t); };
      b.addEventListener('pointerdown', e => { if (e.button) return; e.preventDefault(); stepBy(d); stop(); rep(420); });
      ['pointerup', 'pointerleave', 'pointercancel'].forEach(ev => b.addEventListener(ev, stop));
      // the keyboard and the demo press it with a click, which has no pointer down before it
      b.addEventListener('click', e => { if (e.detail === 0) stepBy(d); });
    });
    paint();
  });
  // the preview's story: two more steps, a budget typed past the most it allows, then a minute less of time
  if (r === c.st) c.story(async s => {
    const up = $('.nmf-f[data-k="steps"] .nmf-up', r), bi = $('.nmf-f[data-k="budget"] input', r), td = $('.nmf-f[data-k="time"] .nmf-dn', r);
    await s.beat(3);
    await s.tap(up); await s.beat(1);
    await s.tap(up); await s.beat(3);
    await s.point(bi, false); bi.value = ''; await s.type(bi, '25', { n:.3 }); await s.beat(1);
    bi.dispatchEvent(new KeyboardEvent('keydown', { key:'Enter' })); s.away(); await s.beat(4);
    await s.tap(td); s.away(); await s.beat(4);
  });
});
06

Questions

What people ask about it.

Why not an input with type="number"?
Its spinner is tiny, looks different in every browser, changes the value when you scroll over it, and can't show a unit. This keeps the same keys and the spinbutton role, with buttons you can hit and a unit beside the value.
What happens to a value that is out of range?
It is kept at the nearest end when you press Enter or leave the field, and the hint says so, like "100 steps is the most". onValueChange only ever gets values inside the range and on a step.
Can it show money?
Yes. Use prefix="$" and decimals={2}, with a step like 0.25. For other currencies, change the prefix or use unit for a code after the value.
07

More in Collaboration

Threads, inboxes and boards for teams working alongside an agent.

BannerAn announcement on a tint of its tone, with its own line drawing.
CommentsA design thread where you can @mention the agent and it replies.
NotificationsAn inbox where agent updates and mentions arrive live.
Invite to collaborateAdd teammates by email as chips, set their role, send.
ReactionsQuick reactions on an agent update, with counts that pop.
Profile menuThe account menu behind an avatar: switch workspace, theme, shortcuts and sign out.
Context menuRight-click an agent reply for its actions, with a submenu that asks the agent about it.
Context switcherThe organisation, project and environment you are in, each part a searchable switcher.
All 12 in Collaboration →