Hero de terminal CRT
Fósforo verde, líneas de barrido y viñeta: una sesión de comandos que se teclea sola y se repite mientras está a la vista.
Tu operación, en una sola línea de comando.
Software a medida con trazabilidad desde el primer día. Esta sesión es una ilustración, no un producto real.
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
import { useEffect, useRef, useState, useSyncExternalStore, type CSSProperties } from "react";
/** Hero de terminal CRT: fósforo verde, líneas de barrido, parpadeo y una sesión que se teclea sola. Con movimiento reducido muestra la sesión completa. React + TypeScript, sin dependencias. */
export type HeroTerminalProps = {
title?: string;
description?: string;
/** Líneas de la sesión. Las que empiezan por "$ " se teclean; el resto aparece como salida. */
script?: string[];
cta?: string;
href?: string;
className?: string;
style?: CSSProperties;
};
const CSS = `
.gchtr-root { box-sizing: border-box; position: relative; container-type: inline-size; width: 100%; min-height: 620px; overflow: hidden; padding: clamp(24px, 5cqi, 64px); color: #7dffa1; background: #050806; font: 500 15px/1.6 ui-monospace, "SF Mono", Menlo, Consolas, monospace; display: grid; align-content: center; gap: 28px; }
.gchtr-root *, .gchtr-root *::before, .gchtr-root *::after { box-sizing: border-box; }
.gchtr-root a { -webkit-tap-highlight-color: transparent; }
.gchtr-root :focus-visible { outline: 2px solid #7dffa1; outline-offset: 4px; }
.gchtr-root ::selection { background: #7dffa1; color: #021006; }
@media (prefers-reduced-motion: reduce) { .gchtr-root *, .gchtr-root *::before, .gchtr-root *::after { animation: none !important; } }
.gchtr-head { position: relative; z-index: 1; max-width: 760px; }
.gchtr-title { margin: 0 0 12px; font: 700 clamp(30px, 6cqi, 60px)/1.05 ui-monospace, "SF Mono", Menlo, Consolas, monospace; letter-spacing: -.04em; color: #c7ffd6; text-shadow: 0 0 12px rgba(125, 255, 161, .55); }
.gchtr-desc { margin: 0; max-width: 56ch; color: #67c383; }
.gchtr-screen { position: relative; z-index: 1; padding: 22px 24px; min-height: 250px; border: 1px solid #1d5a30; border-radius: 14px; background: radial-gradient(120% 120% at 50% 40%, #07160c, #030705 80%); box-shadow: inset 0 0 60px rgba(125, 255, 161, .12), 0 0 40px rgba(125, 255, 161, .12); text-shadow: 0 0 6px rgba(125, 255, 161, .6); animation: gchtr-flick 6s steps(1) infinite; }
.gchtr-bar { display: flex; gap: 6px; margin-bottom: 14px; }
.gchtr-bar i { width: 10px; height: 10px; border-radius: 50%; background: #1d5a30; }
.gchtr-line { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
.gchtr-line[data-k="cmd"] { color: #c7ffd6; }
.gchtr-line[data-k="out"] { color: #67c383; }
.gchtr-line[data-k="ok"] { color: #b6ff7d; }
.gchtr-caret { display: inline-block; width: .6em; height: 1.1em; margin-left: 2px; vertical-align: text-bottom; background: #7dffa1; animation: gchtr-blink 1s steps(1) infinite; }
.gchtr-scan { position: absolute; inset: 0; z-index: 2; pointer-events: none; background: repeating-linear-gradient(180deg, rgba(0, 0, 0, 0) 0 2px, rgba(0, 0, 0, .28) 2px 3px); mix-blend-mode: multiply; }
.gchtr-vig { position: absolute; inset: 0; z-index: 2; pointer-events: none; background: radial-gradient(120% 100% at 50% 50%, transparent 55%, rgba(0, 0, 0, .7)); }
.gchtr-cta { position: relative; z-index: 3; justify-self: start; display: inline-flex; gap: 12px; align-items: center; min-height: 48px; padding: 0 22px; border: 1px solid #7dffa1; border-radius: 6px; color: #7dffa1; text-decoration: none; text-shadow: 0 0 8px rgba(125, 255, 161, .6); transition: background .15s, color .15s; }
.gchtr-cta:hover { background: #7dffa1; color: #021006; text-shadow: none; }
@keyframes gchtr-blink { 50% { opacity: 0; } }
@keyframes gchtr-flick { 0%, 94%, 100% { opacity: 1; } 95% { opacity: .93; } 96% { opacity: 1; } 97% { opacity: .96; } }`;
const REDUCED_QUERY = "(prefers-reduced-motion: reduce)";
function useReduced() {
return useSyncExternalStore(
(notify) => {
const query = window.matchMedia(REDUCED_QUERY);
query.addEventListener("change", notify);
return () => query.removeEventListener("change", notify);
},
() => window.matchMedia(REDUCED_QUERY).matches,
() => false,
);
}
const DEFAULT_SCRIPT = ["$ gassier diagnostico --problema \"todo en hojas de cálculo\"", "analizando flujo de trabajo… listo", "encontrados 3 procesos manuales automatizables", "$ gassier propuesta --sprint 2", "alcance definido · calendario en semanas · precio claro", "ok. sistema a la medida de tu operación."];
export function HeroTerminal({ title = "Tu operación, en una sola línea de comando.", description = "Software a medida con trazabilidad desde el primer día. Esta sesión es una ilustración, no un producto real.", script = DEFAULT_SCRIPT, cta = "$ agendar --diagnostico", href = "#", className = "", style }: HeroTerminalProps) {
const reduced = useReduced();
const [typedLines, setLines] = useState<string[]>([]);
const lines = reduced ? script : typedLines;
const [typing, setTyping] = useState("");
const [live, setLive] = useState(true);
const root = useRef<HTMLElement>(null);
useEffect(() => {
const el = root.current;
if (!el) return;
const io = new IntersectionObserver(([entry]) => setLive(entry.isIntersecting), { threshold: 0.1 });
io.observe(el);
return () => io.disconnect();
}, []);
useEffect(() => {
if (reduced || !live) return;
let cancelled = false;
let timer = 0;
const wait = (ms: number) => new Promise<void>((resolve) => (timer = window.setTimeout(resolve, ms)));
(async () => {
while (!cancelled) {
setLines([]);
for (const line of script) {
if (cancelled) return;
if (line.startsWith("$ ")) {
for (let i = 1; i <= line.length; i++) {
if (cancelled) return;
setTyping(line.slice(0, i));
await wait(28 + Math.random() * 30);
}
setLines((prev) => [...prev, line]);
setTyping("");
await wait(380);
} else {
setLines((prev) => [...prev, line]);
await wait(520);
}
}
await wait(4200);
}
})();
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [live, script, reduced]);
return (
<section ref={root} className={`gchtr-root ${className}`} style={style}>
<style>{CSS}</style>
<div className="gchtr-head">
<h1 className="gchtr-title">{title}</h1>
<p className="gchtr-desc">{description}</p>
</div>
<div className="gchtr-screen" aria-hidden="true">
<div className="gchtr-bar">
<i />
<i />
<i />
</div>
{lines.map((line, index) => (
<p key={index} className="gchtr-line" data-k={line.startsWith("$ ") ? "cmd" : line.startsWith("ok") ? "ok" : "out"}>
{line}
</p>
))}
{typing ? (
<p className="gchtr-line" data-k="cmd">
{typing}
<span className="gchtr-caret" />
</p>
) : (
<p className="gchtr-line" data-k="cmd">
<span className="gchtr-caret" />
</p>
)}
</div>
<span className="sr-only" style={{ position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" }}>
{script.join(". ")}
</span>
<a className="gchtr-cta" href={href}>
{cta}
</a>
<div className="gchtr-scan" />
<div className="gchtr-vig" />
</section>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/hero-terminalEjemplo de uso de Hero de terminal CRT
Después de copiar hero-terminal.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { HeroTerminal } from "./hero-terminal";
export default function Ejemplo() {
return <HeroTerminal title="Ejemplo" />;
}Propiedades de HeroTerminal
HeroTerminal acepta 7 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
title? | string | Opcional. |
description? | string | Opcional. |
script? | string[] | Líneas de la sesión. Las que empiezan por "$ " se teclean; el resto aparece como salida. |
cta? | string | Opcional. |
href? | string | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Accesibilidad y rendimiento
El archivo tiene 147 líneas (7 KB) y define 2 animaciones con @keyframes. Esto es lo que hace Hero de terminal CRT por quien lo usa:
- Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
- Muestra un anillo de foco visible al navegar con teclado (:focus-visible).
- Expone su estado a tecnologías de apoyo con atributos aria-*.
- Se pausa cuando sale de la pantalla para no gastar batería ni CPU.
- Lee preferencias del navegador con useSyncExternalStore, sin parpadeos en la hidratación.
- Es un componente de cliente de Next.js: incluye la directiva "use client".