Inputs

Pack Counter

An order counter that counts packs: the step equals the pack size, and the piece total stays visible under the field.

  • number
  • quantity
  • cart
  • stepper

Preview

1440pxHost theme

Use it with AI

  1. 1. Copy the link.
  2. 2. Write to your agent in your own words and drop the link into the sentence.
  3. 3. The agent opens the link and installs the component from the registry.

put this in the header: https://vibeui.ru/c/number-002?lang=en

упаковка — 6 бут.

2 × 612 бут.

Show the full instructions

What the link says, spelled out. Use it when your agent cannot open links — paste this text instead.

# Install and place "number-002" (Pack Counter) from VibeUI

## 1. Install first — do not skip, do not recreate
Run this exact command before writing any code:
npx shadcn@latest add https://vibeui.ru/r/number-002.json

Registry item: https://vibeui.ru/r/number-002.json
Installs to: components/vibeui/number-002.tsx (the exact path follows this project's components.json aliases).
npm dependencies: none.
Registry dependencies: none.

Install it from the registry. Do not recreate it from the description,
do not substitute a similar component from another library, and do not
rewrite it to match the project's existing style.

## 2. What it is
An order counter that counts packs: the step equals the pack size, and the piece total stays visible under the field.

A quantity counter stepping by a whole pack: it counts packs, shows pieces, and turns minus into remove at the minimum. Zero dependencies, one file.

## 3. How to use it
import { Number002 } from "@/components/vibeui/number-002"

<Number002 label="Bottled water" pack={6} max={24} defaultPacks={2} />

Read the installed file for the full prop list.

## 4. Where to place it
This is a small inline component. Put it exactly where the user asked,
inside the existing markup. Do not create a new page, section or
wrapper for it. If a similar control already sits in that spot,
replace it instead of adding a second one.

Placement: ___
(The user fills this line in. If it is still blank, ask where to put it
instead of guessing.)

## 5. Keep exactly as installed
- the local --vibeui-number-002-* palette — do not swap it for your theme tokens
- its own light surface: the order row gets shown over any background
- the one-pack step: half a pack never leaves the warehouse, so in-between values stay unreachable
- the piece total under the field: people count packs but receive pieces, and that has to be visible before checkout
- the button role swap at the minimum: nobody thinks of zeroing a counter, yet the row still needs removing
- keeping a single source of truth in packs: a second state for pieces would drift after the first manual edit
- the aria-labels on the buttons: a screen reader reads "−", "✕" and "+" as symbols

## 6. You may change
- label — the product caption
- pack — the pack size, which is also the counter step
- min and max — the order bounds
- defaultPacks — how many packs start in the order
- unit — the name of the single-piece unit
- the accent through the accent prop

## 7. Rules
- The value is kept in packs: emit packs * pack outwards or the warehouse gets the wrong number.
- max is expressed in packs, not pieces: 24 here means 24 packs.
- The component is self-contained: add your own onChange to lift the value up.
- Do not lift the CSS variables into globals.css: the component has to stay a single file.

## 8. Verify
- it renders with no console errors;
- it looks like the preview on the VibeUI page you copied this from;
- if it does not, you changed something listed in section 5 — put it back.

For developers

npx shadcn@latest add https://vibeui.ru/r/number-002.json
https://vibeui.ru/r/number-002.json

Компонент самодостаточен: один файл, ноль зависимостей, палитра в локальных переменных --vibeui-number-002-*. Клиентский: "use client" нужен для кнопок и пересчёта. База хранится в упаковках, штуки считаются умножением на лету — два независимых состояния разошлись бы после первого ручного ввода. На минимуме кнопка «минус» меняет роль на удаление строки (data-role="remove"), а нулевое состояние подписывается словами, а не цифрой 0.

Component source

The same file your agent installs. Here in case you would rather copy it by hand.

"use client"

import { useId, useState } from "react"
import type { ComponentPropsWithoutRef, CSSProperties } from "react"

export type Number002Props = Omit<
  ComponentPropsWithoutRef<"div">,
  "children" | "defaultValue" | "onChange"
> & {
  label?: string
  pack?: number
  min?: number
  max?: number
  defaultPacks?: number
  unit?: string
  accent?: string
}

// Идея компонента: счётчик товара, который считает упаковками, а не штуками.
// Шаг равен размеру упаковки, поэтому промежуточные значения физически
// недостижимы, а под полем всегда видно, во что превратится заказ в штуках.
// На минимуме кнопка «минус» становится удалением строки: обнулять счётчик
// «в никуда» пользователи не догадываются, а корзину чистить надо.
const STYLES = `
:where([data-vibeui-block="number-002"]){
--vibeui-number-002-surface:oklch(1 0 0);
--vibeui-number-002-field:oklch(0.975 0.003 265);
--vibeui-number-002-shell:oklch(0.9 0.006 265);
--vibeui-number-002-fg:oklch(0.23 0.014 265);
--vibeui-number-002-muted:oklch(0.55 0.014 265);
--vibeui-number-002-border:oklch(0.88 0.008 265);
--vibeui-number-002-accent:oklch(0.55 0.16 160);
--vibeui-number-002-danger:oklch(0.55 0.19 25);
--vibeui-number-002-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
/* Своя светлая подложка: строку заказа показывают поверх любого фона. */
[data-vibeui-block="number-002"]{
display:flex;flex-direction:column;gap:0.625rem;
width:100%;max-width:19rem;box-sizing:border-box;padding:0.875rem;
background:var(--vibeui-number-002-surface);
border:1px solid var(--vibeui-number-002-shell);border-radius:0.875rem;
font-family:var(--vibeui-number-002-font);color:var(--vibeui-number-002-fg);
}
[data-vibeui-block="number-002"] [data-part="head"]{
display:flex;align-items:baseline;justify-content:space-between;gap:0.75rem;margin:0;
}
[data-vibeui-block="number-002"] label{font-size:0.8125rem;font-weight:650}
[data-vibeui-block="number-002"] [data-part="pack"]{
font-size:0.6875rem;color:var(--vibeui-number-002-muted);white-space:nowrap;
}
[data-vibeui-block="number-002"] [data-part="field"]{
display:flex;align-items:center;
border:1px solid var(--vibeui-number-002-border);border-radius:9999px;
background:var(--vibeui-number-002-field);padding:0.25rem;
}
[data-vibeui-block="number-002"] [data-part="field"]:focus-within{
border-color:var(--vibeui-number-002-accent);
box-shadow:0 0 0 2px oklch(0.55 0.16 160 / 20%);
}
[data-vibeui-block="number-002"] button{
appearance:none;border:0;cursor:pointer;flex:none;
width:2.25rem;height:2.25rem;border-radius:9999px;
background:var(--vibeui-number-002-surface);color:inherit;
font:inherit;font-size:1rem;line-height:1;
box-shadow:0 1px 2px oklch(0.2 0.02 265 / 12%);
transition:color .14s ease,opacity .14s ease;
}
[data-vibeui-block="number-002"] button:focus-visible{outline:2px solid var(--vibeui-number-002-accent);outline-offset:2px}
/* На минимуме «минус» превращается в удаление: обнулять счётчик не догадываются. */
[data-vibeui-block="number-002"] button[data-role="remove"]{color:var(--vibeui-number-002-danger)}
[data-vibeui-block="number-002"] button:disabled{cursor:default;opacity:.45}
[data-vibeui-block="number-002"] input{
flex:1 1 auto;min-width:0;width:100%;
appearance:none;border:0;background:none;outline:none;
height:2.25rem;padding:0 0.25rem;color:inherit;
font:inherit;font-size:1rem;font-weight:680;text-align:center;
font-variant-numeric:tabular-nums;
}
[data-vibeui-block="number-002"] input::-webkit-outer-spin-button,
[data-vibeui-block="number-002"] input::-webkit-inner-spin-button{appearance:none;margin:0}
/* Пересчёт в штуки: упаковки считают, а получают всё равно штуки. */
[data-vibeui-block="number-002"] [data-part="total"]{
display:flex;align-items:baseline;justify-content:space-between;gap:0.75rem;
margin:0;font-size:0.75rem;color:var(--vibeui-number-002-muted);
font-variant-numeric:tabular-nums;
}
[data-vibeui-block="number-002"] [data-part="pieces"]{font-weight:650;color:var(--vibeui-number-002-fg)}
[data-vibeui-block="number-002"] [data-part="empty"]{color:var(--vibeui-number-002-danger);font-weight:600}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="number-002"] *{animation:none!important;transition:none!important}}
`

/**
 * Счётчик, который считает упаковками и пересчитывает их в штуки.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Number002({
  label = "Вода в бутылках",
  pack = 6,
  min = 0,
  max = 24,
  defaultPacks = 2,
  unit = "бут.",
  accent,
  className,
  style,
  ...props
}: Number002Props) {
  const id = useId()
  const [packs, setPacks] = useState(defaultPacks)
  const empty = packs <= min

  const clamp = (value: number) => Math.min(max, Math.max(min, value))

  const palette = {
    ...(accent ? { "--vibeui-number-002-accent": accent } : null),
    ...style,
  } as CSSProperties

  return (
    <>
      <style href="vibeui-number-002" precedence="medium">
        {STYLES}
      </style>
      <div
        {...props}
        data-vibeui-block="number-002"
        className={className}
        style={palette}
      >
        <p data-part="head">
          <label htmlFor={id}>{label}</label>
          <span data-part="pack">
            упаковка — {pack} {unit}
          </span>
        </p>
        <div data-part="field">
          <button
            type="button"
            data-role={packs === min + 1 ? "remove" : "minus"}
            disabled={empty}
            aria-label={
              packs === min + 1 ? "Убрать из заказа" : "На упаковку меньше"
            }
            onClick={() => setPacks(clamp(packs - 1))}
          >
            {packs === min + 1 ? "✕" : "−"}
          </button>
          <input
            id={id}
            type="number"
            inputMode="numeric"
            min={min}
            max={max}
            step={1}
            value={packs}
            aria-describedby={`${id}-total`}
            onChange={(event) => {
              const next = Number(event.target.value)
              setPacks(Number.isFinite(next) ? clamp(next) : min)
            }}
          />
          <button
            type="button"
            disabled={packs >= max}
            aria-label="На упаковку больше"
            onClick={() => setPacks(clamp(packs + 1))}
          >
            +
          </button>
        </div>
        <p id={`${id}-total`} data-part="total" aria-live="polite">
          {empty ? (
            <span data-part="empty">Нет в заказе</span>
          ) : (
            <>
              <span>
                {packs} × {pack}
              </span>
              <span data-part="pieces">
                {packs * pack} {unit}
              </span>
            </>
          )}
        </p>
      </div>
    </>
  )
}