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
Also see: Validated forms.
This page also defines the shared field chrome used by the other inputs.
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
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="e.g. Design team"
/>
<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`: 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, so write
value ?? '' for an API that insists on a string. To emit '' instead, set
null-value="string" on one
field, or the plugin option
formControls.textInputNullValue once for
the whole app.
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: focus(), blur() and select() are gone.
A leftover inputRef.value?.focus() is undefined at runtime, not a compile
error. Use autofocus for focus on mount. For anything else, take the native
<input> through passthrough, :pt="{ input: { attrs: { ref: (el) => (input = el) } } }",
and call input.focus() on it.
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>('[email protected]');
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.
floating alone is not a prop; it falls through to the root and does nothing:
- <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.
<template>
<div class="grid max-w-2xl gap-4 sm:grid-cols-2">
<BbTextInput
id="types-email"
v-model="email"
autocomplete="email"
label="Work email"
name="email"
prepend:icon="lucide:mail"
type="email"
/>
<BbTextInput
id="types-phone"
v-model="phone"
autocomplete="tel"
label="Phone"
name="phone"
prepend:icon="lucide:phone"
type="tel"
/>
<BbTextInput
id="types-website"
v-model="website"
autocomplete="url"
label="Website"
name="website"
placeholder="https://"
prepend:icon="lucide:globe"
type="url"
/>
<!-- A search box keeps its accessible name with hide-label. -->
<BbTextInput
id="types-search"
v-model="search"
class="self-end"
hide-label
label="Search people"
name="search"
placeholder="Search people…"
prepend:icon="lucide:search"
type="search"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbTextInput } from 'bitboss-ui';
const email = ref<string | null>('[email protected]');
const phone = ref<string | null>('+39 055 123 4567');
const website = ref<string | null>(null);
const search = ref<string | null>('Okonkwo');
</script>
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 only mean something on the input, such as maxlength,
pattern, spellcheck and enterkeyhint, go straight to the <input>. Any
other attribute lands on the outer element; reach the input with
pt:input instead.
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, like an unmasked one.
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"
counter
description="Your public profile lives at vantera.io/@handle."
hint="Lowercase letters and digits, 3–20 characters."
label="Handle"
maxlength="20"
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, as in 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.
counter adds a character
count at the end of the hint row. With a native maxlength it reads
12 / 20 and the input stops at the limit.
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="[email protected]"
required
type="email"
@blur="validateEmail"
@input="emailErrors = []"
/>
<!-- Valid, but worth a second look: amber, and no aria-invalid. -->
<BbTextInput
id="company-name"
v-model="company"
autocomplete="organization"
label="Company name"
name="company-name"
:warnings="companyWarnings"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbTextInput } from 'bitboss-ui';
const email = ref<string | null>('billing@vantera');
const company = ref<string | null>('Acme srl');
// Customers already on file; a match is allowed, but it is probably a duplicate.
const customers = ['acme srl', 'northwind trading', 'globex spa'];
// 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 companyWarnings = computed(() =>
customers.includes((company.value ?? '').trim().toLowerCase())
? ['Matches an existing customer. Check before creating a duplicate.']
: []
);
</script>
Non-empty messages imply their visual state. Errors set aria-invalid;
warnings do not. When both exist, errors win.
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, put the field inside a BbForm
and give it rules. It joins
the form under its name, so it needs no v-model. Tab out of the empty
Email, or send the invite, to see the rule.
<template>
<!--
No v-model: inside a BbForm each field joins the form under its `name`,
and the values arrive together in @submit.
-->
<BbForm
class="flex max-w-md flex-col gap-4 rounded-(--bb-radius) border border-(--bb-border) p-5"
@invalid="status = 'Fix the highlighted field.'"
@submit="invite"
>
<header class="flex flex-col gap-1">
<h3 class="m-0 text-base font-semibold">Invite a teammate</h3>
<p class="m-0 text-sm opacity-70">They join the Design workspace as an editor.</p>
</header>
<BbTextInput
id="invite-email"
autocomplete="off"
label="Email"
name="email"
placeholder="[email protected]"
rules="required|email"
type="email"
/>
<BbTextInput
id="invite-note"
hint="Optional. Quoted in the invitation email."
label="Personal note"
name="note"
/>
<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">
Send invite
</BbButton>
</div>
</BbForm>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { defineRule } from 'vee-validate';
import { email, required } from '@vee-validate/rules';
import { BbButton, BbForm, BbTextInput } from 'bitboss-ui';
import { delay } from '~/demo-data';
// In an app these live once in a validators module, not in the component.
defineRule('required', (value: unknown) => required(value) || 'Required.');
defineRule('email', (value: unknown) =>
email(value) ? true : 'Enter a valid email address.'
);
const status = ref('');
/** Runs only once every rule passes; the pending promise keeps the button busy. */
const invite = async (values: { email?: string | null }) => {
status.value = 'Sending…';
await delay(null, 600);
status.value = `Invitation sent to ${values.email}.`;
};
</script>
A field joined to a BbForm is checked as required through rules="required"
or the form's schema, and the asterisk beside the label follows. The
required prop there is a
hint: it draws the asterisk and tells assistive technology, and an empty field
still submits, so use it when the server does the checking.
The rule messages render in the same errors channel, merged with any
errors you pass. A field checks itself when you leave it, then on every
keystroke while it shows an error; validation-mode
changes that timing. Outside a BbForm, rules do nothing, and required is the right prop:
it forwards to the native input and produces no message of its own.
A field inside a form that is not form data, such as a filter, takes
formless: it stays out of
the data, the validation and the save lock. When the label
reads badly in a message ("Search orders is required"), set
validation-label on
a joined field.
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 are short text at the
inner edges of the box, either side of the value.
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 at the inner edges of the box. -->
<BbTextInput
id="affix-slug"
v-model="slug"
label="Workspace URL"
name="workspace-url"
>
<template #prefix>vantera.io/</template>
</BbTextInput>
<BbTextInput
id="affix-mail"
v-model="mailbox"
label="Support address"
name="support-address"
prepend:icon="lucide:mail"
>
<template #suffix>@vantera.io</template>
</BbTextInput>
<!--
#append holds a small button inside the chrome; append:outer takes the
larger action beside the field.
-->
<BbTextInput
id="affix-key"
label="API key"
:model-value="apiKey"
name="api-key"
readonly
>
<template #append>
<BbButton
:icon="copied ? 'lucide:check' : 'lucide:copy'"
size="sm"
variant="ghost"
@click="copy"
>
{{ copied ? 'Copied' : 'Copy API key' }}
</BbButton>
</template>
<template #append:outer>
<BbButton class="ms-1" variant="outline" @click="roll">
Roll key
</BbButton>
</template>
</BbTextInput>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbButton, BbTextInput } from 'bitboss-ui';
const slug = ref<string | null>('engineering');
const mailbox = ref<string | null>('help');
// A fixed rotation so the demo stays deterministic.
const keys = ['sk_live_51Hn3x8Kj2', 'sk_live_7Qe2Lm0Vt9', 'sk_live_3Rb8Wc5Nz4'];
const keyIndex = ref(0);
const apiKey = computed(() => keys[keyIndex.value % keys.length]!);
const copied = ref(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(apiKey.value);
copied.value = true;
} catch {
// No clipboard access (insecure context, denied permission): stay as is.
}
};
const roll = () => {
keyIndex.value += 1;
copied.value = false;
};
</script>
Icons (prepend:icon,
append:icon) and
prefix/suffix text are decoration and never take focus. A small button can
go in #append, as the password toggle does; anything larger sits in
append:outer.
The append icon position shows one thing at a time, 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 as soon as there is a value to clear or an error to
show. Put anything that must stay visible in append:outer.
Variants, density and layout
variant changes only the colours of the box: outline (default), secondary or
ghost. compact reduces the control height. Use one density across a form.
<template>
<!-- Each variant at both densities: same widths, only colour and height change. -->
<div class="grid gap-x-3 gap-y-4 sm:grid-cols-3">
<template v-for="density in densities" :key="density.id">
<BbTextInput
v-for="variant in variants"
:id="`variant-${variant.id}-${density.id}`"
:key="variant.id"
v-model="name"
:compact="density.compact"
:label="`${variant.label}${density.suffix}`"
:name="`variant-${variant.id}-${density.id}`"
:variant="variant.id"
/>
</template>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbTextInput } from 'bitboss-ui';
const variants = [
{ id: 'outline', label: 'Outline' },
{ id: 'secondary', label: 'Secondary' },
{ id: 'ghost', label: 'Ghost' },
] as const;
const densities = [
{ id: 'regular', compact: false, suffix: '' },
{ id: 'compact', compact: true, suffix: ', compact' },
] as const;
const name = ref<string | null>('Marta Villoresi');
</script>
A search box in a table toolbar belongs to the bar, not to a form.
variant="none" is the blank base for it: the box keeps its height and
padding, loses every colour, and pt:box takes your classes. The focus
ring goes with the colours, so draw your own focus state.
<template>
<!-- A table toolbar: the search field belongs to the bar, so it has no frame. -->
<div
class="flex max-w-xl items-center gap-3 rounded-(--bb-radius) border border-(--bb-border) px-3 py-1.5"
>
<span class="text-sm font-semibold">Orders</span>
<span class="h-5 w-px bg-(--bb-border)" aria-hidden="true" />
<BbTextInput
id="orders-search"
v-model="query"
class="flex-1"
clearable
compact
hide-label
label="Search orders"
name="orders-search"
placeholder="Search by customer or order number"
prepend:icon="lucide:search"
pt:box="rounded-(--bb-radius) hover:bg-(--bb-muted) focus-within:bg-(--bb-muted) focus-within:outline-2 focus-within:outline-[color:var(--bb-primary)]"
variant="none"
/>
<BbButton prepend:icon="lucide:download" size="sm" variant="ghost">
Export
</BbButton>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbTextInput } from 'bitboss-ui';
const query = ref<string | null>(null);
</script>
layout puts the label beside the field instead of above it.
layout="horizontal" splits the row 50/50. Two space-separated tokens read
as a ratio: "xx xxxxx" gives the label two parts and the field five. Use the
same value on every row so the fields line up. reverse swaps the two columns.
auto keeps both at their natural width, label-fill gives the free space to
the label (a settings row with a short field on the right), and input-fill
gives it to the field. None of them wraps, so stack them on a phone with a
per-breakpoint map: :layout="{ default: 'vertical', md: 'xx xxxxx' }", as the
demo does.
Billing details
<template>
<section
class="flex max-w-lg flex-col gap-3 rounded-(--bb-radius) border border-(--bb-border) p-5"
>
<h3 class="m-0 text-base font-semibold">Billing details</h3>
<!--
Two tokens read as a ratio: "xx xxxxx" gives the label 2 parts and the
field 5, on every row, so the fields line up. layout="horizontal" is 50/50.
A side-by-side row never wraps, so a per-breakpoint map stacks it below `md`.
-->
<BbTextInput
id="layout-contact"
v-model="contact"
autocomplete="name"
compact
label="Billing contact"
:layout="rowLayout"
name="billing-contact"
/>
<BbTextInput
id="layout-vat"
v-model="vat"
compact
label="VAT ID"
:layout="rowLayout"
name="vat-id"
/>
<BbTextInput
id="layout-po"
v-model="purchaseOrder"
compact
label="PO number"
:layout="rowLayout"
name="po-number"
/>
</section>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbTextInput } from 'bitboss-ui';
import { userById } from '~/demo-data';
const owner = userById[1]!;
const rowLayout = { default: 'vertical', md: 'xx xxxxx' } as const;
const contact = ref<string | null>(owner.fullName);
const vat = ref<string | null>('IT01234567890');
const purchaseOrder = ref<string | null>('PO-2048');
</script>
layout applies only while the resolved label mode is outside, which
is the library default. floating and inside put the label inside
the field and always stack vertically. The resolved mode is label-mode, or
defaultInputLabelMode when the prop is unset.
Coming from v2input wrapper classes
Selectors on .bb-common-input-inner-container* move to
.bb-field-input*. 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 .bb-field__layout--reverse.