Texto tinta fresca
Texto que se escribe con ritmo humano; cada letra nace con color y se asienta al color del texto.
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
/**
* Texto que se escribe como tinta fresca: cada letra nace con un color vivo y se asienta
* al color del texto en menos de un segundo, de modo que el final de la frase siempre
* brilla un poco más que el principio. Ritmo humano (pausas en comas y puntos), cursor
* fijo mientras escribe. Lector de pantalla: recibe la frase completa desde el inicio.
* El texto toma el color de su contenedor; la tinta se cambia con --gcti-fresh.
*
* <TextoTinta text="La operación, bajo control." speed={48} />
*/
"use client";
import { useEffect, useState, type CSSProperties } from "react";
export type TextoTintaProps = {
text?: string;
/** Milisegundos por letra. */
speed?: number;
/** Espera antes de empezar, en milisegundos. */
delay?: number;
/** Color con el que nace cada letra. */
fresh?: string;
className?: string;
style?: CSSProperties;
};
const CSS = `
.gcti-root { display: inline-block; max-width: 100%; font: 600 clamp(28px, 4.4vw, 58px)/1.12 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; letter-spacing: -.035em; overflow-wrap: anywhere; text-wrap: balance; }
.gcti-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.gcti-ch { animation: gcti-dry .9s cubic-bezier(.16, 1, .3, 1) both; }
.gcti-caret { display: inline-block; width: .075em; height: .95em; margin-left: .06em; background: var(--gcti-fresh, #84b3ff); vertical-align: -.1em; }
.gcti-caret[data-idle="true"] { animation: gcti-blink 1.1s steps(1, end) infinite; }
@keyframes gcti-dry { from { color: var(--gcti-fresh, #84b3ff); opacity: 0; transform: translateY(.14em); } 25% { opacity: 1; } to { transform: none; } }
@keyframes gcti-blink { 50% { opacity: 0; } }
@media (prefers-reduced-motion: reduce) { .gcti-ch { animation: none; } .gcti-caret[data-idle="true"] { animation: none; } }
`;
export function TextoTinta({
text = "La operación, bajo control.",
speed = 48,
delay = 300,
fresh = "#84b3ff",
className,
style,
}: TextoTintaProps) {
const [reduced, setReduced] = useState(false);
const [progress, setProgress] = useState({ key: "", shown: 0 });
const chars = Array.from(text);
const shown = reduced ? chars.length : progress.key === text ? progress.shown : 0;
useEffect(() => {
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => setReduced(media.matches);
update();
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, []);
useEffect(() => {
if (reduced) return;
const letters = Array.from(text);
let index = 0;
let timer: ReturnType<typeof setTimeout>;
const step = () => {
index += 1;
setProgress({ key: text, shown: index });
if (index >= letters.length) return;
const previous = letters[index - 1];
const pause = /[.!?]/.test(previous) ? 8 : /[,;:]/.test(previous) ? 4 : 1;
timer = setTimeout(step, Math.max(8, speed) * pause * (0.75 + ((index * 11) % 6) * 0.1));
};
timer = setTimeout(step, Math.max(0, delay));
return () => clearTimeout(timer);
}, [text, speed, delay, reduced]);
return (
<>
<style>{CSS}</style>
<span className={["gcti-root", className].filter(Boolean).join(" ")} style={{ ...style, ["--gcti-fresh" as string]: fresh }}>
<span className="gcti-sr">{text}</span>
<span aria-hidden="true">
{chars.slice(0, shown).map((ch, i) => (
<span key={i} className="gcti-ch">{ch}</span>
))}
<span className="gcti-caret" data-idle={shown >= chars.length} />
</span>
</span>
</>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/texto-tintaEjemplo de uso de Texto tinta fresca
Después de copiar texto-tinta.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { TextoTinta } from "./texto-tinta";
export default function Ejemplo() {
return <TextoTinta text="Ejemplo" />;
}Propiedades de TextoTinta
TextoTinta acepta 6 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
text? | string | Opcional. |
speed? | number | Milisegundos por letra. |
delay? | number | Espera antes de empezar, en milisegundos. |
fresh? | string | Color con el que nace cada letra. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Personaliza Texto tinta fresca con variables CSS
Define estas variables en el contenedor o en tu hoja de estilos para cambiar los colores sin tocar el componente. Si no las defines, se usa el valor por defecto.
| Variable | Valor por defecto |
|---|---|
--gcti-fresh | #84b3ff |
Accesibilidad y rendimiento
El archivo tiene 91 líneas (4 KB) y define 2 animaciones con @keyframes. Esto es lo que hace Texto tinta fresca 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".
Acerca del producto
El final de la frase siempre brilla un poco más que el principio. Pausas en puntuación y cursor fijo mientras escribe. El lector de pantalla recibe la frase completa.
Incluye
Un solo archivo React + TypeScript con estilos dentro. Sin Tailwind ni dependencias externas.
- Color vivo que se asienta por letra
- Pausas en puntuación
- Cursor que parpadea al terminar
- Color de la tinta configurable
- Respeta prefers-reduced-motion