Stepper con línea
Pasos verticales conectados por una línea que se va rellenando.
- Diagnóstico operativoConversamos sobre tu operación: qué funciona, qué duele, qué quieres cambiar.
- Propuesta técnica y comercialAlcance acotado, costo y tiempos claros. Apruebas antes de empezar.
- AhoraConstrucción por sprintsEntregamos en ciclos cortos de 1 a 2 semanas, con demos reales cada semana.
- Lanzamiento y estabilizaciónSalimos a producción contigo y nos quedamos 30 días para asegurar el cambio.
- AcompañamientoPlan mensual de mantenimiento o CTO externo, según tu operación.
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
/**
* Stepper vertical con línea que se rellena: cada paso lleva número, título y cuerpo opcional.
* La línea vertical rellena en cuanto el paso anterior se marca como completado; el activo escala
* y muestra un aura; el pendiente queda tenue. Acepta colores configurables.
*
* <LineStepper
* steps={[
* { title: "Diagnóstico", body: "Entendemos tu operación." },
* { title: "Propuesta", body: "Alcance, costo y tiempos." }
* ]}
* current={1}
* />
*/
import type { CSSProperties } from "react";
export type LineStep = {
title: string;
body?: string;
};
const CSS = `
.glsp-root { list-style: none; margin: 0; padding: 0; font-family: inherit; counter-reset: glsp; }
.glsp-item { position: relative; padding: 0 0 28px 56px; transition: opacity .35s ease; }
.glsp-item:last-child { padding-bottom: 0; }
.glsp-item[data-state="pending"] .glsp-body { opacity: .42; }
.glsp-dot { position: absolute; left: 0; top: 4px; width: 36px; height: 36px; border-radius: 50%; background: var(--glsp-paper, #fff); border: 1.5px solid var(--glsp-rail); display: grid; place-items: center; font-size: 13px; font-weight: 700; color: var(--glsp-muted); transition: transform .42s cubic-bezier(.34, 1.36, .64, 1), background-color .35s ease, border-color .35s ease, color .35s ease, box-shadow .35s ease; }
.glsp-item[data-state="active"] .glsp-dot { border-color: var(--glsp-accent); color: var(--glsp-accent); transform: scale(1.08); box-shadow: 0 0 0 6px var(--glsp-glow); }
.glsp-item[data-state="done"] .glsp-dot { background: var(--glsp-accent); border-color: var(--glsp-accent); color: var(--glsp-paper, #fff); }
.glsp-num { display: block; line-height: 1; }
.glsp-check { display: none; }
.glsp-item[data-state="done"] .glsp-num { display: none; }
.glsp-item[data-state="done"] .glsp-check { display: block; line-height: 1; }
.glsp-line { position: absolute; left: 17px; top: 38px; bottom: -8px; width: 2px; background: var(--glsp-rail); border-radius: 2px; overflow: hidden; }
.glsp-line::after { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 100%; background: var(--glsp-accent); transform: scaleY(0); transform-origin: top; transition: transform .55s cubic-bezier(.7, 0, .3, 1); }
.glsp-item[data-state="done"] + .glsp-item .glsp-line::after,
.glsp-item[data-state="done"] .glsp-line::after { transform: scaleY(1); }
.glsp-item[data-state="active"] .glsp-line::after { transform: scaleY(.5); }
.glsp-body { transition: opacity .35s ease; }
.glsp-title { font-size: 17px; font-weight: 600; line-height: 1.3; letter-spacing: -.015em; color: var(--glsp-ink); }
.glsp-text { margin-top: 4px; font-size: 14px; line-height: 1.55; color: var(--glsp-muted); max-width: 56ch; }
.glsp-tag { display: inline-block; font-size: 11px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; padding: 3px 8px; border-radius: 4px; background: var(--glsp-glow); color: var(--glsp-accent); margin-bottom: 6px; }
@keyframes glsp-pulse { 0%, 100% { box-shadow: 0 0 0 6px var(--glsp-glow); } 50% { box-shadow: 0 0 0 10px var(--glsp-glow); } }
.glsp-item[data-state="active"] .glsp-dot { animation: glsp-pulse 2s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.glsp-item[data-state="active"] .glsp-dot { animation: none; transform: scale(1.08); }
.glsp-dot, .glsp-line::after { transition: none; }
}
`;
function markState(index: number, current: number) {
if (index < current) return "done";
if (index === current) return "active";
return "pending";
}
export function LineStepper({
steps,
current,
accent,
ink,
muted,
rail,
paper,
className,
style,
}: {
steps: LineStep[];
current: number;
accent?: string;
ink?: string;
muted?: string;
rail?: string;
paper?: string;
className?: string;
style?: CSSProperties;
}) {
const accentCss = accent ?? "#0b81ff";
const inkCss = ink ?? "#0e0e0f";
const mutedCss = muted ?? "#545456";
const railCss = rail ?? "rgba(14, 14, 15, .12)";
const paperCss = paper ?? "#ffffff";
const vars = {
"--glsp-accent": accentCss,
"--glsp-ink": inkCss,
"--glsp-muted": mutedCss,
"--glsp-rail": railCss,
"--glsp-paper": paperCss,
"--glsp-glow": `${accentCss}26`,
} as CSSProperties;
return (
<>
<style>{CSS}</style>
<ol className={["glsp-root", className].filter(Boolean).join(" ")} style={{ ...vars, ...style }}>
{steps.map((step, index) => {
const state = markState(index, current);
const num = String(index + 1).padStart(2, "0");
return (
<li key={`${num}-${step.title}`} className="glsp-item" data-state={state}>
<span className="glsp-dot" aria-hidden="true">
<span className="glsp-num">{num}</span>
<svg className="glsp-check" width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 7.5l3 3L11 4" />
</svg>
</span>
<span className="glsp-line" aria-hidden="true" />
<div className="glsp-body">
{state === "active" ? <span className="glsp-tag">Ahora</span> : null}
<div className="glsp-title">{step.title}</div>
{step.body ? <div className="glsp-text">{step.body}</div> : null}
</div>
</li>
);
})}
</ol>
</>
);
}
export function LineStepperDemo() {
const steps: LineStep[] = [
{ title: "Diagnóstico operativo", body: "Conversamos sobre tu operación: qué funciona, qué duele, qué quieres cambiar." },
{ title: "Propuesta técnica y comercial", body: "Alcance acotado, costo y tiempos claros. Apruebas antes de empezar." },
{ title: "Construcción por sprints", body: "Entregamos en ciclos cortos de 1 a 2 semanas, con demos reales cada semana." },
{ title: "Lanzamiento y estabilización", body: "Salimos a producción contigo y nos quedamos 30 días para asegurar el cambio." },
{ title: "Acompañamiento", body: "Plan mensual de mantenimiento o CTO externo, según tu operación." },
];
return (
<div style={{ background: "#f7f8fb", padding: 28, borderRadius: 14, maxWidth: 460 }}>
<LineStepper steps={steps} current={2} />
</div>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/line-stepperEjemplo de uso de Stepper con línea
Después de copiar line-stepper.tsx a tu proyecto, impórtalo y colócalo donde lo necesites. Exporta 2 componentes: LineStepper, LineStepperDemo.
import { LineStepper } from "./line-stepper";
export default function Ejemplo() {
return <LineStepper />;
}Personaliza Stepper con línea 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 |
|---|---|
--glsp-paper | #fff |
Accesibilidad y rendimiento
El archivo tiene 138 líneas (6 KB) y define 1 animación con @keyframes. Esto es lo que hace Stepper con línea 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-*.
- Es un componente de cliente de Next.js: incluye la directiva "use client".
Acerca del producto
Un stepper vertical donde cada paso lleva un círculo con un número y un título. La línea entre los pasos se rellena en cuanto el paso anterior se completa, el activo escala suavemente y el pendiente queda tenue. Acepta un color de acento.
Incluye
Un solo archivo React + TypeScript, con los estilos dentro. No usa Tailwind ni bibliotecas externas.
- Línea entre pasos que rellena según el progreso
- Estado activo con scale y aura
- Estado completado con marca de verificación
- Acento configurable, paleta por defecto
- Cumple prefers-reduced-motion
Cómo usarlo
Pasa steps como un arreglo de { title, body? } y current como índice del paso activo. accent, ink y rail son opcionales.