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()
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 picker from radio buttons
See the codeKeeping the browser's own spin buttons
See the codeBuild 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: textfieldalone leaves Chrome's arrows in place, and::-webkit-inner-spin-buttonalone leaves Firefox's. - Using
:focuson the wrapper. Focus goes to the input, so the rule never matches.:focus-withinis the one that does. - Giving A a class that loses to
input[type="number"]..spin-arrows::-webkit-inner-spin-buttonloses toinput[type="number"]::-webkit-inner-spin-buttonand 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 | ||||
|---|---|---|---|---|
input type=number | 6 | 29 | 5 | 12 |
appearance | 83 | 80 | 15.4 | 83 |
:focus-within | 60 | 52 | 10.1 | 79 |
:in-range / :out-of-range | 53 | 50 | 10.1 | 79 |
filter | 18 | 35 | 6 | 79 |
::-webkit-inner-spin-button | 6 | No | 5 | 79 |
:has() | 105 | 121 | 15.4 | 105 |
color-scheme | 81 | 96 | 13 | 81 |
data URIs | 4 | 2 | 3.1 | 79 |
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 asmax. - 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
steplarger 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?
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?
Why doesn't Firefox show my custom arrows?
::-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?
− in HTML, which is drawn at the same width and height as the plus in most fonts.