Use it for
Reach for BbTextInput for any short, single-line string: a name, an email, a
URL, a coupon code, a search box.
Use something else when
BbTextarea, when the text runs to a second lineBbNumberInput, when it is a number you clamp or stepBbDatePickerInput, when it is a calendar dateBbSelectorBbRadioGroup, when the value comes from a known set
Pass Through
Hover or tap a part to outline it. Toggles flip loading, errors and warnings when the component has them — only parts highlight.
This page also defines the shared field chrome used by the other inputs.
Default
Pass a label and bind v-model.
Model: "Vantera"
<template>
<div class="flex max-w-sm flex-col gap-3">
<BbTextInput
id="workspace-name"
v-model="workspace"
label="Workspace name"
name="workspace"
placeholder="Vantera"
/>
<p class="text-sm opacity-70">
Model: <code>{{ workspace === null ? 'null' : `"${workspace}"` }}</code>
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbTextInput } from 'bitboss-ui';
// `string | null`, never `''` — clear the field and watch the model go null.
const workspace = ref<string | null>('Vantera');
</script>
The model is string | null. An emptied field emits null, never '', so
write value ?? '' for an API that insists on a string.
label does not set name. Pass name for native form submission and an
explicit id for stable prerendered markup.
Coming from v2template ref removed
v3 removed the imperative handle. A leftover inputRef.value?.focus() is
undefined at runtime, not a compile error. Use autofocus for focus on mount,
and reach the native <input> by its id for anything else.
Label modes
label-mode puts the label above the field (outside, the default), resting
inside it like a placeholder (floating), or pinned small inside the top edge
(inside).
<template>
<div class="flex max-w-sm flex-col gap-4">
<BbTextInput
id="label-outside"
v-model="displayName"
label="Outside (default)"
name="display-name"
placeholder="Ada Lovelace"
/>
<!-- No placeholder here: the resting label already occupies that spot. -->
<BbTextInput
id="label-floating"
v-model="email"
label="Floating"
label-mode="floating"
name="email"
type="email"
/>
<BbTextInput
id="label-inside"
v-model="company"
label="Inside"
label-mode="inside"
name="company"
/>
<!-- Still named for screen readers; only the visible label is gone. -->
<BbTextInput
id="label-hidden"
v-model="query"
hide-label
label="Search projects"
name="q"
placeholder="Search projects…"
prepend:icon="lucide:search"
type="search"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbTextInput } from 'bitboss-ui';
const displayName = ref<string | null>('Ada Lovelace');
const email = ref<string | null>('ada@vantera.io');
const company = ref<string | null>('Vantera');
const query = ref<string | null>(null);
</script>
Set the product default with defaultInputLabelMode. Do not combine floating
with a placeholder; both occupy the same space.
One spelling is worth committing to memory. floating is not a prop and never
was, neither in v3 nor in v2. Written bare it lands on the root element through
$attrs and does nothing at all.
- <BbTextInput v-model="email" label="Email" floating />
+ <BbTextInput v-model="email" label="Email" label-mode="floating" />
hide-label drops the label from view and keeps the accessible name, which is
what a search box with nothing but a placeholder wants. Dropping label itself
is never the answer.
Type and keyboard
type tells the browser what kind of text this is: text, email, url,
tel, search, password. That is the whole list.
v3 narrowed it to those six. type="number" and type="date" no longer
compile, and the fix is a different component rather than a different value:
- <BbTextInput v-model="qty" type="number" label="Quantity" />
+ <BbNumberInput v-model="qty" label="Quantity" />
- <BbTextInput v-model="due" type="date" label="Due date" />
+ <BbDatePickerInput v-model="due" label="Due date" />
Set autocomplete in auth and checkout flows. Use input-mode to request the
right mobile keyboard without changing the value type.
There is no revealable prop. A reveal toggle is a type that changes plus a
button in the #append slot.
<template>
<div class="max-w-sm">
<!--
There is no `revealable` prop. Reveal is a `type` that changes, plus a
ghost button in the #append slot to change it.
-->
<BbTextInput
id="current-password"
v-model="password"
autocomplete="current-password"
label="Current password"
name="password"
:type="revealed ? 'text' : 'password'"
>
<template #append>
<BbButton
:icon="revealed ? 'lucide:eye-off' : 'lucide:eye'"
size="sm"
variant="ghost"
@click="revealed = !revealed"
>
{{ revealed ? 'Hide password' : 'Show password' }}
</BbButton>
</template>
</BbTextInput>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbTextInput } from 'bitboss-ui';
const password = ref<string | null>('correct horse battery staple');
const revealed = ref(false);
</script>
Native attributes that are not component props, such as maxlength and
pattern, land on the outer container and do not constrain the input.
Masks
Pass a maska config to mask and the
field formats as the user types: card numbers, VAT ids, licence keys.
- Card model
4242424242424242- VAT model
null
<template>
<div class="flex max-w-sm flex-col gap-4">
<BbTextInput
id="card-number"
v-model="card"
autocomplete="cc-number"
input-mode="numeric"
label="Card number"
:mask="{ mask: '#### #### #### ####' }"
name="card-number"
placeholder="4242 4242 4242 4242"
prepend:icon="lucide:credit-card"
/>
<!-- emitMasked: the model keeps the separators the user sees. -->
<BbTextInput
id="vat-number"
v-model="vat"
emit-masked
label="VAT number"
:mask="{ mask: '@@ ###########' }"
name="vat-number"
placeholder="IT 01234567890"
/>
<dl class="grid grid-cols-[auto_1fr] gap-x-3 text-sm opacity-70">
<dt>Card model</dt>
<dd><code>{{ card ?? 'null' }}</code></dd>
<dt>VAT model</dt>
<dd><code>{{ vat ?? 'null' }}</code></dd>
</dl>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbTextInput } from 'bitboss-ui';
// Unmasked by default: the model holds the digits, the field shows the groups.
const card = ref<string | null>('4242424242424242');
const vat = ref<string | null>(null);
</script>
By default the model holds the unmasked value: the field shows
4242 4242 4242 4242, v-model holds 4242424242424242. Pass emit-masked
when the formatted string is the value, as it is for a VAT id people copy and
paste. Either way an emptied field emits null.
Masks format, they do not validate. A complete but wrong card number passes the mask and still needs an error.
Description and hint
Two channels sit around the field, and the difference is timing. description
is always visible between the label and the field, read before typing.
hint appears below the field on focus, read while typing.
<template>
<div class="flex max-w-sm flex-col gap-4">
<!-- description: always visible, above the field, read before typing. -->
<BbTextInput
id="profile-handle"
v-model="handle"
description="Your public profile lives at vantera.io/@handle."
hint="Lowercase letters and digits, 3–20 characters."
label="Handle"
name="handle"
placeholder="ada"
/>
<!-- persistent-hint: the same hint, kept on screen when focus leaves. -->
<BbTextInput
id="profile-website"
v-model="website"
hint="Include the scheme — https://vantera.io"
label="Website"
name="website"
persistent-hint
type="url"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbTextInput } from 'bitboss-ui';
const handle = ref<string | null>(null);
const website = ref<string | null>(null);
</script>
persistent-hint keeps the hint visible. Both channels are connected through
aria-describedby, so keep them useful and concise.
Errors and warnings
errors says the value cannot be accepted. warnings says the value is
accepted and worth a second look. Two channels, not one channel with a severity
flag.
<template>
<div class="flex max-w-sm flex-col gap-4">
<!--
Validated on blur, cleared on input: the first attempt is never
interrupted mid-keystroke.
-->
<BbTextInput
id="billing-email"
v-model="email"
:errors="emailErrors"
label="Billing email"
name="billing-email"
placeholder="billing@vantera.io"
required
type="email"
@blur="validateEmail"
@input="emailErrors = []"
/>
<!-- Valid, but worth a second look: amber, and no aria-invalid. -->
<BbTextInput
id="seat-count"
v-model="seats"
label="Seats to invoice"
name="seat-count"
:warnings="seatWarnings"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbTextInput } from 'bitboss-ui';
const email = ref<string | null>('billing@vantera');
const seats = ref<string | null>('250');
// Seeded so the error chrome is on screen from the start; typing clears it.
const emailErrors = ref<string[]>([
'Enter a complete email address, including the domain.',
]);
const validateEmail = () => {
const value = email.value ?? '';
emailErrors.value = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
? []
: ['Enter a complete email address, including the domain.'];
};
const seatWarnings = computed(() =>
Number(seats.value) > 100
? ['Over 100 seats moves this account to annual billing.']
: []
);
</script>
Non-empty messages imply their visual state. Errors set aria-invalid;
warnings do not. When both exist, errors win.
required forwards to the native input and produces no message of its own.
Bind server-side errors straight in. With Inertia:
<BbTextInput
v-model="form.email"
label="Billing email"
type="email"
autocomplete="email"
:errors="form.errors.email"
/>
For client rules, import the component from bitboss-ui/validated:
<script setup lang="ts">
import { BbForm, BbTextInput } from 'bitboss-ui/validated';
</script>
<template>
<BbForm @submit="save">
<BbTextInput v-model="form.email" label="Email" rules="required|email" />
</BbForm>
</template>
Your errors merge with rule messages. BbForm has
the full setup.
Clearable, loading, disabled and readonly
Four flags change what the field lets you do, and only two of them stop anything.
<template>
<div class="flex max-w-sm flex-col gap-4">
<!-- Clear button appears on hover or focus while there is a value. -->
<BbTextInput
id="states-filter"
v-model="filter"
clearable
label="Filter orders"
name="filter"
placeholder="Customer or order number"
/>
<!-- A spinner, not a lock: this field is still editable. -->
<BbTextInput
id="states-coupon"
v-model="coupon"
label="Coupon code"
loading
name="coupon"
persistent-hint
hint="Checking this code…"
/>
<BbTextInput
id="states-plan"
disabled
label="Plan (managed by billing)"
:model-value="plan"
name="plan"
/>
<!-- readonly, not disabled: focusable, selectable, copyable. -->
<BbTextInput
id="states-region"
hint="Chosen when the workspace was created."
label="Region"
:model-value="region"
name="region"
persistent-hint
readonly
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbTextInput } from 'bitboss-ui';
const filter = ref<string | null>('Okonkwo');
const coupon = ref<string | null>('LAUNCH25');
const plan = 'Business (annual)';
const region = 'eu-west-1';
</script>
clearable emits null and restores focus. loading only shows status; it
does not lock the field. disabled removes interaction, while readonly keeps
the value focusable and copyable.
Icons and affixes
The slots ring the field from the value outward. prefix and suffix hug the
typed text, prepend and append sit inside the chrome, prepend-outer and
append-outer sit beside the field entirely.
<template>
<div class="flex max-w-sm flex-col gap-4">
<!-- prefix/suffix: short inline text hugging the value. -->
<BbTextInput
id="affix-slug"
v-model="slug"
label="Workspace URL"
name="workspace-url"
>
<template #prefix>vantera.io/</template>
</BbTextInput>
<BbTextInput
id="affix-price"
v-model="price"
label="Seat price"
name="seat-price"
prepend:icon="lucide:tag"
>
<template #suffix>EUR / month</template>
</BbTextInput>
<!-- append-outer: the only affix that may hold something clickable. -->
<BbTextInput
id="affix-key"
label="API key"
:model-value="apiKey"
name="api-key"
readonly
>
<template #append-outer>
<BbButton icon="lucide:copy" variant="ghost">Copy API key</BbButton>
</template>
</BbTextInput>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbTextInput } from 'bitboss-ui';
const slug = ref<string | null>('engineering');
const price = ref<string | null>('12.00');
const apiKey = 'sk_live_51Hn3x8Kj2';
</script>
Icons in the inner positions are decorative. Put interactive content in
append-outer, outside the field chrome.
One position is contested. The append slot shows exactly one thing, by priority:
clear button → spinner → error or warning icon → your append:icon. An
append:icon on a field that is also clearable or validated disappears
exactly when the field gets interesting. Put anything that must stay visible in
append-outer.
Density and layout
compact reduces the control height. Use one density across a form.
direction puts the label beside the field instead of above it.
direction="horizontal" splits the row 50/50, and two space-separated tokens
read as a ratio, so "xx xxxxxx" gives a quarter to the label. reverse swaps
the two columns.
<template>
<div class="flex max-w-md flex-col gap-3">
<!-- direction="horizontal" splits label and field 50/50. -->
<BbTextInput
id="layout-name"
v-model="contact"
compact
direction="horizontal"
label="Billing contact"
name="billing-contact"
/>
<!-- Two tokens, read as a ratio: 2:6 → a quarter label, three quarters field. -->
<BbTextInput
id="layout-vat"
v-model="vat"
compact
direction="xx xxxxxx"
label="VAT ID"
name="vat-id"
/>
<!-- reverse swaps the two columns; the label still reads left-to-right. -->
<BbTextInput
id="layout-po"
v-model="purchaseOrder"
compact
direction="xx xxxxxx"
label="PO number"
name="po-number"
reverse
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbTextInput } from 'bitboss-ui';
import { userById } from '~/demo-data';
const owner = userById[1]!;
const contact = ref<string | null>(owner.fullName);
const vat = ref<string | null>('IT01234567890');
const purchaseOrder = ref<string | null>('PO-2048');
</script>
direction applies only while the resolved label mode is outside, which
is the library default. floating and inside put the label inside
the field, force the vertical layout, and ignore direction without a warning.
Check defaultInputLabelMode before concluding the prop is broken.
Coming from v2input wrapper classes
Selectors on .bb-common-input-inner-container* move to
.common-input-wrapper--*. The layout class
bb-base-input-container__layout--hidden-label is gone as well: it always
appeared on exactly the same condition as --reverse, and nothing in the
library ever painted it. Target --reverse.