Use it for
Reach for BbNumberInput whenever the value is a number somebody types: a
quantity, a price, an age, a threshold, a rate limit. It cleans up what they
type, clamps to min and max, and hands you a real number.
Use something else when
BbSlider, when a bounded value is better dragged than typedBbRating, when it is a score on a fixed scaleBbTextInputwith amask, when the digits are an identifier: a postcode, an order number, anywhere leading zeros matter
Also see: Validated forms.
In v3, numeric text fields move from BbTextInput to BbNumberInput:
- <BbTextInput v-model="quantity" type="number" label="Quantity" />
+ <BbNumberInput v-model="quantity" label="Quantity" />
The shared field props carry across; type does not.
Passthrough
Hover or tap a part to outline it. The toggles turn on loading, errors and warnings where the component has them; only parts get a row.
Default
label is required, and v-model is the whole of the wiring. The model takes
number | string | null and the field emits number | null. A cleared
field emits null, never '' and never NaN.
Model: 8 · typeof number
<template>
<div class="flex max-w-sm flex-col gap-3">
<BbNumberInput
id="basic-headcount"
v-model="headcount"
label="Headcount"
name="headcount"
placeholder="How many people?"
/>
<p class="text-sm text-[color:var(--bb-text-muted)]">
Model: <code>{{ headcount === null ? 'null' : headcount }}</code>
<span> · typeof {{ typeof headcount }}</span>
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbNumberInput } from 'bitboss-ui';
// Seed it with a number, or with null for an empty field. The component emits
// `number | null` — never an empty string, never NaN — so clearing the field
// gives you null and nothing downstream has to parse anything.
const headcount = ref<number | null>(8);
</script>
label does not set name. Pass name for native form submission.
While the text is incomplete, such as - or 12., the model keeps its last
parsed value.
Locale display
The field writes its number the way the app's
locale writes numbers, while the user
types. In English 10005.6 shows 10,005.6; in an Italian app it shows
10.005,6. Grouping follows each locale, so Italian shows 1000 and starts
grouping at 10.000.
<template>
<!--
A plain <form>, so you can see what a native post would carry. The field
groups the digits the locale's way while you type; the model and the
posted value stay the plain number.
-->
<form ref="formEl" class="flex max-w-sm flex-col gap-4" @submit.prevent>
<BbNumberInput
id="locale-budget"
v-model="budget"
hint="Type the digits; the field groups them."
label="Annual budget"
name="budget"
persistent-hint
/>
<!-- The field as it was before: period decimal, no grouping. -->
<BbNumberInput
id="locale-budget-raw"
v-model="raw"
disable-formatting
hint="disable-formatting: shown as typed."
label="Annual budget, unformatted"
name="budget-raw"
persistent-hint
/>
<dl
class="m-0 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-sm text-[color:var(--bb-text-muted)]"
>
<dt>Model</dt>
<dd class="m-0"><code>{{ budget ?? 'null' }}</code></dd>
<dt>Posted</dt>
<dd class="m-0"><code>{{ posted }}</code></dd>
</dl>
</form>
</template>
<script setup lang="ts">
import { nextTick, onMounted, ref, watch } from 'vue';
import { BbNumberInput } from 'bitboss-ui';
const budget = ref<number | null>(10005.6);
const raw = ref<number | null>(10005.6);
// What a native post sends: `name` sits on a hidden input that carries the
// raw value, so FormData never sees the grouped text.
const formEl = ref<HTMLFormElement>();
const posted = ref('');
const readPosted = async () => {
await nextTick();
if (!formEl.value) return;
posted.value = JSON.stringify(Object.fromEntries(new FormData(formEl.value)));
};
onMounted(readPosted);
watch([budget, raw], readPosted);
</script>
Type the digits; the field groups them. Whichever of , or . the user
presses starts the decimals, so nobody types a thousands separator. Delete the
digits and type 1234,5: the field shows 1,234.5.
-typed anywhere flips the sign, and the caret stays on its digit.- A paste is read as a number. Written the locale's way, it is read that
way:
1,234pasted here is 1234, and1.000pasted in Italian is 1000. The rest is cleaned up. Symbols and words go ($1,234.56is 1234.56,15 m²is 15), accounting brackets are negative ((1,234.56)is -1234.56) and1.2E+05is 120000. Only the first of several numbers is kept ($1,234.56 - $2,000is 1234.56). - The field holds 20 characters. A key, a paste or an autofill past that is refused and the field keeps its number; nothing is cut short.
- Blur and Enter tidy the text from the value:
1.50becomes1.5and007becomes7. Keep trailing zeros withmin-precision.
The model is always the plain number. A native post reads a hidden input that
carries name and the raw value
(10005.6); the visible input has no name.
disable-formatting
shows the number as typed, with a period decimal and no grouping, and puts
name back on the visible input.
Code that reads the input's text sees the formatted one: event.target.value,
a pt:input listener, a maxlength or a pattern. A test that typed into
input[name=quantity] reaches the hidden input, so it targets
input[role="spinbutton"] instead. Grouped text is also wider: check a narrow
fixed-width field at 200% zoom.
Bounds and precision
min and max clamp the emitted value. Type past max and it settles back
at once. min waits until you leave the field, because the digits of a valid
number pass through smaller ones: typing 20 into a 16–28 field goes through
2. Enter applies min too, before a surrounding form submits.
maxPrecision caps
decimal places and defaults to 8; 0 pins the field to whole numbers.
minPrecision is its
twin: with 2 the field always shows two decimals (1 shows 1.00), padding
with zeros the user types over, while the model stays 1.
Model: setpoint 21, tolerance 0.5
<template>
<div class="flex max-w-sm flex-col gap-4">
<!--
Whole degrees, 16 to 28. Above 28 it settles back at once; below 16 it
waits for blur, so typing 20 can pass through 2.
-->
<BbNumberInput
id="bounds-setpoint"
v-model="setpoint"
hint="Between 16 and 28 °C, whole degrees."
label="Office setpoint"
:max="28"
:max-precision="0"
:min="16"
name="setpoint"
persistent-hint
unit="celsius"
/>
<!--
Always two decimals: min-precision pads with zeros you type over, and
max-precision refuses a third digit as it is typed.
-->
<BbNumberInput
id="bounds-tolerance"
v-model="tolerance"
hint="Two decimals, always shown. A third is refused."
label="Tolerance"
:max="5"
:max-precision="2"
:min="0"
:min-precision="2"
name="tolerance"
persistent-hint
unit="celsius"
/>
<p class="text-sm text-[color:var(--bb-text-muted)]">
Model: setpoint <code>{{ setpoint ?? 'null' }}</code>, tolerance
<code>{{ tolerance ?? 'null' }}</code>
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbNumberInput } from 'bitboss-ui';
const setpoint = ref<number | null>(21);
const tolerance = ref<number | null>(0.5);
</script>
Clamping is silent, so state the range in hint.
Extra decimals are refused as they are typed, never rounded:
- With
max-precision="0"a typed,or.stays in the text until blur, and the digits after it are refused:12.5is 12, not 125. A pasted12.5is cut to 12. - Below 3 decimals (
max-precision0 to 2, and every currency field) a second,or.is refused once a decimal exists.1,234typed into the tolerance field above is 1.23. A field with 3 or more decimals keeps every digit instead, and reads the earlier mark as grouping when a later one arrives:1.234,56typed key by key is 1234.56. - Backspace walks back over the padding. At
1.00|it moves to1|.00and keeps the value; the next one deletes the digit.
On an iPhone, a field that can be negative (no min, or one below 0) gets a
keypad with a minus key. Set min to 0 or more and it keeps the decimal keypad.
Step controls
step (default 1) is the increment applied by ArrowUp and ArrowDown, and by
the increase() / decrease() functions the slots hand you. Stepping respects
min, max and maxPrecision, and does nothing while the field is disabled
or readonly.
Quill Whiteboard 120×90
€179.00 each · 5 in stock
Subtotal €179.00
<template>
<div
class="flex max-w-md flex-wrap items-end justify-between gap-4 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-4"
>
<div class="flex flex-col gap-0.5">
<p class="m-0 text-sm font-medium">{{ item.name }}</p>
<p class="m-0 text-xs text-[color:var(--bb-text-muted)]">
{{ formatEur(item.price) }} each · {{ item.stock }} in stock
</p>
<p class="m-0 mt-2 text-sm font-medium tabular-nums">
Subtotal {{ formatEur(subtotal) }}
</p>
</div>
<BbNumberInput
id="stepper-quantity"
v-model="quantity"
class="w-36"
label="Quantity"
:max="item.stock"
:max-precision="0"
:min="1"
name="quantity"
:pt="{ input: 'text-center tabular-nums' }"
>
<!--
The slot scope hands you the step functions, so the buttons need no
state of their own. They are icon-only, so they need a label.
-->
<template #prepend="{ decrease }">
<BbButton
aria-label="Decrease quantity"
:disabled="(quantity ?? 0) <= 1"
icon="lucide:minus"
size="xs"
variant="ghost"
@click="decrease"
/>
</template>
<template #append="{ increase }">
<BbButton
aria-label="Increase quantity"
:disabled="(quantity ?? 0) >= item.stock"
icon="lucide:plus"
size="xs"
variant="ghost"
@click="increase"
/>
</template>
</BbNumberInput>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbButton, BbNumberInput } from 'bitboss-ui';
import { productById } from '~/demo-data';
// Quill Whiteboard — five in stock, which is what makes the max visible.
const item = productById[104]!;
const quantity = ref<number | null>(1);
const subtotal = computed(() => (quantity.value ?? 0) * item.price);
const formatEur = (value: number) =>
new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: item.currency,
}).format(value);
</script>
Every content slot receives { increase, decrease }. Give icon-only step
buttons an accessible name.
step affects arrows and buttons, not typed input. Validate required multiples
with errors.
Coming from v2increase() / decrease() moved to the slots
The imperative handle is gone. inputRef.value.increase() no longer exists, and
a stale call is undefined at runtime rather than a compile error. Move
in-field steppers to the slot scope shown above, and drive an external stepper
through the model (quantity += step).
Currency, units and icons
Money and measurements want a symbol in the field and a plain number in the
model. currency takes an
ISO 4217 code and unit a unit
Intl.NumberFormat knows (kilogram, celsius, percent). Either one puts
the symbol inside the text, where the locale writes it: €69.00 in English,
69,00 € in Italian, CHF-1’234.56 in Swiss German.
Submitted as {"price":69,"weight":1.4,"discount":15}
<template>
<div class="flex max-w-sm flex-col gap-4">
<!--
`currency` puts the symbol inside the text, where the locale writes it,
and brings the currency's decimals: two for EUR.
-->
<BbNumberInput
id="affix-price"
v-model="price"
currency="EUR"
label="Unit price"
:min="0"
name="unit-price"
:step="0.5"
/>
<!-- `unit` does the same for a measure. The icon still has its own place. -->
<BbNumberInput
id="affix-weight"
v-model="weight"
label="Shipping weight"
:max-precision="1"
:min="0"
name="weight"
prepend:icon="lucide:weight"
unit="kilogram"
/>
<!-- `percent` holds the number you see: 15% is 15, not 0.15. -->
<BbNumberInput
id="affix-discount"
v-model="discount"
label="Discount"
:max="100"
:max-precision="0"
:min="0"
name="discount"
unit="percent"
/>
<p class="text-sm text-[color:var(--bb-text-muted)]">
Submitted as <code>{{ JSON.stringify({ price, weight, discount }) }}</code>
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbNumberInput } from 'bitboss-ui';
import { productById } from '~/demo-data';
const price = ref<number | null>(productById[110]!.price);
const weight = ref<number | null>(1.4);
const discount = ref<number | null>(15);
</script>
currencybrings its decimals: 2 for EUR, 0 for JPY, 3 for KWD.min-precisionandmax-precisionwin when you set them.unit="percent"holds the number you see:15%is 15, not 0.15.- One or the other. With both,
currencywins.unitis typed, sokilowattorsquare-meteris a type error. - The caret stays on the number. A click, Home, End or an arrow on the
symbol lands on the nearest digit, and the symbol alone cannot be deleted.
Clearing the last digit empties the field, with no lone
€left behind. - A pasted amount keeps its number.
€1,234.56and1,234 kgare read as 1234.56 and 1234; another currency's symbol is dropped, with no conversion. - The unit follows the number's plural (
1 day,2 days), and at rest a screen reader hears the amount spelled out (69.00 euros).
The symbol is part of the input's text, so a maxlength counts it.
prefix and
suffix stay free for
anything else; keep currency symbols out of them, since currency places them
the locale's way. prepend:icon and append:icon put an icon in the same ring.
The end of the field shows one thing at a time, by priority: clear button → spinner
(loading) → error icon (hasErrors) → your append:icon. Put the decorative
icon in prepend:icon, and keep append:icon for fields that are neither
clearable nor validated.
Put interactive content in prepend, append, or the outer slots.
Hints, errors and warnings
description is always visible between the label and
the field. hint appears below the field on focus, and persistent-hint pins
it there: the right home for the range or the unit. errors renders below the
field, is announced, and sets aria-invalid. warnings is its amber
counterpart for a number that is valid and still worth a second look. Type 12
for the error, then 180 for the warning; with both, only the error shows.
<template>
<div class="max-w-sm">
<BbNumberInput
id="messaging-seats"
v-model="seats"
description="Seats are billed monthly and sold in blocks of five."
:errors="errors"
hint="5 to 200 on the Team plan."
label="Seats"
:max="200"
:max-precision="0"
:min="5"
name="seats"
persistent-hint
:warnings="warnings"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbNumberInput } from 'bitboss-ui';
const seats = ref<number | null>(20);
// min/max clamp silently, so a business rule they cannot express — "in blocks
// of five" — is yours to state. Non-empty `errors` is enough; `has-errors` is
// only for when the message lives somewhere else on the page.
const errors = computed(() =>
seats.value !== null && seats.value % 5 !== 0
? 'Seats are sold in blocks of five.'
: []
);
// A warning is for a number that is valid and still worth a second look.
const warnings = computed(() =>
seats.value !== null && seats.value > 150
? 'Above 150 seats, Enterprise is cheaper per seat.'
: []
);
</script>
Non-empty messages imply their visual state. Errors win over warnings and set
aria-invalid; warnings do not.
This is where a business rule lives. min and max can only express a range.
A multiple, a value tied to another field, a limit that depends on the selected
plan: you compute those and put them in errors.
Inside a BbForm
Inside a BbForm the field joins the form under its
name and takes rules. Rule
messages render through the errors surface above, merged with your own.
<template>
<!--
No v-model: the field joins the form under its `name`, and @submit
receives the value as a number.
-->
<BbForm
class="flex max-w-md flex-col gap-4 rounded-(--bb-radius) border border-(--bb-border) p-5"
@invalid="status = 'Enter a quantity to add the line.'"
@submit="addLine"
>
<header class="flex flex-col gap-1">
<h3 class="m-0 text-base font-semibold">Quill Whiteboard 120×90</h3>
<p class="m-0 text-sm opacity-70">Sold singly. Delivered in two days.</p>
</header>
<BbNumberInput
id="order-quantity"
label="Quantity"
:max-precision="0"
:min="1"
name="quantity"
rules="required"
/>
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="m-0 text-sm opacity-70" role="status">{{ status }}</p>
<BbButton type="submit" variant="primary">Add to order</BbButton>
</div>
</BbForm>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { defineRule } from 'vee-validate';
import { required } from '@vee-validate/rules';
import { BbButton, BbForm, BbNumberInput } from 'bitboss-ui';
// In an app this lives once in a validators module, not in the component.
defineRule('required', (value: unknown) => required(value) || 'Required.');
const status = ref('');
// Values arrive keyed by field `name`: here `{ quantity: number | null }`.
const addLine = (values: Record<string, unknown>) => {
status.value = `Submitted ${JSON.stringify(values)}`;
};
</script>
@submit receives { quantity: 3 }, a number. Spend rules on what bounds do
not cover. The usual one is that a value exists: write rules="required" (or
require the key in the schema). The
required prop is for a
field outside a form. On a field joined to one, nothing enforces it and
bitboss-ui check reports it.
min lifts a low value when the field is left or Enter is pressed in it,
before the form submits, so a min_value rule is redundant. Type 0 and press
Enter: @submit receives { quantity: 1 }. A native post carries the same raw number. With a
schema, write
z.number('Enter a quantity'), not a coercion: the field already holds
number | null, and the message covers the empty case.
formless and
validation-label
work as on BbTextInput.
Clearable, loading, disabled and readonly
loading only shows a spinner; the field stays editable.
disabled and readonly are the two that stop
edits.
<template>
<div class="flex max-w-sm flex-col gap-4">
<!-- Clearable: the button appears on hover or focus, and emits null. -->
<BbNumberInput
id="states-budget"
v-model="budget"
clearable
currency="EUR"
label="Monthly budget"
:min="0"
name="budget"
/>
<!-- Loading is visual only: the field stays editable while the check runs. -->
<BbNumberInput
id="states-discount"
v-model="discount"
hint="Checked against the price list after each change."
label="Discount"
:loading="checking"
:max="100"
:max-precision="0"
:min="0"
name="discount"
persistent-hint
unit="percent"
@update:model-value="recheck"
/>
<!-- Derived rather than entered, so readonly: visible and copyable. -->
<BbNumberInput
id="states-total"
currency="EUR"
label="Total after discount"
:model-value="total"
name="total"
readonly
/>
<!-- Not yours to change at all, so disabled. -->
<BbNumberInput
id="states-allocated"
disabled
label="Seats allocated by your admin"
:max-precision="0"
:model-value="64"
name="allocated"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue';
import { BbNumberInput } from 'bitboss-ui';
const budget = ref<number | null>(2500);
const discount = ref<number | null>(10);
const checking = ref(false);
const total = computed(
() => ((budget.value ?? 0) * (100 - (discount.value ?? 0))) / 100
);
// A stand-in for a server check: the spinner shows for a moment after each change.
let timer: ReturnType<typeof setTimeout> | undefined;
function recheck() {
checking.value = true;
clearTimeout(timer);
timer = setTimeout(() => (checking.value = false), 900);
}
onBeforeUnmount(() => clearTimeout(timer));
</script>
clearable emits null. disabled blocks all interaction,
while readonly keeps the value focusable and copyable.
Label modes and density
label-mode puts the label above the field (outside, the default), resting
inside it like a placeholder (floating), or pinned small at the top of the
field (inside).
<template>
<div class="flex max-w-sm flex-col gap-4">
<BbNumberInput
id="label-outside"
v-model="onHand"
label="On hand (outside)"
:max-precision="0"
:min="0"
name="on-hand"
/>
<!--
Left empty on purpose: a floating label rests inside the field like a
placeholder and lifts on focus, so an empty field is where you see it.
Do not pair it with a `placeholder` — they occupy the same spot.
-->
<BbNumberInput
id="label-floating"
v-model="incoming"
label="Incoming (floating)"
label-mode="floating"
:max-precision="0"
:min="0"
name="incoming"
/>
<BbNumberInput
id="label-inside"
v-model="reserved"
label="Reserved (inside)"
label-mode="inside"
:max-precision="0"
:min="0"
name="reserved"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbNumberInput } from 'bitboss-ui';
const onHand = ref<number | null>(148);
const incoming = ref<number | null>(null);
const reserved = ref<number | null>(12);
</script>
Leave label-mode unset to use defaultInputLabelMode. Do not pair floating
with a placeholder. hide-label keeps the accessible name.
variant works as on
BbTextInput, including the blank none.
compact reduces the control height. layout="horizontal" puts the
label beside the field, so in a settings panel each limit reads as one dense
row, label left and value right.
API rate limits
Applied per key, effective immediately.
<template>
<section
class="flex max-w-md flex-col gap-3 rounded-(--bb-radius) border border-(--bb-border) p-5"
>
<header class="flex flex-col gap-1">
<h3 class="m-0 text-base font-semibold">API rate limits</h3>
<p class="m-0 text-sm opacity-70">Applied per key, effective immediately.</p>
</header>
<!--
compact + layout="horizontal": each limit reads as one dense row,
label left and value right, the way a settings screen wants it. Both
depend on the label staying outside the field. Phones get the vertical
layout, since a side-by-side row never wraps.
-->
<BbNumberInput
id="limits-rpm"
v-model="requestsPerMinute"
compact
:layout="rowLayout"
label="Requests / min"
:max="10000"
:max-precision="0"
:min="10"
name="rpm"
:step="10"
/>
<BbNumberInput
id="limits-burst"
v-model="burst"
compact
:layout="rowLayout"
label="Burst limit"
:max="20000"
:max-precision="0"
:min="10"
name="burst"
:step="10"
/>
<BbNumberInput
id="limits-timeout"
v-model="timeout"
compact
:layout="rowLayout"
label="Timeout"
:max="120"
:max-precision="0"
:min="1"
name="timeout"
unit="second"
/>
</section>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbNumberInput, useMobile } from 'bitboss-ui';
const { isMobile } = useMobile();
const rowLayout = computed(() => (isMobile.value ? 'vertical' : 'horizontal'));
const requestsPerMinute = ref<number | null>(500);
const burst = ref<number | null>(1000);
const timeout = ref<number | null>(30);
</script>
layout works only with the resolved outside label mode. Embedded label
modes force a vertical layout.
Coming from v2floating → label-mode
A bare floating attribute was never a prop on this family. It fell through
$attrs onto the root element and did nothing. The spelling is
label-mode="floating". And if your stylesheets or tests target the shared
input wrapper, .bb-common-input-inner-container* was renamed to
.bb-field-input*.