Texto em foco
Frase em que uma palavra por vez fica nítida e as outras desfocadas, com uma moldura (cantos, caixa, sublinhado ou marca-texto) que pula de palavra em palavra, em ordem ou ao acaso. No modo manual, o foco segue o mouse. Porte do TrueFocus do React Bits (reactbits.dev/text-animations/true-focus).
Playground
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<TextoEmFoco>
Frase em que uma palavra por vez fica nítida e as outras desfocadas, com uma moldura (cantos, caixa, sublinhado ou marca-texto) que pula de palavra em palavra. No modo manual, o foco segue o mouse. Leitores de tela leem a frase normalmente; a moldura é decorativa. Com "reduzir movimento", o foco troca sem animar.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| frase | string | "Foco Verdadeiro" | |
| separador | string | " " | |
| manual | boolean | false | Foco segue o mouse em vez de andar sozinho. |
| estilo | EstiloDoFoco | "cantos" | |
| ordem | "sequencial" | "aleatoria" | "sequencial" | |
| suavizacao | "suave" | "mola" | "suave" | |
| desfoque | number | 5 | Desfoque (px) das palavras fora de foco. |
| escalaDoFoco | number | 1 | |
| corDaBorda | string | "#22c55e" | |
| corDoDestaque | string | — | Cor do marca-texto (padrão: a da borda). |
| corDoBrilho | string | "transparent" | |
| duracao | number | 0.5 | Duração (s) da troca de foco. |
| pausa | number | 1 | Pausa (s) entre uma troca e a próxima. |
| tamanhoDoCanto | number | 16 | |
| larguraDaBorda | number | 3 | |
| opacidadeFora | number | 1 | |
| className | string | — |
Dependências
npm install motionCódigo
stories.tsx tem os exemplos usados no preview e no playground.
"use client";
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
import { motion, useReducedMotion, type Transition } from "motion/react";
// Porte do TrueFocus do React Bits (reactbits.dev/text-animations/true-focus).
// A lógica segue o original; a API pública está em português.
export type EstiloDoFoco = "cantos" | "caixa" | "sublinhado" | "marca-texto";
const CANTOS = ["sup-esq", "sup-dir", "inf-esq", "inf-dir"] as const;
type Canto = (typeof CANTOS)[number];
const BORDAS_DO_CANTO: Record<Canto, CSSProperties> = {
"sup-esq": { borderRight: "none", borderBottom: "none" },
"sup-dir": { borderLeft: "none", borderBottom: "none" },
"inf-esq": { borderRight: "none", borderTop: "none" },
"inf-dir": { borderLeft: "none", borderTop: "none" },
};
const FOLGA = 10;
const SUAVE: [number, number, number, number] = [0.65, 0, 0.35, 1];
const SUAVE_CSS = "cubic-bezier(0.65, 0, 0.35, 1)";
const MOLA_CSS = "cubic-bezier(0.34, 1.56, 0.64, 1)";
const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
type Retangulo = { x: number; y: number; width: number; height: number };
export type TextoEmFocoProps = {
frase?: string;
separador?: string;
/** Foco segue o mouse em vez de andar sozinho. */
manual?: boolean;
estilo?: EstiloDoFoco;
ordem?: "sequencial" | "aleatoria";
suavizacao?: "suave" | "mola";
/** Desfoque (px) das palavras fora de foco. */
desfoque?: number;
escalaDoFoco?: number;
corDaBorda?: string;
/** Cor do marca-texto (padrão: a da borda). */
corDoDestaque?: string;
corDoBrilho?: string;
/** Duração (s) da troca de foco. */
duracao?: number;
/** Pausa (s) entre uma troca e a próxima. */
pausa?: number;
tamanhoDoCanto?: number;
larguraDaBorda?: number;
opacidadeFora?: number;
className?: string;
};
/**
* Frase em que uma palavra por vez fica nítida e as outras desfocadas, com uma moldura
* (cantos, caixa, sublinhado ou marca-texto) que pula de palavra em palavra. No modo manual,
* o foco segue o mouse.
*
* Leitores de tela leem a frase normalmente; a moldura é decorativa. Com "reduzir movimento",
* o foco troca sem animar.
*/
export function TextoEmFoco({
frase = "Foco Verdadeiro",
separador = " ",
manual = false,
estilo = "cantos",
ordem = "sequencial",
suavizacao = "suave",
desfoque = 5,
escalaDoFoco = 1,
corDaBorda = "#22c55e",
corDoDestaque,
corDoBrilho = "transparent",
duracao = 0.5,
pausa = 1,
tamanhoDoCanto = 16,
larguraDaBorda = 3,
opacidadeFora = 1,
className,
}: TextoEmFocoProps) {
const palavras = frase.split(separador).filter((p) => p.trim() !== "");
const [indice, setIndice] = useState(0);
const [foco, setFoco] = useState<Retangulo | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const palavrasRef = useRef<(HTMLSpanElement | null)[]>([]);
const semMovimento = useReducedMotion();
const dur = semMovimento ? 0 : duracao;
const ativo = palavras.length ? indice % palavras.length : -1;
const mola = suavizacao === "mola" && !semMovimento;
const transicao: Transition = mola
? { type: "spring", visualDuration: dur, bounce: 0.38 }
: { duration: dur, ease: SUAVE };
useEffect(() => {
if (manual || palavras.length < 2) return;
const id = setInterval(() => {
const sorteio = Math.random();
setIndice((prev) => {
const atual = prev % palavras.length;
if (ordem !== "aleatoria") return (atual + 1) % palavras.length;
const prox = Math.floor(sorteio * (palavras.length - 1));
return prox >= atual ? prox + 1 : prox;
});
}, (duracao + pausa) * 1000);
return () => clearInterval(id);
}, [manual, duracao, pausa, palavras.length, ordem]);
useIsomorphicLayoutEffect(() => {
const container = containerRef.current;
const palavra = palavrasRef.current[ativo];
if (!container || !palavra) return;
const medir = () => {
const r = { x: palavra.offsetLeft, y: palavra.offsetTop, width: palavra.offsetWidth, height: palavra.offsetHeight };
setFoco((p) => (p && p.x === r.x && p.y === r.y && p.width === r.width && p.height === r.height ? p : r));
};
medir();
if (typeof ResizeObserver === "undefined") return;
const obs = new ResizeObserver(medir);
obs.observe(container);
palavrasRef.current.forEach((n) => n && obs.observe(n));
return () => obs.disconnect();
}, [ativo, frase, separador]);
const quadro = foco && {
x: foco.x - (foco.width * (escalaDoFoco - 1)) / 2,
y: foco.y - (foco.height * (escalaDoFoco - 1)) / 2,
width: foco.width * escalaDoFoco,
height: foco.height * escalaDoFoco,
};
const posCantos: Record<Canto, { x: number; y: number }> | null = quadro && {
"sup-esq": { x: quadro.x - FOLGA, y: quadro.y - FOLGA },
"sup-dir": { x: quadro.x + quadro.width + FOLGA - tamanhoDoCanto, y: quadro.y - FOLGA },
"inf-esq": { x: quadro.x - FOLGA, y: quadro.y + quadro.height + FOLGA - tamanhoDoCanto },
"inf-dir": { x: quadro.x + quadro.width + FOLGA - tamanhoDoCanto, y: quadro.y + quadro.height + FOLGA - tamanhoDoCanto },
};
const formas = quadro && {
caixa: { x: quadro.x - FOLGA, y: quadro.y - FOLGA, width: quadro.width + FOLGA * 2, height: quadro.height + FOLGA * 2 },
sublinhado: { x: quadro.x, y: quadro.y + quadro.height + FOLGA * 0.4, width: quadro.width, height: larguraDaBorda },
marca: {
x: quadro.x - quadro.height * 0.14,
y: quadro.y + quadro.height * 0.04,
width: quadro.width + quadro.height * 0.28,
height: quadro.height * 0.92,
},
};
const brilho = `drop-shadow(0 0 4px ${corDoBrilho})`;
const borda = `${larguraDaBorda}px solid ${corDaBorda}`;
const base = "pointer-events-none absolute left-0 top-0";
return (
<div
ref={containerRef}
className={["relative flex select-none flex-wrap items-center justify-center gap-[1em]", className]
.filter(Boolean)
.join(" ")}
>
{estilo === "marca-texto" && formas && (
<motion.span
aria-hidden="true"
className={`${base} rounded-md`}
style={{ backgroundColor: `color-mix(in srgb, ${corDoDestaque ?? corDaBorda} 30%, transparent)` }}
initial={{ ...formas.marca, opacity: 0 }}
animate={{ ...formas.marca, opacity: 1 }}
transition={transicao}
/>
)}
{palavras.map((p, i) => {
const emFoco = i === ativo;
return (
<span
key={i}
ref={(el) => {
palavrasRef.current[i] = el;
}}
className="relative cursor-pointer text-5xl font-black"
style={{
filter: `blur(${emFoco ? 0 : desfoque}px)`,
opacity: emFoco ? 1 : opacidadeFora,
transform: escalaDoFoco !== 1 ? `scale(${emFoco ? escalaDoFoco : 1})` : undefined,
transition: `filter ${dur}s ease, opacity ${dur}s ease, transform ${dur}s ${mola ? MOLA_CSS : SUAVE_CSS}`,
}}
onMouseEnter={() => manual && setIndice(i)}
>
{p}
</span>
);
})}
{formas && posCantos && estilo !== "marca-texto" && (
<div className={base} aria-hidden="true">
{estilo === "caixa" && (
<motion.span
className={`${base} box-border rounded-md`}
style={{ border: borda, filter: brilho }}
initial={{ ...formas.caixa, opacity: 0 }}
animate={{ ...formas.caixa, opacity: 1 }}
transition={transicao}
/>
)}
{estilo === "sublinhado" && (
<motion.span
className={base}
style={{ backgroundColor: corDaBorda, borderRadius: larguraDaBorda / 2, filter: brilho }}
initial={{ ...formas.sublinhado, opacity: 0 }}
animate={{ ...formas.sublinhado, opacity: 1 }}
transition={transicao}
/>
)}
{estilo === "cantos" &&
CANTOS.map((c) => (
<motion.span
key={c}
className={`${base} box-border rounded-[3px]`}
style={{
width: tamanhoDoCanto,
height: tamanhoDoCanto,
border: borda,
filter: brilho,
...BORDAS_DO_CANTO[c],
}}
initial={{ ...posCantos[c], opacity: 0 }}
animate={{ ...posCantos[c], opacity: 1 }}
transition={transicao}
/>
))}
</div>
)}
</div>
);
}
import { defineStories } from "@/registry/stories";
import { TextoEmFoco, type EstiloDoFoco } from "./TextoEmFoco";
export default defineStories({
args: {
frase: "Foco Verdadeiro",
estilo: "cantos",
manual: false,
ordem: "sequencial",
suavizacao: "suave",
desfoque: 5,
escalaDoFoco: 1,
corDaBorda: "#22c55e",
corDoBrilho: "#22c55e99",
duracao: 0.5,
pausa: 1,
tamanhoDoCanto: 16,
larguraDaBorda: 3,
opacidadeFora: 1,
},
controls: {
frase: { type: "text" },
estilo: { type: "select", options: ["cantos", "caixa", "sublinhado", "marca-texto"] },
manual: { type: "boolean", label: "manual (segue o mouse)" },
ordem: { type: "select", options: ["sequencial", "aleatoria"] },
suavizacao: { type: "select", options: ["suave", "mola"] },
desfoque: { type: "number", label: "desfoque (px)", min: 0, max: 15, step: 1 },
escalaDoFoco: { type: "number", min: 1, max: 1.4, step: 0.05 },
corDaBorda: { type: "color" },
corDoBrilho: { type: "color" },
duracao: { type: "number", label: "duracao (s)", min: 0.1, max: 2, step: 0.1 },
pausa: { type: "number", label: "pausa (s)", min: 0, max: 3, step: 0.1 },
tamanhoDoCanto: { type: "number", label: "tamanhoDoCanto (px)", min: 6, max: 32, step: 1 },
larguraDaBorda: { type: "number", label: "larguraDaBorda (px)", min: 1, max: 8, step: 1 },
opacidadeFora: { type: "number", min: 0.1, max: 1, step: 0.05 },
},
render: (a) => (
<TextoEmFoco
frase={a.frase}
estilo={a.estilo as EstiloDoFoco}
manual={a.manual}
ordem={a.ordem as "sequencial" | "aleatoria"}
suavizacao={a.suavizacao as "suave" | "mola"}
desfoque={a.desfoque}
escalaDoFoco={a.escalaDoFoco}
corDaBorda={a.corDaBorda}
corDoBrilho={a.corDoBrilho}
duracao={a.duracao}
pausa={a.pausa}
tamanhoDoCanto={a.tamanhoDoCanto}
larguraDaBorda={a.larguraDaBorda}
opacidadeFora={a.opacidadeFora}
className="text-ink"
/>
),
stories: [
{ name: "Padrão" },
{
name: "Marca-texto com mola",
render: () => (
<TextoEmFoco
frase="Menos ruído mais sinal"
estilo="marca-texto"
suavizacao="mola"
corDaBorda="#f59e0b"
desfoque={3}
opacidadeFora={0.5}
className="text-ink"
/>
),
},
{
name: "Manual, com caixa",
render: () => (
<TextoEmFoco frase="Passe o mouse aqui" manual estilo="caixa" corDaBorda="#3b82f6" className="text-ink" />
),
},
{
name: "Sublinhado aleatório",
render: () => (
<TextoEmFoco frase="Um dois três quatro" estilo="sublinhado" ordem="aleatoria" corDaBorda="#ec4899" className="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.
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);
}