Acordeão com mídia
Lista em que só um item fica aberto (por mouse, foco ou clique) e um painel ao lado troca a imagem ou o vídeo do item aberto, cruzando a opacidade. Inclui o VideoControlado, que só toca quando mandado.
Playground
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<AcordeaoComMidia>
Lista de itens de um lado e um painel de mídia do outro. Só um item fica aberto: o que recebe o mouse, o foco do teclado ou o clique. A mídia troca junto, cruzando a opacidade; todas ficam montadas, mas só a do item aberto aparece. Em telas pequenas a mídia vem antes da lista, mais baixa (16:9), para não empurrar o texto para fora da tela.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| itensobrigatória | ItemDoAcordeao[] | — | |
| ativoInicial | number | 0 | Índice do item aberto no começo. |
| proporcaoDaMidia | string | "4 / 3" | Proporção do painel de mídia a partir de telas largas (`aspect-ratio` do CSS: "4 / 3", "1", "16 / 9"). |
| ladoDaMidia | "direita" | "esquerda" | "direita" | De que lado da lista fica a mídia em telas largas. |
| aoMudar | (indice: number) => void | — | Chamada quando outro item é aberto, com o índice dele. |
<VideoControlado>
Vídeo mudo, em loop, que só toca quando `tocando` é verdadeiro: quem usa decide quando (por exemplo, só o item ativo e em tela). Preenche o contêiner posicionado em que está, cortando as bordas como uma imagem de capa. Detalhes: `playbackRate` não existe como atributo HTML, então é aplicado por script e reaplicado quando a fonte carrega; e com "reduzir movimento" o vídeo fica parado no primeiro quadro (ou no `poster`).
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| srcobrigatória | string | — | Endereço do arquivo de vídeo. |
| tocando | boolean | true | Toca quando verdadeiro e pausa quando falso. |
| velocidade | number | 1 | Velocidade de reprodução (`playbackRate`): 0,5 é metade da velocidade. |
| poster | string | — | Imagem mostrada antes de o vídeo carregar. |
| rotulo | string | — | Descrição para leitores de tela. Sem ela, o vídeo é tratado como decorativo. |
| className | string | "absolute inset-0 h-full w-full object-cover" | Classes do elemento `<video>` (o padrão cobre o contêiner posicionado). |
Código
stories.tsx tem os exemplos usados no preview e no playground.
"use client";
import { useId, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { useEmTela, useSemMovimento } from "@/library/revelar/Movimento";
/** O que a mídia de um item recebe quando é uma função. */
export type EstadoDaMidia = {
/** Verdadeiro para a mídia do item aberto. */
ativa: boolean;
/** Verdadeiro enquanto o componente está na tela (ou perto dela). */
emTela: boolean;
};
export type ItemDoAcordeao = {
titulo: string;
texto: ReactNode;
/**
* Conteúdo do painel de mídia quando o item está aberto: imagem, vídeo,
* ilustração. Ocupa o painel inteiro (use `size-full` ou `absolute inset-0`).
* Como função, recebe `{ ativa, emTela }`, por exemplo para tocar um
* `VideoControlado` só quando ele aparece.
*/
midia: ReactNode | ((estado: EstadoDaMidia) => ReactNode);
};
/**
* Lista de itens de um lado e um painel de mídia do outro. Só um item fica
* aberto: o que recebe o mouse, o foco do teclado ou o clique. A mídia troca
* junto, cruzando a opacidade; todas ficam montadas, mas só a do item aberto
* aparece.
*
* Em telas pequenas a mídia vem antes da lista, mais baixa (16:9), para não
* empurrar o texto para fora da tela.
*/
export function AcordeaoComMidia({
itens,
ativoInicial = 0,
proporcaoDaMidia = "4 / 3",
ladoDaMidia = "direita",
aoMudar,
}: {
itens: ItemDoAcordeao[];
/** Índice do item aberto no começo. */
ativoInicial?: number;
/** Proporção do painel de mídia a partir de telas largas (`aspect-ratio` do CSS: "4 / 3", "1", "16 / 9"). */
proporcaoDaMidia?: string;
/** De que lado da lista fica a mídia em telas largas. */
ladoDaMidia?: "direita" | "esquerda";
/** Chamada quando outro item é aberto, com o índice dele. */
aoMudar?: (indice: number) => void;
}) {
const id = useId();
const ref = useRef<HTMLDivElement>(null);
const semMovimento = useSemMovimento();
// A margem adianta o gatilho: um vídeo tem tempo de carregar antes de aparecer.
const emTela = useEmTela(ref, { margem: "300px 0px", umaVez: false });
const [ativo, setAtivo] = useState(ativoInicial);
const escolher = (i: number) => {
if (i === ativo) return;
setAtivo(i);
aoMudar?.(i);
};
const transicao = semMovimento ? "" : "transition-all duration-500 ease-out";
const midiaADireita = ladoDaMidia === "direita";
return (
<div
ref={ref}
className={`grid items-center gap-10 lg:gap-16 ${midiaADireita ? "lg:grid-cols-[1.15fr_0.85fr]" : "lg:grid-cols-[0.85fr_1.15fr]"}`}
>
{/* Painel único de mídia: uma camada por item, só a do aberto visível. */}
<div
className={`relative aspect-video overflow-hidden rounded-3xl bg-surface lg:aspect-(--proporcao) ${midiaADireita ? "lg:order-2" : ""}`}
style={{ "--proporcao": proporcaoDaMidia } as CSSProperties}
>
{itens.map((item, i) => {
const ativa = i === ativo;
return (
<div
key={`${item.titulo}-${i}`}
aria-hidden={!ativa}
inert={!ativa}
className={`absolute inset-0 ${transicao} ${ativa ? "opacity-100" : "opacity-0"}`}
>
{typeof item.midia === "function" ? item.midia({ ativa, emTela }) : item.midia}
</div>
);
})}
</div>
<ul className={midiaADireita ? "lg:order-1" : ""}>
{itens.map((item, i) => {
const aberto = i === ativo;
return (
<li
key={`${item.titulo}-${i}`}
onMouseEnter={() => escolher(i)}
className={i > 0 ? "border-t border-rule" : ""}
>
<h3>
<button
type="button"
id={`${id}-titulo-${i}`}
aria-expanded={aberto}
aria-controls={`${id}-texto-${i}`}
onClick={() => escolher(i)}
onFocus={() => escolher(i)}
className={`block w-full py-5 text-left font-display text-xl font-semibold outline-accent focus-visible:outline-2 focus-visible:outline-offset-2 lg:py-6 lg:text-2xl ${semMovimento ? "" : "transition-colors duration-500"} ${aberto ? "text-ink" : "text-ink/35 hover:text-ink/60"}`}
>
{item.titulo}
</button>
</h3>
{/* Grade de uma linha que anima de 0fr a 1fr: a altura segue o texto, sem max-height chutado. */}
<div
id={`${id}-texto-${i}`}
inert={!aberto}
className={`grid ${transicao} ${aberto ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"}`}
>
<div className="overflow-hidden">
<div className="max-w-md pb-6 leading-relaxed text-ink-2">{item.texto}</div>
</div>
</div>
</li>
);
})}
</ul>
</div>
);
}
"use client";
import { useEffect, useRef } from "react";
import { useSemMovimento } from "@/library/revelar/Movimento";
/**
* Vídeo mudo, em loop, que só toca quando `tocando` é verdadeiro: quem usa
* decide quando (por exemplo, só o item ativo e em tela). Preenche o contêiner
* posicionado em que está, cortando as bordas como uma imagem de capa.
*
* Detalhes: `playbackRate` não existe como atributo HTML, então é aplicado por
* script e reaplicado quando a fonte carrega; e com "reduzir movimento" o vídeo
* fica parado no primeiro quadro (ou no `poster`).
*/
export function VideoControlado({
src,
tocando = true,
velocidade = 1,
poster,
rotulo,
className = "absolute inset-0 h-full w-full object-cover",
}: {
/** Endereço do arquivo de vídeo. */
src: string;
/** Toca quando verdadeiro e pausa quando falso. */
tocando?: boolean;
/** Velocidade de reprodução (`playbackRate`): 0,5 é metade da velocidade. */
velocidade?: number;
/** Imagem mostrada antes de o vídeo carregar. */
poster?: string;
/** Descrição para leitores de tela. Sem ela, o vídeo é tratado como decorativo. */
rotulo?: string;
/** Classes do elemento `<video>` (o padrão cobre o contêiner posicionado). */
className?: string;
}) {
const ref = useRef<HTMLVideoElement>(null);
const semMovimento = useSemMovimento();
const deveTocar = tocando && !semMovimento;
useEffect(() => {
const video = ref.current;
if (!video) return;
const aplicar = () => {
video.playbackRate = velocidade;
};
aplicar();
video.addEventListener("loadedmetadata", aplicar);
return () => video.removeEventListener("loadedmetadata", aplicar);
}, [velocidade]);
useEffect(() => {
const video = ref.current;
if (!video) return;
if (deveTocar) {
// play() rejeita se o vídeo for pausado antes de começar (trocar de item rápido faz isso).
video.play().catch(() => {});
} else {
video.pause();
}
}, [deveTocar]);
return (
<video
ref={ref}
className={className}
src={src}
poster={poster}
muted
loop
playsInline
preload="metadata"
aria-label={rotulo}
aria-hidden={rotulo ? undefined : true}
/>
);
}
import type { ReactNode } from "react";
import { defineStories } from "@/registry/stories";
import { AcordeaoComMidia, type ItemDoAcordeao } from "./AcordeaoComMidia";
/** 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="grid size-full place-items-center"
style={{ backgroundColor: `color-mix(in oklab, ${cor} 14%, var(--surface))` }}
>
<span className="grid size-32 place-items-center rounded-full text-page" style={{ backgroundColor: cor }}>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="size-14"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
>
{children}
</svg>
</span>
</div>
);
}
const ITENS: ItemDoAcordeao[] = [
{
titulo: "Agenda compartilhada",
texto: "Toda a equipe vê os mesmos compromissos, com lembretes antes de cada reunião e convites que chegam por e-mail.",
midia: (
<Painel serie={1}>
<rect x="3" y="5" width="18" height="16" rx="2" />
<path d="M3 10h18M8 3v4M16 3v4" />
</Painel>
),
},
{
titulo: "Relatórios automáticos",
texto: "Os números da semana chegam prontos toda segunda-feira, com comparação ao período anterior.",
midia: (
<Painel serie={2}>
<path d="M3 3v18h18" />
<path d="m7 15 4-4 3 3 5-6" />
</Painel>
),
},
{
titulo: "Integrações",
texto: "Conecte a planilha, o e-mail e o sistema de vendas que você já usa, sem copiar dados à mão.",
midia: (
<Painel serie={3}>
<path d="M9 7H6a4 4 0 0 0 0 8h3M15 7h3a4 4 0 0 1 0 8h-3M8 11h8" />
</Painel>
),
},
{
titulo: "Atendimento",
texto: "Uma pessoa de verdade responde em até duas horas nos dias úteis, por chat ou telefone.",
midia: (
<Painel serie={4}>
<path d="M21 12a8 8 0 0 1-11.6 7.1L4 20l1-4.6A8 8 0 1 1 21 12Z" />
</Painel>
),
},
];
export default defineStories({
args: { quantidade: 3, ativoInicial: 0, ladoDaMidia: "direita", proporcaoDaMidia: "4 / 3" },
controls: {
quantidade: { type: "number", label: "itens (quantidade)", min: 2, max: 4 },
ativoInicial: { type: "number", min: 0, max: 3 },
ladoDaMidia: { type: "select", options: ["direita", "esquerda"] },
proporcaoDaMidia: { type: "select", options: ["4 / 3", "1", "16 / 9", "3 / 4"] },
},
render: (a) => (
<div className="w-full max-w-6xl">
<AcordeaoComMidia
key={`${a.quantidade}-${a.ativoInicial}`}
itens={ITENS.slice(0, a.quantidade)}
ativoInicial={Math.min(a.ativoInicial, a.quantidade - 1)}
ladoDaMidia={a.ladoDaMidia as "direita" | "esquerda"}
proporcaoDaMidia={a.proporcaoDaMidia}
/>
</div>
),
stories: [
{ name: "Mídia à direita" },
{ name: "Mídia à esquerda", args: { ladoDaMidia: "esquerda", ativoInicial: 1 } },
{ name: "Quatro itens, mídia quadrada", args: { quantidade: 4, proporcaoDaMidia: "1" } },
],
});
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);
}