Use it for
Reach for BbTextarea when the text runs to more than one line: a comment, a
bio, release notes, a support message, a postal address.
Use something else when
BbTextInput, when it is one short stringBbNumberInput, when it is a numberBbDatePickerInput, when it is a dateBbSelect, 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.
Ask whether someone will ever want a second line, not whether the value happens to be long today.
BbTextarea is BbTextInput with a taller box, and it shares the whole input
chrome. That vocabulary is documented once, on
BbTextInput. This page covers what is different:
height, the Enter constraint, and counters.
Default
A label and a v-model, exactly as on a text input.
<template>
<div class="max-w-sm">
<BbTextarea
id="release-notes"
v-model="notes"
label="Release notes"
name="release-notes"
placeholder="What changed in this release?"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbTextarea } from 'bitboss-ui';
// Same contract as BbTextInput: `string | null`, and `null` once emptied.
const notes = ref<string | null>(
'Adds keyboard navigation to the command palette and fixes two focus traps.'
);
</script>
Same model contract too: string | null, and null once the field is emptied,
never ''. label is display and accessibility only, so pass name whenever
the value is submitted natively or read from FormData, and id to keep
prerendered markup stable.
There is no type, no mask and no input-mode here. Multi-line text is text.
Rows and auto-grow
This is the one decision the component adds, and it has three answers.
<template>
<div class="flex max-w-sm flex-col gap-4">
<!-- rows alone: fixed height, native resize handle, content scrolls. -->
<BbTextarea
id="sizing-fixed"
v-model="fixed"
label="Summary (fixed at 3 rows)"
name="summary"
:rows="3"
/>
<!-- auto-grow alone: no handle, height follows the content from one line. -->
<BbTextarea
id="sizing-grow"
v-model="grow"
auto-grow
label="Reply (grows as you type)"
name="reply"
placeholder="Type a few lines…"
/>
<!-- Both: rows is the floor an empty field never falls below. -->
<BbTextarea
id="sizing-floor"
v-model="floor"
auto-grow
label="Description (grows, never below 3 rows)"
name="description"
placeholder="Starts at three rows…"
:rows="3"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbTextarea } from 'bitboss-ui';
const fixed = ref<string | null>(
'Rolled the 2.4 release, migrated the search index, and closed twelve issues. The palette rewrite lands next week.'
);
const grow = ref<string | null>('Looks good to me — shipping it.');
const floor = ref<string | null>(null);
</script>
rows on its own fixes the visible height and leaves the native resize handle
in place: content scrolls, and the user can drag the box taller. Choose it when
a stable form layout matters more than seeing every line at once.
auto-grow on its own drops the handle and grows the field to fit its content,
animated through BbSmoothHeight. Choose it when people should see everything
they wrote: a composer, a review, a message.
Together, rows becomes a floor. The field starts at that height, grows past it
as the content demands, and never shrinks below it. Use it instead of a CSS
min-height, which fights the growth calculation.
The trade-off: auto-grow moves everything below the field while someone types.
In a long form that is a page that will not sit still, so keep it for the field
people actually write in.
The shared field chrome
Everything that surrounds the box is the input family's, unchanged.
<template>
<div class="flex max-w-sm flex-col gap-4">
<!-- The same four channels a BbTextInput has, in the same order. -->
<BbTextarea
id="chrome-bio"
v-model="bio"
description="Shown on your public profile, under your name."
:errors="bioErrors"
hint="Two or three sentences is plenty."
label="Bio"
name="bio"
persistent-hint
:rows="3"
/>
<BbTextarea
id="chrome-address"
v-model="address"
label="Shipping address"
name="shipping-address"
:rows="3"
:warnings="addressWarnings"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbTextarea } from 'bitboss-ui';
const LIMIT = 160;
const bio = ref<string | null>(
'Design lead at Vantera. Previously typography and editorial tooling. I care about forms that tell you what went wrong before you press submit, and about keyboards.'
);
const address = ref<string | null>('Via Larga 12\nMilano');
const bioErrors = computed(() =>
(bio.value?.length ?? 0) > LIMIT
? [`Keep the bio under ${LIMIT} characters.`]
: []
);
// Valid, deliverable, and still worth flagging — amber, not red.
const addressWarnings = computed(() =>
(address.value ?? '').split('\n').filter(Boolean).length < 3
? ['No postal code yet — couriers may reject this address.']
: []
);
</script>
description sits above the field and is read before typing. hint appears
below it on focus, and persistent-hint keeps it there. errors and warnings
are the red and the amber channel, both wired to the <textarea> through
aria-describedby. Errors and
warnings has the full account.
clearable, loading, disabled and readonly behave as they do on a text
input, down to loading being a status light rather than a lock. See
Clearable, loading, disabled and
readonly. readonly is the more useful of
the last two here: a long block of terms or generated text is something people
need to select and copy.
label-mode works too. floating and inside place the label in the field,
which on a tall box is a stronger effect than on a single-line one. Pick the
mode for the product rather than the field, through defaultInputLabelMode in
the plugin config.
Enter does not reach your form
In a textarea Enter means "new line", and the component makes sure of it. It
calls stopPropagation() on keydown, so no ancestor ever sees the key.
Amara Okonkwo · 2 days ago
Can we ship this behind the beta-editor flag first?
Lukas Brandt · yesterday
Flag added — ready for another look.
<template>
<div class="flex max-w-md flex-col gap-3">
<article
v-for="comment in comments"
:key="comment.id"
class="flex flex-col gap-0.5 border-b pb-2 text-sm"
>
<p class="opacity-70">{{ comment.author }} · {{ comment.postedAt }}</p>
<p>{{ comment.body }}</p>
</article>
<BbTextarea
id="composer-body"
v-model="draft"
auto-grow
hide-label
label="Add a comment"
name="comment"
placeholder="Leave a comment…"
:rows="3"
/>
<!--
The action is a button, not Enter: the textarea stops keydown from
bubbling, so no ancestor handler — form, dialog or list — ever sees it.
-->
<div class="flex justify-end">
<BbButton :disabled="!draft" variant="primary" @click="post">
Comment
</BbButton>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbTextarea } from 'bitboss-ui';
import { delay, userById } from '~/demo-data';
interface PrComment {
id: number;
author: string;
postedAt: string;
body: string;
}
const reviewer = userById[4]!;
const author = userById[2]!;
const comments = ref<PrComment[]>([
{
id: 1,
author: reviewer.fullName,
postedAt: '2 days ago',
body: 'Can we ship this behind the beta-editor flag first?',
},
{
id: 2,
author: author.fullName,
postedAt: 'yesterday',
body: 'Flag added — ready for another look.',
},
]);
const draft = ref<string | null>(null);
// Async handler, so BbButton shows its pending state until the promise settles.
async function post() {
const body = draft.value;
if (!body) return;
await delay(null, 600);
comments.value.push({
id: comments.value.length + 1,
author: 'You',
postedAt: 'just now',
body,
});
draft.value = null;
}
</script>
That is a wider consequence than it looks. A ⌘+Enter send shortcut bound on the form, a list with arrow-key navigation, a wrapper that closes on Escape: none of them fire while focus is in this field, and nothing warns you. Wire the action to a button instead, which is the better interaction anyway.
input is stopped the same way, so an ancestor listening for input events
across a whole form will not hear this field either. The component's own
keydown and input events still fire. Bind to those, or watch the model.
Because the button owns the action, an async click handler gets BbButton's
loading state for free while the comment saves.
Character counters
A counter belongs in the suffix slot, beside the value rather than below it,
where it would compete with the hint.
<template>
<div class="max-w-sm">
<BbTextarea
id="counter-message"
v-model="message"
auto-grow
:errors="errors"
hint="The more detail you give, the faster we can help."
label="How can we help?"
name="message"
persistent-hint
placeholder="Describe what happened…"
:rows="3"
>
<!--
The suffix is a readout, not a limit: the field accepts more. The
cap reaches assistive tech through `errors`.
-->
<template #suffix>{{ used }}/{{ LIMIT }}</template>
</BbTextarea>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbTextarea } from 'bitboss-ui';
const LIMIT = 280;
const message = ref<string | null>(null);
const used = computed(() => message.value?.length ?? 0);
const errors = computed(() =>
used.value > LIMIT
? [`Keep it under ${LIMIT} characters — you are at ${used.value}.`]
: []
);
</script>
A counter is visual only. The textarea enforces nothing, and someone using a
screen reader does not see a number ticking down. When the cap is a hard rule,
surface it through errors as well, as the demo does.
Do not reach for a maxlength attribute to enforce it. It is not a prop of this
component, so it lands on the outer container and constrains nothing. Truncating
what someone typed in silence is worse than telling them anyway.
The rest of the affix ring is the same as on a text input, including the append position's priority order. Icons and affixes covers it.
Density and layout
compact reduces the control height, per view rather than per field.
direction puts the label beside the box instead of above it:
direction="horizontal" for a 50/50 split, two space-separated tokens for a
ratio, reverse to swap the columns. A textarea is a good candidate. The label
beside the field keeps a settings page scannable when one row is four lines
tall.
<template>
<div class="flex max-w-md flex-col gap-3">
<!-- Label beside the field: a settings row, not a form field stack. -->
<BbTextarea
id="layout-summary"
v-model="summary"
compact
direction="horizontal"
label="Summary"
name="summary"
:rows="2"
/>
<BbTextarea
id="layout-changelog"
v-model="changelog"
compact
direction="xx xxxxxx"
label="Changelog"
name="changelog"
:rows="3"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbTextarea } from 'bitboss-ui';
const summary = ref<string | null>(
'Adds keyboard navigation to the command palette.'
);
const changelog = ref<string | null>(
'- Fix the focus trap in the palette\n- Add ⌘K to open it\n- Restore scroll position on close'
);
</script>
As everywhere in the family, direction applies only while the resolved label
mode is outside. floating and inside embed the label in the field and
force the vertical layout, so direction is ignored without a warning. Check
defaultInputLabelMode before assuming the prop is broken.