Ir al contenido
Evaluar mi proyecto
Componentes · Texto y animacionesDisponible

Texto tinta fresca

Texto que se escribe con ritmo humano; cada letra nace con color y se asienta al color del texto.

La operación, bajo control.Hecho para equipos críticos.
Demostración interactiva

Código completo

Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.

texto-tinta.tsx91 líneas
Descargar
/**
 * 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.

terminal.sh1 líneas
npx shadcn@latest add https://www.gassierconsulting.com/r/texto-tinta

Ejemplo de uso de Texto tinta fresca

Después de copiar texto-tinta.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.

ejemplo.tsx5 líneas
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.

PropiedadTipoDescripción
text?stringOpcional.
speed?numberMilisegundos por letra.
delay?numberEspera antes de empezar, en milisegundos.
fresh?stringColor con el que nace cada letra.
className?stringOpcional.
style?CSSPropertiesOpcional.

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.

VariableValor 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".
Licencia
MIT
Dependencias
React >=18

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