Skip to content

role spinbutton

Number field draft v0.1.0

Lets people enter a number, by typing it or stepping it up and down.

A number field collects a numeric value within optional bounds and steps: a quantity, a percentage, a year. It is a Text field for numbers, with the platform's own stepping control (arrow keys, a spin control) and validation of the bounds. A value whose exact figure matters less than its position in a range is a Slider; a phone number or a postal code is text, not a number: use a Text field.

Specification

From packages/components/specs/components/number-field.spec.json

Raw file packages/components/specs/components/number-field.spec.json
{
  "$schema": "../schema/component.schema.json",
  "id": "number-field",
  "name": "Number field",
  "version": "0.1.0",
  "status": "draft",
  "role": "spinbutton",
  "summary": "Lets people enter a number, by typing it or stepping it up and down.",
  "description": "A number field collects a numeric value within optional bounds and steps: a quantity, a percentage, a year. It is a Text field for numbers, with the platform's own stepping control (arrow keys, a spin control) and validation of the bounds. A value whose exact figure matters less than its position in a range is a Slider; a phone number or a postal code is text, not a number: use a Text field.",
  "tags": [
    "form",
    "input",
    "number"
  ],
  "anatomy": [
    {
      "id": "container",
      "role": "container",
      "required": true,
      "description": "Root. Stacks the label, the control, the description and the error message; carries the size and the forced state."
    },
    {
      "id": "label",
      "role": "label",
      "required": true,
      "description": "Text naming the field. Provides the accessible name; activating it focuses the control."
    },
    {
      "id": "control",
      "role": "value",
      "required": true,
      "description": "The editable box: the native number input, drawn by the component. Receives focus, holds the value, steps it with the arrow keys and the browser's spin control, and shows the placeholder while empty."
    },
    {
      "id": "description",
      "role": "description",
      "required": false,
      "description": "Optional help text under the control (format, example, consequence), associated with it as its description."
    },
    {
      "id": "error-message",
      "role": "errormessage",
      "required": false,
      "visibleIn": [
        "invalid"
      ],
      "description": "Text describing the error when the field is invalid, associated with the control as a description. Shown only while invalid."
    }
  ],
  "properties": [
    {
      "id": "size",
      "name": "Size",
      "type": "enum",
      "default": "medium",
      "description": "Control height, inline padding and text size scale together; label, description and error keep their size.",
      "affects": [
        "control"
      ],
      "options": [
        {
          "value": "small",
          "description": "Dense forms, toolbars, table rows (32 px high)."
        },
        {
          "value": "medium",
          "description": "Default (40 px)."
        },
        {
          "value": "large",
          "description": "Prominent, single fields: a search page, a sign-in form (48 px)."
        }
      ]
    },
    {
      "id": "label",
      "name": "Label",
      "type": "string",
      "required": true,
      "bindsTo": "label",
      "description": "What the field asks for, as a noun phrase (Email address). Always visible."
    },
    {
      "id": "value",
      "name": "Value",
      "type": "number",
      "bindsTo": "control",
      "description": "Current value of the field."
    },
    {
      "id": "min",
      "name": "Minimum",
      "type": "number",
      "description": "Lowest value accepted."
    },
    {
      "id": "max",
      "name": "Maximum",
      "type": "number",
      "description": "Highest value accepted."
    },
    {
      "id": "step",
      "name": "Step",
      "type": "number",
      "description": "Increment of the arrow keys and the spin control, and the granularity of valid values (1 by default; any for decimals is passed as an extra attribute)."
    },
    {
      "id": "placeholder",
      "name": "Placeholder",
      "type": "string",
      "description": "Short hint shown while the field is empty (an example value). Never the label."
    },
    {
      "id": "description",
      "name": "Description",
      "type": "string",
      "bindsTo": "description",
      "description": "Help text: expected format, example, or what the value is used for."
    },
    {
      "id": "errorMessage",
      "name": "Error message",
      "type": "string",
      "bindsTo": "error-message",
      "description": "What is wrong and how to fix it, shown when the field is invalid."
    },
    {
      "id": "required",
      "name": "Required",
      "type": "boolean",
      "default": false,
      "description": "The field must be filled to submit the form."
    },
    {
      "id": "readonly",
      "name": "Read-only",
      "type": "boolean",
      "default": false,
      "description": "The value can be read, focused and copied but not edited (a generated identifier)."
    },
    {
      "id": "invalid",
      "name": "Invalid",
      "type": "boolean",
      "default": false,
      "description": "The value failed validation: the border takes the error color and the error message is shown."
    },
    {
      "id": "disabled",
      "name": "Disabled",
      "type": "boolean",
      "default": false,
      "description": "Non-interactive and skipped by the keyboard; the value stays visible. Prefer read-only when people may need to read or copy the value."
    }
  ],
  "states": [
    {
      "id": "default",
      "trigger": "interaction",
      "description": "Resting: an outlined box on the default surface."
    },
    {
      "id": "hover",
      "trigger": "interaction",
      "inputs": [
        "pointer"
      ],
      "description": "Pointer over the control. The box takes the surface hover tint; the border does not change. Needs a pointing device: touch has no hover."
    },
    {
      "id": "focus-visible",
      "trigger": "interaction",
      "description": "Focus on the control, from the keyboard or a pointer: an editable field shows the shared focus ring outside its border however it was focused."
    },
    {
      "id": "invalid",
      "trigger": "property",
      "property": "invalid",
      "description": "Validation failed: the border takes the error color and the error message is shown under the control."
    },
    {
      "id": "readonly",
      "trigger": "property",
      "property": "readonly",
      "description": "Not editable: the box takes the level 1 surface; the value stays focusable and selectable."
    },
    {
      "id": "disabled",
      "trigger": "property",
      "property": "disabled",
      "description": "Non-interactive. Box, value, label and description drop to the shared disabled colors; hover and focus do not apply."
    }
  ],
  "layout": {
    "direction": "vertical",
    "align": "stretch",
    "justify": "start",
    "wrap": false,
    "sizing": "fill",
    "notes": [
      "Label, control, description and error message stack, separated by container.gap; the control fills the width of the component, which the layout sizes.",
      "Inline padding, minimum height and text size come from the size option; the corner radius and the border width do not change.",
      "The browser's spin control keeps its native look and sits at the end edge of the control."
    ]
  },
  "accessibility": {
    "ariaRole": "spinbutton",
    "name": {
      "text": "Computed from the label, associated with the control as its label element on the web. The value, the minimum and the maximum are exposed as the spinbutton's value and bounds. The description and the error message are exposed as the accessible description.",
      "refs": [
        "wai-labels",
        "html-aam-name-input",
        "apg-describing",
        "html-aam-placeholder"
      ]
    },
    "keyboard": [
      {
        "key": "Tab",
        "action": "Moves focus to the control. A disabled control is not focusable and is skipped.",
        "refs": [
          "wcag:2.1.1",
          "html-focusable-area",
          "html-disabled"
        ],
        "platforms": [
          "web"
        ]
      },
      {
        "key": "Arrow keys",
        "action": "Up increases the value by one step, Down decreases it, within the bounds; the browser provides it for a native number input.",
        "refs": [
          "apg-spinbutton-keyboard",
          "html-input-number"
        ],
        "platforms": [
          "web"
        ]
      },
      {
        "key": "Printable characters",
        "action": "Type the value directly.",
        "refs": [
          "apg-spinbutton-keyboard"
        ],
        "platforms": [
          "web"
        ]
      }
    ],
    "guidelines": [
      {
        "text": "Expose the spinbutton role, the name, the value and its bounds, and the required, read-only, disabled and invalid states through the platform's native numeric control, so assistive technology receives them without extra work.",
        "refs": [
          "wcag:4.1.2",
          "aria-spinbutton",
          "apg-spinbutton"
        ]
      },
      {
        "text": "Use the native input type=number: it exposes the spinbutton role, the value, min and max without ARIA, and provides the arrow keys and a spin control. When the value is not really a number (a phone number, a postal code, a card number), use a text input with inputmode instead: a spinbutton is wrong for them.",
        "refs": [
          "html-aria-input-number",
          "html-aam-input-number",
          "html-input-number",
          "mdn-input-number"
        ],
        "platforms": [
          "web"
        ]
      },
      {
        "text": "Every field has a visible label associated with the control programmatically; help text that people need to fill the field (format, example) is visible too, as the description.",
        "refs": [
          "wcag:3.3.2",
          "wcag:1.3.1",
          "wai-labels",
          "wai-instructions",
          "html-label"
        ]
      },
      {
        "text": "Associate the label with a label element whose for attribute names the control's id; reference the description and the error message with aria-describedby, so they are read after the name.",
        "refs": [
          "wcag-h44",
          "wcag-aria1",
          "apg-describing"
        ],
        "platforms": [
          "web"
        ]
      },
      {
        "text": "The placeholder is a short hint, never the label and never an instruction: it disappears as soon as people type, and is not exposed as the name when a label exists.",
        "refs": [
          "html-placeholder",
          "wai-instructions-placeholder",
          "html-aam-placeholder"
        ],
        "platforms": [
          "web"
        ]
      },
      {
        "text": "A stepper is a two-segment control that people use to increase or decrease an incremental value; it sits next to a field that displays the current value: the iOS counterpart of this field's spin control.",
        "refs": [
          "hig-steppers"
        ],
        "platforms": [
          "ios"
        ]
      },
      {
        "text": "When the value is invalid, identify the field and describe the error in text next to it: the error message names what is wrong and how to fix it, and is associated with the control.",
        "refs": [
          "wcag:3.3.1",
          "wai-notifications",
          "wcag-g83"
        ]
      },
      {
        "text": "Say that the value is invalid with aria-invalid on the control, and that a field is required with the required attribute, which the browser exposes as required.",
        "refs": [
          "wcag-aria21",
          "aria-invalid",
          "html-required",
          "html-aam-required",
          "wai-validation"
        ],
        "platforms": [
          "web"
        ]
      },
      {
        "text": "Mark required fields in the label or the description, never by the error color alone.",
        "refs": [
          "wcag-h90",
          "wcag-f81",
          "wcag:1.4.1"
        ]
      },
      {
        "text": "Label, value, description and error text contrast at least 4.5:1 with their background in every color mode and enabled state: label and value 19.07:1 (light) and 11.27:1 (dark) on the default surface, description 12.87:1 and 8.80:1, placeholder 6.08:1 and 6.36:1, error text 4.52:1 and 4.97:1 (4.26:1 on a level 1 surface in light mode, a Figma library limit tracked as a known issue). Disabled text is inactive and exempt.",
        "refs": [
          "wcag:1.4.3"
        ]
      },
      {
        "text": "The border is what identifies the field, so it contrasts at least 3:1 with the adjacent surface in every color mode: 12.87:1 (light) and 8.80:1 (dark) on the default surface at rest, 12.14:1 and 8.06:1 on the hover tint; the error border 6.12:1 and 3.82:1; the focus ring 5.79:1 and 12.80:1. Disabled is exempt.",
        "refs": [
          "wcag:1.4.11",
          "understanding-non-text-contrast-boundaries"
        ]
      },
      {
        "text": "A visible focus indicator appears when the control receives focus, drawn outside its border.",
        "refs": [
          "wcag:2.4.7",
          "wcag:2.4.13"
        ]
      },
      {
        "text": "The focus ring is drawn with :focus-visible, which browsers match whenever an editable field has focus, pointer or keyboard.",
        "refs": [
          "mdn-focus-visible"
        ],
        "platforms": [
          "web"
        ]
      },
      {
        "text": "Every size is a pointer target of at least 24 x 24 CSS pixels through its minimum height: 32, 40 and 48 pixels.",
        "refs": [
          "wcag:2.5.8"
        ]
      },
      {
        "text": "A read-only field keeps its value focusable and selectable and is exposed as read-only; a disabled field leaves the focus order. Prefer read-only when people may need to read or copy the value.",
        "refs": [
          "html-readonly",
          "html-aam-readonly",
          "html-disabled",
          "apg-disabled-focus"
        ],
        "platforms": [
          "web"
        ]
      },
      {
        "text": "Apple recommends a hit region of at least 44 x 44 pt; the small and medium sizes (32 and 40 pt) fall under it on iOS today: the Figma library has no platform heights.",
        "refs": [
          "hig-accessibility-mobility"
        ],
        "platforms": [
          "ios"
        ]
      },
      {
        "text": "Android recommends a touch target of at least 48 x 48 dp; the small and medium sizes (32 and 40 dp) fall under it on Android today. Expose an invalid value with the error semantics so the message is announced with the field.",
        "refs": [
          "android-large-controls",
          "compose-target-sizes",
          "compose-error-components"
        ],
        "platforms": [
          "android"
        ]
      },
      {
        "text": "Color transitions of the control are removed when the user asks for reduced motion.",
        "refs": [
          "wcag:2.3.3",
          "mdn-reduced-motion"
        ]
      }
    ],
    "references": [
      {
        "id": "aria-invalid",
        "title": "WAI-ARIA 1.2: aria-invalid",
        "url": "https://www.w3.org/TR/wai-aria-1.2/#aria-invalid"
      },
      {
        "id": "apg-describing",
        "title": "APG: Describing with aria-describedby",
        "url": "https://www.w3.org/WAI/ARIA/apg/practices/names-and-descriptions/#describing_with_aria-describedby"
      },
      {
        "id": "apg-disabled-focus",
        "title": "APG: Focusability of disabled controls",
        "url": "https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_disabled_controls"
      },
      {
        "id": "html-aam-name-input",
        "title": "HTML-AAM: accessible name computation of input elements (associated label element)",
        "url": "https://www.w3.org/TR/html-aam-1.0/#input-type-text-input-type-password-input-type-number-input-type-search-input-type-tel-input-type-email-input-type-url-and-textarea-elements-accessible-name-computation"
      },
      {
        "id": "html-aam-placeholder",
        "title": "HTML-AAM: placeholder attribute (aria-placeholder, a last-resort name)",
        "url": "https://www.w3.org/TR/html-aam-1.0/#att-placeholder"
      },
      {
        "id": "html-aam-readonly",
        "title": "HTML-AAM: readonly attribute (aria-readonly)",
        "url": "https://www.w3.org/TR/html-aam-1.0/#att-readonly"
      },
      {
        "id": "html-aam-required",
        "title": "HTML-AAM: required attribute (aria-required)",
        "url": "https://www.w3.org/TR/html-aam-1.0/#att-required"
      },
      {
        "id": "html-label",
        "title": "HTML: the label element (for attribute, labeled control)",
        "url": "https://html.spec.whatwg.org/multipage/forms.html#the-label-element"
      },
      {
        "id": "html-placeholder",
        "title": "HTML: the placeholder attribute (not an alternative to a label)",
        "url": "https://html.spec.whatwg.org/multipage/input.html#attr-input-placeholder"
      },
      {
        "id": "html-readonly",
        "title": "HTML: the readonly attribute",
        "url": "https://html.spec.whatwg.org/multipage/input.html#attr-input-readonly"
      },
      {
        "id": "html-required",
        "title": "HTML: the required attribute",
        "url": "https://html.spec.whatwg.org/multipage/input.html#attr-input-required"
      },
      {
        "id": "html-disabled",
        "title": "HTML: disabled form controls",
        "url": "https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#concept-fe-disabled"
      },
      {
        "id": "html-focusable-area",
        "title": "HTML: focusable area",
        "url": "https://html.spec.whatwg.org/multipage/interaction.html#focusable-area"
      },
      {
        "id": "mdn-focus-visible",
        "title": "MDN: :focus-visible",
        "url": "https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:focus-visible"
      },
      {
        "id": "mdn-reduced-motion",
        "title": "MDN: prefers-reduced-motion",
        "url": "https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-reduced-motion"
      },
      {
        "id": "wai-labels",
        "title": "WAI tutorials: Labeling controls",
        "url": "https://www.w3.org/WAI/tutorials/forms/labels/"
      },
      {
        "id": "wai-instructions",
        "title": "WAI tutorials: Form instructions (required fields, placeholder text, aria-describedby)",
        "url": "https://www.w3.org/WAI/tutorials/forms/instructions/"
      },
      {
        "id": "wai-instructions-placeholder",
        "title": "WAI tutorials: Form instructions, placeholder text",
        "url": "https://www.w3.org/WAI/tutorials/forms/instructions/#placeholder-text"
      },
      {
        "id": "wai-validation",
        "title": "WAI tutorials: Validating input (the required attribute)",
        "url": "https://www.w3.org/WAI/tutorials/forms/validation/"
      },
      {
        "id": "wai-notifications",
        "title": "WAI tutorials: User notifications (error messages)",
        "url": "https://www.w3.org/WAI/tutorials/forms/notifications/"
      },
      {
        "id": "wcag-h44",
        "title": "WCAG technique H44: label elements to associate text labels with form controls",
        "url": "https://www.w3.org/WAI/WCAG22/Techniques/html/H44"
      },
      {
        "id": "wcag-h90",
        "title": "WCAG technique H90: indicating required form controls",
        "url": "https://www.w3.org/WAI/WCAG22/Techniques/html/H90"
      },
      {
        "id": "wcag-g83",
        "title": "WCAG technique G83: text descriptions for required fields not completed",
        "url": "https://www.w3.org/WAI/WCAG22/Techniques/general/G83"
      },
      {
        "id": "wcag-aria1",
        "title": "WCAG technique ARIA1: aria-describedby for a descriptive label",
        "url": "https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA1"
      },
      {
        "id": "wcag-aria21",
        "title": "WCAG technique ARIA21: aria-invalid to indicate an error field",
        "url": "https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA21"
      },
      {
        "id": "wcag-f81",
        "title": "WCAG failure F81: required or error fields identified by color only",
        "url": "https://www.w3.org/WAI/WCAG22/Techniques/failures/F81"
      },
      {
        "id": "understanding-non-text-contrast-boundaries",
        "title": "Understanding WCAG 1.4.11 Non-text Contrast: user interface components and boundaries",
        "url": "https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html#boundaries"
      },
      {
        "id": "hig-accessibility-mobility",
        "title": "Apple HIG: Accessibility, mobility (control sizes)",
        "url": "https://developer.apple.com/design/human-interface-guidelines/accessibility#Mobility"
      },
      {
        "id": "android-large-controls",
        "title": "Android accessibility: use large, simple controls",
        "url": "https://developer.android.com/guide/topics/ui/accessibility/apps#large-controls"
      },
      {
        "id": "compose-target-sizes",
        "title": "Jetpack Compose accessibility: minimum touch target sizes",
        "url": "https://developer.android.com/develop/ui/compose/accessibility/api-defaults#minimum-target-sizes"
      },
      {
        "id": "compose-error-components",
        "title": "Jetpack Compose accessibility: error semantics of components",
        "url": "https://developer.android.com/develop/ui/compose/accessibility/semantics#error-components"
      },
      {
        "id": "apg-spinbutton",
        "title": "APG: Spinbutton pattern",
        "url": "https://www.w3.org/WAI/ARIA/apg/patterns/spinbutton/"
      },
      {
        "id": "apg-spinbutton-keyboard",
        "title": "APG: Spinbutton pattern, keyboard interaction",
        "url": "https://www.w3.org/WAI/ARIA/apg/patterns/spinbutton/#keyboardinteraction"
      },
      {
        "id": "aria-spinbutton",
        "title": "WAI-ARIA 1.2: spinbutton role",
        "url": "https://www.w3.org/TR/wai-aria-1.2/#spinbutton"
      },
      {
        "id": "html-aria-input-number",
        "title": "ARIA in HTML: input type=number (role spinbutton)",
        "url": "https://www.w3.org/TR/html-aria/#el-input-number"
      },
      {
        "id": "html-aam-input-number",
        "title": "HTML-AAM: input type=number (spinbutton)",
        "url": "https://www.w3.org/TR/html-aam-1.0/#el-input-number"
      },
      {
        "id": "html-input-number",
        "title": "HTML: input type=number (a spinbox interface; type=text with inputmode when it does not fit)",
        "url": "https://html.spec.whatwg.org/multipage/input.html#number-state-(type=number)"
      },
      {
        "id": "mdn-input-number",
        "title": "MDN: input type=number, accessibility (spinbutton; inputmode for non-numeric values)",
        "url": "https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/number#accessibility"
      },
      {
        "id": "hig-steppers",
        "title": "Apple HIG: Steppers (increase or decrease an incremental value)",
        "url": "https://developer.apple.com/design/human-interface-guidelines/steppers"
      }
    ]
  },
  "content": {
    "guidelines": [
      "The label names the quantity and its unit when it has one: Quantity, Weight (kg), Year.",
      "The description gives the bounds or the step when they matter: Between 1 and 99.",
      "The placeholder, when any, is an example value, never the label.",
      "The error message says what is wrong and how to fix it: Enter a number between 1 and 99."
    ]
  },
  "examples": [
    {
      "id": "medium",
      "name": "Medium",
      "props": {
        "size": "medium",
        "label": "Quantity",
        "value": 2,
        "min": 1,
        "max": 99
      }
    },
    {
      "id": "medium-description",
      "name": "Medium, with description and bounds",
      "props": {
        "size": "medium",
        "label": "Seats",
        "description": "Between 1 and 12 per booking.",
        "value": 4,
        "min": 1,
        "max": 12,
        "required": true
      }
    },
    {
      "id": "small",
      "name": "Small",
      "props": {
        "size": "small",
        "label": "Rows per page",
        "value": 25,
        "min": 10,
        "max": 100,
        "step": 5
      }
    },
    {
      "id": "large",
      "name": "Large",
      "props": {
        "size": "large",
        "label": "Year",
        "value": 2026,
        "min": 1900,
        "max": 2100
      }
    },
    {
      "id": "medium-invalid",
      "name": "Medium, invalid",
      "description": "The value is out of bounds; the error message says how to fix it.",
      "props": {
        "size": "medium",
        "label": "Quantity",
        "value": 120,
        "min": 1,
        "max": 99,
        "invalid": true,
        "errorMessage": "Enter a number between 1 and 99."
      }
    },
    {
      "id": "medium-readonly",
      "name": "Medium, read-only",
      "props": {
        "size": "medium",
        "label": "Invoice number",
        "value": 10482,
        "readonly": true
      }
    },
    {
      "id": "medium-disabled",
      "name": "Medium, disabled",
      "props": {
        "size": "medium",
        "label": "Credits",
        "value": 0,
        "disabled": true
      }
    }
  ],
  "tokens": {
    "source": "packages/tokens/src/components/number-field.tokens.json",
    "prefix": "number-field"
  },
  "implementations": {
    "web": {
      "css": "packages/components/src/components/number-field/number-field.css",
      "template": "packages/components/src/components/number-field/number-field.template.mjs",
      "selector": ".ds-number-field",
      "element": "div",
      "attributes": {
        "size": "data-size",
        "disabled": "data-disabled",
        "invalid": "data-invalid",
        "readonly": "data-readonly"
      },
      "stateAttribute": "data-state",
      "rolePart": "control",
      "partSelectors": {
        "container": ".ds-number-field",
        "label": ".ds-number-field__label",
        "control": ".ds-number-field__control",
        "description": ".ds-number-field__description",
        "error-message": ".ds-number-field__error-message"
      }
    }
  },
  "related": [
    "text-field",
    "slider"
  ],
  "decisions": [
    {
      "id": "field-colors",
      "date": "2026-10-03",
      "version": "0.1.0",
      "area": "color",
      "status": "active",
      "text": "The control is a box on the default surface outlined in the accent neutral border color; hover tints it with the surface hover color and keeps the border; invalid takes the base error border; read-only takes the level 1 surface; disabled takes the shared disabled colors. Value text is primary, the placeholder tertiary, the description secondary, the error message the error text color.",
      "rationale": "The Figma library maps no color to form controls. The accent neutral border is the lightest neutral border that keeps 3:1 against the surface in both modes (WCAG 1.4.11); the hover tint reuses the surface's own hover step.",
      "tokens": [
        "number-field.control.background.*",
        "number-field.control.border-color.*",
        "number-field.control.color.*"
      ],
      "refs": [
        "wcag:1.4.11"
      ]
    },
    {
      "id": "sizes",
      "date": "2026-10-03",
      "version": "0.1.0",
      "area": "size",
      "status": "active",
      "text": "Three sizes from the Figma library's space and body scales: small (32 px high, 8 px inline padding, 14 px text), medium (40, 12, 16, the default) and large (48, 16, 20); the border is 0.6 px (stroke-width.sm) and the corners 4 px (corner-radius.xs) at every size; label, description and error message keep 14 px.",
      "tokens": [
        "number-field.control.min-height.*",
        "number-field.control.padding-inline.*",
        "number-field.control.typography.*",
        "number-field.control.border-width",
        "number-field.control.border-radius"
      ]
    },
    {
      "id": "label-description-error",
      "date": "2026-10-03",
      "version": "0.1.0",
      "area": "anatomy",
      "status": "active",
      "text": "A field always has a visible label above the control; help text goes under the control as the description, and the error message under it while the field is invalid. Both are exposed as the control's description.",
      "refs": [
        "wcag:3.3.2",
        "wai-labels",
        "wai-instructions",
        "apg-describing"
      ]
    },
    {
      "id": "native-spin-control",
      "date": "2026-10-03",
      "version": "0.1.0",
      "area": "behaviour",
      "status": "active",
      "text": "The field is the native number input: the component styles the box; the arrow keys, the spin control and the validation of min, max and step are the browser's, and the spin control keeps its native look. Values that are not numbers (phone, postal code) use the Text field.",
      "rationale": "There is no portable way to draw the spin control from tokens, and a custom one needs script.",
      "refs": [
        "html-input-number",
        "mdn-input-number"
      ]
    }
  ],
  "changelog": [
    {
      "version": "0.1.0",
      "date": "2026-10-03",
      "note": "Initial draft (decisions field-colors, sizes, label-description-error, native-spin-control). Native number input in a wrapper with a label element, a description and an error message, bounds and step; states default, hover, focus-visible, invalid, readonly, disabled; sizes small, medium, large. Contrast as the Text field."
    }
  ]
}

Follows the Color foundation (16 tokens), the Focus ring foundation (3 tokens), the Motion foundation (2 tokens), the Shape foundation (2 tokens), the Spacing and sizing foundation (7 tokens), the Typography foundation (6 tokens).

Anatomy

PartRoleRequiredDescriptionWeb selector
container container yes Root. Stacks the label, the control, the description and the error message; carries the size and the forced state. .ds-number-field
label label yes Text naming the field. Provides the accessible name; activating it focuses the control. .ds-number-field__label
control value yes The editable box: the native number input, drawn by the component. Receives focus, holds the value, steps it with the arrow keys and the browser's spin control, and shows the placeholder while empty. .ds-number-field__control
description description no Optional help text under the control (format, example, consequence), associated with it as its description. .ds-number-field__description
error-message errormessage no Text describing the error when the field is invalid, associated with the control as a description. Shown only while invalid.
Shown when invalid
.ds-number-field__error-message

Hover or focus a row to outline the part in the rendering above; hover the rendering to find its row.

Properties

PropertyTypeOptions / defaultBinds to / affectsDescription
size enum
  • small — Dense forms, toolbars, table rows (32 px high).
  • medium (default) — Default (40 px).
  • large — Prominent, single fields: a search page, a sign-in form (48 px).
affects control
web: data-size
Control height, inline padding and text size scale together; label, description and error keep their size.
label required string —
binds label
What the field asks for, as a noun phrase (Email address). Always visible.
value number —
binds control
Current value of the field.
min number — Lowest value accepted.
max number — Highest value accepted.
step number — Increment of the arrow keys and the spin control, and the granularity of valid values (1 by default; any for decimals is passed as an extra attribute).
placeholder string — Short hint shown while the field is empty (an example value). Never the label.
description string —
binds description
Help text: expected format, example, or what the value is used for.
errorMessage string —
binds error-message
What is wrong and how to fix it, shown when the field is invalid.
required boolean false The field must be filled to submit the form.
readonly boolean false
web: data-readonly
The value can be read, focused and copied but not edited (a generated identifier).
invalid boolean false
web: data-invalid
The value failed validation: the border takes the error color and the error message is shown.
disabled boolean false
web: data-disabled
Non-interactive and skipped by the keyboard; the value stays visible. Prefer read-only when people may need to read or copy the value.

States

StateTriggerApplies toDescription
default interaction every platform and input Resting: an outlined box on the default surface.
hover interaction input: pointer Pointer over the control. The box takes the surface hover tint; the border does not change. Needs a pointing device: touch has no hover.
focus-visible interaction every platform and input Focus on the control, from the keyboard or a pointer: an editable field shows the shared focus ring outside its border however it was focused.
invalid property (invalid) every platform and input Validation failed: the border takes the error color and the error message is shown under the control.
readonly property (readonly) every platform and input Not editable: the box takes the level 1 surface; the value stays focusable and selectable.
disabled property (disabled) every platform and input Non-interactive. Box, value, label and description drop to the shared disabled colors; hover and focus do not apply.

Layout

direction verticalalign stretchjustify startwrap falsesizing fill

  • Label, control, description and error message stack, separated by container.gap; the control fills the width of the component, which the layout sizes.
  • Inline padding, minimum height and text size come from the size option; the corner radius and the border width do not change.
  • The browser's spin control keeps its native look and sits at the end edge of the control.

Accessibility

Target: WCAG 2.2 level AA. Every statement cites the trusted source it comes from.

ARIA role spinbutton.

Accessible name: Computed from the label, associated with the control as its label element on the web. The value, the minimum and the maximum are exposed as the spinbutton's value and bounds. The description and the error message are exposed as the accessible description. WAI tutorials: Labeling controlsHTML-AAM: accessible name computation of input elements (associated label element)APG: Describing with aria-describedbyHTML-AAM: placeholder attribute (aria-placeholder, a last-resort name)

KeyActionPlatformsSources
Tab Moves focus to the control. A disabled control is not focusable and is skipped. Web WCAG 2.1.1 Keyboard (A)HTML: focusable areaHTML: disabled form controls
Arrow keys Up increases the value by one step, Down decreases it, within the bounds; the browser provides it for a native number input. Web APG: Spinbutton pattern, keyboard interactionHTML: input type=number (a spinbox interface; type=text with inputmode when it does not fit)
Printable characters Type the value directly. Web APG: Spinbutton pattern, keyboard interaction

Every platform

Web

iOS

Android

WCAG success criteria

CriterionLevelUnderstanding
1.3.1 Info and Relationships A Understanding 1.3.1
1.4.1 Use of Color A Understanding 1.4.1
1.4.3 Contrast (Minimum) AA Understanding 1.4.3
1.4.11 Non-text Contrast AA Understanding 1.4.11
2.1.1 Keyboard A Understanding 2.1.1
2.3.3 Animation from Interactions AAA (beyond the AA target) Understanding 2.3.3
2.4.7 Focus Visible AA Understanding 2.4.7
2.4.13 Focus Appearance AAA (beyond the AA target) Understanding 2.4.13
2.5.8 Target Size (Minimum) AA Understanding 2.5.8
3.3.1 Error Identification A Understanding 3.3.1
3.3.2 Labels or Instructions A Understanding 3.3.2
4.1.2 Name, Role, Value A Understanding 4.1.2

References

Content

  • The label names the quantity and its unit when it has one: Quantity, Weight (kg), Year.
  • The description gives the bounds or the step when they matter: Between 1 and 99.
  • The placeholder, when any, is an example value, never the label.
  • The error message says what is wrong and how to fix it: Enter a number between 1 and 99.

Changelog

  • 0.1.0 2026-10-03 — Initial draft (decisions field-colors, sizes, label-description-error, native-spin-control). Native number input in a wrapper with a label element, a description and an error message, bounds and step; states default, hover, focus-visible, invalid, readonly, disabled; sizes small, medium, large. Contrast as the Text field.