Mischief

130 / Controls

Quantity Button

An Add button that becomes a stepper in the same footprint once there is something to count, and turns back when the last one is removed.

  • Margherita pizza

    $11.00 · Tomato, mozzarella, basil

  • Dal makhani

    $9.00 · Slow cooked black lentils

  • Mint lemonade

    $3.50 · Up to 4 per order

2 items$18.00

Installation

Copy the source into your project, or keep it behind a package.

npx shadcn@latest add Tinkerers-Labs/mischief-ui/quantity-button
import { QuantityButton } from "mischief-ui/quantity-button"
Also installs
  • lucide-react

Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.

registry/default/quantity-button/quantity-button.tsx
"use client" import * as React from "react"import { Minus, Plus, Trash2 } from "lucide-react" import { cn } from "@/lib/utils" export type QuantityButtonProps = Omit<  React.HTMLAttributes<HTMLDivElement>,  "onChange" | "defaultValue" | "children"> & {  /** What is being counted, such as Margherita pizza. Used in every name. */  label: string  value?: number

Usage

export function MenuItem({ item }: { item: Item }) {
  const cart = useCart()

  return (
    <li className="flex items-center justify-between">
      {item.name}
      <QuantityButton
        label={item.name}
        value={cart.count(item.id)}
        onValueChange={(count) => cart.set(item.id, count)}
      />
    </li>
  )
}

One footprint, two shapes

At zero it is an Add button. Pressing it sets the count to min and the minus and plus slide out from the middle. At min the minus becomes a trash icon and removes the item, which brings the Add button back. The control is the same width throughout, so a list of items does not shift as things go in and out of the basket.

min is both the count Add jumps to and the count where minus turns into remove, so an item sold in pairs uses min={2}. max stops the plus.

When the basket lives on a server

onValueChange may return a promise. The new count shows at once, marked as pending, and the control reports itself busy until the promise settles. Taps made in the meantime are folded together: once the request settles, only the newest count is sent. A rejection puts the last confirmed count back and says so to screen readers.

<QuantityButton
  label="Mint lemonade"
  max={4}
  value={count}
  onValueChange={async (next) => {
    await updateBasket(item.id, next)
    setCount(next)
  }}
/>

Rolling digits

Only the digits that change roll, up when the count rises and down when it falls, so 10 to 11 moves the ones and leaves the tens alone. The count fades in and out with the stepper rather than rolling from or to zero. Reduced motion swaps the numbers in place.

API

labelstringWhat is being counted, such as Margherita pizza. Every button name includes it.
valuenumberThe count, for a controlled button. Zero shows Add.
defaultValuenumberThe starting count. Defaults to 0.
onValueChange(value: number) => void | Promise<void>Called with the new count. A promise holds it as pending, and a rejection reverts it.
minnumberThe count Add jumps to and where minus becomes remove. Defaults to 1.
maxnumberThe highest count. Defaults to no limit.
addLabelstringThe visible text on the Add button. Defaults to Add.
disabledbooleanStops every change.
data-state"empty" | "filled"Which shape is showing, written to the root for styling.
classNamestringClasses for placement and appearance.
...divPropsHTMLAttributesNative div attributes.

Accessibility

The control is a group named by label. Add is named Add plus the item. The stepper's buttons are named One fewer, Remove at the minimum, and One more, each with the item. The count is a spinbutton: Up and Right add one, Down and Left take one away and stop at min rather than removing, and Home and End go to min and max. Focus moves to the plus after Add and back to Add after a removal, and only when focus was already inside, so a count changed elsewhere never pulls focus. The hidden shape is out of the tab order and hidden from assistive technology. A polite live region says the new count, the removal, or a refused change. Plus is marked unavailable at max. Reduced motion removes the slide, the fade and the digit roll.