Estado de trabajo y profundidad
Barra de estado con cronómetro real y un control para elegir qué tan a fondo trabaja el asistente.
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
/**
* 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.
npx shadcn@latest add https://www.gassierconsulting.com/r/ai-churnEjemplo 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.
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.
| Propiedad | Tipo | Descripción |
|---|---|---|
label? | string | Opcional. |
seconds? | number | Segundos transcurridos al montar. |
running? | boolean | Si es true el cronómetro avanza cada segundo. |
modes? | string[] | Opcional. |
defaultMode? | string | Opcional. |
onModeChange? | (mode: string) => void | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
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.
| Variable | Valor por defecto |
|---|---|
--gcai-ink | #ededee |
--gcai-muted | #9b9ca3 |
--gcai-line | #26272c |
--gcai-surface | #111113 |
--gcai-well | #1a1b1e |
--gcai-accent | #84b3ff |
--gcac-r | 100% |
--gcac-x | 0px |
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".
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