Forms
Textarea
Multiline text field with a floating label
Preview
You can enter up to 200 characters
size
label
required
error
disabled
counter
counter.max
counter.threshold
Code
import css from "./Textarea.module.css";
import { useEffect, useId, useRef, useState } from "react";
type Size = "xs" | "sm" | "md" | "lg";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface TextareaCounter {
max: number;
threshold?: number;
remaining: (left: number) => string;
exceeded: (over: number) => string;
description: (max: number) => string;
}
interface TextareaProps {
name: string;
size: Size | ResponsiveSize;
label: string;
required?: boolean;
error?: boolean;
disabled?: boolean;
counter?: TextareaCounter;
value: string;
onChange: (value: string) => void;
}
const STATUS_DELAY = 1000;
function getSizeClasses(size: Size | ResponsiveSize): string {
if (typeof size === "string") {
return css[size];
}
const classes = [css[`${size.base}_base`]];
if (size.mobile) classes.push(css[`${size.mobile}_mobile`]);
if (size.tablet) classes.push(css[`${size.tablet}_tablet`]);
if (size.desktop) classes.push(css[`${size.desktop}_desktop`]);
if (size.wide) classes.push(css[`${size.wide}_wide`]);
return classes.join(" ");
}
export default function Textarea({
name,
size,
label,
required,
error,
disabled,
counter,
value,
onChange,
}: TextareaProps) {
const [focused, setFocused] = useState(false);
const [tabbed, setTabbed] = useState(false);
const pointer = useRef(false);
const fieldId = useId();
const active = focused || value.length > 0;
const [status, setStatus] = useState("");
const mounted = useRef(false);
const descriptionId = `${fieldId}-description`;
const count = value.length;
const left = counter ? counter.max - count : 0;
const over = left < 0;
const shown =
counter !== undefined &&
(!counter.threshold || count >= (counter.max * counter.threshold) / 100);
const text = counter ? (over ? counter.exceeded(-left) : counter.remaining(left)) : "";
const onFocus = () => {
setFocused(true);
setTabbed(!pointer.current);
};
const onBlur = () => {
setFocused(false);
setTabbed(false);
pointer.current = false;
};
const onPointerDown = () => {
pointer.current = true;
};
const handleChange = (ev: React.ChangeEvent<HTMLTextAreaElement>) =>
onChange(ev.target.value);
useEffect(() => {
if (!mounted.current) {
mounted.current = true;
return;
}
const timer = setTimeout(() => setStatus(shown ? text : ""), STATUS_DELAY);
return () => clearTimeout(timer);
}, [shown, text]);
return (
<div
className={`
${css.textarea}
${getSizeClasses(size)}
${active ? css.active : ""}
${tabbed ? css.tabbed : ""}
${error ? css.error : ""}
${disabled ? css.disabled : ""}
`}
onPointerDown={onPointerDown}
>
<div className={css.textarea_field}>
<textarea
id={fieldId}
name={name}
value={value}
required={required}
disabled={disabled}
aria-invalid={error}
aria-describedby={counter ? descriptionId : undefined}
onFocus={onFocus}
onBlur={onBlur}
onChange={handleChange}
></textarea>
<fieldset className={css.textarea_fieldset}>
<legend className={css.fieldset_legend}>{label}</legend>
</fieldset>
<label htmlFor={fieldId} className={css.textarea_label}>
{label}
{required && <span aria-hidden="true">*</span>}
</label>
</div>
{counter && (
<>
<p
className={`${css.textarea_counter} ${shown ? css.shown : ""} ${over ? css.over : ""}`}
aria-hidden="true"
>
{shown ? text : ""}
</p>
<p id={descriptionId} className={css.textarea_description}>
{counter.description(counter.max)}
</p>
<p className={css.textarea_status} aria-live="polite">
{status}
</p>
</>
)}
</div>
);
}
.textarea {
position: relative;
/* The field ladder, same as Input: the min-height steps every tier, the font plateaus at
--text-16. */
&.xs {
--textarea-height: var(--size-64);
font-size: var(--text-14);
}
&.sm {
--textarea-height: var(--size-88);
font-size: var(--text-14);
}
&.md {
--textarea-height: var(--size-112);
font-size: var(--text-16);
}
&.lg {
--textarea-height: var(--size-136);
font-size: var(--text-16);
}
&.xs_base {
--textarea-height: var(--size-64);
font-size: var(--text-14);
}
&.sm_base {
--textarea-height: var(--size-88);
font-size: var(--text-14);
}
&.md_base {
--textarea-height: var(--size-112);
font-size: var(--text-16);
}
&.lg_base {
--textarea-height: var(--size-136);
font-size: var(--text-16);
}
@media (min-width: 480px) {
&.xs_mobile {
--textarea-height: var(--size-64);
font-size: var(--text-14);
}
&.sm_mobile {
--textarea-height: var(--size-88);
font-size: var(--text-14);
}
&.md_mobile {
--textarea-height: var(--size-112);
font-size: var(--text-16);
}
&.lg_mobile {
--textarea-height: var(--size-136);
font-size: var(--text-16);
}
}
@media (min-width: 768px) {
&.xs_tablet {
--textarea-height: var(--size-64);
font-size: var(--text-14);
}
&.sm_tablet {
--textarea-height: var(--size-88);
font-size: var(--text-14);
}
&.md_tablet {
--textarea-height: var(--size-112);
font-size: var(--text-16);
}
&.lg_tablet {
--textarea-height: var(--size-136);
font-size: var(--text-16);
}
}
@media (min-width: 1024px) {
&.xs_desktop {
--textarea-height: var(--size-64);
font-size: var(--text-14);
}
&.sm_desktop {
--textarea-height: var(--size-88);
font-size: var(--text-14);
}
&.md_desktop {
--textarea-height: var(--size-112);
font-size: var(--text-16);
}
&.lg_desktop {
--textarea-height: var(--size-136);
font-size: var(--text-16);
}
}
@media (min-width: 1440px) {
&.xs_wide {
--textarea-height: var(--size-64);
font-size: var(--text-14);
}
&.sm_wide {
--textarea-height: var(--size-88);
font-size: var(--text-14);
}
&.md_wide {
--textarea-height: var(--size-112);
font-size: var(--text-16);
}
&.lg_wide {
--textarea-height: var(--size-136);
font-size: var(--text-16);
}
}
&:has(textarea:focus-visible) .textarea_fieldset {
border-color: var(--color-contour-accent);
}
/* On text fields :focus-visible also fires on click, so the Tab signal needs its
own class. The repeated :has() outranks .active, and .error still wins. */
&.tabbed:has(textarea:focus-visible) .textarea_fieldset {
border-width: 2px;
border-color: var(--color-accent);
}
.textarea_field {
position: relative;
min-height: var(--textarea-height);
textarea {
width: 99%;
min-height: inherit;
padding: 0 var(--space-12) var(--space-12);
background: none;
border: 0;
border-top: var(--space-12) solid transparent;
border-radius: var(--radius-8);
outline: none;
font-family: var(--font-body);
font-size: inherit;
font-weight: var(--weight-500);
color: var(--color-text);
resize: vertical;
/* iOS zooms in on a focused field under 16px; a phone's short side is under 600, a tablet's
744+. */
@media (pointer: coarse) and (max-width: 599px),
(pointer: coarse) and (max-height: 599px) {
font-size: max(1em, 16px);
}
}
.textarea_fieldset {
position: absolute;
inset: 0;
border: var(--border-surface) solid var(--color-text);
border-radius: var(--radius-8);
padding: 0;
margin: 0;
pointer-events: none;
.fieldset_legend {
width: 0;
height: 0;
overflow: hidden;
padding: 0;
margin-left: var(--space-8);
/* The notch mirrors the floated label: 0.875em (inherit × its scale) and
weight 600, so it fits at every size. */
font-size: 0.875em;
font-weight: var(--weight-600);
visibility: hidden;
transition: width 0.2s ease;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
}
.textarea_label {
position: absolute;
top: var(--space-12);
left: var(--space-12);
transform-origin: left center;
font-family: var(--font-body);
font-size: inherit;
font-weight: var(--weight-500);
color: var(--color-text);
pointer-events: none;
/* The float shrinks with transform, not font-size: smooth, and instant when the size changes
at rest. */
transition:
top 0.2s ease,
left 0.2s ease,
transform 0.2s ease;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
}
.textarea_counter {
font-family: var(--font-body);
font-size: var(--text-12);
font-weight: var(--weight-500);
line-height: var(--leading-normal);
font-variant-numeric: tabular-nums;
text-align: end;
color: var(--color-text-secondary);
opacity: 0;
transition: opacity 0.2s ease;
&.shown {
margin-top: var(--space-4);
opacity: 1;
}
&.over {
color: var(--color-error);
}
}
.textarea_description,
.textarea_status {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
&.active {
.textarea_fieldset {
border-color: var(--color-contour-accent);
.fieldset_legend {
width: auto;
padding: 0 var(--space-12);
}
}
.textarea_label {
top: 0;
left: var(--space-20);
transform: translateY(-65%) scale(0.875);
font-weight: var(--weight-600);
}
}
&.error {
.textarea_fieldset {
border-color: var(--color-error);
}
/* Red wins even when focused: it overrides the accent from :focus-visible */
&.active .textarea_fieldset,
&:has(textarea:focus-visible) .textarea_fieldset {
border-color: color-mix(in srgb, var(--color-error) 99%, transparent);
}
}
&.disabled {
opacity: 0.5;
pointer-events: none;
}
}
<script setup lang="ts">
import { computed, onUnmounted, ref, useId, watch } from "vue";
type Size = "xs" | "sm" | "md" | "lg";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface TextareaCounter {
max: number;
threshold?: number;
remaining: (left: number) => string;
exceeded: (over: number) => string;
description: (max: number) => string;
}
interface TextareaProps {
name: string;
size: Size | ResponsiveSize;
label: string;
required?: boolean;
error?: boolean;
disabled?: boolean;
counter?: TextareaCounter;
}
const STATUS_DELAY = 1000;
const { size, counter } = defineProps<TextareaProps>();
const model = defineModel<string>({ required: true });
const focused = ref(false);
const tabbed = ref(false);
let pointer = false;
const fieldId = useId();
const active = computed(() => focused.value || model.value.length > 0);
const status = ref("");
let statusTimer = 0;
const descriptionId = `${fieldId}-description`;
const count = computed(() => model.value.length);
const left = computed(() => (counter ? counter.max - count.value : 0));
const over = computed(() => left.value < 0);
const shown = computed(
() =>
counter !== undefined &&
(!counter.threshold || count.value >= (counter.max * counter.threshold) / 100),
);
const text = computed(() =>
counter ? (over.value ? counter.exceeded(-left.value) : counter.remaining(left.value)) : "",
);
const sizeClasses = computed(() => {
if (typeof size === "string") {
return size;
}
const classes = [`${size.base}_base`];
if (size.mobile) classes.push(`${size.mobile}_mobile`);
if (size.tablet) classes.push(`${size.tablet}_tablet`);
if (size.desktop) classes.push(`${size.desktop}_desktop`);
if (size.wide) classes.push(`${size.wide}_wide`);
return classes.join(" ");
});
const onFocus = () => {
focused.value = true;
tabbed.value = !pointer;
};
const onBlur = () => {
focused.value = false;
tabbed.value = false;
pointer = false;
};
const onPointerDown = () => {
pointer = true;
};
watch([shown, text], () => {
clearTimeout(statusTimer);
statusTimer = window.setTimeout(() => {
status.value = shown.value ? text.value : "";
}, STATUS_DELAY);
});
onUnmounted(() => clearTimeout(statusTimer));
</script>
<template>
<div
class="textarea"
:class="[sizeClasses, { active, error, disabled, tabbed }]"
@pointerdown="onPointerDown"
>
<div class="textarea_field">
<textarea
:id="fieldId"
v-model="model"
:name="name"
:required="required"
:disabled="disabled"
:aria-invalid="error"
:aria-describedby="counter ? descriptionId : undefined"
@focus="onFocus"
@blur="onBlur"
></textarea>
<fieldset class="textarea_fieldset">
<legend class="fieldset_legend">{{ label }}</legend>
</fieldset>
<label :for="fieldId" class="textarea_label">
{{ label }}<span v-if="required" aria-hidden="true">*</span>
</label>
</div>
<template v-if="counter">
<p class="textarea_counter" :class="{ shown, over }" aria-hidden="true">{{ shown ? text : "" }}</p>
<p :id="descriptionId" class="textarea_description">{{ counter.description(counter.max) }}</p>
<p class="textarea_status" aria-live="polite">{{ status }}</p>
</template>
</div>
</template>
<style scoped>
.textarea {
position: relative;
/* The field ladder, same as Input: the min-height steps every tier, the font plateaus at
--text-16. */
&.xs {
--textarea-height: var(--size-64);
font-size: var(--text-14);
}
&.sm {
--textarea-height: var(--size-88);
font-size: var(--text-14);
}
&.md {
--textarea-height: var(--size-112);
font-size: var(--text-16);
}
&.lg {
--textarea-height: var(--size-136);
font-size: var(--text-16);
}
&.xs_base {
--textarea-height: var(--size-64);
font-size: var(--text-14);
}
&.sm_base {
--textarea-height: var(--size-88);
font-size: var(--text-14);
}
&.md_base {
--textarea-height: var(--size-112);
font-size: var(--text-16);
}
&.lg_base {
--textarea-height: var(--size-136);
font-size: var(--text-16);
}
@media (min-width: 480px) {
&.xs_mobile {
--textarea-height: var(--size-64);
font-size: var(--text-14);
}
&.sm_mobile {
--textarea-height: var(--size-88);
font-size: var(--text-14);
}
&.md_mobile {
--textarea-height: var(--size-112);
font-size: var(--text-16);
}
&.lg_mobile {
--textarea-height: var(--size-136);
font-size: var(--text-16);
}
}
@media (min-width: 768px) {
&.xs_tablet {
--textarea-height: var(--size-64);
font-size: var(--text-14);
}
&.sm_tablet {
--textarea-height: var(--size-88);
font-size: var(--text-14);
}
&.md_tablet {
--textarea-height: var(--size-112);
font-size: var(--text-16);
}
&.lg_tablet {
--textarea-height: var(--size-136);
font-size: var(--text-16);
}
}
@media (min-width: 1024px) {
&.xs_desktop {
--textarea-height: var(--size-64);
font-size: var(--text-14);
}
&.sm_desktop {
--textarea-height: var(--size-88);
font-size: var(--text-14);
}
&.md_desktop {
--textarea-height: var(--size-112);
font-size: var(--text-16);
}
&.lg_desktop {
--textarea-height: var(--size-136);
font-size: var(--text-16);
}
}
@media (min-width: 1440px) {
&.xs_wide {
--textarea-height: var(--size-64);
font-size: var(--text-14);
}
&.sm_wide {
--textarea-height: var(--size-88);
font-size: var(--text-14);
}
&.md_wide {
--textarea-height: var(--size-112);
font-size: var(--text-16);
}
&.lg_wide {
--textarea-height: var(--size-136);
font-size: var(--text-16);
}
}
&:has(textarea:focus-visible) .textarea_fieldset {
border-color: var(--color-contour-accent);
}
/* On text fields :focus-visible also fires on click, so the Tab signal needs its
own class. The repeated :has() outranks .active, and .error still wins. */
&.tabbed:has(textarea:focus-visible) .textarea_fieldset {
border-width: 2px;
border-color: var(--color-accent);
}
.textarea_field {
position: relative;
min-height: var(--textarea-height);
textarea {
width: 99%;
min-height: inherit;
padding: 0 var(--space-12) var(--space-12);
background: none;
border: 0;
border-top: var(--space-12) solid transparent;
border-radius: var(--radius-8);
outline: none;
font-family: var(--font-body);
font-size: inherit;
font-weight: var(--weight-500);
color: var(--color-text);
resize: vertical;
/* iOS zooms in on a focused field under 16px; a phone's short side is under 600, a tablet's
744+. */
@media (pointer: coarse) and (max-width: 599px),
(pointer: coarse) and (max-height: 599px) {
font-size: max(1em, 16px);
}
}
.textarea_fieldset {
position: absolute;
inset: 0;
border: var(--border-surface) solid var(--color-text);
border-radius: var(--radius-8);
padding: 0;
margin: 0;
pointer-events: none;
.fieldset_legend {
width: 0;
height: 0;
overflow: hidden;
padding: 0;
margin-left: var(--space-8);
/* The notch mirrors the floated label: 0.875em (inherit × its scale) and
weight 600, so it fits at every size. */
font-size: 0.875em;
font-weight: var(--weight-600);
visibility: hidden;
transition: width 0.2s ease;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
}
.textarea_label {
position: absolute;
top: var(--space-12);
left: var(--space-12);
transform-origin: left center;
font-family: var(--font-body);
font-size: inherit;
font-weight: var(--weight-500);
color: var(--color-text);
pointer-events: none;
/* The float shrinks with transform, not font-size: smooth, and instant when the size changes
at rest. */
transition:
top 0.2s ease,
left 0.2s ease,
transform 0.2s ease;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
}
.textarea_counter {
font-family: var(--font-body);
font-size: var(--text-12);
font-weight: var(--weight-500);
line-height: var(--leading-normal);
font-variant-numeric: tabular-nums;
text-align: end;
color: var(--color-text-secondary);
opacity: 0;
transition: opacity 0.2s ease;
&.shown {
margin-top: var(--space-4);
opacity: 1;
}
&.over {
color: var(--color-error);
}
}
.textarea_description,
.textarea_status {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
&.active {
.textarea_fieldset {
border-color: var(--color-contour-accent);
.fieldset_legend {
width: auto;
padding: 0 var(--space-12);
}
}
.textarea_label {
top: 0;
left: var(--space-20);
transform: translateY(-65%) scale(0.875);
font-weight: var(--weight-600);
}
}
&.error {
.textarea_fieldset {
border-color: var(--color-error);
}
/* Red wins even when focused: it overrides the accent from :focus-visible */
&.active .textarea_fieldset,
&:has(textarea:focus-visible) .textarea_fieldset {
border-color: color-mix(in srgb, var(--color-error) 99%, transparent);
}
}
&.disabled {
opacity: 0.5;
pointer-events: none;
}
}
</style>
Props
| Prop | Type | Required | Description |
|---|---|---|---|
| name | string | required | Field name, for forms and browser autofill. |
| size | "xs" | "sm" | "md" | "lg" | ResponsiveSize | required | Minimum field height and its text size. The user can stretch it vertically. Fixed or responsive. |
| label | string | required | Floating label that rises on focus or when filled. |
| required | boolean | — | Marks the field as required (adds * to the label and the native required). |
| error | boolean | — | Visual error state (red border, aria-invalid). |
| disabled | boolean | — | Disables the field (not editable, reduced opacity). |
| counter | { max: number; threshold?: number; remaining: (left) => string; exceeded: (over) => string; description: (max) => string } | — | Turns the counter on whole or not at all: the limit, the percentage it shows from (without it, always), the phrases for what is left and what is over, and the one that tells screen readers the limit. remaining gets what is left, exceeded what is over and description the limit; each returns the whole phrase, singular and plural as the caller writes them. Typing past the limit is allowed and turns the phrase red; the red border is the form's call, through the error prop. |
| value | string | required | Field value (controlled). |
| onChange | (value: string) => void | required | Callback with the new value on every change. |