Lista em foco
Pilha de títulos grandes em que só um fica aceso: o mais perto do centro da tela ao rolar, ou o que recebe mouse, foco ou clique. O aceso abre a descrição e uma ação opcional.
Playground
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<ListaEmFoco>
Pilha de títulos grandes em que só um fica aceso por vez: os outros ficam apagados e recolhidos, e o aceso escurece e abre a descrição (e a ação, se houver). Ao rolar, acende o item cujo centro está mais perto do centro da tela; mouse, foco e clique também escolhem, para funcionar sem rolar. Com "reduzir movimento", a rolagem não troca o item (só mouse, foco e clique) e as trocas acontecem sem transição.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| itensobrigatória | ItemEmFoco[] | — | |
| alinhamento | "esquerda" | "centro" | "centro" | Alinhamento dos títulos e textos. |
| ativoInicial | number | 0 | Índice do item aceso antes de qualquer rolagem ou interação. |
| aoMudar | (indice: number) => void | — | Chamada quando outro item acende, com o índice dele. |
Código
stories.tsx tem os exemplos usados no preview e no playground.
"use client";
import { useEffect, useId, useRef, useState, type PointerEvent, type ReactNode } from "react";
import { useSemMovimento } from "@/library/revelar/Movimento";
export type ItemEmFoco = {
titulo: string;
/** Aparece abaixo do título quando o item está aceso. */
descricao: ReactNode;
/** Botão ou link mostrado abaixo da descrição do item aceso. Nos apagados, fica fora do alcance do teclado. */
acao?: ReactNode;
};
/**
* Pilha de títulos grandes em que só um fica aceso por vez: os outros ficam
* apagados e recolhidos, e o aceso escurece e abre a descrição (e a ação, se
* houver).
*
* Ao rolar, acende o item cujo centro está mais perto do centro da tela; mouse,
* foco e clique também escolhem, para funcionar sem rolar. Com "reduzir
* movimento", a rolagem não troca o item (só mouse, foco e clique) e as trocas
* acontecem sem transição.
*/
export function ListaEmFoco({
itens,
alinhamento = "centro",
ativoInicial = 0,
aoMudar,
}: {
itens: ItemEmFoco[];
/** Alinhamento dos títulos e textos. */
alinhamento?: "centro" | "esquerda";
/** Índice do item aceso antes de qualquer rolagem ou interação. */
ativoInicial?: number;
/** Chamada quando outro item acende, com o índice dele. */
aoMudar?: (indice: number) => void;
}) {
const id = useId();
const ref = useRef<HTMLUListElement>(null);
const semMovimento = useSemMovimento();
const [ativo, setAtivo] = useState(ativoInicial);
// Avisa a troca depois que ela acontece, venha da rolagem ou da interação.
// A ref guarda o último índice avisado, para não repetir o aviso.
const avisado = useRef(ativoInicial);
useEffect(() => {
if (avisado.current === ativo) return;
avisado.current = ativo;
aoMudar?.(ativo);
}, [ativo, aoMudar]);
useEffect(() => {
const lista = ref.current;
if (semMovimento || !lista) return;
let quadro = 0;
// Um item por vez, sempre o mais perto do centro: com faixas fixas por
// item, rolagens rápidas pulariam itens ou deixariam buracos sem nenhum aceso.
const escolherPeloCentro = () => {
quadro = 0;
const altura = window.innerHeight;
const caixa = lista.getBoundingClientRect();
if (caixa.bottom < 0 || caixa.top > altura) return;
const meio = altura / 2;
let perto = 0;
let menor = Infinity;
Array.from(lista.children).forEach((item, i) => {
const c = item.getBoundingClientRect();
const distancia = Math.abs(c.top + c.height / 2 - meio);
if (distancia < menor) {
menor = distancia;
perto = i;
}
});
setAtivo(perto);
};
const agendar = () => {
if (!quadro) quadro = requestAnimationFrame(escolherPeloCentro);
};
agendar();
window.addEventListener("scroll", agendar, { passive: true });
window.addEventListener("resize", agendar);
return () => {
cancelAnimationFrame(quadro);
window.removeEventListener("scroll", agendar);
window.removeEventListener("resize", agendar);
};
}, [semMovimento]);
// Ao rolar com o mouse parado, o navegador avisa que o ponteiro "entrou" no
// item que passou por baixo dele, e isso brigaria com a escolha pelo centro.
// Por isso o mouse só escolhe quando o ponteiro se move de verdade.
const ponteiro = useRef({ x: -1, y: -1 });
const aoMoverPonteiro = (i: number, e: PointerEvent<HTMLLIElement>) => {
if (e.pointerType !== "mouse") return;
const { clientX: x, clientY: y } = e;
if (ponteiro.current.x === x && ponteiro.current.y === y) return;
ponteiro.current = { x, y };
setAtivo(i);
};
const centro = alinhamento === "centro";
const transicao = semMovimento ? "" : "transition-all duration-500 ease-out";
return (
<ul ref={ref} className={`w-full max-w-4xl ${centro ? "mx-auto text-center" : "text-left"}`}>
{itens.map((item, i) => {
const aceso = i === ativo;
return (
<li key={`${item.titulo}-${i}`} className="py-8 lg:py-12" onPointerMove={(e) => aoMoverPonteiro(i, e)}>
<h3>
<button
type="button"
aria-expanded={aceso}
aria-controls={`${id}-item-${i}`}
onClick={() => setAtivo(i)}
onFocus={() => setAtivo(i)}
className={`block w-full font-display text-4xl leading-[1.05] tracking-[-0.01em] outline-accent focus-visible:outline-2 focus-visible:outline-offset-4 sm:text-5xl lg:text-7xl ${
centro ? "text-center" : "text-left"
} ${semMovimento ? "" : "transition-colors duration-500"} ${aceso ? "text-ink" : "text-ink/20 hover:text-ink/40"}`}
>
{item.titulo}
</button>
</h3>
{/* Grade de uma linha que anima de 0fr a 1fr: a altura segue o conteúdo, sem max-height chutado.
Recolhido, o trecho fica inerte: some do teclado e dos leitores de tela. */}
<div
id={`${id}-item-${i}`}
inert={!aceso}
className={`grid ${transicao} ${aceso ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"}`}
>
<div className="overflow-hidden px-1 pb-1">
<div className={`mt-5 max-w-xl leading-relaxed text-ink-2 lg:text-lg ${centro ? "mx-auto" : ""}`}>
{item.descricao}
</div>
{item.acao && <div className="mt-6">{item.acao}</div>}
</div>
</div>
</li>
);
})}
</ul>
);
}
import { defineStories } from "@/registry/stories";
import { ListaEmFoco, type ItemEmFoco } from "./ListaEmFoco";
function Acao({ children }: { children: string }) {
return (
<button
type="button"
className="inline-flex h-11 items-center rounded-full bg-accent px-6 text-sm font-medium text-page outline-accent transition-opacity hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2"
>
{children}
</button>
);
}
const SERVICOS = [
{
titulo: "Consultoria",
descricao: "Um diagnóstico do seu processo atual e um plano com prioridades, prazos e custos estimados.",
},
{
titulo: "Treinamentos",
descricao: "Turmas presenciais ou online para a sua equipe, com material próprio e certificado.",
},
{
titulo: "Suporte técnico",
descricao: "Atendimento em horário comercial por chat e telefone, com resposta em até duas horas.",
},
{
titulo: "Análise de dados",
descricao: "Painéis e relatórios com os números que importam, atualizados todo dia.",
},
{
titulo: "Projetos sob medida",
descricao: "Quando nada pronto serve: desenhamos e construímos junto com você.",
},
];
const itens = (quantidade: number, comAcao: boolean): ItemEmFoco[] =>
SERVICOS.slice(0, quantidade).map((s) => ({ ...s, acao: comAcao ? <Acao>Falar com a equipe</Acao> : undefined }));
export default defineStories({
args: { quantidade: 5, alinhamento: "centro", ativoInicial: 0, comAcao: true, espacos: true },
controls: {
quantidade: { type: "number", label: "itens (quantidade)", min: 2, max: 5 },
alinhamento: { type: "select", options: ["centro", "esquerda"] },
ativoInicial: { type: "number", min: 0, max: 4 },
comAcao: { type: "boolean", label: "com ação" },
espacos: { type: "boolean", label: "espaço antes e depois (para rolar)" },
},
render: (a) => (
<div className="w-full max-w-5xl">
{a.espacos && <p className="pb-[45vh] text-center text-sm text-muted">Role para baixo dentro do preview ↓</p>}
<ListaEmFoco
key={`${a.quantidade}-${a.ativoInicial}`}
itens={itens(a.quantidade, a.comAcao)}
alinhamento={a.alinhamento as "centro" | "esquerda"}
ativoInicial={Math.min(a.ativoInicial, a.quantidade - 1)}
/>
{a.espacos && <div className="h-[50vh]" />}
</div>
),
stories: [
{ name: "Lista", args: { quantidade: 4, espacos: false } },
{ name: "Ao rolar (playground)" },
{ name: "À esquerda, sem ação", args: { alinhamento: "esquerda", comAcao: false } },
],
});
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);
}