Ir al contenido
Evaluar mi proyecto
Componentes · IADisponible

Estado de trabajo y profundidad

Barra de estado con cronómetro real y un control para elegir qué tan a fondo trabaja el asistente.

Trabajando4 min 25 s

Código completo

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

ai-churn.tsx132 líneas
Descargar
/**
 * Barra de estado de un asistente que trabaja: cuánto lleva y con qué profundidad.
 * A la izquierda un indicador, la etiqueta y un cronómetro real; a la derecha un
 * control segmentado (radios nativos, se maneja con flechas) cuyo selector se desliza
 * al modo activo. Los colores se sobrescriben con --gcai-ink, --gcai-surface,
 * --gcai-well, --gcai-line, --gcai-muted y --gcai-accent.
 *
 *   <AiChurn
 *     label="Trabajando"
 *     seconds={265}
 *     modes={["Rápido", "Equilibrado", "Profundo"]}
 *     defaultMode="Equilibrado"
 *   />
 */

"use client";

import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties } from "react";

export type AiChurnProps = {
  label?: string;
  /** Segundos transcurridos al montar. */
  seconds?: number;
  /** Si es true el cronómetro avanza cada segundo. */
  running?: boolean;
  modes?: string[];
  defaultMode?: string;
  onModeChange?: (mode: string) => void;
  className?: string;
  style?: CSSProperties;
};

const CSS = `
.gcac-root { --gcac-ink: var(--gcai-ink, #ededee); --gcac-muted: var(--gcai-muted, #9b9ca3); --gcac-line: var(--gcai-line, #26272c); --gcac-surface: var(--gcai-surface, #111113); --gcac-well: var(--gcai-well, #1a1b1e); --gcac-accent: var(--gcai-accent, #84b3ff); display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 28px; box-sizing: border-box; width: min(100%, 560px); margin-inline: auto; padding: 12px 14px 12px 18px; border: 1px solid var(--gcac-line); border-radius: 14px; background: var(--gcac-surface); color: var(--gcac-ink); font: 14px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.gcac-status { display: inline-flex; align-items: center; gap: 12px; min-width: 0; }
.gcac-bars { display: inline-flex; align-items: flex-end; gap: 3px; height: 14px; }
.gcac-bars i { display: block; width: 3px; height: 100%; border-radius: 1px; background: var(--gcac-accent); transform-origin: bottom; transform: scaleY(.35); }
.gcac-root[data-running="true"] .gcac-bars i { animation: gcac-bar 1.1s cubic-bezier(.45, 0, .25, 1) infinite; }
.gcac-bars i:nth-child(2) { animation-delay: .18s; }
.gcac-bars i:nth-child(3) { animation-delay: .36s; }
.gcac-label { font-weight: 600; letter-spacing: -.005em; }
.gcac-time { color: var(--gcac-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
.gcac-seg { position: relative; display: inline-flex; padding: 3px; border-radius: 10px; background: var(--gcac-well); }
.gcac-thumb { position: absolute; inset: 3px 0; background: #2a2b30; clip-path: inset(0 var(--gcac-r, 100%) 0 var(--gcac-x, 0px) round 7px); opacity: 0; }
.gcac-seg[data-ready="true"] .gcac-thumb { opacity: 1; transition: clip-path .38s cubic-bezier(.16, 1, .3, 1); }
.gcac-opt { position: relative; z-index: 1; display: inline-flex; }
.gcac-opt input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; }
.gcac-opt span { padding: 7px 14px; border-radius: 7px; color: var(--gcac-muted); font-weight: 500; white-space: nowrap; transition: color .2s; }
.gcac-opt:hover span { color: var(--gcac-ink); }
.gcac-opt input:checked + span { color: var(--gcac-ink); }
.gcac-opt input:focus-visible + span { outline: 2px solid var(--gcac-accent); outline-offset: 1px; }
@keyframes gcac-bar { 0%, 100% { transform: scaleY(.35); } 45% { transform: scaleY(1); } }
@media (prefers-reduced-motion: reduce) { .gcac-root[data-running="true"] .gcac-bars i { animation: none; transform: scaleY(.7); } .gcac-seg[data-ready="true"] .gcac-thumb { transition: none; } }
`;

function formatElapsed(total: number) {
  const m = Math.floor(total / 60);
  const s = total % 60;
  return m > 0 ? `${m} min ${String(s).padStart(2, "0")} s` : `${s} s`;
}

export function AiChurn({
  label = "Trabajando",
  seconds = 265,
  running = true,
  modes = ["Rápido", "Equilibrado", "Profundo"],
  defaultMode,
  onModeChange,
  className,
  style,
}: AiChurnProps) {
  const name = useId();
  const segRef = useRef<HTMLDivElement>(null);
  const [active, setActive] = useState(defaultMode && modes.includes(defaultMode) ? defaultMode : modes[0] ?? "");
  const [ticks, setTicks] = useState(0);
  const [base, setBase] = useState(seconds);
  if (base !== seconds) { setBase(seconds); setTicks(0); }
  const elapsed = seconds + ticks;
  const [ready, setReady] = useState(false);

  useEffect(() => {
    if (!running) return;
    const timer = setInterval(() => setTicks((n) => n + 1), 1000);
    return () => clearInterval(timer);
  }, [seconds, running]);

  useLayoutEffect(() => {
    const seg = segRef.current;
    if (!seg) return;
    const place = () => {
      const index = modes.indexOf(active);
      const option = seg.querySelectorAll<HTMLElement>(".gcac-opt")[index];
      if (!option) return;
      seg.style.setProperty("--gcac-x", `${option.offsetLeft}px`);
      seg.style.setProperty("--gcac-r", `${seg.clientWidth - option.offsetLeft - option.offsetWidth}px`);
    };
    place();
    const frame = requestAnimationFrame(() => setReady(true));
    const observer = new ResizeObserver(place);
    observer.observe(seg);
    return () => { cancelAnimationFrame(frame); observer.disconnect(); };
  }, [active, modes]);

  return (
    <>
      <style>{CSS}</style>
      <div className={["gcac-root", className].filter(Boolean).join(" ")} style={style} data-running={running}>
        <div className="gcac-status">
          <span className="gcac-bars" aria-hidden="true"><i /><i /><i /></span>
          <span className="gcac-label">{label}</span>
          <span className="gcac-time" role="timer" aria-label={`Tiempo transcurrido: ${formatElapsed(elapsed)}`}>{formatElapsed(elapsed)}</span>
        </div>
        <div className="gcac-seg" ref={segRef} role="radiogroup" aria-label="Profundidad de análisis" data-ready={ready}>
          <span className="gcac-thumb" aria-hidden="true" />
          {modes.map((mode) => (
            <label key={mode} className="gcac-opt">
              <input
                type="radio"
                name={name}
                value={mode}
                checked={mode === active}
                onChange={() => { setActive(mode); onModeChange?.(mode); }}
              />
              <span>{mode}</span>
            </label>
          ))}
        </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-churn

Ejemplo de uso de Estado de trabajo y profundidad

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

ejemplo.tsx5 líneas
import { AiChurn } from "./ai-churn";

export default function Ejemplo() {
  return <AiChurn label="Ejemplo" running />;
}

Propiedades de AiChurn

AiChurn acepta 8 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.

PropiedadTipoDescripción
label?stringOpcional.
seconds?numberSegundos transcurridos al montar.
running?booleanSi es true el cronómetro avanza cada segundo.
modes?string[]Opcional.
defaultMode?stringOpcional.
onModeChange?(mode: string) => voidOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

Personaliza Estado de trabajo y profundidad 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-ink#ededee
--gcai-muted#9b9ca3
--gcai-line#26272c
--gcai-surface#111113
--gcai-well#1a1b1e
--gcai-accent#84b3ff
--gcac-r100%
--gcac-x0px

Accesibilidad y rendimiento

El archivo tiene 132 líneas (6 KB) y define 1 animación con @keyframes. Esto es lo que hace Estado de trabajo y profundidad 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: timer, radiogroup.
  • Anima con requestAnimationFrame, sincronizado con el refresco de pantalla.
  • Es un componente de cliente de Next.js: incluye la directiva "use client".
Licencia
MIT
Dependencias
React >=18

Acerca del producto

Muestra que el asistente está trabajando, cuánto lleva y deja cambiar la profundidad con un control segmentado cuyo selector se desliza. Los modos son radios nativos: se manejan con flechas.

Incluye

Un solo archivo React + TypeScript con estilos dentro. Sin Tailwind ni dependencias externas.

  • Cronómetro que avanza cada segundo
  • Control segmentado con radios nativos
  • Selector que se desliza y se re-mide al cambiar de tamaño
  • Modos configurables
  • Respeta prefers-reduced-motion