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
Pass Through
Hover or tap a part to outline it. Toggles flip loading, errors and warnings when the component has them — only parts highlight.
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.
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.
Bounds and precision
min and max clamp the emitted value: type past the ceiling and it
settles back to max. maxPrecision caps decimal places and defaults to 8.
Use 0 to pin a field to whole numbers, 2 to currency.
Setpoint 21 · tolerance 0.5
<template>
<div class="flex max-w-sm flex-col gap-4">
<!-- Whole degrees only, and the value can never leave 16–28. -->
<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
prepend:icon="lucide:thermometer"
>
<template #suffix>°C</template>
</BbNumberInput>
<!-- Two decimals, so a third one is dropped as it is typed. -->
<BbNumberInput
id="bounds-tolerance"
v-model="tolerance"
hint="Two decimals. Type a third and it is dropped."
label="Tolerance"
:max="5"
:max-precision="2"
:min="0"
name="tolerance"
persistent-hint
>
<template #suffix>°C</template>
</BbNumberInput>
<p class="text-sm text-[color:var(--bb-text-muted)]">
Setpoint {{ setpoint ?? '—' }} · tolerance {{ tolerance ?? '—' }}
</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. Commas are accepted as the
decimal separator and normalized to a period.
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-sm flex-col gap-3">
<div>
<p class="text-sm font-medium">{{ item.name }}</p>
<p class="text-xs text-[color:var(--bb-text-muted)]">
{{ formatEur(item.price) }} each · {{ item.stock }} in stock
</p>
</div>
<BbNumberInput
id="stepper-quantity"
v-model="quantity"
label="Quantity"
:max="item.stock"
:max-precision="0"
:min="1"
name="quantity"
>
<!--
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"
icon="lucide:minus"
size="xs"
variant="ghost"
@click="decrease"
/>
</template>
<template #append="{ increase }">
<BbButton
aria-label="Increase quantity"
icon="lucide:plus"
size="xs"
variant="ghost"
@click="increase"
/>
</template>
</BbNumberInput>
<p class="text-sm font-medium">Subtotal {{ formatEur(subtotal) }}</p>
</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. prefix and suffix render inline, either side of the typed value.
prepend:icon and append:icon put an icon in the same ring.
Submitted as {"price":69,"weight":1.4}
<template>
<div class="flex max-w-sm flex-col gap-4">
<BbNumberInput
id="affix-price"
v-model="price"
label="Unit price"
:max-precision="2"
:min="0"
name="unit-price"
:step="0.5"
>
<template #prefix>€</template>
<template #suffix>/ unit</template>
</BbNumberInput>
<BbNumberInput
id="affix-weight"
v-model="weight"
label="Shipping weight"
:max-precision="1"
:min="0"
name="weight"
prepend:icon="lucide:weight"
>
<template #suffix>kg</template>
</BbNumberInput>
<!-- The affixes are chrome. What you submit is still two plain numbers. -->
<p class="text-sm text-[color:var(--bb-text-muted)]">
Submitted as <code>{{ JSON.stringify({ price, weight }) }}</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);
</script>
Affixes do not change the submitted value. Format display values with
Intl.NumberFormat.
The append position is contested, and 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
Four surfaces, four jobs. 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.
Try 12 for an error, then 180 for a warning. Both at once and only the error shows.
<template>
<div class="flex max-w-sm flex-col gap-3">
<BbNumberInput
id="messaging-seats"
v-model="seats"
description="Seats are billed monthly and sold in blocks of five."
:errors="errors"
hint="1 to 200 on the Team plan."
label="Seats"
:max="200"
:max-precision="0"
:min="1"
name="seats"
persistent-hint
:warnings="warnings"
/>
<p class="text-sm text-[color:var(--bb-text-muted)]">
Try 12 for an error, then 180 for a warning. Both at once and only the
error shows.
</p>
</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 validated form
Import the same field from bitboss-ui/validated and it takes rules,
validates itself, and renders its own messages through the errors surface
above.
<script setup lang="ts">
import { BbButton } from 'bitboss-ui';
import { BbForm, BbNumberInput } from 'bitboss-ui/validated';
</script>
<template>
<BbForm @submit="addLine">
<BbNumberInput label="Quantity" :min="1" rules="required" />
<BbButton type="submit" variant="primary">Add to order</BbButton>
</BbForm>
</template>
The validated entrypoint needs vee-validate. Use rules for conditions that
bounds do not cover, such as required; your own errors merge with rule
messages.
Clearable, loading, disabled and readonly
Four props change what the field will accept, and one of them is not what it looks like.
<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
label="Monthly budget"
:max-precision="2"
:min="0"
name="budget"
>
<template #prefix>€</template>
</BbNumberInput>
<!-- Loading is visual only: the field stays editable while the check runs. -->
<BbNumberInput
id="states-discount"
v-model="discount"
label="Discount"
:loading="checking"
:max="100"
:max-precision="0"
:min="0"
name="discount"
>
<template #suffix>%</template>
</BbNumberInput>
<BbButton class="self-start" size="sm" variant="outline" @click="recheck">
Re-check the discount
</BbButton>
<!-- Derived rather than entered, so readonly: visible and copyable. -->
<BbNumberInput
id="states-total"
label="Total after discount"
:max-precision="2"
: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, ref } from 'vue';
import { BbButton, BbNumberInput } from 'bitboss-ui';
import { delay } from '~/demo-data';
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
);
async function recheck() {
checking.value = true;
await delay(null, 900);
checking.value = false;
}
</script>
clearable emits null. loading is visual only. 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.
compact reduces the control height, and direction="horizontal" puts the
label beside the field. Settings screens are where the pair earns its keep: each
limit reads as one dense row, label left and value right.
API rate limits
Applied per key, effective immediately.
<template>
<div
class="flex max-w-md flex-col gap-3 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-4"
>
<div>
<p class="text-sm font-medium">API rate limits</p>
<p class="text-xs text-[color:var(--bb-text-muted)]">
Applied per key, effective immediately.
</p>
</div>
<!--
compact + direction="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.
-->
<BbNumberInput
id="limits-rpm"
v-model="requestsPerMinute"
compact
direction="horizontal"
label="Requests / min"
:max="10000"
:max-precision="0"
:min="10"
name="rpm"
:step="10"
/>
<BbNumberInput
id="limits-burst"
v-model="burst"
compact
direction="horizontal"
label="Burst limit"
:max="20000"
:max-precision="0"
:min="10"
name="burst"
:step="10"
/>
<BbNumberInput
id="limits-timeout"
v-model="timeout"
compact
direction="horizontal"
label="Timeout"
:max="120"
:max-precision="0"
:min="1"
name="timeout"
>
<template #suffix>s</template>
</BbNumberInput>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbNumberInput } from 'bitboss-ui';
const requestsPerMinute = ref<number | null>(500);
const burst = ref<number | null>(1000);
const timeout = ref<number | null>(30);
</script>
direction 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
.common-input-wrapper--*.