Revelar e Movimento
Revelar faz o conteúdo subir e aparecer ao entrar na tela. Movimento.tsx traz o que as animações da biblioteca compartilham: useSemMovimento (respeita “reduzir movimento”), useEmTela e o ProvedorDeMovimento, que desliga as animações de um trecho.
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 /** */.
<ProvedorDeMovimento>
Desliga as animações dos componentes abaixo dele: eles aparecem já no estado final. Útil em miniaturas, impressão e testes.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| reduzido | boolean | true | |
| childrenobrigatória | ReactNode | — |
<Revelar>
Faz o conteúdo subir e aparecer quando entra na tela. Com "reduzir movimento" (do sistema ou de um ProvedorDeMovimento), aparece direto, sem animação.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| childrenobrigatória | ReactNode | — | |
| deslocamento | number | 32 | Distância (px) de onde o conteúdo sobe. |
| atraso | number | 0 | Espera (ms) antes de começar; útil para escalonar vários itens. |
| duracao | number | 800 | Duração (ms) da animação. |
| margem | string | "0px 0px -15% 0px" | Margem do IntersectionObserver: o padrão espera o conteúdo entrar 15% na tela. |
| className | string | "" |
Código
stories.tsx tem os exemplos usados no preview e no playground.
"use client";
import {
createContext,
useContext,
useEffect,
useState,
useSyncExternalStore,
type ReactNode,
type RefObject,
} from "react";
const ContextoDeMovimento = createContext(false);
/**
* Desliga as animações dos componentes abaixo dele: eles aparecem já no estado
* final. Útil em miniaturas, impressão e testes.
*/
export function ProvedorDeMovimento({ reduzido = true, children }: { reduzido?: boolean; children: ReactNode }) {
return <ContextoDeMovimento.Provider value={reduzido}>{children}</ContextoDeMovimento.Provider>;
}
const CONSULTA = "(prefers-reduced-motion: reduce)";
function assinar(aviso: () => void) {
const consulta = matchMedia(CONSULTA);
consulta.addEventListener("change", aviso);
return () => consulta.removeEventListener("change", aviso);
}
/** Verdadeiro quando o sistema pede menos movimento ou um ProvedorDeMovimento desligou as animações. */
export function useSemMovimento() {
const doSistema = useSyncExternalStore(assinar, () => matchMedia(CONSULTA).matches, () => false);
return useContext(ContextoDeMovimento) || doSistema;
}
/** Fica verdadeiro quando o elemento entra na tela (só na primeira vez, por padrão). */
export function useEmTela<T extends Element>(
ref: RefObject<T | null>,
{ margem = "0px", limiar = 0, umaVez = true }: { margem?: string; limiar?: number; umaVez?: boolean } = {},
) {
const [emTela, setEmTela] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observador = new IntersectionObserver(
([entrada]) => {
if (entrada.isIntersecting) {
setEmTela(true);
if (umaVez) observador.disconnect();
} else if (!umaVez) {
setEmTela(false);
}
},
{ rootMargin: margem, threshold: limiar },
);
observador.observe(el);
return () => observador.disconnect();
}, [ref, margem, limiar, umaVez]);
return emTela;
}
/** Curva de saída suave usada pelas animações da biblioteca. */
export const CURVA_SUAVE = "cubic-bezier(0.16, 1, 0.3, 1)";
"use client";
import { useRef, type ReactNode } from "react";
import { CURVA_SUAVE, useEmTela, useSemMovimento } from "./Movimento";
/**
* Faz o conteúdo subir e aparecer quando entra na tela. Com "reduzir movimento"
* (do sistema ou de um ProvedorDeMovimento), aparece direto, sem animação.
*/
export function Revelar({
children,
deslocamento = 32,
atraso = 0,
duracao = 800,
margem = "0px 0px -15% 0px",
className = "",
}: {
children: ReactNode;
/** Distância (px) de onde o conteúdo sobe. */
deslocamento?: number;
/** Espera (ms) antes de começar; útil para escalonar vários itens. */
atraso?: number;
/** Duração (ms) da animação. */
duracao?: number;
/** Margem do IntersectionObserver: o padrão espera o conteúdo entrar 15% na tela. */
margem?: string;
className?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const semMovimento = useSemMovimento();
const emTela = useEmTela(ref, { margem });
const visivel = semMovimento || emTela;
return (
<div
ref={ref}
className={className}
style={{
opacity: visivel ? 1 : 0,
transform: visivel ? "none" : `translateY(${deslocamento}px)`,
transition: semMovimento
? undefined
: `opacity ${duracao}ms ${CURVA_SUAVE} ${atraso}ms, transform ${duracao}ms ${CURVA_SUAVE} ${atraso}ms`,
}}
>
{children}
</div>
);
}
import { defineStories } from "@/registry/stories";
import { Revelar } from "./Revelar";
const ITENS = ["Planejamento", "Execução", "Acompanhamento", "Resultados"];
function Cartao({ titulo }: { titulo: string }) {
return (
<div className="rounded-2xl border border-rule bg-surface p-6">
<p className="font-display text-xl font-bold">{titulo}</p>
<p className="mt-2 text-sm text-ink-2">Cada cartão aparece quando entra na tela.</p>
</div>
);
}
export default defineStories({
args: { deslocamento: 32, duracao: 800, escalonar: 120 },
controls: {
deslocamento: { type: "number", min: 0, max: 120, step: 4 },
duracao: { type: "number", min: 100, max: 2000, step: 100 },
escalonar: { type: "number", label: "atraso entre itens (ms)", min: 0, max: 400, step: 20 },
},
render: (a) => (
<div className="w-full max-w-3xl">
<p className="pb-[60vh] text-center text-sm text-muted">Role para baixo dentro do preview ↓</p>
<div className="grid gap-4 sm:grid-cols-2">
{ITENS.map((titulo, i) => (
<Revelar
key={`${titulo}-${a.deslocamento}-${a.duracao}-${a.escalonar}`}
deslocamento={a.deslocamento}
duracao={a.duracao}
atraso={i * a.escalonar}
>
<Cartao titulo={titulo} />
</Revelar>
))}
</div>
<div className="h-[40vh]" />
</div>
),
stories: [
{
name: "Cartões",
render: () => (
<div className="grid w-full max-w-3xl gap-4 sm:grid-cols-2">
{ITENS.map((titulo, i) => (
<Revelar key={titulo} atraso={i * 120}>
<Cartao titulo={titulo} />
</Revelar>
))}
</div>
),
},
{ name: "Ao rolar (playground)" },
],
});
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.
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);
}