CSS Numeric Stepper
A styled <input type="number"> with custom spin button appearance using CSS pseudo-elements.
A CSS numeric stepper is <input type="number"> with the browser's spin buttons taken away and a pair of styled buttons put back on either side. appearance: textfield handles Firefox and ::-webkit-inner-spin-button handles Chrome, Edge and Safari, and between them the field ends up as a plain centered number that matches the rest of the form.
One thing to be clear about: CSS can style the stepper but it cannot make the buttons change the value. Incrementing a number needs stepUp() and stepDown(), which are script. What is genuinely script free is a quantity picker built from radio buttons, and that is the first variant below. The second keeps the browser's own spin buttons and makes them permanently visible instead of hiding them.
Number field and stepper row
HTML
<div class="stepper-group">
<div class="stepper-field">
<label>Plain styled number input</label>
<input type="number" value="42" min="0" max="100">
</div>
<div class="stepper-field">
<label>Inline stepper layout</label>
<!-- The styling is all CSS. Wire the two buttons to
stepUp() / stepDown() if you want them clickable. -->
<div class="stepper-row">
<button class="step-btn">−</button>
<input type="number" value="1" min="0" max="99">
<button class="step-btn">+</button>
</div>
</div>
</div>
CSS
.stepper-group {
display: flex;
flex-direction: column;
gap: 1.25rem;
max-width: 280px;
margin: 0 auto;
}
.stepper-field {
display: flex;
flex-direction: column;
gap: .3rem;
}
.stepper-field label {
font-size: .82rem;
color: #88888f;
font-weight: 500;
}
input[type="number"] {
appearance: textfield;
-moz-appearance: textfield;
width: 100%;
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;
}
/* Kill the native spinner arrows */
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
appearance: none;
}
.stepper-row {
display: flex;
align-items: center;
gap: 0;
border: 2px solid #2a2a2d;
border-radius: 8px;
overflow: hidden;
background: #1c1c1e;
transition: border-color .2s;
}
.stepper-row:focus-within {
border-color: #b8ff57;
}
.stepper-row input[type="number"] {
border: none;
border-radius: 0;
flex: 1;
background: transparent;
padding: .7rem .5rem;
}
.stepper-row input[type="number"]:focus {
border-color: transparent;
}
.step-btn {
width: 40px;
background: #141415;
border: none;
border-left: 1px solid #2a2a2d;
color: #f0f0f0;
font-size: 1.2rem;
font-weight: 300;
cursor: pointer;
padding: .6rem 0;
line-height: 1;
transition: background .15s, color .15s;
font-family: system-ui, sans-serif;
}
.step-btn:first-child {
border-left: none;
border-right: 1px solid #2a2a2d;
}
.step-btn:hover {
background: rgba(184, 255, 87, .1);
color: #b8ff57;
}
Other ways to build it
Quantity picker from radio buttons, with no script at all
For a quantity between one and five, a radio group beats a number field on every count that matters: every option is visible, nothing can be mistyped, and the value changes with no stepUp() call behind it. 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
<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
Hiding the native spinners is not the only option. Chrome, Edge and Safari only draw them while the pointer is over the field, and opacity: 1 on ::-webkit-inner-spin-button pins them in place permanently, which makes the control look like a stepper without any buttons of your own. The arrows are drawn dark, so on a dark field they need filter: invert(1) in the same way a calendar button does on a date input. These arrows really do change the value, because the browser owns them.
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
.number-spin {
appearance: auto;
width: 150px;
text-align: left;
padding-right: .35rem;
}
/* normally only drawn on hover; opacity pins them in place */
.number-spin::-webkit-inner-spin-button {
-webkit-appearance: inner-spin-button;
appearance: auto;
opacity: 1;
height: 2rem;
cursor: pointer;
/* the arrows are dark, so invert them on a dark field */
filter: invert(1) opacity(.7);
}
.number-spin:focus-visible {
outline: 2px solid #b8ff57;
outline-offset: 3px;
}
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. The custom stepper wraps the input in a row with two styled buttons. Those buttons need stepUp() and stepDown() to actually change the value, since CSS cannot do arithmetic on a form control.
Two different resets are needed because the two engines put the spinners in different places. Firefox draws them 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. Set only one and the field looks correct in half of the browsers people use.
The row uses :focus-within rather than :focus. Focus lands on the input, not on the wrapper, so a rule watching the wrapper's own focus would never fire. :focus-within matches an element that contains the focused node, which lets the whole bordered row light up when the field inside it is being typed into. overflow: hidden on the row is what makes the buttons take on its rounded corners without needing their own radius.
The minus sign in the button is U+2212, the mathematical minus, not a hyphen. A hyphen is noticeably shorter and sits lower than the plus sign next to it, which reads as sloppy at any size above about 14 pixels. Fonts differ, so if the two still look mismatched, setting an explicit line-height and equal width on both buttons is usually enough 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, stepUp() and stepDown() 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.
For a quantity between one and five, radio buttons are usually a better control than a number field: every option is visible, there is nothing to mistype, and it works with no script at all. That is the same segmented control pattern shown on custom radio buttons, and the first variant below applies it here.
CSS properties used
appearancetextfieldremoves Firefox's built in spinners and leaves an ordinary text field behind.::-webkit-inner-spin-button- The spin buttons in Chrome, Edge and Safari.
appearance: nonehides them,opacity: 1forces them permanently visible. :focus-within- Matches the wrapper while the input inside it has focus, which is how the whole row can show a focus state.
overflowhiddenon the row clips the buttons to its rounded corners, so they need no radius of their own.:in-range / :out-of-range- Style the field according to
minandmaxwithout any script reading the value. filterinvert(1)makes the browser's own spin arrows visible on a dark field, the same trick a date input's calendar button needs.
Browser support
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
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 |
Figures come from Can I Use. ::-webkit-inner-spin-button and ::-webkit-outer-spin-button have no Can I Use entry because they are not standard: they work in Chrome, Edge, Safari and other WebKit or Blink browsers, and Firefox ignores them 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.
Accessibility notes
A number input is keyboard operable before any styling. The up and down arrows step by the step value, Page Up and Page Down move by a larger amount, and typed digits go straight in. Hiding the visual spinners does not affect any of that, so the keyboard path stays intact even when the buttons on screen are decorative.
The two stepper buttons need accessible names. A + character is announced as plus, which does not say what it adds, so give each button an aria-label such as Increase quantity and Decrease quantity. Also give them type="button", because a <button> inside a form defaults to submitting it, and clicking plus would send the form instead of adding one.
The labels in the base demo have no for attribute and do not wrap their input, so they name nothing. The variants below pair each label with its control by id, which is what makes the field announce as Quantity rather than as an unnamed spin button.
The base rule sets outline: none on the input, and inside the row the focus indicator is the wrapper's border via :focus-within. That is a real visible indicator, which is the requirement, but this page's stylesheet also adds a :focus-visible ring to the standalone field, where the border color is the only cue otherwise.
The stepper buttons are 40 pixels wide but only about 33 tall, which is over the 24 by 24 minimum WCAG 2.2 asks for. Worth checking if you shrink the row, since a stepper is often the smallest control on a page and the one people are most likely to miss on a phone.
What you can build with it
- Cart quantity fields. A number with plus and minus either side, 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, which needs no script and shows every option at once.
- Recipe and dosage calculators.
step="0.5"allows halves while still refusing anything between them.
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. - Wiring the stepper buttons without
type="button". A button inside a form submits by default, so clicking plus posts the form. - Using
:focuson the wrapper. Focus goes to the input, so the rule never matches.:focus-withinis the one that does. - Labeling the buttons with
+and-alone. A screen reader announces plus and minus with no indication of what is being changed. - Assuming the buttons work because they look like a stepper. Nothing in CSS can increment a value, so without
stepUp()andstepDown()they are decoration.
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 does clicking my plus button submit the form?
<button> inside a form defaults to type="submit". Add type="button" to both stepper buttons and they stop submitting.How do I stop someone typing a number outside the limits?
min and max stop the form submitting and light up :out-of-range for styling, but they do not stop the digits 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.