Use it for
Use BbTag when users invent the values, such as recipients, skills, or issue labels.
Also see: Validated forms.
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.
Invite recipients with feedback
Seed a string[] model and show feedback for values rejected by the cap or duplicate check.
3 of 5 invited, 1 as guest
<template>
<div class="flex max-w-lg flex-col gap-2">
<!--
:pt:badge runs once per tag with the tag string, so an address outside
the workspace domain gets an amber wash on its own chip.
-->
<BbTag
id="invite-recipients"
v-model="recipients"
autocomplete="email"
description="Addresses outside vantera.io get guest access."
label="Invite people"
:max="5"
name="recipients"
placeholder="Add an email address"
prepend:icon="lucide:mail"
:pt:badge="({ item }) => isExternal(item) && externalChip"
:warnings="warning"
@duplicate="onDuplicate"
@max="onMax"
@update:model-value="warning = []"
/>
<p class="m-0 text-sm text-(--bb-text-muted)">
{{ recipients.length }} of 5 invited, {{ guests }} as
{{ guests === 1 ? 'guest' : 'guests' }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbTag } from 'bitboss-ui';
import { users } from '~/demo-data';
const recipients = ref<string[]>([
users[0]!.email,
users[1]!.email,
'[email protected]',
]);
const warning = ref<string[]>([]);
const isExternal = (email: string) => !email.endsWith('@vantera.io');
const guests = computed(() => recipients.value.filter(isExternal).length);
// A soft amber chip, mixed from theme tokens so it holds up in dark mode.
const externalChip =
'[--bg:color-mix(in_oklab,var(--bb-warn)_22%,var(--bb-panel))] [--fg:color-mix(in_oklab,var(--bb-text-warn)_85%,var(--bb-text))]';
// A refused entry is reported, not rendered. Showing it is our job: try
// re-adding an address that is already there, or adding a sixth one.
const onDuplicate = (value: string) => {
warning.value = [`${value} is already on the list.`];
};
const onMax = (value: string) => {
warning.value = [`Five invites is the cap, so ${value} was not added.`];
};
</script>
duplicate and max report the refused string but render no message. Put application wording in warnings or a toast.
A function on pt:badge runs once per tag and receives { item, highlighted }, with item the tag string. One chip can then stand out by its value, as the guest addresses do here. Say it in text too: colour alone is not a signal.
Coming from v2
modelValue is now strictly string[]; object arrays no longer compile. The removed multiple prop was always true, so delete it. Duplicates matched exactly in v2 and now ignore case: 'Vue' then 'vue' fires duplicate. Add case-sensitive to keep the old matching.
Commit and normalize entries
divider chooses the commit key. Add comma only when commas cannot belong inside a valid token.
<template>
<!-- Either key commits the current text, and a pasted list is split on commit. -->
<BbTag
id="divider-labels"
v-model="labels"
class="max-w-md"
:divider="[',', 'Enter']"
hint="Separate labels with a comma, or press Enter. A pasted list becomes one chip per label."
label="Issue labels"
name="labels"
persistent-hint
placeholder="bug, regression, needs-triage"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbTag } from 'bitboss-ui';
const labels = ref<string[]>(['bug', 'needs-triage']);
</script>
Entries are trimmed. Duplicate matching ignores case; use case-sensitive only when case changes identity.
Committing splits the text on every single-character divider, so with comma among them a pasted a, b, c becomes three chips on the next Enter. Each piece is trimmed and meets the duplicate and max checks on its own; pieces that do not fit under the cap stay in the field.
Edit tokens without a pointer
With an empty input, Backspace highlights then removes, arrows move between chips, Home/End jump, and Escape returns to typing.
<template>
<BbTag
id="keyboard-skills"
v-model="chosen"
class="max-w-md"
hint="Empty field: Backspace highlights, Backspace again removes, arrows move, Escape returns to typing."
label="Skills"
name="skills"
persistent-hint
placeholder="Add a skill"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbTag } from 'bitboss-ui';
import { skills } from '~/demo-data';
// Four chips, so the highlight has somewhere to walk.
const chosen = ref<string[]>(skills.slice(0, 4));
</script>
Keep instructions in a hint when the workflow depends on keyboard editing. The input retains DOM focus and announces the highlighted chip.
Validate and lock the field
A field joined to a BbForm by its name is validated through rules, which see the whole array. Write the requirement there, as rules="required" for at least one tag or a function for a larger minimum; the asterisk follows. The required prop on a joined field is a hint: it marks the field and checks nothing.
<template>
<!--
Joined to the BbForm by its `name`, the tags field needs no v-model: the
form holds the array and `rules` validates it as one value, when focus
leaves the field and on submit.
-->
<BbForm
class="flex max-w-md flex-col gap-4"
@submit="publish"
>
<BbTag
id="states-keywords"
description="At least three, so the listing shows up in search."
label="Search keywords"
name="keywords"
placeholder="Add a keyword"
:rules="atLeastThree"
/>
<!-- A spinner, not a lock: the field stays editable while the check runs.
It starts the first time the field gets focus, and on every edit. -->
<BbTag
id="states-aliases"
v-model="aliases"
:hint="aliasHint"
label="Domain aliases"
:loading="checking"
name="aliases"
persistent-hint
@focus="checked || check()"
@update:model-value="check"
/>
<!--
readonly, not disabled: chips stay visible, focusable and copyable.
formless keeps a value the listing does not own out of the submit.
-->
<BbTag
id="states-inherited"
formless
label="Inherited from the workspace"
:model-value="inherited"
name="inherited"
readonly
/>
<div class="flex flex-wrap items-center gap-3">
<BbButton type="submit" variant="primary">
Publish listing
</BbButton>
<p class="m-0 text-sm text-(--bb-text-muted)" role="status">
{{ status }}
</p>
</div>
</BbForm>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbButton, BbForm, BbTag } from 'bitboss-ui';
import { clone, delay, initialTags } from '~/demo-data';
const aliases = ref<string[]>(['vantera.io', 'vantera.dev']);
const inherited = clone(initialTags);
const status = ref('');
const checking = ref(false);
const checked = ref(false);
const aliasHint = computed(() => {
if (checking.value) return 'Checking these against the registry…';
if (checked.value) return 'Every alias points to this listing.';
return 'Checked against the registry when you edit them.';
});
// A ticket per check, so only the latest one reports its result.
let ticket = 0;
async function check() {
const mine = ++ticket;
checking.value = true;
await delay(null, 1400);
if (mine !== ticket) return;
checking.value = false;
checked.value = true;
}
// A rule sees the whole array, so it covers "required" and any larger minimum.
// `required` as a prop would be an error on a field joined to the form.
const atLeastThree = (value: string[] | undefined) =>
(value?.length ?? 0) >= 3 || 'Add at least three keywords.';
async function publish(values: Record<string, unknown>) {
await delay(null, 600);
status.value = `Published with ${(values.keywords as string[]).length} keywords.`;
}
</script>
Outside a form, compute the message yourself and pass it through errors; there required marks the field. loading is only status and does not lock editing. Use readonly for a set people may copy, adding formless when the form should not own it. disabled removes the field from the tab order. Add clearable only when deleting the whole array is expected.
comma is a compact display mode with no per-chip remove buttons. For read-only tokens outside a field, render BbBadge instead.
Look and density
variant, label-mode and compact change how the field looks, never how it behaves; pick them to match the text fields around it.
<template>
<!-- Paired by label position, so the fields in each row start level. -->
<div class="grid max-w-2xl items-start gap-4 sm:grid-cols-2">
<!-- One model for every field, so only the look changes between them. -->
<BbTag
v-for="field in fields"
:id="`tag-look-${field.id}`"
:key="field.id"
v-model="skills"
:clearable="field.clearable"
:comma="field.comma"
:compact="field.compact"
:disabled="field.disabled"
:label="field.label"
:label-mode="field.labelMode"
:name="`tag-look-${field.id}`"
placeholder="Add a skill"
:variant="field.variant"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import type { InputVariantType } from 'bitboss-ui';
import { BbTag } from 'bitboss-ui';
const skills = ref<string[]>(['Vue', 'Nuxt']);
type Field = {
id: string;
label: string;
variant?: InputVariantType;
labelMode?: 'outside' | 'floating' | 'inside';
compact?: boolean;
comma?: boolean;
clearable?: boolean;
disabled?: boolean;
};
const fields: Field[] = [
{ id: 'outline', label: 'Outline (default)' },
{ id: 'secondary', label: 'Secondary', variant: 'secondary' },
{ id: 'ghost', label: 'Ghost', variant: 'ghost' },
{ id: 'disabled', label: 'Disabled', disabled: true },
{ id: 'compact', label: 'Compact', compact: true },
{ id: 'comma', label: 'Comma list, clearable', comma: true, clearable: true },
{ id: 'floating', label: 'Floating label', labelMode: 'floating' },
{ id: 'inside', label: 'Inside label', labelMode: 'inside' },
];
</script>
variant="none" drops every colour, focus ring included, for a field you style yourself, like the flat search field on BbTextInput.
The slots match the text fields too: prepend and append sit inside the box, prepend:outer and append:outer beside it, for a button such as "Import list" that belongs to the field but not to the chips.