Use it for
Reach for BbTooltip to hang a short label on something the user is already
looking at. What an icon-only button does, what a piece of jargon means, the
full value behind a truncated cell.
Use something else when
BbPopover: the hint holds something to click, or should stay open while the reader works through itBbHoverCard: the hint previews what a link points at, such as a person or an eventBbDropdown: it is a menu of actions
A tooltip describes, a popover contains. The bubble never takes focus, so a link or a button inside it is unreachable from a keyboard.
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.
Hints on a control
Keep required guidance visible and use a tooltip for the aside. Put
v-bb-tooltip on the control, with the label as its value.
<template>
<div class="flex max-w-sm items-end gap-1.5">
<!-- The rule the user must follow is in `description`: on screen, in the
accessibility tree, and printable. The tooltip only adds context. -->
<BbTextInput
id="tooltip-api-key-name"
v-model="keyName"
class="min-w-0 flex-1"
description="Letters, numbers and dashes. Cannot be changed later."
label="API key name"
name="apiKeyName"
/>
<!-- A button, not a span: a tooltip on a non-focusable element opens on
hover only, so keyboard users never see it. The argument is the placement:
`right` keeps the bubble off the description it sits beside. -->
<BbButton
v-bb-tooltip:right="'The name appears in audit logs next to every request this key makes.'"
aria-label="About API key names"
class="shrink-0"
icon="lucide:circle-question-mark"
variant="ghost"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbTextInput, vBbTooltip } from 'bitboss-ui';
const keyName = ref<string | null>('billing-sync');
</script>
The classic pairing is an icon-only button. Keep its aria-label as well: the
label names the control, the tooltip describes it. Delete the label because "the
tooltip already says it" and the button becomes nameless for a screen reader.
Directive or component
When the label is plain text, use the directive. v-bb-tooltip attaches a
tooltip to any element with no wrapper and no slot, which is what makes it the
right tool inside a table. Its three forms:
<!-- string shorthand: the value is the label; an info tip is an icon-only ghost button -->
<BbButton variant="ghost" icon="lucide:info" v-bb-tooltip="'Prices include VAT'">About prices</BbButton>
<!-- the directive argument sets the placement -->
<BbButton
v-bb-tooltip:bottom="'Saved automatically'"
aria-label="Saved"
icon="lucide:check"
/>
<!-- object form: a required text plus any BbTooltip prop but `activator` (the directive uses its own element) -->
<BbButton
v-bb-tooltip="{ text: 'Archive (E)', placement: 'right', delay: 300 }"
aria-label="Archive"
icon="lucide:archive"
/>
<!-- an empty value attaches nothing, so it is safe to bind conditionally;
the one span here, because the truncated text is already in the page -->
<span v-bb-tooltip="isTruncated ? label : ''">{{ label }}</span>
- Q3-2026 board deck, revenue and churn summary (final).key8.4 MB
- invoice-INV-1042.pdf88 KB
- customer-churn-analysis-2026-06-consolidated.xlsx1.2 MB
- brand-guidelines.pdf640 KB
<template>
<div class="w-full max-w-sm overflow-hidden rounded-(--bb-radius) border border-(--bb-border)">
<div class="flex items-baseline justify-between gap-2 border-b border-(--bb-border) px-3 py-2">
<span class="text-sm font-medium">Attachments</span>
<span class="text-xs opacity-70">INV-1042 · {{ attachments.length }} files</span>
</div>
<ul class="m-0 list-none divide-y divide-(--bb-border) p-0">
<li
v-for="file in attachments"
:key="file.name"
class="flex items-center gap-2 px-3 py-1.5 text-sm"
>
<BbIcon class="shrink-0 opacity-60" :icon="file.icon" size="sm" />
<!-- Object form: text plus any BbTooltip prop. An empty value
attaches nothing, so names that fit get no bubble at all. -->
<span
v-bb-tooltip="file.name.length > 34 ? { text: file.name, delay: 400 } : ''"
class="min-w-0 flex-1 truncate"
>
{{ file.name }}
</span>
<span class="shrink-0 text-xs opacity-60">{{ file.size }}</span>
<!-- String form; the argument sets the placement. -->
<BbButton
v-bb-tooltip:right="'Download'"
:aria-label="`Download ${file.name}`"
icon="lucide:download"
size="sm"
variant="ghost"
/>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { BbButton, BbIcon, vBbTooltip } from 'bitboss-ui';
// A length threshold stands in for a real truncation test; in an app compare
// the element's `scrollWidth` with its `clientWidth`.
const attachments = [
{
name: 'Q3-2026 board deck, revenue and churn summary (final).key',
size: '8.4 MB',
icon: 'lucide:presentation',
},
{ name: 'invoice-INV-1042.pdf', size: '88 KB', icon: 'lucide:file-text' },
{
name: 'customer-churn-analysis-2026-06-consolidated.xlsx',
size: '1.2 MB',
icon: 'lucide:file-spreadsheet',
},
{ name: 'brand-guidelines.pdf', size: '640 KB', icon: 'lucide:file-text' },
];
</script>
Escalate to the component only when you need a slot: markup inside the label, or
the activator scope to make the trigger react. Pass the label to
text, put the trigger in the activator slot and spread its props:
<BbTooltip text="Download CSV">
<template #activator="{ props }">
<BbButton v-bind="props" aria-label="Download CSV" icon="lucide:download" />
</template>
</BbTooltip>
If the label is a string, it is the directive.
When the trigger is not yours to wrap (an element another component renders,
like the native input of a selection tile), pass it as
activator instead: an element,
a component ref or a CSS selector string, looked up once the tooltip mounts.
<BbTooltip activator="#export-format-csv" text="Comma-separated, opens in any spreadsheet" />
Each element with the directive mounts one small BbTooltip as soon as it
mounts, not on the first hover. That is what wires aria-describedby before
anyone points at it, so a screen reader reads the label on first focus. A long table therefore mounts one per row: bind an empty value on
the rows that do not need one.
The directive adds nothing to server-rendered HTML: it attaches on the client.
The component with a text prop puts its description in the server HTML,
which matters only when a crawler or a no-JS reader must get the text.
The plugin registers the directive globally (injectDirectives, on by
default). Importing vBbTooltip in <script setup> registers it locally
instead, which is what the demos on this site do. To register it by hand, or
under another name, install BbTooltipDirectivePlugin:
import { BbTooltipDirectivePlugin } from 'bitboss-ui';
app.use(BbTooltipDirectivePlugin); // v-bb-tooltip
app.use(BbTooltipDirectivePlugin, 'tooltip'); // v-tooltip (not typed)
Keyboard and assistive technology
A tooltip needs a focusable trigger. It opens on hover, after delay,
and on :focus-visible, at once. That only helps if the element can take
focus. A <span> cannot, so put the tooltip on a button, a link or a field. On a
bare BbIcon, a <span> or an <svg> the tip is pointer-only: keyboard and
touch never open it, so use that only when the tip repeats text already on the
page. An info icon is an icon-only ghost BbButton, its slot text the name and
the tip the description. Text that must be readable on touch belongs in a
BbPopover toggletip. Never add a tabindex to an
icon: it becomes a tab stop with no role.
The bubble is hidden from assistive technology. Its text reaches a screen
reader as the trigger's description, once, and a tooltip inside a <label> does
not join the control's name. In tests, assert the trigger's description
(toHaveAccessibleDescription('…')) instead of looking the bubble up by role.
A tooltip is never the only home for a fact. Hover text does not exist on a
touch screen. It does not print, and it is gone the moment the pointer moves.
Anything the user must know to fill the field in correctly belongs in
description or hint on the field itself. The tooltip carries the aside.
The truncated cell above is the one exception: the full value is already a text node in the page, so a screen reader reads it either way.
Placement, timing and width
placement defaults to top and offers the top, right, bottom and left
families with -start and -end alignment.
<template>
<div class="flex flex-wrap items-center gap-2">
<!-- Hover or focus each one. Near a viewport edge the bubble flips on its own. -->
<BbButton
v-for="side in sides"
:key="side"
v-bb-tooltip:[side]="`Opens ${side}`"
size="sm"
variant="outline"
>
{{ side }}
</BbButton>
</div>
</template>
<script setup lang="ts">
import { BbButton, vBbTooltip, type Placement } from 'bitboss-ui';
const sides: Placement[] = ['top', 'right', 'bottom', 'left', 'bottom-start'];
</script>
delay (ms, 100 by default) holds the hover opening back until the pointer
settles, so a cursor crossing a row of buttons does not flash a bubble for each.
Once one tooltip has shown, a neighbour reached within 300 ms opens at once,
and keyboard focus never waits; :delay="0" opens on hover at once too. It delays only the open, so it is not a
way to keep a tooltip on screen longer.
<template>
<div
aria-label="Document actions"
class="inline-flex w-fit items-center gap-0.5 rounded-(--bb-radius) border border-(--bb-border) p-0.5"
role="toolbar"
>
<!-- `delay` holds each bubble back until the pointer settles, so crossing
the row does not fire five of them. `width` narrows the long one. -->
<BbButton
v-for="action in actions"
:key="action.label"
v-bb-tooltip="{
text: action.hint ?? action.label,
delay: 400,
placement: 'bottom',
width: action.width,
}"
:aria-label="action.label"
:icon="action.icon"
size="sm"
variant="ghost"
/>
</div>
</template>
<script setup lang="ts">
import { BbButton, vBbTooltip } from 'bitboss-ui';
interface ToolbarAction {
label: string;
/** The tooltip text, when it says more than the label. */
hint?: string;
icon: string;
/** A narrower maximum than the default 40ch, in pixels. */
width?: number;
}
const actions: ToolbarAction[] = [
{ label: 'Undo', icon: 'lucide:undo-2' },
{ label: 'Redo', icon: 'lucide:redo-2' },
{ label: 'Copy link', icon: 'lucide:link' },
{ label: 'Download PDF', icon: 'lucide:download' },
{
label: 'Delete',
hint: 'Deletes the document for everyone it is shared with. This cannot be undone.',
icon: 'lucide:trash-2',
width: 200,
},
];
</script>
Bubbles wrap at 40ch on their own. width sets a different maximum for one
hint: pixels, a percentage of the trigger, or any CSS length. A short hint still
shrinks to its text.
A tooltip gives way to anything the user opened on purpose. A click or
right-click on the trigger hides it, and while a menu, popover, dialog, select
or picker is open, tooltips on triggers outside it stay quiet. Nothing reshows
when the overlay closes. disabled switches one off, and closes it if it is
open.
Labels with markup
The default slot takes markup, which is how an action name gets its keyboard shortcut beside it.
<template>
<div
aria-label="Editor"
class="inline-flex w-fit items-center gap-0.5 rounded-(--bb-radius) border border-(--bb-border) p-0.5"
role="toolbar"
>
<BbTooltip v-for="item in items" :key="item.label" :delay="200" placement="bottom">
<template #activator="{ props }">
<BbButton
v-bind="props"
:aria-label="item.label"
:icon="item.icon"
size="sm"
variant="ghost"
/>
</template>
<!-- Markup, but still no links and no buttons: the bubble never takes
focus, so anything clickable in here is unreachable. -->
<span class="flex items-center gap-1.5">
{{ item.label }}
<kbd
class="rounded border border-current px-1 font-mono text-[10px] leading-4 opacity-75"
>
{{ modifier }} {{ item.key }}
</kbd>
</span>
</BbTooltip>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { BbButton, BbTooltip } from 'bitboss-ui';
const items = [
{ label: 'Search', key: 'K', icon: 'lucide:search' },
{ label: 'Toggle sidebar', key: 'B', icon: 'lucide:panel-left' },
{ label: 'New document', key: 'N', icon: 'lucide:square-pen' },
];
// Read the platform after mount, so the server and the first client render agree.
const modifier = ref('Ctrl');
onMounted(() => {
if (/Mac|iPhone|iPad/.test(navigator.platform)) modifier.value = '⌘';
});
</script>
The content stays a description. Everything in the slot is flattened into one
string, so a screen reader hears "Search ⌘K" as a single phrase. The rule about
interactive content holds here too: markup does not make the bubble focusable,
and an <a> in there is unreachable.
Variants and styling
variant tints the bubble: default is the primary bubble and destructive
the danger tint. hide-arrow drops the point when it would land on a
neighbouring control.
<template>
<div class="flex flex-wrap items-center gap-2">
<BbButton
v-bb-tooltip="'Duplicate this record'"
prepend:icon="lucide:copy"
variant="outline"
>
Duplicate
</BbButton>
<!-- `destructive` is the danger tint, for a hint that warns. -->
<BbButton
v-bb-tooltip="{
text: 'Deleting removes every version of this record',
variant: 'destructive',
}"
prepend:icon="lucide:trash-2"
variant="destructive"
>
Delete
</BbButton>
<!-- No arrow: a plain bubble, for a dense row where the point would land
on top of a neighbouring control. -->
<BbButton
v-bb-tooltip="{ text: 'Visible to everyone with the link', hideArrow: true }"
prepend:icon="lucide:share-2"
variant="ghost"
>
Share
</BbButton>
</div>
</template>
<script setup lang="ts">
import { BbButton, vBbTooltip } from 'bitboss-ui';
</script>
variant accepts only registered names, so variant="info" does not compile
until info is in the plugin config:
// nuxt.config.ts
export default defineNuxtConfig({
bitboss: { tooltipVariants: ['info'] },
});
Registering a name gets you the type; the CSS is yours. Colour comes from three custom properties on the tooltip root, which the component re-applies to the bubble and the arrow:
.bb-tooltip.bb-tooltip--info {
--bg: var(--bb-panel);
--fg: var(--bb-text);
--border-color: var(--bb-border);
}
--fs, --px and --py size the text and padding on the same root. To
restyle one tooltip, pt:panel reaches the bubble and pt:root the floating
element with its arrow; both take the open state.
Coming from v2
The v2 theme prop became variant, and it is typed. A hover now waits
100 ms before the bubble opens (focus still opens it at once); :delay="0"
restores the instant hover.