Título vivo
Título em que cada palavra fica mais leve conforme o mouse se aproxima, o mesmo efeito do título da página inicial. Mede as palavras só ao carregar e no resize e anima num único requestAnimationFrame, para não pesar. Precisa de fonte variável.
Playground
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<TituloVivo>
Título em que cada palavra fica mais leve conforme o mouse se aproxima (o efeito do título da landing). Precisa de uma fonte variável, que aceite qualquer peso. Para não pesar: as caixas das palavras são medidas só ao carregar e no resize, cada palavra reserva a largura que tem no peso base (mudar o peso não empurra as vizinhas) e a animação roda num único requestAnimationFrame que para quando tudo assenta. Só reage a mouse; no toque e com "reduzir movimento" o título fica parado no peso base.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| linhasobrigatória | string[] | — | Uma linha por item; cada linha é quebrada em palavras pelos espaços. |
| pesoBase | number | 800 | Peso das palavras longe do mouse. |
| pesoPerto | number | 450 | Peso de uma palavra com o mouse em cima. |
| alcance | number | 320 | Distância (px) a partir da qual o mouse deixa de afetar a palavra. |
| className | string | "text-[clamp(44px,13vw,64px)] leading-[0.9] font-extrabold tracking-[-0.05em] text-ink md:text-[clamp(48px,6.5vw,104px)]" | Classes do h1 (substituem as padrão: tamanho, entrelinha, peso e cor). |
Código
stories.tsx tem os exemplos usados no preview e no playground.
"use client";
import { useEffect, useRef } from "react";
import { assinarPonteiro, useMiniatura, useSemMovimento } from "@/library/revelar/Movimento";
/**
* Título em que cada palavra fica mais leve conforme o mouse se aproxima (o efeito do
* título da landing). Precisa de uma fonte variável, que aceite qualquer peso.
*
* Para não pesar: as caixas das palavras são medidas só ao carregar e no resize, cada palavra
* reserva a largura que tem no peso base (mudar o peso não empurra as vizinhas) e a animação
* roda num único requestAnimationFrame que para quando tudo assenta. Só reage a mouse; no toque
* e com "reduzir movimento" o título fica parado no peso base.
*/
export function TituloVivo({
linhas,
pesoBase = 800,
pesoPerto = 450,
alcance = 320,
className = "text-[clamp(44px,13vw,64px)] leading-[0.9] font-extrabold tracking-[-0.05em] text-ink md:text-[clamp(48px,6.5vw,104px)]",
}: {
/** Uma linha por item; cada linha é quebrada em palavras pelos espaços. */
linhas: string[];
/** Peso das palavras longe do mouse. */
pesoBase?: number;
/** Peso de uma palavra com o mouse em cima. */
pesoPerto?: number;
/** Distância (px) a partir da qual o mouse deixa de afetar a palavra. */
alcance?: number;
/** Classes do h1 (substituem as padrão: tamanho, entrelinha, peso e cor). */
className?: string;
}) {
const palavras = useRef<(HTMLSpanElement | null)[]>([]);
const semMovimento = useSemMovimento();
const miniatura = useMiniatura();
const titulo = useRef<HTMLHeadingElement>(null);
const assinatura = linhas.join("\n");
useEffect(() => {
const h1 = titulo.current;
if (semMovimento || !h1) return;
const els = palavras.current.filter((el): el is HTMLSpanElement => el !== null);
const atual = els.map(() => pesoBase);
const alvo = els.map(() => pesoBase);
let caixas: { x1: number; y1: number; x2: number; y2: number }[] = [];
let quadro = 0;
const medir = () => {
for (const el of els) {
el.style.fontVariationSettings = "";
el.style.minWidth = "";
}
caixas = els.map((el) => {
const c = el.getBoundingClientRect();
el.style.minWidth = `${c.width}px`;
return { x1: c.left + scrollX, y1: c.top + scrollY, x2: c.right + scrollX, y2: c.bottom + scrollY };
});
atual.fill(pesoBase);
alvo.fill(pesoBase);
};
const passo = () => {
let mexendo = false;
els.forEach((el, i) => {
const antes = Math.round(atual[i]);
atual[i] += (alvo[i] - atual[i]) * 0.18;
if (Math.abs(alvo[i] - atual[i]) < 0.5) atual[i] = alvo[i];
else mexendo = true;
const depois = Math.round(atual[i]);
if (depois !== antes) el.style.fontVariationSettings = `"wght" ${depois}`;
});
quadro = mexendo ? requestAnimationFrame(passo) : 0;
};
const animar = () => {
if (!quadro) quadro = requestAnimationFrame(passo);
};
const mover = (ponteiro: { x: number; y: number }) => {
const px = ponteiro.x + scrollX;
const py = ponteiro.y + scrollY;
caixas.forEach((c, i) => {
const dx = px - Math.max(c.x1, Math.min(px, c.x2));
const dy = py - Math.max(c.y1, Math.min(py, c.y2));
alvo[i] = pesoBase - (pesoBase - pesoPerto) * Math.max(0, 1 - Math.hypot(dx, dy) / alcance);
});
animar();
};
const sair = () => {
alvo.fill(pesoBase);
animar();
};
// Mede depois que a fonte carrega, senão as larguras saem da fonte reserva
let vivo = true;
document.fonts.ready.then(() => vivo && medir());
window.addEventListener("resize", medir);
const cancelar = assinarPonteiro(h1, miniatura, (p) => {
if (!p) sair();
else if (p.mouse) mover(p);
});
return () => {
vivo = false;
cancelAnimationFrame(quadro);
window.removeEventListener("resize", medir);
cancelar();
for (const el of els) {
el.style.fontVariationSettings = "";
el.style.minWidth = "";
}
};
}, [semMovimento, miniatura, pesoBase, pesoPerto, alcance, assinatura]);
let i = 0;
return (
<h1 ref={titulo} className={className}>
{linhas.map((linha, l) => (
<span key={l} className="block">
{linha
.split(/\s+/)
.filter(Boolean)
.map((texto, p) => {
const indice = i++;
return (
<span key={p}>
{p > 0 && " "}
<span
ref={(el) => {
palavras.current[indice] = el;
}}
className="inline-block"
>
{texto}
</span>
</span>
);
})}
</span>
))}
</h1>
);
}
import { defineStories } from "@/registry/stories";
import { TituloVivo } from "./TituloVivo";
export default defineStories({
args: {
texto: "Componentes / prontos. / Código aberto.",
pesoBase: 800,
pesoPerto: 450,
alcance: 320,
},
controls: {
texto: { type: "text", label: "texto (linhas separadas por /)" },
pesoBase: { type: "number", min: 100, max: 900, step: 50 },
pesoPerto: { type: "number", min: 100, max: 900, step: 50 },
alcance: { type: "number", label: "alcance (px)", min: 40, max: 800, step: 20 },
},
render: (a) => (
<TituloVivo
linhas={a.texto.split("/").map((l: string) => l.trim())}
pesoBase={a.pesoBase}
pesoPerto={a.pesoPerto}
alcance={a.alcance}
/>
),
stories: [
{ name: "Padrão" },
{
name: "Leve que engorda",
render: () => (
<TituloVivo
linhas={["Passe o mouse", "por aqui."]}
pesoBase={300}
pesoPerto={900}
alcance={200}
className="text-6xl leading-none tracking-tight text-ink"
/>
),
},
],
});
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);
}