Number Field

Number Field provides users with a numeric input, with buttons and a scrub area to increment or decrement its value.

Give FeedbackWAI-ARIABundle Size
'use client';
import * as React from 'react';
import { NumberField } from '@base_ui/react/NumberField';
import { useTheme } from '@mui/system';

function useIsDarkMode() {
  const theme = useTheme();
  return theme.palette.mode === 'dark';
}

export default function UnstyledNumberFieldIntroduction() {
  // Replace this with your app logic for determining dark mode
  const isDarkMode = useIsDarkMode();
  const id = React.useId();

  return (
    <div className={isDarkMode ? 'dark' : ''}>
      <NumberField.Root
        id={id}
        className="NumberField"
        aria-label="Basic number field, default value"
      >
        <NumberField.ScrubArea className="NumberField-ScrubArea">
          <label htmlFor={id} className="NumberField-label">
            Amount
          </label>
          <NumberField.ScrubAreaCursor className="NumberField-ScrubAreaCursor">
            <svg
              width="26"
              height="14"
              viewBox="0 0 24 14"
              fill="none"
              xmlns="http://www.w3.org/2000/svg"
              shapeRendering="crispEdges"
            >
              <path
                d="M19.3382 3.00223V5.40757L13.0684 5.40757L13.0683 5.40757L6.59302 5.40964V3V1.81225L5.74356 2.64241L1.65053 6.64241L1.28462 7L1.65053 7.35759L5.74356 11.3576L6.59302 12.1878V11L6.59302 8.61585L13.0684 8.61585H19.3382V11V12.1741L20.1847 11.3605L24.3465 7.36049L24.7217 6.9999L24.3464 6.63941L20.1846 2.64164L19.3382 1.82862V3.00223Z"
                fill="black"
                stroke="white"
              />
            </svg>
          </NumberField.ScrubAreaCursor>
        </NumberField.ScrubArea>
        <NumberField.Group className="NumberField-Group">
          <NumberField.Decrement className="NumberField-Button NumberField-Decrement">
            &minus;
          </NumberField.Decrement>
          <NumberField.Input
            className="NumberField-Input"
            placeholder="Enter value"
          />
          <NumberField.Increment className="NumberField-Button NumberField-Increment">
            +
          </NumberField.Increment>
        </NumberField.Group>
      </NumberField.Root>
      <Styles />
    </div>
  );
}

function Styles() {
  return (
    <style>
      {`
        .NumberField {
          font-family: 'IBM Plex Sans', sans-serif;
        }

        .NumberField-ScrubArea {
          cursor: ns-resize;
          font-weight: bold;
          user-select: none;
        }

        .NumberField-ScrubAreaCursor {
          filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.3));
        }

        .NumberField-label {
          cursor: unset;
          color: ${grey[800]};
        }

        .NumberField-Group {
          display: flex;
          align-items: center;
          margin-top: 0.25rem;
          border-radius: 0.25rem;
          border: 1px solid ${grey[300]};
          border-color: ${grey[300]};
          overflow: hidden;
        }

        .NumberField-Group:focus-within {
          outline: 2px solid ${blue[100]};
          border-color: ${blue[400]};
        }

        .NumberField-Group:focus-within .dark {
          box-shadow: 0 0 0 2px ${blue[300]};
          border-color: ${blue[400]};
        }

        .NumberField-Input {
          position: relative;
          z-index: 10;
          align-self: stretch;
          padding: 0.25rem 0.5rem;
          font-size: 1rem;
          line-height: 1.5;
          border: none;
          background-color: #fff;
          color: ${grey[800]};
          box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
          overflow: hidden;
          max-width: 150px;
          font: inherit;
        }

        .NumberField-Input:focus {
          outline: none;
          z-index: 10;
        }

        .NumberField-Button {
          position: relative;
          border: none;
          font-weight: bold;
          transition-property: background-color, border-color, color;
          transition-duration: 100ms;
          padding: 0.5rem 0.75rem;
          flex: 1;
          align-self: stretch;
          font-family: inherit;
          background-color: ${grey[50]};
          color: ${grey[700]};
          margin: 0;
          font-family: math;
        }

        .NumberField-Button:not([disabled]):hover {
          background-color: ${grey[100]};
          border-color: ${grey[200]};
          color: ${grey[800]};
        }

        .NumberField-Button:not([disabled]):active {
          background-color: ${grey[200]};
        }

        .Button[disabled] {
          opacity: 0.4;
          cursor: not-allowed;
        }

        .NumberField-Decrement {
          border-right: 1px solid ${grey[200]};
          border-top-right-radius: 0;
          border-bottom-right-radius: 0;
        }

        .NumberField-Increment {
          border-top-left-radius: 0;
          border-bottom-left-radius: 0;
          border-left: 1px solid ${grey[200]};
        }

        .dark .NumberField-Group {
          display: flex;
          align-items: center;
          margin-top: 0.25rem;
          border-radius: 0.25rem;
          border: 1px solid ${grey[700]};
          border-color: ${grey[700]};
        }

        .dark .NumberField-Group:focus-within {
          outline: 2px solid ${blue[800]};
          border-color: ${blue[400]};
        }

        .dark .NumberField-Input {
          background-color: ${grey[900]};
          border-color: ${grey[700]};
          color: ${grey[300]};
        }

        .dark .NumberField-Input:focus {
          border-color: ${blue[600]};
        }

        .dark .NumberField-Button {
          background-color: ${grey[800]};
          color: ${grey[300]};
          border-color: ${grey[700]};
        }

        .dark .NumberField-Button:hover {
          background-color: ${grey[800]};
          border-color: ${grey[700]};
          color: ${grey[200]};
        }

        .dark .NumberField-Button:active {
          background-color: ${grey[700]};
        }

        .dark .NumberField-Decrement {
          border-right-color: ${grey[700]};
        }

        .dark .NumberField-Increment {
          border-left-color: ${grey[700]};
        }

        .dark .NumberField-label {
          color: ${grey[300]};
        }
      `}
    </style>
  );
}

const blue = {
  100: '#CCE5FF',
  200: '#99CCFF',
  300: '#66B3FF',
  400: '#3399FF',
  600: '#0072E6',
  800: '#004C99',
};

const grey = {
  50: '#F9FAFB',
  100: '#E5EAF2',
  200: '#DAE2ED',
  300: '#C7D0DD',
  400: '#B0B8C4',
  500: '#9DA8B7',
  600: '#6B7A90',
  700: '#434D5B',
  800: '#303740',
  900: '#1C2025',
};

Installation

Base UI components are all available as a single package.

npm install @base_ui/react

Once you have the package installed, import the component.

import { NumberField } from '@base_ui/react/NumberField';

Anatomy

Number Field is implemented using a collection of related components:

<NumberField.Root>
  <NumberField.Group>
    <NumberField.Decrement />
    <NumberField.Input />
    <NumberField.Increment />
    <NumberField.ScrubArea>
      <NumberField.ScrubAreaCursor />
    </NumberField.ScrubArea>
  </NumberField.Group>
</NumberField.Root>

Value

Default value

When Number Field is uncontrolled, the defaultValue prop sets the initial value of the input.

<NumberField.Root defaultValue={10}>
  <NumberField.Group>
    <NumberField.Decrement>&minus;</NumberField.Decrement>
    <NumberField.Input />
    <NumberField.Increment>+</NumberField.Increment>
  </NumberField.Group>
</NumberField.Root>

Controlled

The value prop holds the number value, and onValueChange is called when it updates.

function App() {
  const [value, setValue] = useState(0);
  return (
    <NumberField.Root value={value} onValueChange={setValue}>
      <NumberField.Group>
        <NumberField.Decrement>&minus;</NumberField.Decrement>
        <NumberField.Input />
        <NumberField.Increment>+</NumberField.Increment>
      </NumberField.Group>
    </NumberField.Root>
  );
}

Validation

Min and max

The min and max props can be used to prevent the value from going above or below a certain range.

<NumberField.Root min={0} max={100}>
  <NumberField.Group>
    <NumberField.Decrement>&minus;</NumberField.Decrement>
    <NumberField.Input />
    <NumberField.Increment>+</NumberField.Increment>
  </NumberField.Group>
</NumberField.Root>

Step

The step prop snaps the input value to multiples of the given number. In the below example, the input value snaps to multiples of step starting from the min value: 2, 7, 12, 17, and so on.

<NumberField.Root step={5} min={2}>
  <NumberField.Group>
    <NumberField.Decrement>&minus;</NumberField.Decrement>
    <NumberField.Input />
    <NumberField.Increment>+</NumberField.Increment>
  </NumberField.Group>
</NumberField.Root>

You can specify the largeStep and smallStep props to change the step when the user holds a modifier key:

<NumberField.Root step={5} largeStep={50} smallStep={0.5}>
  <NumberField.Group>
    <NumberField.Decrement>&minus;</NumberField.Decrement>
    <NumberField.Input />
    <NumberField.Increment>+</NumberField.Increment>
  </NumberField.Group>
</NumberField.Root>

Format

The format prop accepts Intl.NumberFormat options to customize the formatting of the input value:

'use client';
import * as React from 'react';
import { NumberField as BaseNumberField } from '@base_ui/react/NumberField';
import { styled, css } from '@mui/system';

export default function UnstyledNumberFieldFormat() {
  const id = React.useId();
  return (
    <NumberField
      id={id}
      format={{ style: 'currency', currency: 'USD' }}
      defaultValue={10}
      min={0}
    >
      <NumberLabel htmlFor={id}>Cost</NumberLabel>
      <NumberFieldGroup style={{ display: 'flex', gap: 4 }}>
        <NumberFieldDecrement>&minus;</NumberFieldDecrement>
        <NumberFieldInput />
        <NumberFieldIncrement>+</NumberFieldIncrement>
      </NumberFieldGroup>
    </NumberField>
  );
}

const blue = {
  100: '#CCE5FF',
  200: '#99CCFF',
  300: '#66B3FF',
  400: '#3399FF',
  600: '#0072E6',
  800: '#004C99',
};

const grey = {
  50: '#F9FAFB',
  100: '#E5EAF2',
  200: '#DAE2ED',
  300: '#C7D0DD',
  400: '#B0B8C4',
  500: '#9DA8B7',
  600: '#6B7A90',
  700: '#434D5B',
  800: '#303740',
  900: '#1C2025',
};

const NumberLabel = styled('label')`
  font-family: 'IBM Plex Sans', sans-serif;
  font-size: 1rem;
  font-weight: bold;
`;

const NumberField = styled(BaseNumberField.Root)`
  font-family: 'IBM Plex Sans', sans-serif;
  font-size: 1rem;
`;

const NumberFieldGroup = styled(BaseNumberField.Group)`
  display: flex;
  align-items: center;
  margin-top: 0.25rem;
  border-radius: 0.25rem;
  border: 1px solid ${grey[300]};
  border-color: ${grey[300]};
  overflow: hidden;

  &:focus-within {
    outline: 2px solid ${blue[100]};
    border-color: ${blue[400]};
  }

  .dark & {
    border: 1px solid ${grey[700]};
    border-color: ${grey[700]};

    &:focus-within {
      outline: 2px solid ${blue[800]};
      border-color: ${blue[400]};
    }
  }
`;

const NumberFieldInput = styled(BaseNumberField.Input)`
  position: relative;
  z-index: 10;
  align-self: stretch;
  padding: 0.25rem 0.5rem;
  line-height: 1.5;
  border: none;
  background-color: #fff;
  color: ${grey[800]};
  box-shadow: 0 1px 2px 0 rgba(0 0 0 / 0.05);
  overflow: hidden;
  max-width: 150px;
  font-family: inherit;
  font-size: inherit;

  &:focus {
    outline: none;
    z-index: 10;
  }

  .dark & {
    background-color: ${grey[900]};
    border-color: ${grey[700]};
    color: ${grey[300]};

    &:focus {
      border-color: ${blue[600]};
    }
  }
`;

const buttonStyles = css`
  position: relative;
  border: none;
  font-weight: bold;
  transition-property: background-color, border-color, color;
  transition-duration: 100ms;
  padding: 0.5rem 0.75rem;
  flex: 1;
  align-self: stretch;
  background-color: ${grey[50]};
  color: ${grey[700]};
  margin: 0;
  font-family: math, sans-serif;

  &[disabled] {
    opacity: 0.4;
    cursor: not-allowed;
  }

  .dark & {
    background-color: ${grey[800]};
    color: ${grey[300]};
    border-color: ${grey[700]};

    &[disabled] {
      opacity: 0.4;
      cursor: not-allowed;
    }
  }

  &:hover:not([disabled]) {
    background-color: ${grey[100]};
    border-color: ${grey[200]};
    color: ${grey[800]};
  }

  &:active:not([disabled]) {
    background-color: ${grey[200]};
  }

  .dark {
    &:hover:not([disabled]) {
      background-color: ${grey[800]};
      border-color: ${grey[700]};
      color: ${grey[200]};
    }

    &:active:not([disabled]) {
      background-color: ${grey[700]};
    }
  }
`;

const NumberFieldDecrement = styled(BaseNumberField.Decrement)`
  ${buttonStyles}
  border-right: 1px solid ${grey[200]};
  border-top-right-radius: 0;
  border-bottom-right-radius: 0;

  .dark & {
    border-right-color: ${grey[700]};
  }
`;

const NumberFieldIncrement = styled(BaseNumberField.Increment)`
  ${buttonStyles}
  border-top-left-radius: 0;
  border-bottom-left-radius: 0;
  border-left: 1px solid ${grey[200]};

  .dark & {
    border-left-color: ${grey[700]};
  }
`;

Scrubbing

The NumberField.ScrubArea subcomponent lets users increment/decrement the value via a click+drag interaction with pointer, as a faster alternative to the stepper buttons. This is useful in high-density UIs, such as an image editor that changes the width, height, or location of a layer. You could wrap an icon or a <label/> in the NumberField.ScrubArea component.

'use client';
import * as React from 'react';
import { NumberField as BaseNumberField } from '@base_ui/react/NumberField';
import { styled } from '@mui/system';

export default function UnstyledNumberFieldScrub() {
  const id = React.useId();
  return (
    <NumberField id={id} defaultValue={100}>
      <NumberFieldScrubArea style={{ cursor: 'ns-resize' }}>
        <label htmlFor={id} style={{ cursor: 'unset' }}>
          Scrub
        </label>
      </NumberFieldScrubArea>
      <NumberFieldGroup>
        <NumberFieldInput />
      </NumberFieldGroup>
    </NumberField>
  );
}

const blue = {
  100: '#CCE5FF',
  200: '#99CCFF',
  300: '#66B3FF',
  400: '#3399FF',
  600: '#0072E6',
  800: '#004C99',
};

const grey = {
  50: '#F9FAFB',
  100: '#E5EAF2',
  200: '#DAE2ED',
  300: '#C7D0DD',
  400: '#B0B8C4',
  500: '#9DA8B7',
  600: '#6B7A90',
  700: '#434D5B',
  800: '#303740',
  900: '#1C2025',
};

const NumberField = styled(BaseNumberField.Root)`
  font-family: 'IBM Plex Sans', sans-serif;
  font-size: 1rem;
`;

const NumberFieldGroup = styled(BaseNumberField.Group)`
  display: flex;
  align-items: center;
  margin-top: 0.25rem;
  border-radius: 0.25rem;
  border: 1px solid ${grey[300]};
  border-color: ${grey[300]};
  overflow: hidden;

  &:focus-within {
    outline: 2px solid ${blue[100]};
    border-color: ${blue[400]};
  }

  .dark & {
    border: 1px solid ${grey[700]};
    border-color: ${grey[700]};

    &:focus-within {
      outline: 2px solid ${blue[800]};
      border-color: ${blue[400]};
    }
  }
`;

const NumberFieldScrubArea = styled(BaseNumberField.ScrubArea)`
  cursor: ns-resize;
  font-weight: bold;
  user-select: none;
`;

const NumberFieldInput = styled(BaseNumberField.Input)`
  position: relative;
  z-index: 10;
  align-self: stretch;
  padding: 0.25rem 0.5rem;
  line-height: 1.5;
  border: none;
  background-color: #fff;
  color: ${grey[800]};
  box-shadow: 0 1px 2px 0 rgba(0 0 0 / 0.05);
  overflow: hidden;
  max-width: 150px;
  font-family: inherit;
  font-size: inherit;

  &:focus {
    outline: none;
    z-index: 10;
  }

  .dark & {
    background-color: ${grey[900]};
    border-color: ${grey[700]};
    color: ${grey[300]};

    &:focus {
      border-color: ${blue[600]};
    }
  }
`;

The pointer is locked while scrubbing, allowing the user to scrub infinitely without hitting the window boundary. Since this hides the cursor, you can add a virtual cursor asset using the <NumberField.ScrubAreaCursor /> subcomponent, which automatically loops around the boundary.

<NumberField.ScrubArea direction="horizontal" style={{ cursor: 'ew-resize' }}>
  <label htmlFor={id} style={{ cursor: 'unset' }}>
    Scrub
  </label>
  <NumberField.ScrubAreaCursor>
    <span style={{ filter: 'drop-shadow(2px 0 2px rgb(0 0 0 / 0.3))' }}>
      <svg
        width="26"
        height="14"
        viewBox="0 0 24 12"
        fill="none"
        xmlns="http://www.w3.org/2000/svg"
        shapeRendering="crispEdges"
      >
        <path
          d="M19.3382 3.00223V5.40757L13.0684 5.40757L13.0683 5.40757L6.59302 5.40964V3V1.81225L5.74356 2.64241L1.65053 6.64241L1.28462 7L1.65053 7.35759L5.74356 11.3576L6.59302 12.1878V11L6.59302 8.61585L13.0684 8.61585H19.3382V11V12.1741L20.1847 11.3605L24.3465 7.36049L24.7217 6.9999L24.3464 6.63941L20.1846 2.64164L19.3382 1.82862V3.00223Z"
          fill="black"
          stroke="white"
        />
      </svg>
    </span>
    )}
  </NumberField.ScrubAreaCursor>
</NumberField.ScrubArea>

In your CSS, ensure any <label> elements inside <ScrubArea /> specify cursor: unset. You can rotate the above macOS-style cursor 90 degrees using a transform style.

In Safari, the pointer is not locked. However, this doesn't affect the ability to scrub infinitely.

Teleport distance

Rather than teleporting the virtual cursor at the viewport boundary, you can use the teleportDistance prop to teleport the cursor at a custom boundary.

<NumberField.ScrubArea teleportDistance={200}>
  <NumberField.ScrubAreaCursor />
</NumberField.ScrubArea>

This specifies the px distance the cursor can travel from the center of the scrub area element before it loops back around.

Wheel scrubbing

To allow the input to be scrubbed using the mouse wheel, add the allowWheelScrub prop. The input must be focused and the pointer must be hovering over it.

'use client';
import * as React from 'react';
import { NumberField as BaseNumberField } from '@base_ui/react/NumberField';
import { styled } from '@mui/system';

export default function UnstyledNumberFieldWheelScrub() {
  const id = React.useId();
  return (
    <NumberField id={id} defaultValue={100} allowWheelScrub>
      <NumberLabel htmlFor={id}>Amount</NumberLabel>
      <NumberFieldGroup>
        <NumberFieldInput />
      </NumberFieldGroup>
    </NumberField>
  );
}

const blue = {
  100: '#CCE5FF',
  200: '#99CCFF',
  300: '#66B3FF',
  400: '#3399FF',
  600: '#0072E6',
  800: '#004C99',
};

const grey = {
  50: '#F9FAFB',
  100: '#E5EAF2',
  200: '#DAE2ED',
  300: '#C7D0DD',
  400: '#B0B8C4',
  500: '#9DA8B7',
  600: '#6B7A90',
  700: '#434D5B',
  800: '#303740',
  900: '#1C2025',
};

const NumberLabel = styled('label')`
  font-family: 'IBM Plex Sans', sans-serif;
  font-size: 1rem;
  font-weight: bold;
`;

const NumberField = styled(BaseNumberField.Root)`
  font-family: 'IBM Plex Sans', sans-serif;
  font-size: 1rem;
`;

const NumberFieldGroup = styled(BaseNumberField.Group)`
  display: flex;
  align-items: center;
  margin-top: 0.25rem;
  border-radius: 0.25rem;
  border: 1px solid ${grey[300]};
  border-color: ${grey[300]};
  overflow: hidden;

  &:focus-within {
    outline: 2px solid ${blue[100]};
    border-color: ${blue[400]};
  }

  .dark & {
    border: 1px solid ${grey[700]};
    border-color: ${grey[700]};

    &:focus-within {
      outline: 2px solid ${blue[800]};
      border-color: ${blue[400]};
    }
  }
`;

const NumberFieldInput = styled(BaseNumberField.Input)`
  position: relative;
  z-index: 10;
  align-self: stretch;
  padding: 0.25rem 0.5rem;
  line-height: 1.5;
  border: none;
  background-color: #fff;
  color: ${grey[800]};
  box-shadow: 0 1px 2px 0 rgba(0 0 0 / 0.05);
  overflow: hidden;
  max-width: 150px;
  font-family: inherit;
  font-size: inherit;

  &:focus {
    outline: none;
    z-index: 10;
  }

  .dark & {
    background-color: ${grey[900]};
    border-color: ${grey[700]};
    color: ${grey[300]};

    &:focus {
      border-color: ${blue[600]};
    }
  }
`;

Overriding default components

Use the render prop to override the rendered elements with your own components.

<NumberField.Input render={(props) => <MyCustomInput {...props} />}> />

All subcomponents accept the render prop.

Accessibility

Ensure the Number Field has an accessible name via a <label> element.

API Reference

NumberFieldRoot

The foundation for building custom-styled number fields.

PropTypeDefaultDescription
allowWheelScrubboolfalseWhether to allow the user to scrub the input value with the mouse wheel while focused and hovering over the input.
autoFocusboolfalseIf true, the input element is focused on mount.
classNameunionClass names applied to the element or a function that returns them based on the component's state.
defaultValuenumberThe default value of the input element. Use when the component is not controlled.
disabledboolfalseIf true, the input element is disabled.
formatshapeOptions to format the input value.
idstringThe id of the input element.
invalidboolfalseIf true, the input element is invalid.
largeStepnumber10The large step value of the input element when incrementing while the shift key is held. Snaps to multiples of this value.
maxnumberThe maximum value of the input element.
minnumberThe minimum value of the input element.
namestringThe name of the input element.
onValueChangefuncCallback fired when the number value changes.
readOnlyboolfalseIf true, the input element is read only.
renderunionA function to customize rendering of the component.
requiredboolfalseIf true, the input element is required.
smallStepnumber0.1The small step value of the input element when incrementing while the meta key is held. Snaps to multiples of this value.
stepnumberThe step value of the input element when incrementing, decrementing, or scrubbing. It will snap to multiples of this value. When unspecified, decimal values are allowed, but the stepper buttons will increment or decrement by 1.
valuenumberThe raw number value of the input element.

NumberFieldGroup

Groups interactive `NumberField` components together.

PropTypeDefaultDescription
classNameunionClass names applied to the element or a function that returns them based on the component's state.
renderunionA function to customize rendering of the component.

NumberFieldInput

The input element for the number field.

PropTypeDefaultDescription
classNameunionClass names applied to the element or a function that returns them based on the component's state.
renderunionA function to customize rendering of the component.

NumberFieldIncrement

The increment stepper button.

PropTypeDefaultDescription
classNameunionClass names applied to the element or a function that returns them based on the component's state.
renderunionA function to customize rendering of the component.

NumberFieldDecrement

The decrement stepper button.

PropTypeDefaultDescription
classNameunionClass names applied to the element or a function that returns them based on the component's state.
renderunionA function to customize rendering of the component.

NumberFieldScrubArea

The scrub area element.

PropTypeDefaultDescription
classNameunionClass names applied to the element or a function that returns them based on the component's state.
directionenum'vertical'The direction that the scrub area should change the value.
pixelSensitivitynumber2Determines the number of pixels the cursor must move before the value changes. A higher value will make the scrubbing less sensitive.
renderunionA function to customize rendering of the component.
teleportDistancenumberIf specified, how much the cursor can move around the center of the scrub area element before it will loop back around.

NumberFieldScrubAreaCursor

The scrub area cursor element.

PropTypeDefaultDescription
classNameunionClass names applied to the element or a function that returns them based on the component's state.
renderunionA function to customize rendering of the component.

Contents