Default

Number input with +/- buttons.

<div class="number-input">
  <button data-action="decrement">−</button>
  <input type="number" value="1" min="0" max="99" step="1">
  <button data-action="increment">+</button>
</div>

With step

<input type="number" value="20.0" min="-40" max="60" step="0.5">

CSS view file

/* -- Number Input component ------------------------------------- */

@layer components {
  .number-input {
    display: inline-flex;
    align-items: center;
    border: 1px solid var(--input);
    border-radius: var(--radius-md);
    background: var(--background);
    box-shadow: var(--shadow-xs);
    overflow: hidden;

    & input {
      border: none;
      outline: none;
      background: transparent;
      height: 2.5rem;
      width: 4rem;
      text-align: center;
      font-size: 0.875rem;
      font-family: var(--font-sans);
      color: var(--foreground);
      -moz-appearance: textfield;

      &::-webkit-inner-spin-button,
      &::-webkit-outer-spin-button {
        -webkit-appearance: none;
        margin: 0;
      }
    }

    & button {
      display: flex;
      align-items: center;
      justify-content: center;
      width: 2rem;
      height: 2.5rem;
      border: none;
      background: transparent;
      color: var(--muted-foreground);
      cursor: pointer;
      font-size: 1rem;
      font-family: var(--font-sans);
      transition: background 150ms, color 150ms;
      flex-shrink: 0;

      &:hover {
        background: var(--accent);
        color: var(--accent-foreground);
      }

      &:first-child {
        border-right: 1px solid var(--input);
      }

      &:last-child {
        border-left: 1px solid var(--input);
      }
    }

    &:focus-within {
      border-color: var(--ring);
      box-shadow: 0 0 0 2px oklch(from var(--ring) l c h / 0.2);
    }
  }
}

JavaScript view file

Number Input

// -- Number Input ---------------------------------------------
// Increment/decrement buttons for .number-input containers.

function init() {
  document.querySelectorAll('.number-input:not([data-init])').forEach((wrapper) => {
  wrapper.dataset.init = '';
  const input = wrapper.querySelector('input[type="number"]');
  const decBtn = wrapper.querySelector('[data-action="decrement"]');
  const incBtn = wrapper.querySelector('[data-action="increment"]');
  if (!input) return;

  const update = (direction) => {
    try {
      if (direction > 0) input.stepUp();
      else input.stepDown();
      input.dispatchEvent(new Event('input', { bubbles: true }));
      input.dispatchEvent(new Event('change', { bubbles: true }));
    } catch(e) { /* min/max boundary */ }
  };

  if (decBtn) decBtn.addEventListener('click', () => { update(-1); });
  if (incBtn) incBtn.addEventListener('click', () => { update(1); });
});
}

init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });