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
Also see: Validated forms.
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, Enter, and counters.
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
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
('' if you set the plugin option
formControls.textareaNullValue to 'string'). 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 and no mask here. input-mode still reaches the
<textarea> if you need a specific mobile keyboard.
Rows and auto-grow
Pick rows for a fixed box, auto-grow to fit the text, or both for a growing
box with a floor.
<template>
<div class="flex max-w-sm flex-col gap-4">
<!-- rows alone: fixed height, no resize handle, content scrolls. -->
<BbTextarea
id="sizing-fixed"
v-model="fixed"
label="Summary (fixed height)"
name="summary"
:rows="3"
/>
<!-- auto-grow alone: no handle; one text-input tall until the content needs more. -->
<BbTextarea
id="sizing-grow"
v-model="grow"
auto-grow
label="Reply (grows as you type)"
name="reply"
placeholder="Type a few lines…"
/>
<!-- Both: rows sets the starting height, and the field grows from there. -->
<BbTextarea
id="sizing-floor"
v-model="floor"
auto-grow
label="Description (grows from a 3-row start)"
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: the box is that many lines tall and
the content scrolls. There is no resize handle. 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. Empty, it is exactly one text input tall, so
it sits flush in a row of text inputs. 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. With neither prop, the box is
a fixed 150px.
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
Description, hint, errors and warnings work as on BbTextInput.
<template>
<div class="flex max-w-sm flex-col gap-4">
<!--
The same four channels a BbTextInput has, in the same order. `rows` matches
the seeded bio, so the box is the right height before auto-grow measures.
-->
<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"
auto-grow
persistent-hint
:rows="4"
/>
<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,
including rules,
formless and
validation-label.
A field joined to a BbForm by its name is required through
rules="required" or the schema, not the
required prop, which is for a
field outside a form.
clearable, loading, disabled and readonly behave as they do on a
text input. readonly is the useful one
here: a long block of terms or generated text is something people select and
copy.
variant (including the blank none), compact and
label-mode work as in Variants, density and
layout. A floating label on a tall box
is a stronger effect than on one line, so pick the mode for the product, through
defaultInputLabelMode.
Enter is a new line
In a textarea Enter means "new line", and the component makes sure of it: a plain Enter stops at the field, so a form never submits on it.
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 gap-3 border-b border-[color:var(--bb-border)] pb-3 text-sm"
>
<BbAvatar :alt="comment.author" size="md" :src="comment.avatar" />
<div class="flex flex-col gap-0.5">
<p class="m-0 opacity-70">
<strong class="font-medium">{{ comment.author }}</strong> ·
{{ comment.postedAt }}
</p>
<p class="m-0 whitespace-pre-line">{{ comment.body }}</p>
</div>
</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: a plain Enter is a new line, and the textarea
stops it from bubbling. ⌘/Ctrl+Enter still reaches a form shortcut.
-->
<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 { BbAvatar, BbButton, BbTextarea } from 'bitboss-ui';
import { delay, initialsAvatar, userById } from '~/demo-data';
interface PrComment {
id: number;
author: string;
avatar: string;
postedAt: string;
body: string;
}
const reviewer = userById[4]!;
const author = userById[2]!;
const comments = ref<PrComment[]>([
{
id: 1,
author: reviewer.fullName,
avatar: reviewer.avatar,
postedAt: '2 days ago',
body: 'Can we ship this behind the beta-editor flag first?',
},
{
id: 2,
author: author.fullName,
avatar: author.avatar,
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',
avatar: initialsAvatar('YO'),
postedAt: 'just now',
body,
});
draft.value = null;
}
</script>
Only a plain Enter is stopped. Every other key bubbles as usual. A
⌘/Ctrl+Enter send shortcut or a ⌘+S bound on the form fires while focus is in
this field, and so does a wrapper that closes on Escape. input events bubble
too. Still give the action a visible button. The component's own
keydown and input events fire as well.
Character counters
Add counter and the field counts
its characters at the end of the hint row: 42, or 42 / 160 when you also pass
the native maxlength. That attribute reaches the <textarea>, which stops
input and cuts a paste at the cap, and the counter reads the same number.
<template>
<div class="max-w-sm">
<!--
`maxlength` reaches the native <textarea>, which stops input at the cap.
`counter` shows the same number as `used / limit`.
-->
<BbTextarea
id="counter-sms"
v-model="message"
auto-grow
counter
hint="Sent the day before the appointment."
label="SMS reminder"
:maxlength="160"
name="sms-reminder"
persistent-hint
:rows="4"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbTextarea } from 'bitboss-ui';
// 160 characters is one SMS segment. Past it, carriers split and bill twice.
const message = ref<string | null>(
'Hi Sofia, this is a reminder of your appointment at Studio Ottica tomorrow, 12 March, at 09:30. Reply C to confirm or R to reschedule.'
);
</script>
The count turns to body text from 90% of the limit and to the warning colour at it. A full field is not an error, so the box does not change. A screen reader hears the count as part of the field's description, and a polite live region announces it only near and at the limit, never on every keystroke.
For a soft limit, keep counter and leave maxlength off: nothing stops the
typing, and the overflow goes in errors. The text
stays, and the field says what to cut.
The rest of the affix ring is the same as on a text input, including the priority order of the append icon position. Icons and affixes covers it.
Density and layout
layout puts the label beside the box instead of above it:
layout="horizontal" for a 50/50 split, two space-separated tokens for a
ratio, reverse to swap the columns. The label beside the field keeps a
panel scannable when one row is four lines tall. Use the same ratio on every
row so the boxes line up. Keep it to a wide container: on a phone the box gets
too narrow, so the demo passes a per-breakpoint map,
{ default: 'vertical', md: 'xx xxxxx' }, and stacks below md.
Release 2.4
<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">Release 2.4</h3>
<!--
The same ratio on every row keeps the boxes aligned down the panel. A
side-by-side row never wraps, so a per-breakpoint map stacks it below `md`.
-->
<BbTextarea
id="layout-summary"
v-model="summary"
auto-grow
compact
label="Summary"
:layout="rowLayout"
name="summary"
/>
<BbTextarea
id="layout-changelog"
v-model="changelog"
auto-grow
compact
label="Changelog"
:layout="rowLayout"
name="changelog"
:rows="3"
/>
<BbTextarea
id="layout-upgrade"
v-model="upgrade"
auto-grow
compact
label="Upgrade notes"
:layout="rowLayout"
name="upgrade-notes"
placeholder="Anything people must do before updating?"
/>
</section>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbTextarea } from 'bitboss-ui';
const rowLayout = { default: 'vertical', md: 'xx xxxxx' } as const;
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'
);
const upgrade = ref<string | null>(null);
</script>
compact tightens each row, as in the panel above. Set it per view, not per field.
As everywhere in the family, layout applies only while the resolved label
mode is outside. floating and inside embed the label in the field and
always stack vertically. The resolved mode is label-mode, or
defaultInputLabelMode when the prop is unset.