Pilha de cartões
Cartões que grudam no topo ao rolar: o seguinte sobe por cima e o de baixo recua, encolhendo e perdendo opacidade. Feito com position: sticky e um cálculo por quadro, sem bibliotecas; em telas pequenas vira uma lista simples.
Esta variação é uma composição fixa. Escolha outra para mexer nas props no playground.
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<PilhaDeCartoes>
Cartões que se empilham ao rolar: cada um gruda no topo da tela, o seguinte sobe por cima, e o de baixo recua (encolhe, desce um pouco e perde opacidade), ficando como uma camada atrás da pilha. O texto viaja junto com o cartão. Os cartões ficam no fluxo normal com `position: sticky`; um listener de rolagem (um cálculo por quadro) mede quanto o próximo cartão já cobriu cada um e aplica o recuo. Abaixo de `lg`, ou com "reduzir movimento", vira uma lista vertical simples.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| itensobrigatória | CartaoDaPilha[] | — | |
| topo | string | number | 96 | Distância do topo da tela em que os cartões grudam: px (número) ou medida do CSS ("6rem", "10vh"). |
| degrau | number | 0 | Px a mais no topo de cada cartão em relação ao anterior. 0 empilha todos no mesmo lugar. |
| alturaDoCartao | string | number | "max(46vh, 22rem)" | Altura de cada cartão em telas largas: px (número) ou medida do CSS. |
| escalaAoSair | number | 0.95 | Escala do cartão já coberto (1 = não encolhe). |
| opacidadeAoSair | number | 0.35 | Opacidade do cartão já coberto. |
| deslocamentoAoSair | number | 45 | Quanto (px) o cartão já coberto desce. |
Código
stories.tsx tem os exemplos usados no preview e no playground.
"use client";
import { useEffect, useRef, type CSSProperties, type ReactNode } from "react";
import { useSemMovimento } from "@/library/revelar/Movimento";
const limitar = (n: number) => Math.min(Math.max(n, 0), 1);
/** Saída suave: o cartão recua rápido no começo da cobertura e assenta no fim. */
const suavizar = (t: number) => 1 - (1 - t) ** 2;
const medida = (v: number | string) => (typeof v === "number" ? `${v}px` : v);
export type CartaoDaPilha = {
titulo: string;
descricao: ReactNode;
/** Imagem ou ilustração: coluna à direita em telas largas, faixa no topo nas pequenas. Ocupa a área inteira (use `size-full` ou `absolute inset-0`). */
midia?: ReactNode;
/** Botão ou link no pé do texto do cartão. */
acao?: ReactNode;
};
/**
* Cartões que se empilham ao rolar: cada um gruda no topo da tela, o seguinte
* sobe por cima, e o de baixo recua (encolhe, desce um pouco e perde opacidade),
* ficando como uma camada atrás da pilha. O texto viaja junto com o cartão.
*
* Os cartões ficam no fluxo normal com `position: sticky`; um listener de
* rolagem (um cálculo por quadro) mede quanto o próximo cartão já cobriu cada
* um e aplica o recuo. Abaixo de `lg`, ou com "reduzir movimento", vira uma
* lista vertical simples.
*/
export function PilhaDeCartoes({
itens,
topo = 96,
degrau = 0,
alturaDoCartao = "max(46vh, 22rem)",
escalaAoSair = 0.95,
opacidadeAoSair = 0.35,
deslocamentoAoSair = 45,
}: {
itens: CartaoDaPilha[];
/** Distância do topo da tela em que os cartões grudam: px (número) ou medida do CSS ("6rem", "10vh"). */
topo?: number | string;
/** Px a mais no topo de cada cartão em relação ao anterior. 0 empilha todos no mesmo lugar. */
degrau?: number;
/** Altura de cada cartão em telas largas: px (número) ou medida do CSS. */
alturaDoCartao?: number | string;
/** Escala do cartão já coberto (1 = não encolhe). */
escalaAoSair?: number;
/** Opacidade do cartão já coberto. */
opacidadeAoSair?: number;
/** Quanto (px) o cartão já coberto desce. */
deslocamentoAoSair?: number;
}) {
const ref = useRef<HTMLUListElement>(null);
const semMovimento = useSemMovimento();
const empilhar = !semMovimento && itens.length > 1;
useEffect(() => {
const lista = ref.current;
if (!empilhar || !lista) return;
const lis = Array.from(lista.children) as HTMLElement[];
// O recuo vai no cartão de dentro: o <li> grudado fica sem transformação e serve de régua.
const cartoes = lis.map((li) => li.firstElementChild as HTMLElement | null);
const limpar = () => {
cartoes.forEach((cartao) => {
cartao?.style.removeProperty("transform");
cartao?.style.removeProperty("opacity");
cartao?.style.removeProperty("will-change");
});
};
let quadro = 0;
const atualizar = () => {
quadro = 0;
// Abaixo de lg os cartões não grudam: lista simples, sem recuo.
if (getComputedStyle(lis[0]).position !== "sticky") {
limpar();
return;
}
for (let i = 0; i < lis.length - 1; i++) {
const cartao = cartoes[i];
if (!cartao) continue;
const atual = lis[i].getBoundingClientRect();
const proximo = lis[i + 1].getBoundingClientRect();
// Cobertura total: o próximo grudado, um `degrau` abaixo do topo deste.
const vao = parseFloat(getComputedStyle(lis[i + 1]).top) - parseFloat(getComputedStyle(lis[i]).top);
const total = atual.height - (Number.isFinite(vao) ? vao : 0);
const coberto = total > 0 ? limitar((atual.bottom - proximo.top) / total) : 0;
const t = suavizar(coberto);
cartao.style.transform = `translateY(${t * deslocamentoAoSair}px) scale(${1 - t * (1 - escalaAoSair)})`;
cartao.style.opacity = String(1 - t * (1 - opacidadeAoSair));
cartao.style.willChange = "transform, opacity";
}
};
const agendar = () => {
if (!quadro) quadro = requestAnimationFrame(atualizar);
};
agendar();
window.addEventListener("scroll", agendar, { passive: true });
window.addEventListener("resize", agendar);
return () => {
cancelAnimationFrame(quadro);
window.removeEventListener("scroll", agendar);
window.removeEventListener("resize", agendar);
limpar();
};
}, [empilhar, itens.length, topo, degrau, alturaDoCartao, escalaAoSair, opacidadeAoSair, deslocamentoAoSair]);
return (
<ul
ref={ref}
className="flex w-full flex-col gap-6 lg:gap-10"
style={{ "--altura-cartao": medida(alturaDoCartao) } as CSSProperties}
>
{itens.map((item, i) => (
<li
key={`${item.titulo}-${i}`}
className={empilhar ? "lg:sticky lg:top-(--topo-cartao) lg:h-(--altura-cartao)" : "lg:min-h-(--altura-cartao)"}
style={{ "--topo-cartao": `calc(${medida(topo)} + ${i * degrau}px)` } as CSSProperties}
>
<article className="flex h-full flex-col overflow-hidden rounded-3xl bg-surface lg:flex-row">
{/* A mídia sangra até a borda: faixa no topo em telas pequenas, coluna à direita a partir de lg. */}
{item.midia && (
<div className="relative h-48 w-full shrink-0 sm:h-56 lg:order-2 lg:h-auto lg:w-[54%]">{item.midia}</div>
)}
<div className="flex min-w-0 flex-1 flex-col justify-between gap-10 p-8 lg:order-1 lg:p-12">
<div>
<h3 className="font-display text-2xl font-semibold text-ink lg:text-3xl">{item.titulo}</h3>
<div className="mt-3 max-w-md leading-relaxed text-ink-2 lg:text-lg">{item.descricao}</div>
</div>
{item.acao && <div>{item.acao}</div>}
</div>
</article>
</li>
))}
</ul>
);
}
import type { ReactNode } from "react";
import { defineStories } from "@/registry/stories";
import { PilhaDeCartoes, type CartaoDaPilha } from "./PilhaDeCartoes";
/** Mídia de exemplo: área 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"
style={{ backgroundColor: `color-mix(in oklab, ${cor} 18%, var(--surface))`, color: cor }}
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="size-24 lg:size-32"
fill="none"
stroke="currentColor"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
>
{children}
</svg>
</div>
);
}
function Acao() {
return (
<a
href="#saiba-mais"
onClick={(e) => e.preventDefault()}
className="inline-flex w-max items-center gap-2 rounded-full bg-accent px-5 py-3 text-sm font-medium text-page outline-accent transition-opacity hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2"
>
Saiba mais
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="size-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</a>
);
}
const CARTOES: CartaoDaPilha[] = [
{
titulo: "Lojas virtuais",
descricao: "Do catálogo ao pagamento, uma loja pronta para vender no celular e no computador.",
midia: (
<Painel serie={1}>
<path d="M6 7h12l-1 13H7L6 7Z" />
<path d="M9 7a3 3 0 0 1 6 0" />
</Painel>
),
},
{
titulo: "Aplicativos internos",
descricao: "Ferramentas sob medida para a sua equipe trocar planilhas por telas simples.",
midia: (
<Painel serie={2}>
<rect x="3" y="4" width="18" height="14" rx="2" />
<path d="M8 21h8M12 18v3" />
</Painel>
),
},
{
titulo: "Painéis de dados",
descricao: "Os números do negócio num lugar só, atualizados todo dia e fáceis de ler.",
midia: (
<Painel serie={3}>
<path d="M3 3v18h18" />
<path d="M8 16v-5M13 16V8M18 16v-9" />
</Painel>
),
},
{
titulo: "Manutenção",
descricao: "Atualizações, cópias de segurança e um contato direto quando algo sai do lugar.",
midia: (
<Painel serie={4}>
<path d="M14.7 6.3a4 4 0 0 0-5.4 5.4L3 18l3 3 6.3-6.3a4 4 0 0 0 5.4-5.4l-2.5 2.5-2.4-.6-.6-2.4 2.5-2.5Z" />
</Painel>
),
},
];
const montar = (quantidade: number, comMidia: boolean, comAcao: boolean): CartaoDaPilha[] =>
CARTOES.slice(0, quantidade).map((c) => ({
...c,
midia: comMidia ? c.midia : undefined,
acao: comAcao ? <Acao /> : undefined,
}));
function Cabecalho() {
return (
<header className="mb-10 text-center">
<h2 className="font-display text-4xl font-bold">Nossos serviços</h2>
<p className="mx-auto mt-3 max-w-xl text-ink-2">Role para ver os cartões se empilharem.</p>
</header>
);
}
export default defineStories({
args: {
quantidade: 4,
topo: 48,
degrau: 0,
alturaDoCartao: 360,
escalaAoSair: 0.95,
opacidadeAoSair: 0.35,
deslocamentoAoSair: 45,
comMidia: true,
comAcao: true,
},
controls: {
quantidade: { type: "number", label: "cartões (quantidade)", min: 2, max: 4 },
topo: { type: "number", label: "topo (px)", min: 0, max: 200, step: 8 },
degrau: { type: "number", label: "degrau (px)", min: 0, max: 40, step: 2 },
alturaDoCartao: { type: "number", label: "alturaDoCartao (px)", min: 240, max: 520, step: 20 },
escalaAoSair: { type: "number", min: 0.7, max: 1, step: 0.01 },
opacidadeAoSair: { type: "number", min: 0, max: 1, step: 0.05 },
deslocamentoAoSair: { type: "number", label: "deslocamentoAoSair (px)", min: 0, max: 120, step: 5 },
comMidia: { type: "boolean", label: "com mídia" },
comAcao: { type: "boolean", label: "com ação" },
},
render: (a) => (
<div className="w-full max-w-4xl">
<p className="pb-[50vh] text-center text-sm text-muted">Role para baixo dentro do preview ↓</p>
<PilhaDeCartoes
itens={montar(a.quantidade, a.comMidia, a.comAcao)}
topo={a.topo}
degrau={a.degrau}
alturaDoCartao={a.alturaDoCartao}
escalaAoSair={a.escalaAoSair}
opacidadeAoSair={a.opacidadeAoSair}
deslocamentoAoSair={a.deslocamentoAoSair}
/>
<div className="h-[80vh]" />
</div>
),
stories: [
{
name: "Serviços",
render: () => (
<div className="w-full max-w-4xl">
<Cabecalho />
<PilhaDeCartoes itens={montar(4, true, true)} topo={48} alturaDoCartao={360} />
{/* Espaço depois da pilha: dá rolagem para o último cartão chegar ao topo. */}
<div className="h-[60vh]" />
</div>
),
},
{ name: "Ao rolar (playground)" },
{
name: "Com degrau, sem mídia",
args: { degrau: 16, comMidia: false, alturaDoCartao: 280, deslocamentoAoSair: 0, escalaAoSair: 0.97 },
},
],
});
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);
}