Etapas interativas
Processo numa linha do tempo horizontal: ao aparecer, a linha se desenha e os marcos numerados saltam; depois, a etapa com mouse, foco ou clique cresce e mostra a descrição, e as outras se comprimem.
Playground
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<EtapasInterativas>
Processo em etapas numa linha do tempo horizontal. Entrada (uma vez, ao aparecer na tela): a linha se desenha da esquerda para a direita, cada marco numerado salta quando a linha chega nele e os painéis sobem; no fim, a primeira etapa se abre. Depois disso, passar o mouse, focar ou clicar numa etapa abre ela: o painel cresce, os outros se comprimem, e o rótulo mostra a descrição. Abaixo de `lg` a linha continua horizontal, com rolagem lateral e painéis de larguras iguais. Com "reduzir movimento", tudo aparece pronto, com a primeira etapa aberta.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| itensobrigatória | EtapaInterativa[] | — | |
| alturaDoPainel | string | number | 440 | Altura dos painéis: px (número) ou qualquer medida do CSS ("60vh", "28rem"). |
| crescimentoAberto | number | 2.8 | Quantas vezes o painel aberto cresce em relação aos outros (`flex-grow`), a partir de telas largas. |
| duracaoDaLinha | number | 3000 | Duração (ms) do desenho da linha na entrada. Os marcos e painéis acompanham. |
| fundo | "page" | "surface" | "page" | Token do fundo onde o componente fica. A borda dos marcos usa essa cor para abrir um vão na linha, e os painéis usam a outra superfície para contrastar. |
| aoMudar | (indice: number) => void | — | Chamada quando outra etapa é aberta, com o índice dela. |
Código
stories.tsx tem os exemplos usados no preview e no playground.
"use client";
import { useEffect, useId, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { CURVA_SUAVE, useEmTela, useSemMovimento } from "@/library/revelar/Movimento";
/** Curva com um leve passo além do fim: o marco "salta" ao aparecer. */
const CURVA_SALTO = "cubic-bezier(0.34, 1.56, 0.64, 1)";
/** Classes que dependem do fundo da seção (escritas por extenso para o Tailwind encontrar). */
const CORES = {
page: { borda: "border-page", cartao: "bg-surface", rotulo: "bg-surface/95" },
surface: { borda: "border-surface", cartao: "bg-page", rotulo: "bg-page/95" },
} as const;
export type EtapaInterativa = {
titulo: string;
/** Aparece no rótulo da etapa quando ela está aberta. */
descricao: ReactNode;
/** Imagem ou ilustração que ocupa o painel inteiro (use `size-full` ou `absolute inset-0`). Sem ela, o painel mostra o número da etapa. */
midia?: ReactNode;
};
/**
* Processo em etapas numa linha do tempo horizontal.
*
* Entrada (uma vez, ao aparecer na tela): a linha se desenha da esquerda para a
* direita, cada marco numerado salta quando a linha chega nele e os painéis
* sobem; no fim, a primeira etapa se abre. Depois disso, passar o mouse, focar
* ou clicar numa etapa abre ela: o painel cresce, os outros se comprimem, e o
* rótulo mostra a descrição.
*
* Abaixo de `lg` a linha continua horizontal, com rolagem lateral e painéis de
* larguras iguais. Com "reduzir movimento", tudo aparece pronto, com a primeira
* etapa aberta.
*/
export function EtapasInterativas({
itens,
alturaDoPainel = 440,
crescimentoAberto = 2.8,
duracaoDaLinha = 3000,
fundo = "page",
aoMudar,
}: {
itens: EtapaInterativa[];
/** Altura dos painéis: px (número) ou qualquer medida do CSS ("60vh", "28rem"). */
alturaDoPainel?: number | string;
/** Quantas vezes o painel aberto cresce em relação aos outros (`flex-grow`), a partir de telas largas. */
crescimentoAberto?: number;
/** Duração (ms) do desenho da linha na entrada. Os marcos e painéis acompanham. */
duracaoDaLinha?: number;
/**
* Token do fundo onde o componente fica. A borda dos marcos usa essa cor para
* abrir um vão na linha, e os painéis usam a outra superfície para contrastar.
*/
fundo?: "page" | "surface";
/** Chamada quando outra etapa é aberta, com o índice dela. */
aoMudar?: (indice: number) => void;
}) {
const id = useId();
const ref = useRef<HTMLDivElement>(null);
const semMovimento = useSemMovimento();
const emTela = useEmTela(ref, { margem: "0px 0px -35% 0px" });
const [escolhido, setEscolhido] = useState<number | null>(null);
const [entradaConcluida, setEntradaConcluida] = useState(false);
const entrou = semMovimento || emTela;
// Até alguém escolher, a primeira etapa abre quando a entrada termina.
const aberto = escolhido ?? (semMovimento || entradaConcluida ? 0 : null);
useEffect(() => {
if (!emTela || semMovimento || entradaConcluida) return;
const espera = setTimeout(() => setEntradaConcluida(true), duracaoDaLinha);
return () => clearTimeout(espera);
}, [emTela, semMovimento, entradaConcluida, duracaoDaLinha]);
const escolher = (i: number) => {
if (i === aberto) return;
setEscolhido(i);
aoMudar?.(i);
};
const cores = CORES[fundo];
const altura = typeof alturaDoPainel === "number" ? `${alturaDoPainel}px` : alturaDoPainel;
const n = itens.length;
return (
// Abaixo de lg: rolagem lateral, sem barra visível (os botões em foco rolam até aparecer).
<div
ref={ref}
className="w-full overflow-x-auto pb-4 [scrollbar-width:none] lg:overflow-visible [&::-webkit-scrollbar]:hidden"
>
<div
className="relative min-w-[calc(var(--quantidade)*13rem)] lg:min-w-0"
style={{ "--quantidade": n, "--altura-painel": altura } as CSSProperties}
>
{/* Linha na altura do centro dos marcos (size-10): trilho e traço que se desenha. */}
<div aria-hidden="true" className="absolute inset-x-0 top-5 h-px bg-rule" />
<div
aria-hidden="true"
className="absolute inset-x-0 top-5 h-px origin-left bg-accent"
style={{
transform: `scaleX(${entrou ? 1 : 0})`,
transition: semMovimento ? undefined : `transform ${duracaoDaLinha}ms linear`,
}}
/>
<ol className="flex gap-4 lg:gap-5">
{itens.map((etapa, i) => {
const estaAberta = aberto === i;
// Instante em que o traço chega ao marco, que fica na borda esquerda da coluna i.
const atraso = Math.round((duracaoDaLinha * i) / n);
return (
<li
key={`${etapa.titulo}-${i}`}
className="relative min-w-0 grow basis-0 lg:grow-(--crescimento)"
style={
{
"--crescimento": estaAberta ? crescimentoAberto : 1,
transition: semMovimento ? undefined : `flex-grow 650ms ${CURVA_SUAVE}`,
} as CSSProperties
}
onMouseEnter={() => escolher(i)}
onFocus={() => escolher(i)}
// O clique em qualquer parte do painel abre a etapa (o botão do título também chega aqui).
onClick={() => escolher(i)}
>
{/* Marco: o número fica sobre a linha; a borda na cor do fundo abre um vão nela. */}
<span
aria-hidden="true"
className={`absolute top-0 left-0 z-10 grid size-10 place-items-center rounded-full border-4 font-display text-lg leading-none ${cores.borda} ${
estaAberta ? "bg-accent text-page" : `${cores.cartao} text-ink/45`
}`}
style={{
transform: entrou ? "scale(1)" : "scale(0)",
opacity: entrou ? 1 : 0,
transition: semMovimento
? undefined
: `transform 400ms ${CURVA_SALTO} ${atraso}ms, opacity 300ms ease-out ${atraso}ms, background-color 500ms, color 500ms`,
}}
>
{i + 1}
</span>
<div
className="pt-16"
style={{
transform: entrou ? "none" : "translateY(28px)",
opacity: entrou ? 1 : 0,
transition: semMovimento
? undefined
: `transform 550ms ${CURVA_SUAVE} ${atraso + 80}ms, opacity 550ms ${CURVA_SUAVE} ${atraso + 80}ms`,
}}
>
<div className={`relative h-(--altura-painel) overflow-hidden rounded-3xl ${cores.cartao}`}>
{etapa.midia ?? (
<span
aria-hidden="true"
className="absolute inset-0 grid place-items-center font-display text-7xl text-ink/10"
>
{i + 1}
</span>
)}
{/* Rótulo: só o título quando comprimida; a descrição aparece na etapa aberta. */}
<div
className={`absolute inset-x-3 bottom-3 rounded-[max(var(--radius-sm),calc(var(--radius-3xl)_-_var(--spacing)*3))] p-4 backdrop-blur-sm lg:inset-x-4 lg:bottom-4 lg:rounded-[max(var(--radius-sm),calc(var(--radius-3xl)_-_var(--spacing)*4))] lg:p-5 ${cores.rotulo}`}
>
<h3>
<button
type="button"
aria-expanded={estaAberta}
aria-controls={`${id}-descricao-${i}`}
className="block w-full truncate text-left font-semibold text-ink outline-accent focus-visible:outline-2 focus-visible:outline-offset-4 lg:text-lg"
>
{etapa.titulo}
</button>
</h3>
<div
id={`${id}-descricao-${i}`}
inert={!estaAberta}
className={`grid ${semMovimento ? "" : "transition-all duration-500 ease-out"} ${
estaAberta ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
}`}
>
<div className="overflow-hidden">
<div className="pt-2 text-sm leading-relaxed text-ink-2 lg:text-base">{etapa.descricao}</div>
</div>
</div>
</div>
</div>
</div>
</li>
);
})}
</ol>
</div>
</div>
);
}
import { useState, type ReactNode } from "react";
import { defineStories } from "@/registry/stories";
import { EtapasInterativas, type EtapaInterativa } from "./EtapasInterativas";
/** Mídia de exemplo: painel na cor de uma série do tema com um ícone grande (sem arquivos externos). */
function Painel({ serie, children }: { serie: number; children: ReactNode }) {
const cor = `var(--series-${serie})`;
return (
<div
className="absolute inset-0 grid place-items-center pb-24"
style={{ backgroundColor: `color-mix(in oklab, ${cor} 16%, var(--surface))`, color: cor }}
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="size-24"
fill="none"
stroke="currentColor"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
>
{children}
</svg>
</div>
);
}
const ETAPAS: EtapaInterativa[] = [
{
titulo: "Conversa inicial",
descricao: "Entendemos o que você precisa, o prazo e o orçamento numa reunião de meia hora.",
midia: (
<Painel serie={1}>
<path d="M21 12a8 8 0 0 1-11.6 7.1L4 20l1-4.6A8 8 0 1 1 21 12Z" />
</Painel>
),
},
{
titulo: "Proposta",
descricao: "Em até três dias você recebe o escopo, o cronograma e o valor fechado, sem letras miúdas.",
midia: (
<Painel serie={2}>
<path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9l-6-6Z" />
<path d="M14 3v6h6M8 13h8M8 17h5" />
</Painel>
),
},
{
titulo: "Produção",
descricao: "A equipe trabalha em ciclos de uma semana e mostra o andamento ao fim de cada um.",
midia: (
<Painel serie={3}>
<circle cx="12" cy="12" r="3" />
<path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1" />
</Painel>
),
},
{
titulo: "Entrega e suporte",
descricao: "Entregamos com documentação e acompanhamos os primeiros meses de uso de perto.",
midia: (
<Painel serie={4}>
<path d="m5 12 5 5L20 7" />
</Painel>
),
},
];
const SEM_MIDIA = ETAPAS.map(({ titulo, descricao }) => ({ titulo, descricao }));
type Valores = {
quantidade: number;
comMidia: boolean;
crescimentoAberto: number;
duracaoDaLinha: number;
alturaDoPainel: number;
};
/** Playground com um botão para ver a entrada de novo (a chave remonta o componente). */
function ExemploComRepetir(a: Valores) {
const [vez, setVez] = useState(0);
return (
<div className="flex w-full max-w-6xl flex-col items-start gap-8">
<EtapasInterativas
key={`${vez}-${a.quantidade}-${a.duracaoDaLinha}`}
itens={(a.comMidia ? ETAPAS : SEM_MIDIA).slice(0, a.quantidade)}
crescimentoAberto={a.crescimentoAberto}
duracaoDaLinha={a.duracaoDaLinha}
alturaDoPainel={a.alturaDoPainel}
/>
<button
type="button"
onClick={() => setVez((v) => v + 1)}
className="rounded-full border border-rule-strong px-4 py-2 text-sm font-medium text-ink outline-accent hover:bg-surface focus-visible:outline-2 focus-visible:outline-offset-2"
>
Ver a entrada de novo
</button>
</div>
);
}
export default defineStories({
args: { quantidade: 4, comMidia: true, crescimentoAberto: 2.8, duracaoDaLinha: 3000, alturaDoPainel: 400 },
controls: {
quantidade: { type: "number", label: "etapas (quantidade)", min: 2, max: 4 },
comMidia: { type: "boolean", label: "com mídia" },
crescimentoAberto: { type: "number", min: 1, max: 5, step: 0.2 },
duracaoDaLinha: { type: "number", label: "duracaoDaLinha (ms)", min: 300, max: 6000, step: 100 },
alturaDoPainel: { type: "number", label: "alturaDoPainel (px)", min: 200, max: 600, step: 20 },
},
render: (a) => <ExemploComRepetir {...a} />,
stories: [
{ name: "Com mídia" },
{ name: "Sem mídia (número no painel)", args: { comMidia: false, alturaDoPainel: 320 } },
{
name: "Sobre fundo surface",
render: () => (
<div className="w-full max-w-6xl rounded-3xl bg-surface p-6 sm:p-10">
<EtapasInterativas itens={SEM_MIDIA.slice(0, 3)} fundo="surface" alturaDoPainel={300} duracaoDaLinha={1800} />
</div>
),
},
],
});
Para usar em outro projeto, copie também
Os componentes de que este depende e o tema. O CSS do tema é um arquivo só: importe depois do Tailwind.
Componentes da biblioteca (diretos e indiretos)
Tema escolhido no cabeçalho (com a ponte para o Tailwind)
Ver o CSS do tema
/* Tema "Editorial", gerado no Component Vault.
Importe depois de @import "tailwindcss". Carregue as fontes "Archivo" e "Inter" no projeto. */
@theme inline {
--color-page: var(--page);
--color-surface: var(--surface);
--color-track: var(--track);
--color-grid: var(--grid);
--color-ink: var(--ink);
--color-ink-2: var(--ink-2);
--color-muted: var(--muted);
--color-rule: var(--rule);
--color-rule-strong: var(--rule-strong);
--color-axis: var(--axis);
--color-accent: var(--accent);
--color-success: var(--success);
--color-warning: var(--warning);
--color-critical: var(--critical);
--font-display: var(--fonte-titulo);
--shadow-flutuante: var(--sombra-flutuante);
}
:root {
color-scheme: light dark;
--page: light-dark(#ffffff, #0f0f0e);
--surface: light-dark(#f5f5f5, #1a1a19);
--track: light-dark(#f0f0f0, #262624);
--grid: light-dark(#ededed, #232321);
--ink: light-dark(#111111, #f0efec);
--ink-2: light-dark(#4a4a4a, #c3c2b7);
--muted: light-dark(#666666, #a3a29a);
--rule: light-dark(#e3e3e3, #2c2c2a);
--rule-strong: light-dark(#111111, #f0efec);
--axis: light-dark(#9a9a9a, #6b6a64);
--accent: light-dark(#1a56db, #7aa2f7);
--success: light-dark(#13865c, #3fc48f);
--warning: #fab219;
--critical: #d03b3b;
--series-1: light-dark(#2a78d6, #3987e5);
--series-2: light-dark(#eb6834, #d95926);
--series-3: light-dark(#1baf7a, #199e70);
--series-4: light-dark(#eda100, #c98500);
--series-5: light-dark(#e87ba4, #d55181);
--series-6: #008300;
--series-7: light-dark(#6250d6, #9085e9);
--series-8: light-dark(#e34948, #e66767);
--fonte-titulo: "Archivo", system-ui, -apple-system, "Segoe UI", sans-serif;
--fonte-texto: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
--spacing: 0.25rem;
--text-xs: 0.8rem;
--text-sm: 0.8944rem;
--text-base: 1rem;
--text-lg: 1.118rem;
--text-xl: 1.25rem;
--text-2xl: 1.5625rem;
--text-3xl: 1.9531rem;
--text-4xl: 2.4414rem;
--text-5xl: 3.0518rem;
--text-6xl: 3.8147rem;
--text-7xl: 4.7684rem;
--radius-xs: 2px;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-2xl: 16px;
--radius-3xl: 24px;
--radius-4xl: 32px;
--sombra-flutuante: 0 8px 24px -6px rgb(0 0 0 / 0.14), 0 2px 6px -2px rgb(0 0 0 / 0.08);
color: var(--ink);
font-family: var(--fonte-texto);
}
Na vitrine, a ponte com o Tailwind fica em src/themes/tokens.css:
/* Ponte entre os tokens do tema e o Tailwind.
Os componentes da biblioteca usam classes comuns do Tailwind (p-5, text-sm,
rounded-xl...) e as cores semânticas abaixo (bg-page, text-ink...). Dentro de
[data-theme="vault"], o tema redefine as variáveis que essas classes leem:
--spacing, --text-*, --radius-* e as cores. Os valores vêm do tema escolhido
na vitrine (src/themes/presets.ts) e são gerados por themeCss(). */
@theme inline {
--color-page: var(--page);
--color-surface: var(--surface);
--color-ink: var(--ink);
--color-ink-2: var(--ink-2);
--color-muted: var(--muted);
--color-rule: var(--rule);
--color-rule-strong: var(--rule-strong);
--color-track: var(--track);
--color-grid: var(--grid);
--color-axis: var(--axis);
--color-accent: var(--accent);
--color-success: var(--success);
--color-warning: var(--warning);
--color-critical: var(--critical);
--font-display: var(--fonte-titulo);
--shadow-flutuante: var(--sombra-flutuante);
}