Most demos need a newer browser

They use :has() and container queries. Update your browser, or on an iPhone update iOS to 16.4 or later.

CSS Number Input Stepper

Build a CSS numeric stepper without JavaScript using restyled browser arrows or radio buttons with minus and plus labels.

Level
Beginner
Published
Updated
Reading time
6 min

Try it

3 live demos

Your browser can't run this demo

It needs :has(), which this browser doesn't support yet. It works in Chrome 105+, Edge 105+, Firefox 121+ and Safari 15.4+.

Needs :has()

B. Radio buttons, 0 to 10

A restyles the browser's own arrows through ::-webkit-inner-spin-button. It works for any range and allows typing. B uses radio buttons with minus and plus labels for a fixed range of 0 to 10. It renders consistently across browsers and dims the end buttons, but typing is not allowed.

Number field and stepper row

Other ways to build it

Quantity

Quantity picker from radio buttons

See the code

Build it

6 snippets to copy

CSS Number Input Stepper

HTML

<div class="stepper-group">
  <div class="stepper-field">
    <label for="amount">Plain styled number input</label>
    <input type="number" id="amount" value="42" min="0" max="100">
  </div>

  <!-- A: the field's own arrows, restyled in CSS -->
  <div class="stepper-field">
    <label for="quantity">A. Own arrows, restyled</label>
    <input type="number" id="quantity" class="spin-arrows" value="3" min="0" max="10">
  </div>

  <!-- B: one radio per value, each followed by its minus, its
       value and its plus. The minus and plus are labels for the
       neighboring radios, and only the three after the checked
       radio are shown. The arrow keys move through the group. -->
  <div class="stepper-field">
    <span class="stepper-label" id="guests-label">B. Radio buttons, 0 to 10</span>
    <div class="radio-stepper" role="radiogroup" aria-labelledby="guests-label">
        <input type="radio" name="guests" id="guests-0" value="0" aria-label="0">
        <span class="rs-dec rs-end" aria-hidden="true">−</span>
        <span class="rs-val" aria-hidden="true">0</span>
        <label class="rs-inc" for="guests-1" aria-hidden="true">+</label>

        <input type="radio" name="guests" id="guests-1" value="1" aria-label="1">
        <label class="rs-dec" for="guests-0" aria-hidden="true">−</label>
        <span class="rs-val" aria-hidden="true">1</span>
        <label class="rs-inc" for="guests-2" aria-hidden="true">+</label>

        <input type="radio" name="guests" id="guests-2" value="2" aria-label="2" checked>
        <label class="rs-dec" for="guests-1" aria-hidden="true">−</label>
        <span class="rs-val" aria-hidden="true">2</span>
        <label class="rs-inc" for="guests-3" aria-hidden="true">+</label>

        <input type="radio" name="guests" id="guests-3" value="3" aria-label="3">
        <label class="rs-dec" for="guests-2" aria-hidden="true">−</label>
        <span class="rs-val" aria-hidden="true">3</span>
        <label class="rs-inc" for="guests-4" aria-hidden="true">+</label>

        <input type="radio" name="guests" id="guests-4" value="4" aria-label="4">
        <label class="rs-dec" for="guests-3" aria-hidden="true">−</label>
        <span class="rs-val" aria-hidden="true">4</span>
        <label class="rs-inc" for="guests-5" aria-hidden="true">+</label>

        <input type="radio" name="guests" id="guests-5" value="5" aria-label="5">
        <label class="rs-dec" for="guests-4" aria-hidden="true">−</label>
        <span class="rs-val" aria-hidden="true">5</span>
        <label class="rs-inc" for="guests-6" aria-hidden="true">+</label>

        <input type="radio" name="guests" id="guests-6" value="6" aria-label="6">
        <label class="rs-dec" for="guests-5" aria-hidden="true">−</label>
        <span class="rs-val" aria-hidden="true">6</span>
        <label class="rs-inc" for="guests-7" aria-hidden="true">+</label>

        <input type="radio" name="guests" id="guests-7" value="7" aria-label="7">
        <label class="rs-dec" for="guests-6" aria-hidden="true">−</label>
        <span class="rs-val" aria-hidden="true">7</span>
        <label class="rs-inc" for="guests-8" aria-hidden="true">+</label>

        <input type="radio" name="guests" id="guests-8" value="8" aria-label="8">
        <label class="rs-dec" for="guests-7" aria-hidden="true">−</label>
        <span class="rs-val" aria-hidden="true">8</span>
        <label class="rs-inc" for="guests-9" aria-hidden="true">+</label>

        <input type="radio" name="guests" id="guests-9" value="9" aria-label="9">
        <label class="rs-dec" for="guests-8" aria-hidden="true">−</label>
        <span class="rs-val" aria-hidden="true">9</span>
        <label class="rs-inc" for="guests-10" aria-hidden="true">+</label>

        <input type="radio" name="guests" id="guests-10" value="10" aria-label="10">
        <label class="rs-dec" for="guests-9" aria-hidden="true">−</label>
        <span class="rs-val" aria-hidden="true">10</span>
        <span class="rs-inc rs-end" aria-hidden="true">+</span>
    </div>
  </div>
</div>

CSS

.stepper-group {
  display: grid;
  gap: 1.25rem;
  max-width: 18rem;
  margin: 0 auto;
}

@media (min-width: 48em) {
  .stepper-group {
    grid-template-columns: repeat(3, 1fr);
    align-items: end;
    gap: 1.5rem;
    max-width: 46rem;
  }
}

.stepper-field {
  display: flex;
  flex-direction: column;
  gap: .3rem;
}

.stepper-field label,
.stepper-label {
  font-size: .82rem;
  color: #88888f;
  font-weight: 500;
}

input[type="number"] {
  appearance: textfield;
  -moz-appearance: textfield;
  box-sizing: border-box;
  width: 100%;
  height: 3.25rem;
  padding: .7rem 1rem;
  background: #1c1c1e;
  border: 2px solid #2a2a2d;
  border-radius: 8px;
  color: #f0f0f0;
  font-family: "DM Mono", "Fira Code", Consolas, monospace;
  font-size: 1.1rem;
  font-weight: 500;
  text-align: center;
  outline: none;
  transition: border-color .2s;
}

input[type="number"]:focus {
  border-color: #b8ff57;
}

input[type="number"]:focus-visible {
  outline: 2px solid #b8ff57;
  outline-offset: 3px;
}

/* Kill the native spinner arrows */
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
  -webkit-appearance: none;
  appearance: none;
}

/* A: bring the arrows back and draw them yourself. The top
   half of the column adds one, the bottom half takes one
   away. Firefox keeps its own small arrows; color-scheme
   makes them dark to match the field. */
input[type="number"].spin-arrows {
  appearance: auto;
  -moz-appearance: number-input;
  padding: 0 0 0 2.75rem;
  color-scheme: dark;
}

input[type="number"].spin-arrows::-webkit-inner-spin-button {
  -webkit-appearance: none;
  appearance: none;
  opacity: 1;
  width: 2.75rem;
  height: 3rem;
  margin: 0;
  border-left: 1px solid #2a2a2d;
  background:
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 48'%3E%3Cpath d='M4 14l4-4 4 4M4 34l4 4 4-4' fill='none' stroke='%23f0f0f0' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / 1rem 3rem no-repeat,
    linear-gradient(#2a2a2d, #2a2a2d) center / 100% 1px no-repeat,
    #141415;
  cursor: pointer;
  transition: background-color .15s;
}

input[type="number"].spin-arrows::-webkit-inner-spin-button:hover {
  background-color: rgba(184, 255, 87, .1);
}

/* Firefox has no column to balance, so less left padding
   keeps the number centered beside its own arrows */
@supports not selector(::-webkit-inner-spin-button) {
  input[type="number"].spin-arrows {
    padding-left: 1.25rem;
  }
}

/* B: minus, value and plus share one grid row */
.radio-stepper {
  position: relative;
  box-sizing: border-box;
  display: grid;
  grid-template-columns: 2.75rem 1fr 2.75rem;
  height: 3.25rem;
  border: 2px solid #2a2a2d;
  border-radius: 8px;
  overflow: hidden;
  background: #1c1c1e;
  transition: border-color .2s;
}

.radio-stepper:focus-within {
  border-color: #b8ff57;
}

/* The radios are hidden, so the keyboard ring goes on the row */
.radio-stepper:has(input:focus-visible) {
  outline: 2px solid #b8ff57;
  outline-offset: 3px;
}

/* Clipped, not removed, so the group keeps its tab stop */
.radio-stepper input {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  overflow: hidden;
  clip-path: inset(50%);
}

/* Hidden until they follow the checked radio */
.radio-stepper .rs-dec,
.radio-stepper .rs-val,
.radio-stepper .rs-inc {
  display: none;
  grid-row: 1;
  place-items: center;
  line-height: 1;
}

.radio-stepper input:checked + .rs-dec,
.radio-stepper input:checked + .rs-dec + .rs-val,
.radio-stepper input:checked + .rs-dec + .rs-val + .rs-inc {
  display: grid;
}

.radio-stepper .rs-val {
  grid-column: 2;
  font-family: "DM Mono", "Fira Code", Consolas, monospace;
  font-size: 1.1rem;
  font-weight: 500;
  color: #f0f0f0;
}

.radio-stepper .rs-dec,
.radio-stepper .rs-inc {
  background: #141415;
  color: #f0f0f0;
  font-family: system-ui, sans-serif;
  font-size: 1.3rem;
  font-weight: 400;
  cursor: pointer;
  user-select: none;
  transition: background-color .15s, color .15s;
}

.radio-stepper .rs-dec {
  grid-column: 1;
  border-right: 1px solid #2a2a2d;
}

.radio-stepper .rs-inc {
  grid-column: 3;
  border-left: 1px solid #2a2a2d;
}

.radio-stepper label:hover {
  background-color: rgba(184, 255, 87, .1);
  color: #b8ff57;
}

/* At either end of the range that side is a span, not a
   label, so there is nothing to click */
.radio-stepper .rs-end {
  color: #88888f;
  opacity: .4;
  cursor: not-allowed;
}

Quantity picker from radio buttons

For a quantity between one and five, a radio group beats a number field: every option is visible and nothing can be mistyped. The inputs are clipped rather than removed, so the group keeps its single tab stop and the arrow keys still move between values. Tab into the row and press the arrow keys.

HTML

<span class="qty-heading" id="qty-label">Quantity</span>
<div class="qty-picker" role="radiogroup" aria-labelledby="qty-label">
  <label class="qty-opt">
    <input type="radio" name="qty" value="1" checked><span>1</span>
  </label>
  <!-- 2 through 5 follow -->
</div>

CSS

.qty-picker {
  display: inline-flex;
  border: 2px solid #2a2a2d;
  border-radius: 8px;
  overflow: hidden;
  background: #1c1c1e;
}

/* clipped, not display:none, so arrow keys still reach the group */
.qty-opt input {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  overflow: hidden;
  clip-path: inset(50%);
}

.qty-opt span {
  display: block;
  width: 44px;
  padding: .6rem 0;
  text-align: center;
  border-left: 1px solid #2a2a2d;
  font-family: "DM Mono", Consolas, monospace;
  color: #88888f;
  cursor: pointer;
  transition: background .15s, color .15s;
}

.qty-opt:first-child span {
  border-left: none;
}

.qty-opt input:checked + span {
  background: #b8ff57;
  color: #0c0c0d;
}

.qty-opt input:focus-visible + span {
  outline: 2px solid #b8ff57;
  outline-offset: -2px;
}

Keeping the browser's own spin buttons

In Chromium the browser's own arrows show only on hover or focus, and opacity: 1 keeps them showing; WebKit draws them at rest anyway.

HTML

<div class="stepper-field">
  <label for="spin">Servings</label>
  <input type="number" id="spin" class="number-spin" value="4" min="1" max="12">
</div>

CSS

input[type="number"].number-spin {
  appearance: auto;
  width: 150px;
  text-align: left;
  padding-right: .35rem;
}

/* Chromium draws these only on hover or focus; opacity keeps them showing */
input[type="number"].number-spin::-webkit-inner-spin-button {
  -webkit-appearance: inner-spin-button;
  appearance: auto;
  opacity: 1;
  height: 2rem;
  cursor: pointer;
  /* turns the light button dark to match a dark field */
  filter: invert(1) opacity(.7);
}

input[type="number"].number-spin:focus-visible {
  outline: 2px solid #b8ff57;
  outline-offset: 3px;
}

Understand it

How it works and 5 properties

A CSS numeric stepper is a control with up and down buttons to step through values. It can be <input type="number"> with its arrows restyled. appearance: textfield hides Firefox's arrows and ::-webkit-inner-spin-button handles the other browsers. This page shows two script-free steppers: one restyles the field's own arrows, and the other builds minus and plus from radio buttons.

The restyled arrows work because the browser still runs them, although Firefox keeps its small default arrows instead of drawing the custom column. The radio stepper works because each minus and plus is a label for a neighboring radio, so a click checks that radio and the value changes with no script. The variants show a segmented radio group for shorter ranges, and the browser's own arrows pinned in place instead of hidden.

How it works

The native number input is restyled by removing the default spinners with appearance: textfield, plus -moz-appearance: textfield for older Firefox, and by hiding the WebKit spin buttons through ::-webkit-inner-spin-button. A brings the arrows back by setting appearance: auto and styling the pseudo-element as a column of two chevrons split by a line, where the top half adds one and the bottom half subtracts one. B is one radio per value from 0 to 10, each followed by a minus, the value, and a plus, where only the three after the checked radio are shown.

Firefox draws the spinners as part of the control itself, so appearance: textfield switches the whole widget to a plain text field. WebKit and Blink draw them as a separate shadow element, which is why they need ::-webkit-inner-spin-button with appearance: none.

The minus sign in B is U+2212, the mathematical minus, not a hyphen. A hyphen is noticeably shorter and sits lower than the plus sign next to it. B uses line-height: 1 and two equal 2.75rem columns to line them up.

min and max on the input are doing real work even without script. The browser refuses to submit a value outside them, the arrows stop at the boundary rather than running past it, and CSS can style the field with :out-of-range when a number typed by hand falls outside. That behavior is described in more detail on input validation.

Accessibility notes

A number input is keyboard operable before any styling. The up and down arrows step by the step value and typed digits go straight in. Hiding the visual spinners in the plain field does not affect any of that, so the keyboard path stays intact. In B the arrow keys move through the radio group instead of stepping a field.

The plain field and A pair their labels with the input through for and id. B's group is named through aria-labelledby and each radio has an aria-label with its number. The minus and plus are aria-hidden because the radios carry the value. The base rule sets outline: none on the input and adds a :focus-visible ring. B's radio group shows focus through the border via :focus-within and a 2px ring via :has(input:focus-visible). Both are visible indicators, and the :focus-visible ring is in the snippet so it appears on pasted fields as well.

In B the minus and plus are 44 by 48 pixels. In A's custom column each half is 44 by 24 pixels. In Firefox A keeps the browser's default arrows, which are about 16 wide and 7 to 9 tall.

Mistakes worth avoiding

  • Hiding the spinners in only one engine. appearance: textfield alone leaves Chrome's arrows in place, and ::-webkit-inner-spin-button alone leaves Firefox's.
  • Using :focus on the wrapper. Focus goes to the input, so the rule never matches. :focus-within is the one that does.
  • Giving A a class that loses to input[type="number"]. .spin-arrows::-webkit-inner-spin-button loses to input[type="number"]::-webkit-inner-spin-button and the column disappears.
  • Assuming A's arrows dim when they reach the end. At 0 and 10 the column looks exactly the same because the page has no rule reacting to the value, and the dead half does nothing when clicked because the value is already at the limit.
  • Building B for a long range. The markup grows by four elements per value and typing is not allowed, so a range of 0 to 100 means 404 elements and no way to jump to 50.

Ship it

9 features in 4 browsers, 5 questions

Browser support

Feature Chrome Firefox Safari Edge
input type=number629512
appearance838015.483
:focus-within605210.179
:in-range / :out-of-range535010.179
filter1835679
::-webkit-inner-spin-button6No579
:has()10512115.4105
color-scheme81961381
data URIs423.179

Most figures come from Can I Use. The ::-webkit-inner-spin-button and color-scheme rows come from MDN's browser-compat-data. The pseudo-element works in Chrome, Edge, Safari and other WebKit or Blink browsers, and Firefox ignores it entirely, which is why appearance: textfield has to be there as well. The -moz-appearance line is redundant on any current Firefox but costs nothing and covers older releases.

What you can build with it

  • Cart quantity fields. A number with plus and minus, bounded by min="1" and the stock level as max.
  • Booking guest counts. Adults, children and rooms as three identical rows, each with its own bounds.
  • Settings with numeric values. Page size, retention days, retry limits. A step larger than one keeps the values sensible.
  • Small quantity pickers. One to five as radio buttons.
  • Recipe and dosage calculators. step="0.5" allows halves while still refusing anything between them.

Frequently asked questions

How do I remove the arrows from a number input?
Two rules, because browsers differ. appearance: textfield covers Firefox, and input[type="number"]::-webkit-inner-spin-button { appearance: none } covers Chrome, Edge and Safari. Both are needed, and the WebKit rule usually wants ::-webkit-outer-spin-button alongside it.
Can a numeric stepper work without JavaScript?
Yes. The browser's own arrows work without script because the browser runs them, and radio buttons with minus and plus labels work because each label checks a neighboring radio. For a short fixed range like 1 to 5, a segmented radio group is another option, which is the first variant on this page.
Why doesn't Firefox show my custom arrows?
Firefox has no hook for ::-webkit-inner-spin-button. It keeps its own small arrows instead. Set color-scheme: dark on the input to make them dark and match the field, or use @supports not selector(::-webkit-inner-spin-button) to cut the left padding so the number stays centered.
How do I stop someone from typing a number outside the limits?
min and max stop the form from submitting and light up :out-of-range for styling, but they do not stop the digits from being typed. Nothing in HTML or CSS blocks the keystrokes, so the value still has to be checked on the server.
Why is my minus sign smaller than my plus sign?
The minus sign is probably a hyphen rather than a minus. Use the character U+2212, written as &minus; in HTML, which is drawn at the same width and height as the plus in most fonts.