Counter
Counter represents a bounded numeric quantity, with decrement and increment buttons flanking an editable field. The field itself can always be typed into directly, and the buttons are simply a pointer-optimized shortcut for the same step change its own arrow keys already apply.
Dependencies
When to use
Reach for a counter when someone is adjusting a quantity one step at a time, such as items in a cart, number of guests, or a rating out of 5, and having dedicated +/- buttons makes that faster than typing. If the value is really just a single on/off state rather than a range of numbers, or the number is typed or pasted rather than stepped through, see Alternatives below for a better fit.
Install & usage
Pick a framework in the toolbar above and these snippets adapt.
npm install @eunomia/elements
import "@eunomia/elements/counter.js";
<eun-counter name="quantity" label="Quantity" value="1" min="0" max="10">
</eun-counter>
Importing the file registers <eun-counter> as a custom element, with no
further setup needed. It works with any framework, or none, since it's a
standard web component.
npm install @eunomia/elements
<script type="module">
import "@eunomia/elements/counter.js";
</script>
<eun-counter name="quantity" label="Quantity" value="1" min="0" max="10">
</eun-counter>
npm install @eunomia/elements
import "@eunomia/elements/counter.js";
function QuantityField() {
return (
<eun-counter name="quantity" label="Quantity" value={1} min={0} max={10} />
);
}
npm install @eunomia/elements
"use client";
import "@eunomia/elements/counter.js";
export function QuantityField() {
return (
<eun-counter name="quantity" label="Quantity" value={1} min={0} max={10} />
);
}
npm install @eunomia/elements
<script setup>
import "@eunomia/elements/counter.js";
</script>
<template>
<eun-counter name="quantity" label="Quantity" value="1" min="0" max="10" />
</template>
npm install @eunomia/elements
import { CUSTOM_ELEMENTS_SCHEMA, Component } from "@angular/core";
import "@eunomia/elements/counter.js";
@Component({
selector: "app-quantity-field",
template: `
<eun-counter name="quantity" label="Quantity" value="1" min="0" max="10">
</eun-counter>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class QuantityFieldComponent {}
Alternatives
eun-inputeun-checkboxeun-switchGuidance
- Use a counter for a small, bounded numeric quantity the user adjusts in discrete steps (cart quantity, number of guests, a rating)
- Set
min/maxwhenever the quantity has real bounds, so both the buttons and native validation enforce them - Set a
stepthat matches the unit the value represents (e.g.step="0.25"for quarter-hour increments)
- Using a counter for free-form numeric entry with no natural "one step at a time" meaning: see Alternatives above
- Using a counter for a single on/off quantity (0 or 1): see Alternatives above
- Overriding colors with inline styles instead of the
--counter-*CSS variables
Live testing
Properties
Counter <eun-counter>
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| default-value | number | — | The default value applied when the field connects, and restored on form reset |
| hide-error | boolean | false | Whether the errors are hidden |
| aria-label-increment | string | 'Increase value' | The accessible label of the increment button |
| aria-label-decrement | string | 'Decrease value' | The accessible label of the decrement button |
| min | number | — | The minimum value allowed |
| max | number | — | The maximum value allowed |
| step | number | 1 | The amount the value changes on every increment or decrement |
| value | number | — | The current numeric value |
Import the exact TypeScript type behind any property above, see
Properties
JS-only — no matching HTML attribute, set these from a script or a template binding.
| Name | Type | Default | Description |
|---|---|---|---|
| checkLocalValidators | — | Reads native HTML constraint validation from the underlying field element (`required`, `min`, `max`, `step`, `badInput`). | |
| variant | 'outline' | 'fill' | 'underline' | 'outline' | The visual variant to apply to the field |
| label | string | — | The title label of the field, rendered through the label component |
| instructions | string | — | Instructions displayed below the label |
| name | string | — | The field name |
| disabled | boolean | false | Whether the field is disabled |
| readonly | boolean | false | Whether the field is read only |
| required | boolean | false | Whether the field is required |
| hideError | boolean | false | Whether the errors are hidden |
| validators | Array<Validators<number>> | — | The list of validation rules applied to the field value |
Events
| Name | Type | Description |
|---|---|---|
| eunchange | ChangeEvent | Fired on every value change |
Every event above follows the same naming convention, covered in
CSS custom properties
| Name | Description |
|---|---|
| --counter-background | Sets the background color of the field |
| --counter-border-color | Sets the border color of the field |
| --counter-border-color-hover | Sets the border color on hover |
| --counter-border-color-focus | Sets the border color on focus |
| --counter-border-radius | Sets the corner radius of the field |
| --counter-text-color | Sets the text color of the field |
| --counter-button-color | Sets the color of the increment and decrement buttons |
| --counter-button-background-hover | Sets the background color of the buttons on hover |
| --counter-error-color | Sets the color of the error message |
| --counter-hint-color | Sets the color of the hint message |
| --counter-focus-outline-color | Sets the color of the focus outline |
| --counter-field-width | Sets the width of the numeric value area |
Basic
<eun-counter name="quantity" label="Quantity" value="1" min="0" max="10">
</eun-counter>
States
Disabled
<eun-counter label="Disabled" value="3" disabled></eun-counter>
Readonly
A readonly counter still shows and submits its value, but the buttons and
the field can't change it. Unlike disabled, it stays focusable and legible
rather than looking inert.
<eun-counter label="Readonly" value="4" readonly></eun-counter>
Required
<eun-counter label="Guests" required></eun-counter>
With hint
<eun-counter
label="Seats"
value="2"
min="1"
max="8"
hint="Up to 8 seats per table"
>
</eun-counter>
Error
Note that if a hint is defined, it renders in the same place as the error
message. The error message takes priority over the hint.
<eun-counter
label="Guests"
min="1"
max="6"
required
hint="At least 1 guest is required"
>
</eun-counter>
Min, max and step
Buttons clamp to min/max and disable themselves at the corresponding
bound. The native field enforces the same bounds through
rangeUnderflow/rangeOverflow, and off-step values through
stepMismatch (see the Accessibility tab).
<eun-counter label="Even numbers only" value="4" min="0" max="20" step="2">
</eun-counter>
Decimal step
<eun-counter label="Hours" value="1.5" min="0" max="8" step="0.5"></eun-counter>
Forms
Like every other Eunomia field, eun-counter is a real
<form>'s
submission and reset.
default-valueis what a reset restores, not whatever was last clicked/typed. With nodefault-valueat all, a reset clears the field, matching a native<input>with novalueattribute.- Resetting doesn't fire
eunchange. Exactly like a native<input>,form.reset()changesvaluewithout dispatching a change event. Listen forreseton the<form>itself if you need to react to it (see the demo below).
{}
<form id="my-form">
<eun-counter
name="quantity"
label="Quantity"
default-value="1"
min="0"
max="10"
>
</eun-counter>
<eun-counter
name="guests"
label="Guests"
min="1"
max="6"
required
></eun-counter>
<button type="submit">Submit</button>
<button type="reset">Reset</button>
</form>
const form = document.querySelector("#my-form");
// Fires on every step/edit, not on reset.
form.addEventListener("eunchange", () => {
console.log(Object.fromEntries(new FormData(form)));
});
// Fires on reset (native form event), not on eunchange ; `setTimeout` lets
// the counters' own reactive update settle first, same as any other
// property change reflected asynchronously.
form.addEventListener("reset", () => {
setTimeout(() => console.log(Object.fromEntries(new FormData(form))));
});
"Quantity" comes back to 1 after Reset (from its default-value). "Guests" comes back empty, since it never had one.
Custom
Override the --counter-* CSS variables. The full list is in the API tab.
<eun-counter class="custom-counter" label="Custom" value="3"></eun-counter>
.custom-counter {
--counter-border-color: #be185d;
--counter-border-color-hover: #be185d;
--counter-border-color-focus: #be185d;
--counter-focus-outline-color: #be185d;
}
Variants
Same outline (default), fill and underline variants as eun-input.
<eun-counter label="Outline" value="1" variant="outline"></eun-counter>
<eun-counter label="Fill" value="1" variant="fill"></eun-counter>
<eun-counter label="Underline" value="1" variant="underline"></eun-counter>
Keyboard interactions
When focus is on the field:
| Key | Action |
|---|---|
Tab / Shift+Tab |
Moves focus in/out of the field, natively (single tab stop) |
Any digit / - / . |
Types into the field, natively |
ArrowUp |
Increments by step, natively, clamped to max |
ArrowDown |
Decrements by step, natively, clamped to min |
Home |
Jumps to min, when defined |
End |
Jumps to max, when defined |
The decrement/increment buttons are rendered tabindex="-1" on purpose.
They duplicate what the field's own native ArrowUp/ArrowDown stepping
already does for a keyboard user, so they're pointer-optimized shortcuts,
not an alternate keyboard path, following the same convention eun-input
already uses for its clear/show-password buttons. They remain reachable and
operable by mouse, touch, and switch-access, and both still expose a proper
accessible name so screen-reader users navigating by their virtual cursor
can find and activate them. They're simply excluded from the sequential Tab
order.
Aria attributes
The native field is a real <input type="number">, so its implicit
role="spinbutton" and its aria-valuenow/aria-valuemin/aria-valuemax
are exposed by the browser itself from value/min/max. Nothing here
sets them manually. On top of that native baseline:
aria-invalid="true"|"false": kept in sync withvalidaria-required: set natively by the underlyingrequiredattributearia-label: set from thelabelproperty directly on the native field, for the same cross-shadow-boundary reason documented oneun-input's own Accessibility tab (aria-labelledby/fordon't cross shadow-root boundaries, so a plain-textaria-labelis used instead of pointing at the rendered<eun-label>)aria-describedby="description": set on the field whenever a hint or error message is actually rendered below itaria-labelon each button (aria-label-decrement/aria-label-increment, defaulting to "Decrease value"/"Increase value"): their icon content is otherwise not announced
Label accessibility
Always set label (or slot equivalent content into the label slot). A
counter with no accessible name is one of the most disruptive
screen-reader failures. State the unit where it isn't obvious from context
(e.g. "Hours" rather than a bare "Quantity" next to a step="0.5" field).
Disabled vs. readonly
disabled removes the field from the tab order (tabindex="-1",
aria-disabled="true"), disables both buttons, and excludes the value from
form submission entirely. readonly (native readonly attribute on the
field, reflected as aria-readonly="true") keeps the field focusable and
its value selectable and submitted, and disables both buttons, communicating
"you can't change this right now", not "this doesn't apply".
Prefer readonly over disabled whenever the value is still meaningful
context for the user.
Required field
required behaves exactly like a native required <input type="number">:
empty is invalid, any value (including 0) is valid. Prefer hint to
explain why a value is required, not just that it is.
Error / validation state
Error messages replace the hint text in the same #description region and
are linked via aria-describedby. min/max/step are native constraint
attributes on the underlying field, so rangeUnderflow, rangeOverflow
and stepMismatch are validated by the browser itself and reported through
the same checkValidity()/validity surface as every custom validators
error, not a separate mechanism.
Reference links