Texto aceso
Parágrafos cujas palavras acendem uma a uma, presas à rolagem, conforme o bloco atravessa a tela. Só a opacidade muda: o texto inteiro fica no HTML para leitores de tela e buscadores.
Playground
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<TextoAceso>
Parágrafos cujas palavras acendem uma a uma, presas à rolagem: o texto começa apagado e ganha opacidade conforme o bloco atravessa a tela. O progresso vai de 0, quando o topo do bloco passa pela linha `inicio`, até 1, quando a base do bloco passa pela linha `fim` (frações da altura da janela, contadas de cima). As palavras ficam no HTML; a animação só muda a opacidade, então leitores de tela e buscadores leem o texto inteiro. Com "reduzir movimento", o texto aparece aceso.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| paragrafosobrigatória | string[] | — | Um texto por parágrafo. |
| opacidadeApagada | number | 0.15 | Opacidade das palavras ainda não acesas (0 a 1). |
| inicio | number | 0.78 | Linha da janela (0 = topo, 1 = base) que o topo do bloco cruza para começar a acender. O padrão, 0,78, fica um pouco abaixo de três quartos da tela. |
| fim | number | 0.62 | Linha da janela (0 = topo, 1 = base) que a base do bloco cruza quando a última palavra acende. Com o padrão, 0,62, o texto termina de acender quando a base do bloco está a 38% da borda de baixo da tela. |
| progresso | number | — | Progresso fixo (0 a 1) que ignora a rolagem e o "reduzir movimento". Para mostrar o efeito parado (miniaturas, documentação) ou controlá-lo de fora. |
| className | string | "text-xl leading-relaxed text-ink sm:text-2xl" | Classes de cada parágrafo (substituem as padrão: tamanho, entrelinha e cor). |
Código
stories.tsx tem os exemplos usados no preview e no playground.
"use client";
import { useEffect, useRef, type CSSProperties } from "react";
import { useSemMovimento } from "@/library/revelar/Movimento";
const limitar = (n: number) => Math.min(Math.max(n, 0), 1);
/** Opacidade da palavra `i` de `total` quando o bloco está em `progresso` (0 a 1): uma palavra acende por vez. */
function opacidadeDaPalavra(i: number, total: number, progresso: number, apagada: number) {
const local = limitar(progresso * total - i);
return apagada + (1 - apagada) * local;
}
/**
* Parágrafos cujas palavras acendem uma a uma, presas à rolagem: o texto começa
* apagado e ganha opacidade conforme o bloco atravessa a tela.
*
* O progresso vai de 0, quando o topo do bloco passa pela linha `inicio`, até 1,
* quando a base do bloco passa pela linha `fim` (frações da altura da janela,
* contadas de cima). As palavras ficam no HTML; a animação só muda a opacidade,
* então leitores de tela e buscadores leem o texto inteiro. Com "reduzir
* movimento", o texto aparece aceso.
*/
export function TextoAceso({
paragrafos,
opacidadeApagada = 0.15,
inicio = 0.78,
fim = 0.62,
progresso,
className = "text-xl leading-relaxed text-ink sm:text-2xl",
}: {
/** Um texto por parágrafo. */
paragrafos: string[];
/** Opacidade das palavras ainda não acesas (0 a 1). */
opacidadeApagada?: number;
/**
* Linha da janela (0 = topo, 1 = base) que o topo do bloco cruza para começar
* a acender. O padrão, 0,78, fica um pouco abaixo de três quartos da tela.
*/
inicio?: number;
/**
* Linha da janela (0 = topo, 1 = base) que a base do bloco cruza quando a
* última palavra acende. Com o padrão, 0,62, o texto termina de acender
* quando a base do bloco está a 38% da borda de baixo da tela.
*/
fim?: number;
/**
* Progresso fixo (0 a 1) que ignora a rolagem e o "reduzir movimento". Para
* mostrar o efeito parado (miniaturas, documentação) ou controlá-lo de fora.
*/
progresso?: number;
/** Classes de cada parágrafo (substituem as padrão: tamanho, entrelinha e cor). */
className?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const semMovimento = useSemMovimento();
const fixo = progresso !== undefined;
const animar = !fixo && !semMovimento;
const palavras = paragrafos.map((p) => p.split(/\s+/).filter(Boolean));
const total = palavras.reduce((soma, lista) => soma + lista.length, 0);
// Índice da primeira palavra de cada parágrafo na contagem geral.
const primeiras = palavras.map((_, p) => palavras.slice(0, p).reduce((soma, lista) => soma + lista.length, 0));
const assinatura = paragrafos.join("\n");
useEffect(() => {
const bloco = ref.current;
if (!animar || !bloco) return;
const elementos = Array.from(bloco.querySelectorAll<HTMLElement>("[data-palavra]"));
let quadro = 0;
const atualizar = () => {
quadro = 0;
const caixa = bloco.getBoundingClientRect();
const altura = window.innerHeight;
const percurso = (inicio - fim) * altura + caixa.height;
const p = percurso > 0 ? limitar((inicio * altura - caixa.top) / percurso) : 1;
elementos.forEach((el, i) => {
el.style.opacity = String(opacidadeDaPalavra(i, elementos.length, p, opacidadeApagada));
});
};
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);
// O React cuida só da variável --opacidade; a opacidade escrita aqui sai junto com o efeito.
elementos.forEach((el) => el.style.removeProperty("opacity"));
};
}, [animar, inicio, fim, opacidadeApagada, assinatura]);
return (
<div
ref={ref}
className="flex flex-col gap-6"
style={{ "--opacidade": opacidadeApagada } as CSSProperties}
>
{palavras.map((lista, p) => (
<p key={p} className={className}>
{lista.map((palavra, i) => (
// O espaço fica fora da palavra, para a quebra de linha continuar natural.
<span key={i}>
<span
data-palavra=""
// Antes do efeito rodar, a palavra já começa apagada (sem piscar acesa).
className={animar || fixo ? "opacity-(--opacidade)" : undefined}
style={
fixo
? ({
"--opacidade": opacidadeDaPalavra(primeiras[p] + i, total, limitar(progresso), opacidadeApagada),
} as CSSProperties)
: undefined
}
>
{palavra}
</span>{" "}
</span>
))}
</p>
))}
</div>
);
}
import { defineStories } from "@/registry/stories";
import { TextoAceso } from "./TextoAceso";
const PARAGRAFOS = [
"Começamos como uma pequena oficina de bairro e hoje atendemos empresas em todo o país, sem perder o cuidado com cada pedido.",
"Nossa equipe acompanha cada projeto do primeiro esboço à entrega, com prazos claros e uma pessoa responsável do início ao fim.",
];
export default defineStories({
args: {
paragrafos: 2,
opacidadeApagada: 0.15,
inicio: 0.78,
fim: 0.62,
fixarProgresso: false,
progresso: 0.45,
},
controls: {
paragrafos: { type: "number", label: "parágrafos (quantidade)", min: 1, max: 2 },
opacidadeApagada: { type: "number", min: 0, max: 1, step: 0.05 },
inicio: { type: "number", label: "inicio (fração da tela)", min: 0, max: 1, step: 0.02 },
fim: { type: "number", label: "fim (fração da tela)", min: 0, max: 1, step: 0.02 },
fixarProgresso: { type: "boolean", label: "fixar progresso (ignora a rolagem)" },
progresso: { type: "number", label: "progresso (com fixar)", min: 0, max: 1, step: 0.05 },
},
render: (a) => (
<div className="w-full max-w-2xl">
{!a.fixarProgresso && <p className="pb-[60vh] text-center text-sm text-muted">Role para baixo dentro do preview ↓</p>}
<TextoAceso
paragrafos={PARAGRAFOS.slice(0, a.paragrafos)}
opacidadeApagada={a.opacidadeApagada}
inicio={a.inicio}
fim={a.fim}
progresso={a.fixarProgresso ? a.progresso : undefined}
/>
{!a.fixarProgresso && <div className="h-[70vh]" />}
</div>
),
stories: [
{ name: "Parado no meio", args: { fixarProgresso: true, progresso: 0.45 } },
{ name: "Ao rolar (playground)" },
{
name: "Texto menor e mais apagado",
render: () => (
<div className="w-full max-w-xl">
<p className="pb-[60vh] text-center text-sm text-muted">Role para baixo dentro do preview ↓</p>
<TextoAceso
paragrafos={[PARAGRAFOS[1]]}
opacidadeApagada={0.08}
className="text-lg leading-relaxed text-ink-2"
/>
<div className="h-[70vh]" />
</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);
}