Proceso en cuatro etapas
Una línea con iconos se traza al entrar en pantalla y cada etapa se ilumina en secuencia. Editorial.
Cómo trabajamos, paso a paso
- Etapa 1 de 4
Escuchamos
Entendemos cómo trabajas hoy y qué te frena.
- Etapa 2 de 4
Proponemos
Alcance, calendario y precio claros, por escrito.
- Etapa 3 de 4
Construimos
Sprints cortos con entregas que puedes probar.
- Etapa 4 de 4
Acompañamos
Lanzamiento, capacitación y soporte continuo.
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, type ReactNode } from "react";
/** Proceso en cuatro etapas: una línea con iconos dibujados se traza al entrar en pantalla y cada etapa se ilumina en secuencia. El orden importa, por eso las etapas van numeradas. Estilo editorial. React + TypeScript, sin dependencias. */
export type SeccionProcesoEtapa = { title: string; text: string; icon: ReactNode };
export type SeccionProcesoProps = {
title?: string;
steps?: SeccionProcesoEtapa[];
className?: string;
style?: CSSProperties;
};
const CSS = `
.gcspr-root { box-sizing: border-box; container-type: inline-size; width: 100%; padding: clamp(32px, 6cqi, 88px) clamp(18px, 5cqi, 72px); background: #f1ede4; color: #17150f; font: 400 15px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
.gcspr-root *, .gcspr-root *::before, .gcspr-root *::after { box-sizing: border-box; }
@media (prefers-reduced-motion: reduce) { .gcspr-root * { transition-duration: .01ms !important; } }
.gcspr-title { max-width: 16ch; margin: 0 0 clamp(28px, 5cqi, 56px); font: 500 clamp(30px, 5cqi, 60px)/1.04 "Newsreader", Georgia, ui-serif, serif; letter-spacing: -.03em; text-wrap: balance; }
.gcspr-grid { position: relative; display: grid; grid-template-columns: repeat(var(--n), minmax(0, 1fr)); gap: 24px; margin: 0; padding: 0; list-style: none; }
.gcspr-line { position: absolute; top: 34px; left: 34px; right: 34px; height: 2px; background: #d6d0c0; }
.gcspr-line::after { content: ""; position: absolute; inset: 0; background: #17150f; transform: scaleX(var(--p, 0)); transform-origin: left; transition: transform 1.6s cubic-bezier(.65, 0, .35, 1); }
.gcspr-step { position: relative; display: grid; gap: 14px; align-content: start; }
.gcspr-ico { display: grid; place-items: center; width: 68px; height: 68px; border: 2px solid #d6d0c0; border-radius: 50%; background: #f1ede4; color: #a69f8b; transition: border-color .5s, color .5s, background .5s, transform .6s cubic-bezier(.34, 1.56, .64, 1); }
.gcspr-step[data-on="true"] .gcspr-ico { border-color: #17150f; color: #fff; background: #17150f; transform: scale(1.04); }
.gcspr-ico svg { width: 28px; height: 28px; }
.gcspr-n { font: 500 13px/1 ui-monospace, "SF Mono", Menlo, monospace; letter-spacing: .08em; color: #8c8573; }
.gcspr-step h3 { margin: 0; font: 500 24px/1.1 "Newsreader", Georgia, ui-serif, serif; letter-spacing: -.01em; }
.gcspr-step p { margin: 0; color: #5f594a; max-width: 28ch; }
@container (max-width: 760px) { .gcspr-grid { grid-template-columns: minmax(0, 1fr); gap: 28px; } .gcspr-line { top: 34px; bottom: 34px; left: 34px; right: auto; width: 2px; height: auto; } .gcspr-line::after { transform: scaleY(var(--p, 0)); transform-origin: top; } .gcspr-step { grid-template-columns: 68px 1fr; column-gap: 16px; row-gap: 4px; align-content: center; } .gcspr-step .gcspr-ico { grid-row: span 2; } .gcspr-step .gcspr-n { grid-column: 2; align-self: end; } .gcspr-step > div { grid-column: 2; } }`;
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 I = ({ d }: { d: string }) => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d={d} />
</svg>
);
const DEFAULT: SeccionProcesoEtapa[] = [
{ title: "Escuchamos", text: "Entendemos cómo trabajas hoy y qué te frena.", icon: <I d="M4 5h16v11H9l-5 4z" /> },
{ title: "Proponemos", text: "Alcance, calendario y precio claros, por escrito.", icon: <I d="M6 3h9l4 4v14H6zM14 3v5h5M9 13h6M9 17h6" /> },
{ title: "Construimos", text: "Sprints cortos con entregas que puedes probar.", icon: <I d="m8 8-4 4 4 4M16 8l4 4-4 4M13.500 5l-3 14" /> },
{ title: "Acompañamos", text: "Lanzamiento, capacitación y soporte continuo.", icon: <I d="M12 21s-7-4.500-9-9a5 5 0 0 1 9-3 5 5 0 0 1 9 3c-2 4.500-9 9-9 9z" /> },
];
export function SeccionProceso({ title = "Cómo trabajamos, paso a paso", steps = DEFAULT, className = "", style }: SeccionProcesoProps) {
const ref = useRef<HTMLOListElement>(null);
const reduced = useReduced();
const [active0, setActive] = useState(0);
const [run, setRun] = useState(false);
const active = reduced ? steps.length : active0;
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(([entry]) => entry.isIntersecting && (setRun(true), io.disconnect()), { threshold: 0.35 });
io.observe(el);
return () => io.disconnect();
}, []);
useEffect(() => {
if (!run || reduced) return;
const ids = steps.map((_, i) => window.setTimeout(() => setActive(i + 1), 400 + i * 520));
return () => ids.forEach((id) => window.clearTimeout(id));
}, [run, steps, reduced]);
return (
<section className={`gcspr-root ${className}`} style={style}>
<style>{CSS}</style>
<h2 className="gcspr-title">{title}</h2>
<ol ref={ref} className="gcspr-grid" style={{ "--n": steps.length } as CSSProperties}>
<span className="gcspr-line" aria-hidden="true" style={{ "--p": active > 0 ? (active - 1) / (steps.length - 1) : 0 } as CSSProperties} />
{steps.map((step, i) => (
<li key={step.title} className="gcspr-step" data-on={active > i || !run}>
<span className="gcspr-ico">{step.icon}</span>
<span className="gcspr-n">Etapa {i + 1} de {steps.length}</span>
<div>
<h3>{step.title}</h3>
<p>{step.text}</p>
</div>
</li>
))}
</ol>
</section>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/seccion-procesoEjemplo de uso de Proceso en cuatro etapas
Después de copiar seccion-proceso.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { SeccionProceso } from "./seccion-proceso";
export default function Ejemplo() {
return <SeccionProceso title="Ejemplo" />;
}Propiedades de SeccionProceso
SeccionProceso acepta 4 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
title? | string | Opcional. |
steps? | SeccionProcesoEtapa[] | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Personaliza Proceso en cuatro etapas 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 |
|---|---|
--p | 0 |
Accesibilidad y rendimiento
El archivo tiene 99 líneas (6 KB) y no necesita @keyframes. Esto es lo que hace Proceso en cuatro etapas por quien lo usa:
- Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
- 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".