Forms
Textarea
Campo de texto multilínea con label flotante
Preview
Puedes escribir hasta 200 caracteres
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 | Nombre del campo, para formularios y autofill del navegador. |
| size | "xs" | "sm" | "md" | "lg" | ResponsiveSize | required | Altura mínima del campo y tamaño de su texto. El usuario puede estirarlo en vertical. Fijo o responsive. |
| label | string | required | Etiqueta flotante que sube al enfocar o con contenido. |
| required | boolean | — | Marca el campo como obligatorio (añade * al label y el required nativo). |
| error | boolean | — | Estado de error visual (borde rojo, aria-invalid). |
| disabled | boolean | — | Desactiva el campo (no editable, opacidad reducida). |
| counter | { max: number; threshold?: number; remaining: (left) => string; exceeded: (over) => string; description: (max) => string } | — | Activa el contador entero o nada: el tope, el porcentaje desde el que se ve (sin él, siempre), las frases de lo que queda y lo que sobra y la que dice el tope al lector de pantalla. remaining recibe lo que queda, exceeded lo que sobra y description el tope, y cada una devuelve la frase completa, con el singular y el plural que decida quien la escribe. Deja escribir por encima del tope y pinta la frase en rojo; el borde rojo lo decide el formulario con la prop error. |
| value | string | required | Valor del campo (controlado). |
| onChange | (value: string) => void | required | Callback con el nuevo valor en cada cambio. |