Generación de imagen en curso
El lienzo pasa de desenfocado a nítido con una barra de pasos y regeneración; ilustración SVG propia, simulación visual.
Descripción: Un faro al amanecer sobre un mar tranquilo
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
import { useEffect, useState, useSyncExternalStore, type CSSProperties } from "react";
/** Generación de imagen en curso: el lienzo pasa de ruido desenfocado a imagen nítida con una barra de pasos. Simulación visual con una ilustración SVG propia. React + TypeScript, sin dependencias. Personaliza con --gcai-*. */
export type AiImagenProps = {
prompt?: string;
/** Segundos que dura la generación simulada. */
seconds?: number;
autoStart?: boolean;
className?: string;
style?: CSSProperties;
};
const CSS = `
.gcaiim-root { box-sizing: border-box; width: 100%; max-width: 420px; padding: 14px; border: 1px solid var(--gcai-line, #26272c); border-radius: 16px; background: var(--gcai-surface, #111113); color: var(--gcai-ink, #ededee); font: 400 14px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
.gcaiim-root *, .gcaiim-root *::before, .gcaiim-root *::after { box-sizing: border-box; }
.gcaiim-root button { font: inherit; -webkit-tap-highlight-color: transparent; }
.gcaiim-root :focus-visible { outline: 2px solid var(--gcai-accent, #84b3ff); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .gcaiim-root * { animation: none !important; transition-duration: .01ms !important; } }
.gcaiim-prompt { margin: 0 0 12px; padding: 10px 12px; border-radius: 8px; background: var(--gcai-well, #1a1b1e); color: var(--gcai-muted, #9b9ca3); font-size: 13px; }
.gcaiim-prompt b { color: var(--gcai-ink, #ededee); font-weight: 500; }
.gcaiim-canvas { position: relative; aspect-ratio: 1; overflow: hidden; border-radius: 10px; background: #0d0d0f; }
.gcaiim-art { position: absolute; inset: 0; width: 100%; height: 100%; transition: filter .4s linear, opacity .4s linear; }
.gcaiim-shimmer { position: absolute; inset: 0; background: linear-gradient(110deg, transparent 30%, rgba(255, 255, 255, .06) 50%, transparent 70%); background-size: 200% 100%; animation: gcaiim-sweep 1.6s linear infinite; pointer-events: none; }
.gcaiim-root[data-done="true"] .gcaiim-shimmer { display: none; }
.gcaiim-foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 12px; }
.gcaiim-track { flex: 1; height: 4px; overflow: hidden; border-radius: 2px; background: var(--gcai-well, #1a1b1e); }
.gcaiim-fill { height: 100%; background: var(--gcai-accent, #84b3ff); transition: width .2s linear; }
.gcaiim-pct { min-width: 40px; text-align: right; font-size: 12px; color: var(--gcai-muted, #9b9ca3); font-variant-numeric: tabular-nums; }
.gcaiim-redo { min-height: 32px; padding: 0 12px; border: 1px solid var(--gcai-line, #26272c); border-radius: 8px; background: transparent; color: inherit; cursor: pointer; font-size: 13px; }
.gcaiim-redo:hover { border-color: #3a3b42; background: var(--gcai-well, #1a1b1e); }
@keyframes gcaiim-sweep { to { background-position: -200% 0; } }`;
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,
);
}
export function AiImagen({ prompt = "Un faro al amanecer sobre un mar tranquilo", seconds = 6, autoStart = true, className = "", style }: AiImagenProps) {
const reduced = useReduced();
const [p0, setP] = useState(autoStart ? 0 : 1);
const [run, setRun] = useState(autoStart ? 1 : 0);
const p = reduced ? 1 : p0;
useEffect(() => {
if (!run || reduced) return;
const start = performance.now();
const id = window.setInterval(() => {
const next = Math.min(1, (performance.now() - start) / (seconds * 1000));
setP(next);
if (next >= 1) window.clearInterval(id);
}, 100);
return () => window.clearInterval(id);
}, [run, seconds, reduced]);
const done = p >= 1;
const blur = Math.max(0, (1 - p) * 26);
const step = Math.min(30, Math.round(p * 30));
return (
<div className={`gcaiim-root ${className}`} style={style} data-done={done}>
<style>{CSS}</style>
<p className="gcaiim-prompt"><b>Descripción:</b> {prompt}</p>
<div className="gcaiim-canvas" role="img" aria-label={done ? `Imagen generada: ${prompt}` : "Imagen generándose"}>
<svg className="gcaiim-art" viewBox="0 0 400 400" preserveAspectRatio="xMidYMid slice" style={{ filter: `blur(${blur}px) saturate(${0.3 + p * 0.7})`, opacity: 0.35 + p * 0.65 }} aria-hidden="true">
<defs>
<linearGradient id="gcaiim-sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#2b3a67" /><stop offset=".55" stopColor="#f2a76b" /><stop offset="1" stopColor="#ffd9a0" /></linearGradient>
<linearGradient id="gcaiim-sea" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#3b5b8c" /><stop offset="1" stopColor="#1c2f55" /></linearGradient>
</defs>
<rect width="400" height="400" fill="url(#gcaiim-sky)" />
<circle cx="260" cy="236" r="44" fill="#fff1c2" />
<rect y="250" width="400" height="150" fill="url(#gcaiim-sea)" />
<path d="M0 262h400M0 284h400M0 312h400" stroke="#fff" strokeOpacity=".15" strokeWidth="2" />
<path d="M120 330 L140 150 L160 150 L180 330 Z" fill="#f5efe2" />
<path d="M126 270h48M130 220h40" stroke="#d94a3a" strokeWidth="14" />
<rect x="134" y="128" width="32" height="24" fill="#2a2f3f" />
<path d="M130 128 L150 104 L170 128 Z" fill="#d94a3a" />
<path d="M104 332h92l-8-14h-76z" fill="#40485f" />
</svg>
<div className="gcaiim-shimmer" />
</div>
<div className="gcaiim-foot">
<div className="gcaiim-track" role="progressbar" aria-label="Progreso de generación" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(p * 100)}>
<div className="gcaiim-fill" style={{ width: `${p * 100}%` }} />
</div>
<span className="gcaiim-pct">{done ? "Listo" : `${step}/30`}</span>
<button className="gcaiim-redo" type="button" onClick={() => { setP(0); setRun((value) => value + 1); }}>
{done ? "Regenerar" : "Reiniciar"}
</button>
</div>
</div>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/ai-imagenEjemplo de uso de Generación de imagen en curso
Después de copiar ai-imagen.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { AiImagen } from "./ai-imagen";
export default function Ejemplo() {
return <AiImagen prompt="Ejemplo" autoStart />;
}Propiedades de AiImagen
AiImagen acepta 5 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
prompt? | string | Opcional. |
seconds? | number | Segundos que dura la generación simulada. |
autoStart? | boolean | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Personaliza Generación de imagen en curso 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 |
|---|---|
--gcai-line | #26272c |
--gcai-surface | #111113 |
--gcai-ink | #ededee |
--gcai-accent | #84b3ff |
--gcai-well | #1a1b1e |
--gcai-muted | #9b9ca3 |
Accesibilidad y rendimiento
El archivo tiene 104 líneas (6 KB) y define 1 animación con @keyframes. Esto es lo que hace Generación de imagen en curso 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).
- Declara roles ARIA para lectores de pantalla: img, progressbar.
- 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".