Área de texto con anillo contador
Textarea que crece con el contenido y muestra caracteres restantes con un anillo que pasa a ámbar y a rojo.
280 caracteres disponibles
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
import { useId, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
/** Área de texto que crece con el contenido y muestra el límite con un anillo que cambia de color. Estilo material oscuro. React + TypeScript, sin dependencias. */
export type AreaTextoContadorProps = {
label?: string;
placeholder?: string;
maxLength?: number;
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
minRows?: number;
maxRows?: number;
className?: string;
style?: CSSProperties;
};
const CSS = `
.gcatc-root { box-sizing: border-box; display: block; width: 100%; max-width: 420px; padding: 16px 18px 14px; border-radius: 18px; background: #1b1c22; color: #ececf1; font: 400 15px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; box-shadow: inset 0 0 0 1px #2c2e37; transition: box-shadow .2s; }
.gcatc-root:focus-within { box-shadow: inset 0 0 0 1.5px #7b8cff, 0 0 0 5px rgba(123, 140, 255, .16); }
.gcatc-root *, .gcatc-root *::before, .gcatc-root *::after { box-sizing: border-box; }
.gcatc-root textarea { font: inherit; }
@media (prefers-reduced-motion: reduce) { .gcatc-root *, .gcatc-root *::before, .gcatc-root *::after { transition-duration: .01ms !important; } }
.gcatc-label { display: block; margin-bottom: 6px; font-size: 12px; font-weight: 600; letter-spacing: .04em; color: #9da0b0; text-transform: uppercase; }
.gcatc-area { display: block; width: 100%; resize: none; border: 0; padding: 0; background: transparent; color: inherit; outline: 0; overflow-y: auto; }
.gcatc-area::placeholder { color: #6c6f80; }
.gcatc-area::selection { background: #7b8cff; color: #0d0e12; }
.gcatc-foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 10px; font-size: 12px; color: #9da0b0; font-variant-numeric: tabular-nums; }
.gcatc-ring { width: 24px; height: 24px; transform: rotate(-90deg); }
.gcatc-ring circle { fill: none; stroke-width: 3; }
.gcatc-ring .t { stroke: #2c2e37; }
.gcatc-ring .v { stroke: #7b8cff; stroke-linecap: round; transition: stroke-dashoffset .2s, stroke .2s; }
.gcatc-root[data-state="warn"] .gcatc-ring .v { stroke: #f5b44a; }
.gcatc-root[data-state="over"] .gcatc-ring .v { stroke: #ff6b6b; }
.gcatc-root[data-state="over"] .gcatc-count { color: #ff8a8a; }`;
export function AreaTextoContador({ label = "Cuéntanos tu proyecto", placeholder = "Qué haces hoy a mano y qué te gustaría que hiciera solo el sistema…", maxLength = 280, value, defaultValue = "", onChange, minRows = 3, maxRows = 8, className = "", style }: AreaTextoContadorProps) {
const id = useId();
const ref = useRef<HTMLTextAreaElement>(null);
const [inner, setInner] = useState(defaultValue);
const text = value ?? inner;
const length = text.length;
const ratio = length / maxLength;
const state = ratio > 1 ? "over" : ratio > 0.85 ? "warn" : "ok";
const remaining = maxLength - length;
const circumference = 2 * Math.PI * 9;
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const line = parseFloat(getComputedStyle(el).lineHeight) || 23;
el.style.height = "auto";
el.style.height = `${Math.min(Math.max(el.scrollHeight, line * minRows), line * maxRows)}px`;
}, [text, minRows, maxRows]);
return (
<div className={`gcatc-root ${className}`} style={style} data-state={state}>
<style>{CSS}</style>
<label className="gcatc-label" htmlFor={id}>
{label}
</label>
<textarea
id={id}
ref={ref}
className="gcatc-area"
rows={minRows}
placeholder={placeholder}
value={text}
aria-describedby={`${id}-c`}
onChange={(event) => {
setInner(event.target.value);
onChange?.(event.target.value);
}}
/>
<div className="gcatc-foot">
<span id={`${id}-c`} className="gcatc-count" aria-live="polite">
{remaining >= 0 ? `${remaining} caracteres disponibles` : `${-remaining} caracteres de más`}
</span>
<svg className="gcatc-ring" viewBox="0 0 24 24" aria-hidden="true">
<circle className="t" cx="12" cy="12" r="9" />
<circle className="v" cx="12" cy="12" r="9" strokeDasharray={circumference} strokeDashoffset={circumference * (1 - Math.min(1, ratio))} />
</svg>
</div>
</div>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/area-texto-contadorEjemplo de uso de Área de texto con anillo contador
Después de copiar area-texto-contador.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { AreaTextoContador } from "./area-texto-contador";
export default function Ejemplo() {
return <AreaTextoContador label="Ejemplo" />;
}Propiedades de AreaTextoContador
AreaTextoContador acepta 10 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
label? | string | Opcional. |
placeholder? | string | Opcional. |
maxLength? | number | Opcional. |
value? | string | Opcional. |
defaultValue? | string | Opcional. |
onChange? | (value: string) => void | Opcional. |
minRows? | number | Opcional. |
maxRows? | number | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Accesibilidad y rendimiento
El archivo tiene 88 líneas (4 KB) y no necesita @keyframes. Esto es lo que hace Área de texto con anillo contador por quien lo usa:
- Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
- Expone su estado a tecnologías de apoyo con atributos aria-*.
- Es un componente de cliente de Next.js: incluye la directiva "use client".