Eunomia v1.0.0-beta.2
AllAngularReactNext.jsJavaScriptVue
EunomiaSalmonForestVioletOceanGoldFireCustom…
🇬🇧 English🇫🇷 Français

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

eun-icon eun-label · if it has a label
Overview API Examples Accessibility

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.

Alternatives

You want .. Prefers Free-form numeric entry with no natural step-by-step feel eun-input A single on/off value applied later via form submission eun-checkbox A single on/off value that takes effect immediately eun-switch

Guidance

  • Use a counter for a small, bounded numeric quantity the user adjusts in discrete steps (cart quantity, number of guests, a rating)
  • Set min/max whenever the quantity has real bounds, so both the buttons and native validation enforce them
  • Set a step that 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

NameTypeDefaultDescription
default-valuenumberThe default value applied when the field connects, and restored on form reset
hide-errorbooleanfalseWhether the errors are hidden
aria-label-incrementstring'Increase value'The accessible label of the increment button
aria-label-decrementstring'Decrease value'The accessible label of the decrement button
minnumberThe minimum value allowed
maxnumberThe maximum value allowed
stepnumber1The amount the value changes on every increment or decrement
valuenumberThe current numeric value

Import the exact TypeScript type behind any property above, see Types.

Properties

JS-only — no matching HTML attribute, set these from a script or a template binding.

NameTypeDefaultDescription
checkLocalValidatorsReads 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
labelstringThe title label of the field, rendered through the label component
instructionsstringInstructions displayed below the label
namestringThe field name
disabledbooleanfalseWhether the field is disabled
readonlybooleanfalseWhether the field is read only
requiredbooleanfalseWhether the field is required
hideErrorbooleanfalseWhether the errors are hidden
validatorsArray<Validators<number>>The list of validation rules applied to the field value

Events

NameTypeDescription
eunchangeChangeEventFired on every value change

Every event above follows the same naming convention, covered in Events.

CSS custom properties

NameDescription
--counter-backgroundSets the background color of the field
--counter-border-colorSets the border color of the field
--counter-border-color-hoverSets the border color on hover
--counter-border-color-focusSets the border color on focus
--counter-border-radiusSets the corner radius of the field
--counter-text-colorSets the text color of the field
--counter-button-colorSets the color of the increment and decrement buttons
--counter-button-background-hoverSets the background color of the buttons on hover
--counter-error-colorSets the color of the error message
--counter-hint-colorSets the color of the hint message
--counter-focus-outline-colorSets the color of the focus outline
--counter-field-widthSets 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-associated custom element. It participates in a native <form>'s submission and reset.

  • default-value is what a reset restores, not whatever was last clicked/typed. With no default-value at all, a reset clears the field, matching a native <input> with no value attribute.
  • Resetting doesn't fire eunchange. Exactly like a native <input>, form.reset() changes value without dispatching a change event. Listen for reset on the <form> itself if you need to react to it (see the demo below).
Submit Reset
{}
<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 with valid
  • aria-required: set natively by the underlying required attribute
  • aria-label: set from the label property directly on the native field, for the same cross-shadow-boundary reason documented on eun-input's own Accessibility tab (aria-labelledby/for don't cross shadow-root boundaries, so a plain-text aria-label is 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 it
  • aria-label on 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

WAI-ARIA Authoring Practices: Spinbutton Pattern
WAI Web Accessibility Tutorials: Labeling Controls
WHATWG HTML: The number state of the input element