Ir al contenido
Evaluar mi proyecto
Componentes · NavegaciónDisponible

Asistente por pasos Material 3

Indicador de círculos con línea que se llena, estados hecho/actual/pendiente y botones atrás y siguiente.

Proyecto

Describe el problema con tus palabras. No hace falta lenguaje técnico.

Código completo

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

nav-pasos.tsx86 líneas
Descargar
"use client";

import { useState, type CSSProperties } from "react";

/** Asistente por pasos estilo Material 3: indicador con círculos y línea de progreso, estados completado/actual/pendiente y botones atrás/siguiente. React + TypeScript, sin dependencias. */
export type NavPasosProps = {
  steps?: { title: string; desc: string }[];
  defaultStep?: number;
  onChange?: (step: number) => void;
  className?: string;
  style?: CSSProperties;
};

const CSS = `
.gcnps-root { box-sizing: border-box; width: 100%; max-width: 640px; padding: 24px; border-radius: 28px; background: #f7f2fa; color: #1d1b20; font: 400 14px/20px Roboto, "Google Sans", ui-sans-serif, system-ui, sans-serif; letter-spacing: .25px; }
.gcnps-root *, .gcnps-root *::before, .gcnps-root *::after { box-sizing: border-box; }
.gcnps-root button { font: inherit; letter-spacing: inherit; }
.gcnps-root :focus-visible { outline: 3px solid #1d1b20; outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .gcnps-root * { transition-duration: .01ms !important; animation: none !important; } }
.gcnps-track { position: relative; display: grid; grid-template-columns: repeat(var(--n), 1fr); margin: 0 0 28px; padding: 0; list-style: none; }
.gcnps-track::before, .gcnps-line { content: ""; position: absolute; top: 15px; height: 3px; border-radius: 2px; }
.gcnps-track::before { left: calc(50% / var(--n)); right: calc(50% / var(--n)); background: #cac4d0; }
.gcnps-line { left: calc(50% / var(--n)); width: calc((100% - 100% / var(--n)) * var(--p)); background: #6750a4; transition: width .5s cubic-bezier(.2, 0, 0, 1); }
.gcnps-step { position: relative; display: grid; justify-items: center; gap: 8px; border: 0; background: none; padding: 0; color: inherit; cursor: pointer; text-align: center; }
.gcnps-dot { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 50%; background: #e6e0e9; color: #49454f; font-weight: 500; box-shadow: 0 0 0 4px #f7f2fa; transition: background .3s, color .3s, transform .3s cubic-bezier(.34, 1.56, .64, 1); }
.gcnps-step[data-s="done"] .gcnps-dot { background: #6750a4; color: #fff; }
.gcnps-step[data-s="now"] .gcnps-dot { background: #eaddff; color: #21005d; box-shadow: 0 0 0 4px #f7f2fa, 0 0 0 6px #6750a4; transform: scale(1.06); }
.gcnps-name { font-weight: 500; color: #49454f; }
.gcnps-step[data-s="now"] .gcnps-name { color: #1d1b20; }
.gcnps-card { min-height: 108px; padding: 18px 20px; border-radius: 16px; background: #fff; box-shadow: 0 1px 2px rgba(0, 0, 0, .3), 0 1px 3px 1px rgba(0, 0, 0, .15); animation: gcnps-in .35s cubic-bezier(.2, 0, 0, 1); }
.gcnps-card h3 { margin: 0 0 6px; font: 400 22px/28px Roboto, "Google Sans", ui-sans-serif, sans-serif; letter-spacing: 0; }
.gcnps-card p { margin: 0; color: #49454f; }
.gcnps-actions { display: flex; justify-content: space-between; margin-top: 20px; }
.gcnps-btn { height: 40px; padding: 0 24px; border: 0; border-radius: 20px; background: #6750a4; color: #fff; font-weight: 500; letter-spacing: .1px; cursor: pointer; transition: box-shadow .2s; }
.gcnps-btn:hover:not(:disabled) { box-shadow: 0 1px 3px 1px rgba(0, 0, 0, .15), 0 1px 2px rgba(0, 0, 0, .3); }
.gcnps-btn[data-t="text"] { background: none; color: #6750a4; padding: 0 12px; }
.gcnps-btn[data-t="text"]:hover:not(:disabled) { background: rgba(103, 80, 164, .08); box-shadow: none; }
.gcnps-btn:disabled { background: rgba(29, 27, 32, .12); color: rgba(29, 27, 32, .38); cursor: not-allowed; }
.gcnps-btn[data-t="text"]:disabled { background: none; }
@keyframes gcnps-in { from { opacity: 0; transform: translateY(6px); } }
@media (max-width: 520px) { .gcnps-name { font-size: 12px; } }`;

const DEFAULT = [
  { title: "Datos", desc: "Cuéntanos quién eres y cómo localizarte. Solo pedimos lo necesario." },
  { title: "Proyecto", desc: "Describe el problema con tus palabras. No hace falta lenguaje técnico." },
  { title: "Alcance", desc: "Elige tamaño y plazos aproximados para orientar una primera cifra." },
  { title: "Revisión", desc: "Confirma tu información antes de enviarla." },
];

export function NavPasos({ steps = DEFAULT, defaultStep = 1, onChange, className = "", style }: NavPasosProps) {
  const [step, setStep] = useState(defaultStep);
  const go = (next: number) => {
    const value = Math.max(0, Math.min(steps.length - 1, next));
    setStep(value);
    onChange?.(value);
  };
  return (
    <div className={`gcnps-root ${className}`} style={style}>
      <style>{CSS}</style>
      <ol className="gcnps-track" style={{ "--n": steps.length, "--p": step / (steps.length - 1) } as CSSProperties} aria-label="Progreso">
        <span className="gcnps-line" aria-hidden="true" />
        {steps.map((item, index) => (
          <li key={item.title} style={{ display: "contents" }}>
            <button className="gcnps-step" type="button" data-s={index < step ? "done" : index === step ? "now" : "todo"} aria-current={index === step ? "step" : undefined} onClick={() => go(index)}>
              <span className="gcnps-dot">{index < step ? <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5" /></svg> : index + 1}</span>
              <span className="gcnps-name">{item.title}</span>
            </button>
          </li>
        ))}
      </ol>
      <div key={step} className="gcnps-card" aria-live="polite">
        <h3>{steps[step].title}</h3>
        <p>{steps[step].desc}</p>
      </div>
      <div className="gcnps-actions">
        <button className="gcnps-btn" data-t="text" type="button" disabled={step === 0} onClick={() => go(step - 1)}>
          Atrás
        </button>
        <button className="gcnps-btn" type="button" disabled={step === steps.length - 1} onClick={() => go(step + 1)}>
          {step === steps.length - 2 ? "Revisar" : "Siguiente"}
        </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/nav-pasos

Ejemplo de uso de Asistente por pasos Material 3

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

ejemplo.tsx5 líneas
import { NavPasos } from "./nav-pasos";

export default function Ejemplo() {
  return <NavPasos />;
}

Propiedades de NavPasos

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

PropiedadTipoDescripción
steps?{ title: string; desc: string }[]Opcional.
defaultStep?numberOpcional.
onChange?(step: number) => voidOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

Accesibilidad y rendimiento

El archivo tiene 86 líneas (6 KB) y define 1 animación con @keyframes. Esto es lo que hace Asistente por pasos Material 3 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).
  • Expone su estado a tecnologías de apoyo con atributos aria-*.
  • 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)