Ir al contenido
Evaluar mi proyecto
Componentes · IADisponible

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

0/30
Demostración interactiva

Código completo

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

ai-imagen.tsx104 líneas
Descargar
"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.

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

Ejemplo 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.

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

PropiedadTipoDescripción
prompt?stringOpcional.
seconds?numberSegundos que dura la generación simulada.
autoStart?booleanOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

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.

VariableValor 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".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React 18 y 19 · Next.js (componente de cliente)