Use it for
Wrap a region whose height moves on its own: a panel that grows when data lands, a validation message under a field, a list gaining rows. It has no model, no trigger and no events.
Use something else when
BbAccordion, if you need a title that opens a bodyBbCollapsible, if you need a body you open from your own trigger
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 component hides nothing. Whatever you put inside stays mounted, focusable and announced to screen readers even at near-zero height. If a region has to genuinely disappear, you want one of the other two.
Wrap what changes height
Put the component around the region that moves. There is nothing else to pass.
Aeris Ergonomic Task Chair
FUR-CHR-1042 · Furniture
<template>
<div
class="flex w-full max-w-sm flex-col gap-3 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-3"
>
<div class="flex items-start justify-between gap-3">
<div class="flex flex-col gap-0.5">
<p class="text-sm font-medium">{{ product.name }}</p>
<p class="text-xs text-[color:var(--bb-text-muted)]">
{{ product.sku }} · {{ product.category }}
</p>
</div>
<BbButton
:aria-expanded="open"
size="sm"
variant="outline"
@click="open = !open"
>
{{ open ? 'Hide' : 'Specifications' }}
</BbButton>
</div>
<!-- Wrap only the region whose height moves. There is no model and no
trigger here: the v-if stays yours, the wrapper just removes the jump
the card would otherwise make. -->
<BbSmoothHeight>
<dl
v-if="open"
class="grid grid-cols-2 gap-x-3 gap-y-1 text-sm text-[color:var(--bb-text-muted)]"
>
<dt>Price</dt>
<dd>{{ product.price.toFixed(2) }} {{ product.currency }}</dd>
<dt>In stock</dt>
<dd>{{ product.stock }}</dd>
<dt>Rating</dt>
<dd>{{ product.rating }} / 5</dd>
<dt>Updated</dt>
<dd>{{ product.updatedAt }}</dd>
</dl>
</BbSmoothHeight>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbSmoothHeight } from 'bitboss-ui';
import { products } from '~/demo-data';
const product = products[0]!;
const open = ref(false);
</script>
While it animates, the wrapper carries a fixed inline height. Do not stretch it
from outside with height: 100%, and do not read its height mid-animation.
It clips vertically only, so shadows, focus rings and popovers still escape sideways.
Measuring starts on mount. A server-rendered page carries the content at its natural height and reads correctly without JavaScript.
Swapping one block for another
The same wrapper handles replacement, not just reveal.
<template>
<div
class="flex w-full max-w-sm flex-col gap-3 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-3"
>
<BbRadioGroup
id="delivery-method"
v-model="method"
compact
input-direction="horizontal"
item-text="label"
item-value="id"
:items="methods"
legend="Delivery"
name="delivery-method"
/>
<!-- The two branches have different heights, so the section glides between
them instead of snapping. Wrap only the part that swaps: the legend
above and the button below stay where they are. -->
<BbSmoothHeight>
<div v-if="method === 'courier'" class="flex flex-col gap-3">
<BbTextInput
id="delivery-street"
v-model="street"
compact
label="Street"
name="delivery-street"
/>
<BbTextInput
id="delivery-city"
v-model="city"
compact
label="City"
name="delivery-city"
/>
</div>
<p v-else class="text-sm text-[color:var(--bb-text-muted)]">
Collect at the Milan warehouse, weekdays 09:00–17:00. Bring the order
reference.
</p>
</BbSmoothHeight>
<BbButton class="self-start" size="sm">Save delivery</BbButton>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import {
BbButton,
BbRadioGroup,
BbSmoothHeight,
BbTextInput,
} from 'bitboss-ui';
const methods = [
{ id: 'courier', label: 'Courier' },
{ id: 'pickup', label: 'Warehouse pickup' },
];
const method = ref('courier');
const street = ref<string | null>('Via Torino 42');
const city = ref<string | null>('Milan');
</script>
A v-if/v-else that swaps two blocks of different height glides instead of
snapping. That covers a wizard step, or a form section that depends on an earlier
choice.
Wrap only the region that actually swaps. The controls above it and the actions below stay put, which is what makes the movement readable.
An async panel is the same case with three branches: spinner, results, empty state. Keep all three inside one wrapper.
Lists that gain and lose rows
Stay on resize, the default: it sees anything that changes the measured box.
Recent orders
- ORD-2026-0417Delivered
- ORD-2026-0418Delivered
<template>
<div
class="flex w-full max-w-sm flex-col gap-2 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-3"
>
<div class="flex items-center justify-between gap-3">
<p class="text-sm font-medium">Recent orders</p>
<div class="flex gap-1.5">
<BbButton
:disabled="visible <= 1"
size="sm"
variant="ghost"
@click="visible -= 1"
>
Remove
</BbButton>
<BbButton
:disabled="visible >= rows.length"
size="sm"
variant="outline"
@click="visible += 1"
>
Add
</BbButton>
</div>
</div>
<!-- The height changes because rows are inserted and removed, which is
what a MutationObserver sees. `subtree` widens the default
`{ childList: true }` scope past the direct children of the wrapper. -->
<BbSmoothHeight
:mutation-options="{ childList: true, subtree: true }"
strategy="mutation"
>
<ul class="flex flex-col divide-y divide-[color:var(--bb-border)]">
<li
v-for="order in rows.slice(0, visible)"
:key="order.id"
class="flex items-baseline justify-between gap-3 py-2 text-sm first:pt-0 last:pb-0"
>
<span class="tabular-nums">{{ order.reference }}</span>
<span class="text-xs text-[color:var(--bb-text-muted)]">
{{ orderStatusLabels[order.status] }}
</span>
</li>
</ul>
</BbSmoothHeight>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbSmoothHeight } from 'bitboss-ui';
import { orders, orderStatusLabels } from '~/demo-data';
const rows = orders.slice(0, 5);
const visible = ref(2);
</script>
Switch to strategy="mutation" when you want to react only to nodes being added
and removed. The default watch scope is { childList: true }: direct children
only. Widen it with mutation-options, a standard MutationObserverInit.
subtree: true catches nested inserts, and attributes or characterData cover
class swaps and text edits.
resize-options is the counterpart on the default path and forwards
ResizeObserverOptions. Changing the strategy, the options, disabled or
transition-duration at runtime rewires the observer on its own.
The element and the timing
tag picks the element: div by default, or span where the surrounding markup
only accepts phrasing content.
Shipping to Milan. Delivery in 2–3 working days.
<template>
<div class="flex w-full max-w-sm flex-col gap-3">
<p class="text-sm">
Shipping to Milan.
<!-- A <div> is invalid inside a paragraph, so the wrapper (and the
measuring element it mirrors) is a span. Both are display: block,
so it animates exactly like a div would. -->
<BbSmoothHeight
:disabled="!animated"
tag="span"
:transition-duration="400"
>
<span v-if="detailed" class="text-[color:var(--bb-text-muted)]">
Courier pickup is scheduled for the next working day, and the
tracking number reaches you by email as soon as the parcel leaves
the warehouse.
</span>
<span v-else class="text-[color:var(--bb-text-muted)]">
Delivery in 2–3 working days.
</span>
</BbSmoothHeight>
</p>
<div class="flex flex-wrap items-center gap-4">
<BbSwitch
id="shipping-detail"
v-model="detailed"
label="Full detail"
name="shipping-detail"
/>
<BbSwitch
id="shipping-animated"
v-model="animated"
label="Animate"
name="shipping-animated"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbSmoothHeight, BbSwitch } from 'bitboss-ui';
const detailed = ref(false);
// `disabled` drops the inline height and returns overflow to visible: layout
// becomes instant, and it is reactive, so this switch flips it live.
const animated = ref(true);
</script>
Both are forced to display: block, so a span measures and animates exactly
like a div. The choice is only about what the surrounding markup accepts.
transition-duration is the length in milliseconds, 250 by default; the easing
comes from the --bb-ease token. disabled opts out entirely, and it is
reactive, so flip it per instance when a parent already animates height.
You do not need disabled for reduced motion. Under
prefers-reduced-motion: reduce the transition collapses in CSS, so the height
still updates and the layout stays correct.
Coming from v2tag takes only div and span
In v3 the list is exactly those two, and the inner measuring element mirrors the
tag instead of always being a <div>. If you passed tag="section" or
tag="ul", wrap that element around BbSmoothHeight instead.
When not to use it
Every animated frame re-runs layout for everything inside. That is nothing for a card. In four cases it is a real problem:
- Continuously resizing content: a textarea being typed into, streaming output, anything animating inside. The transition never stops.
- Large or expensive subtrees: a virtualized table, a long feed. Wrap a small
container near the change instead, and keep
subtree: trueoff a big tree. - Inside
BbCollapsibleorBbAccordion: they already animate height, and a second animation inside gives you double easing. - When the content must genuinely disappear: here it stays focusable and still announced. Use a collapsible.