Texto de partículas
Texto feito de partículas num canvas: elas chegam e montam as letras (juntando, em chuva, espiral ou varredura), fogem do ponteiro, flutuam e explodem com um clique, voltando como molas. Adaptado do ParticleText do React Bits (reactbits.dev/text-animations/particle-text).
Playground
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<TextoDeParticulas>
Texto feito de partículas num canvas: elas chegam de fora e montam as letras (juntando, caindo como chuva, em espiral ou atrás de uma linha de varredura), fogem do ponteiro, flutuam paradas e explodem com um clique, voltando como molas. As que se mexem ganham a cor de destaque, com brilho opcional. Port do ParticleText do React Bits, com as props em português (o miolo do desenho segue o original). O texto fica num span só para leitores de tela; o canvas é decorativo. Com "reduzir movimento", as partículas já aparecem no lugar e não reagem.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| texto | string | "React Bits" | |
| tamanhoParticula | number | 2 | Tamanho de cada partícula, em px. |
| densidade | number | 4 | Espaço em px entre os pontos amostrados do texto: menor, mais partículas. |
| cor | string | "currentColor" | Cor das partículas paradas. Por padrão, a cor do texto. |
| corDestaque | string | "#ff5a36" | Cor das partículas em movimento (ou da ponta do gradiente) e do brilho. |
| destaque | "movimento" | "gradiente" | "movimento" | `movimento`: a partícula esquenta quando sai do lugar. `gradiente`: degradê fixo da esquerda para a direita. |
| forma | Forma | "circulo" | |
| entrada | Entrada | "juntar" | Como as partículas chegam para formar o texto. |
| espalhamento | number | 180 | Distância (px) de onde as partículas partem, no `juntar` e na `espiral`. |
| duracaoEntrada | number | 1600 | Milissegundos da entrada. |
| intervalo | number | 420 | Milissegundos de atraso espalhados entre as partículas. |
| explodirAoClicar | boolean | true | Clique ou toque explode as partículas em volta, que voltam como molas. |
| forcaExplosao | number | 1 | |
| repulsao | number | 40 | Quanto (px) as partículas fogem do ponteiro. 0 desliga. |
| raioRepulsao | number | 120 | Alcance (px) da fuga do ponteiro. |
| flutuacao | number | 0.7 | Amplitude (px) da flutuação parada. 0 desliga. |
| gatilho | "montar" | "passar" | "clicar" | "montar" | Quando refazer a entrada: só ao montar, toda vez que o ponteiro entra ou a cada clique. |
| tamanhoFonte | string | number | "clamp(3rem, 12vw, 8rem)" | Tamanho da fonte: número em px ou valor CSS. Encolhe se o texto não couber. |
| pesoFonte | number | 800 | |
| fonte | string | "inherit" | Família da fonte. Por padrão, a de onde o componente é usado. |
| brilho | boolean | true | Brilho em volta das partículas com destaque. |
| final | "texto" | "particulas" | "particulas" | Como o texto fica depois de montado. `texto`: as partículas se dissolvem num texto normal, na mesma fonte; ao fugir do ponteiro ou explodir, voltam a ser partículas e depois se refazem. |
| className | string | "text-ink" | Classes do contêiner (a cor do texto vira a cor das partículas). Dê uma altura: o mínimo é 240 px. |
| style | CSSProperties | — |
Código
stories.tsx tem os exemplos usados no preview e no playground.
"use client";
import { useEffect, useRef, type CSSProperties } from "react";
import { useMiniatura, useSemMovimento } from "@/library/revelar/Movimento";
const INHERIT = /^(currentcolor|inherit)$/i;
const TONES = 12;
const FADES = 8;
const HEAT_RANGE = 36;
const HEAT_DEADZONE = 3;
const TAU = Math.PI * 2;
const REST_ANGLE = -Math.PI / 4;
const BURST_SPRING = 18;
const BURST_DAMPING = 7.5;
const SOLID_GAP = 1.5;
const SCAN_FADE = [0, 0.12, 0.42, 0.78, 1, 0.78, 0.42, 0.12, 0];
const FADE_FLOOR: Record<Entrada, number> = { juntar: 0, chuva: 0.55, espiral: 0.45, varredura: 0.35 };
type Entrada = "juntar" | "chuva" | "espiral" | "varredura";
type Forma = "circulo" | "quadrado" | "linha";
type Ajustes = {
texto: string;
tamanhoParticula: number;
densidade: number;
cor: string;
corDestaque: string;
destaque: "movimento" | "gradiente";
forma: Forma;
entrada: Entrada;
espalhamento: number;
duracaoEntrada: number;
intervalo: number;
explodirAoClicar: boolean;
forcaExplosao: number;
repulsao: number;
raioRepulsao: number;
flutuacao: number;
gatilho: "montar" | "passar" | "clicar";
tamanhoFonte: number | string;
pesoFonte: number;
fonte: string;
brilho: boolean;
final: "particulas" | "texto";
semMovimento: boolean;
};
type Particula = {
x: number;
y: number;
startX: number;
startY: number;
targetX: number;
targetY: number;
weight: number;
tone: number;
seed: number;
depth: number;
delay: number;
ox: number;
oy: number;
vx: number;
vy: number;
angle: number;
length: number;
radius: number;
heading: number;
startRadius: number;
swirl: number;
};
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
const easeInFall = (t: number) => Math.pow(t, 1.7);
const easeInOutCubic = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
const fadeAlpha = (level: number) => 0.35 + (0.65 * level) / (FADES - 1);
const wrapHalfTurn = (angle: number) => (((angle % Math.PI) + Math.PI * 1.5) % Math.PI) - Math.PI / 2;
function resolveFontSize(value: number | string, container: HTMLElement, fontWeight: number, fontFamily: string) {
if (typeof value === "number") return value;
const probe = document.createElement("span");
probe.textContent = "M";
probe.style.position = "absolute";
probe.style.visibility = "hidden";
probe.style.pointerEvents = "none";
probe.style.fontSize = value;
probe.style.fontWeight = String(fontWeight);
probe.style.fontFamily = fontFamily;
container.appendChild(probe);
const size = parseFloat(window.getComputedStyle(probe).fontSize) || 96;
probe.remove();
return size;
}
async function waitForFonts(font: string) {
if (!("fonts" in document)) return;
try {
await document.fonts.load(font);
} catch {}
await document.fonts.ready;
}
/**
* Texto feito de partículas num canvas: elas chegam de fora e montam as letras (juntando,
* caindo como chuva, em espiral ou atrás de uma linha de varredura), fogem do ponteiro,
* flutuam paradas e explodem com um clique, voltando como molas. As que se mexem ganham a
* cor de destaque, com brilho opcional.
*
* Port do ParticleText do React Bits, com as props em português (o miolo do desenho segue o
* original). O texto fica num span só para leitores de tela; o canvas é decorativo. Com
* "reduzir movimento", as partículas já aparecem no lugar e não reagem.
*/
export function TextoDeParticulas({
texto = "React Bits",
tamanhoParticula = 2,
densidade = 4,
cor = "currentColor",
corDestaque = "#ff5a36",
destaque = "movimento",
forma = "circulo",
entrada = "juntar",
espalhamento = 180,
duracaoEntrada = 1600,
intervalo = 420,
explodirAoClicar = true,
forcaExplosao = 1,
repulsao = 40,
raioRepulsao = 120,
flutuacao = 0.7,
gatilho = "montar",
tamanhoFonte = "clamp(3rem, 12vw, 8rem)",
pesoFonte = 800,
fonte = "inherit",
brilho = true,
final = "particulas",
className = "text-ink",
style,
}: {
texto?: string;
/** Tamanho de cada partícula, em px. */
tamanhoParticula?: number;
/** Espaço em px entre os pontos amostrados do texto: menor, mais partículas. */
densidade?: number;
/** Cor das partículas paradas. Por padrão, a cor do texto. */
cor?: string;
/** Cor das partículas em movimento (ou da ponta do gradiente) e do brilho. */
corDestaque?: string;
/** `movimento`: a partícula esquenta quando sai do lugar. `gradiente`: degradê fixo da esquerda para a direita. */
destaque?: "movimento" | "gradiente";
forma?: Forma;
/** Como as partículas chegam para formar o texto. */
entrada?: Entrada;
/** Distância (px) de onde as partículas partem, no `juntar` e na `espiral`. */
espalhamento?: number;
/** Milissegundos da entrada. */
duracaoEntrada?: number;
/** Milissegundos de atraso espalhados entre as partículas. */
intervalo?: number;
/** Clique ou toque explode as partículas em volta, que voltam como molas. */
explodirAoClicar?: boolean;
forcaExplosao?: number;
/** Quanto (px) as partículas fogem do ponteiro. 0 desliga. */
repulsao?: number;
/** Alcance (px) da fuga do ponteiro. */
raioRepulsao?: number;
/** Amplitude (px) da flutuação parada. 0 desliga. */
flutuacao?: number;
/** Quando refazer a entrada: só ao montar, toda vez que o ponteiro entra ou a cada clique. */
gatilho?: "montar" | "passar" | "clicar";
/** Tamanho da fonte: número em px ou valor CSS. Encolhe se o texto não couber. */
tamanhoFonte?: number | string;
pesoFonte?: number;
/** Família da fonte. Por padrão, a de onde o componente é usado. */
fonte?: string;
/** Brilho em volta das partículas com destaque. */
brilho?: boolean;
/**
* Como o texto fica depois de montado. `texto`: as partículas se dissolvem num texto normal,
* na mesma fonte; ao fugir do ponteiro ou explodir, voltam a ser partículas e depois se refazem.
*/
final?: "particulas" | "texto";
/** Classes do contêiner (a cor do texto vira a cor das partículas). Dê uma altura: o mínimo é 240 px. */
className?: string;
style?: CSSProperties;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const settingsRef = useRef<Ajustes | null>(null);
const wakeRef = useRef<() => void>(() => {});
const semMovimento = useSemMovimento();
const miniatura = useMiniatura();
useEffect(() => {
settingsRef.current = {
texto,
tamanhoParticula,
densidade,
cor,
corDestaque,
destaque,
forma,
entrada,
espalhamento,
duracaoEntrada,
intervalo,
explodirAoClicar,
forcaExplosao,
repulsao,
raioRepulsao,
flutuacao,
gatilho,
tamanhoFonte,
pesoFonte,
fonte,
brilho,
final,
semMovimento,
};
wakeRef.current();
});
useEffect(() => {
const container = containerRef.current;
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
const layer = document.createElement("canvas");
const layerCtx = layer.getContext("2d");
const probe = document.createElement("canvas");
probe.width = 1;
probe.height = 1;
const probeCtx = probe.getContext("2d", { willReadFrequently: true });
if (!container || !canvas || !ctx || !layerCtx || !probeCtx) return;
const motionQuery = window.matchMedia?.("(prefers-reduced-motion: reduce)");
let reducedMotion = !!motionQuery?.matches || !!settingsRef.current?.semMovimento;
let particles: Particula[] = [];
let raf = 0;
let sampleFrame = 0;
let buildId = 0;
let introPending = true;
let sampledKey = "";
let sampledText = "";
let requestedSize = "";
let gathering = false;
let gatherStart = 0;
let introMode: Entrada = "juntar";
let bursting = false;
let width = 0;
let height = 0;
let dpr = 1;
let last = 0;
let visible = true;
let alive = true;
let colorKey = "";
let palette: string[] = [];
let glowColor = "#000000";
// 0 = só partículas, 1 = só o texto sólido (final "texto")
let solid = 0;
const solidText = { font: "", x: 0, y: 0, content: "" };
let accent = { r: 0, g: 0, b: 0 };
const bounds = { left: 0, right: 0, top: 0, bottom: 0 };
const swirl = { x: 0, y: 0, aspect: 1 };
const scan = { from: 0, to: 0, top: 0, bottom: 0, duration: 1 };
const buckets: Particula[][] = Array.from({ length: TONES * FADES }, () => []);
const pointer = { active: false, x: 0, y: 0, smoothX: 0, smoothY: 0 };
const family = (s: Ajustes) =>
!s.fonte || s.fonte === "inherit" ? window.getComputedStyle(container).fontFamily || "sans-serif" : s.fonte;
const layoutKey = (s: Ajustes) => [s.texto, s.tamanhoFonte, s.pesoFonte, family(s), s.densidade].join("|");
const sizeKey = () => {
const rect = container.getBoundingClientRect();
return `${Math.floor(rect.width)}x${Math.floor(rect.height)}@${Math.min(window.devicePixelRatio || 1, 2)}`;
};
const toRgb = (value: string) => {
probeCtx.clearRect(0, 0, 1, 1);
probeCtx.fillStyle = "#000000";
probeCtx.fillStyle = value;
probeCtx.fillRect(0, 0, 1, 1);
const [r, g, b] = probeCtx.getImageData(0, 0, 1, 1).data;
return { r, g, b };
};
const ensureColors = (s: Ajustes) => {
const current = window.getComputedStyle(container).color || "#000000";
const pick = (value: string) => (!value || INHERIT.test(String(value).trim()) ? current : value);
const base = pick(s.cor);
const highlight = pick(s.corDestaque);
const key = `${base}|${highlight}`;
if (key === colorKey) return;
colorKey = key;
const from = toRgb(base);
accent = toRgb(highlight);
glowColor = `rgb(${accent.r}, ${accent.g}, ${accent.b})`;
palette = Array.from({ length: TONES }, (_, i) => {
const t = i / (TONES - 1);
const r = Math.round(from.r + (accent.r - from.r) * t);
const g = Math.round(from.g + (accent.g - from.g) * t);
const b = Math.round(from.b + (accent.b - from.b) * t);
return `rgb(${r}, ${g}, ${b})`;
});
};
const travelTime = (s: Ajustes, particle: Particula) => {
if (reducedMotion) return 1;
if (introMode === "varredura") return s.duracaoEntrada * 0.4;
if (introMode === "chuva") return s.duracaoEntrada * (0.45 + particle.depth * 0.3);
return s.duracaoEntrada;
};
const startIntro = (initial: boolean) => {
const s = settingsRef.current;
if (!particles.length || !s) return;
introMode = s.entrada;
const spread = reducedMotion ? 0 : s.espalhamento;
const tall = Math.max(1, bounds.bottom - bounds.top);
swirl.x = (bounds.left + bounds.right) / 2;
swirl.y = (bounds.top + bounds.bottom) / 2;
swirl.aspect = clamp(width / Math.max(1, height), 1, 2.5);
scan.from = bounds.left - 24;
scan.to = bounds.right + 24;
scan.top = bounds.top - tall * 0.3;
scan.bottom = bounds.bottom + tall * 0.3;
scan.duration = Math.max(200, s.duracaoEntrada * 0.8);
let reach = 1;
if (introMode === "espiral") {
for (const particle of particles) {
const dx = particle.targetX - swirl.x;
const dy = (particle.targetY - swirl.y) * swirl.aspect;
particle.radius = Math.hypot(dx, dy);
particle.heading = Math.atan2(dy, dx);
reach = Math.max(reach, particle.radius);
}
}
for (const particle of particles) {
let delay = particle.seed * s.intervalo;
let startX: number;
let startY: number;
if (introMode === "chuva") {
startX = particle.targetX + (particle.depth - 0.9) * 16;
startY = -12 - particle.depth * 30 - particle.seed * height * 0.22;
delay = particle.seed * s.intervalo * 1.3;
} else if (introMode === "espiral") {
particle.startRadius = particle.radius * (1.04 + particle.depth * 0.22) + spread * 0.3 * particle.seed;
particle.swirl = (1.5 + 2.6 * (1 - particle.radius / reach)) * (0.85 + particle.seed * 0.3);
const angle = particle.heading + particle.swirl;
startX = swirl.x + Math.cos(angle) * particle.startRadius;
startY = swirl.y + (Math.sin(angle) * particle.startRadius) / swirl.aspect;
delay = particle.seed * s.intervalo;
} else if (introMode === "varredura") {
const along = (particle.targetX - scan.from) / Math.max(1, scan.to - scan.from);
startX = particle.targetX + 3 + particle.seed * 8;
startY = scan.top + ((particle.seed * 7.31 + particle.depth * 3.7) % 1) * (scan.bottom - scan.top);
delay = along * scan.duration + particle.seed * s.intervalo * 0.15;
} else {
const angle = particle.seed * TAU;
const distance = spread * (0.35 + particle.depth * 0.75);
startX = particle.targetX + Math.cos(angle) * distance + (particle.seed - 0.5) * spread * 0.45;
startY = particle.targetY + Math.sin(angle) * distance + (particle.depth - 0.9) * spread * 0.45;
}
particle.startX = startX;
particle.startY = startY;
particle.delay = reducedMotion ? 0 : delay;
if (initial) {
particle.x = startX;
particle.y = startY;
}
}
gatherStart = performance.now();
gathering = true;
wake();
};
const burst = (x: number, y: number) => {
const s = settingsRef.current;
if (!s || !particles.length) return;
const strength = Math.max(0, Number(s.forcaExplosao) || 0);
if (!strength) return;
const radius = 90 + 70 * Math.sqrt(strength) + s.raioRepulsao * 0.25;
for (const particle of particles) {
const dx = particle.x - x;
const dy = particle.y - y;
const distance = Math.hypot(dx, dy);
if (distance > radius) continue;
const falloff = 1 - distance / radius;
const power = 950 * strength * (0.2 + 0.8 * falloff * falloff) * (0.65 + particle.depth * 0.45);
const angle = Math.atan2(dy, dx) + (particle.seed - 0.5) * 0.9;
particle.vx += Math.cos(angle) * power;
particle.vy += Math.sin(angle) * power;
}
bursting = true;
wake();
};
const drawGroup = (target: CanvasRenderingContext2D, group: Particula[], base: number, shape: Forma) => {
target.beginPath();
if (shape === "linha") {
for (const particle of group) {
const dx = Math.cos(particle.angle) * particle.length * 0.5;
const dy = Math.sin(particle.angle) * particle.length * 0.5;
target.moveTo(particle.x - dx, particle.y - dy);
target.lineTo(particle.x + dx, particle.y + dy);
}
target.stroke();
return;
}
const square = shape === "quadrado";
for (const particle of group) {
const size = square ? base : Math.max(0.6, base * (0.75 + particle.weight * 0.45));
if (square || size <= 2.1) {
target.rect(particle.x - size / 2, particle.y - size / 2, size, size);
} else {
target.moveTo(particle.x + size / 2, particle.y);
target.arc(particle.x, particle.y, size / 2, 0, TAU);
}
}
target.fill();
};
const drawScan = (now: number, s: Ajustes) => {
const progress = (now - gatherStart) / scan.duration;
if (introMode !== "varredura" || progress < 0 || progress > 1.3 || reducedMotion) return false;
const x = scan.from + (scan.to - scan.from) * clamp(progress, 0, 1);
const alpha = clamp(progress / 0.08, 0, 1) * clamp((1.3 - progress) / 0.3, 0, 1);
const gradient = ctx.createLinearGradient(0, scan.top, 0, scan.bottom);
SCAN_FADE.forEach((level, i) => {
gradient.addColorStop(i / (SCAN_FADE.length - 1), `rgba(${accent.r}, ${accent.g}, ${accent.b}, ${level})`);
});
ctx.globalAlpha = alpha * 0.85;
ctx.fillStyle = gradient;
if (s.brilho) {
ctx.shadowColor = glowColor;
ctx.shadowBlur = 8 * dpr;
}
ctx.fillRect(x - 0.75, scan.top, 1.5, scan.bottom - scan.top);
ctx.shadowBlur = 0;
ctx.shadowColor = "transparent";
ctx.globalAlpha = 1;
return true;
};
const step = (now: number) => {
const s = settingsRef.current;
if (!s) return false;
reducedMotion = !!motionQuery?.matches || s.semMovimento;
const dt = last ? Math.min(0.05, Math.max(0, (now - last) / 1000)) : 1 / 60;
last = now;
ensureColors(s);
const frames = Math.max(dt * 60, 0.001);
const follow = reducedMotion ? 1 : 1 - Math.pow(0.78, frames);
const chase = 1 - Math.pow(0.82, frames);
const turn = 1 - Math.pow(0.72, frames);
pointer.smoothX += (pointer.x - pointer.smoothX) * chase;
pointer.smoothY += (pointer.y - pointer.smoothY) * chase;
const repel = pointer.active && !reducedMotion && s.repulsao > 0 && s.raioRepulsao > 0;
const solidFinal = s.final === "texto";
const drift = !reducedMotion && s.flutuacao > 0 && !solidFinal;
const motionHeat = s.destaque !== "gradiente";
const lines = s.forma === "linha";
const floor = gathering ? (FADE_FLOOR[introMode] ?? 0) : 0;
const time = now * 0.001;
const sizeBase = Math.max(0.1, Number(s.tamanhoParticula) || 0);
const restLength = Math.max(2, sizeBase * 0.8 + Math.max(2, Math.floor(Number(s.densidade) || 0)) * 0.7);
let complete = true;
let moving = false;
let settling = false;
let maxGap = 0;
for (const bucket of buckets) bucket.length = 0;
for (const particle of particles) {
let baseX = particle.targetX;
let baseY = particle.targetY;
let progress = 1;
let hidden = false;
if (gathering) {
const local = (now - gatherStart - particle.delay) / Math.max(1, travelTime(s, particle));
progress = clamp(local, 0, 1);
if (introMode === "varredura" && local < 0) hidden = true;
if (introMode === "espiral") {
const eased = easeInOutCubic(progress);
const radius = particle.startRadius + (particle.radius - particle.startRadius) * eased;
const angle = particle.heading + particle.swirl * (1 - eased);
baseX = swirl.x + Math.cos(angle) * radius;
baseY = swirl.y + (Math.sin(angle) * radius) / swirl.aspect;
} else {
const eased = introMode === "chuva" ? easeInFall(progress) : easeOutCubic(progress);
baseX = particle.startX + (particle.targetX - particle.startX) * eased;
baseY = particle.startY + (particle.targetY - particle.startY) * eased;
}
if (progress < 1) complete = false;
} else if (drift) {
baseX += Math.sin(time * 0.9 + particle.seed * 10) * s.flutuacao * particle.depth;
baseY += Math.cos(time * 0.75 + particle.depth * 10) * s.flutuacao * particle.depth;
}
if (bursting) {
particle.vx += (-BURST_SPRING * particle.ox - BURST_DAMPING * particle.vx) * dt;
particle.vy += (-BURST_SPRING * particle.oy - BURST_DAMPING * particle.vy) * dt;
particle.ox += particle.vx * dt;
particle.oy += particle.vy * dt;
if (Math.abs(particle.ox) + Math.abs(particle.oy) > 0.05 || Math.abs(particle.vx) + Math.abs(particle.vy) > 1) {
settling = true;
} else {
particle.ox = 0;
particle.oy = 0;
particle.vx = 0;
particle.vy = 0;
}
baseX += particle.ox;
baseY += particle.oy;
}
if (repel) {
const dx = baseX - pointer.smoothX;
const dy = baseY - pointer.smoothY;
const distance = Math.hypot(dx, dy);
if (distance > 0 && distance < s.raioRepulsao) {
const force = Math.pow(1 - distance / s.raioRepulsao, 2) * s.repulsao;
baseX += (dx / distance) * force;
baseY += (dy / distance) * force;
}
}
const moveX = (baseX - particle.x) * follow;
const moveY = (baseY - particle.y) * follow;
particle.x += moveX;
particle.y += moveY;
if (Math.abs(moveX) + Math.abs(moveY) > 0.02) moving = true;
if (solidFinal) maxGap = Math.max(maxGap, Math.abs(particle.x - particle.targetX) + Math.abs(particle.y - particle.targetY));
if (lines) {
const speed = Math.hypot(moveX, moveY) / frames;
const aim = speed > 0.3 ? Math.atan2(moveY, moveX) : REST_ANGLE;
const offset = wrapHalfTurn(aim - particle.angle);
particle.angle += offset * turn;
if (Math.abs(offset) > 0.01) moving = true;
particle.length = restLength + Math.min(restLength * 4, speed * 1.6);
}
if (hidden) continue;
const heat = motionHeat
? clamp(
(Math.hypot(particle.x - particle.targetX, particle.y - particle.targetY) - HEAT_DEADZONE) / HEAT_RANGE,
0,
1,
)
: particle.tone;
const tone = Math.round(heat * (TONES - 1));
const fade = Math.round((floor + (1 - floor) * progress) * (FADES - 1));
buckets[fade * TONES + tone].push(particle);
}
if (gathering && complete) gathering = false;
if (bursting && !settling) bursting = false;
const solidTarget = solidFinal && !gathering && !bursting && maxGap < SOLID_GAP ? 1 : 0;
solid = reducedMotion ? solidTarget : solid + (solidTarget - solid) * (1 - Math.pow(0.86, frames));
if (Math.abs(solidTarget - solid) < 0.01) solid = solidTarget;
const melting = solid !== solidTarget;
const particleAlpha = 1 - solid;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.globalAlpha = 1;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const strokeWidth = Math.max(1, sizeBase * 0.65);
if (lines) {
ctx.lineWidth = strokeWidth;
ctx.lineCap = "round";
}
if (s.brilho && particleAlpha > 0) {
layerCtx.setTransform(1, 0, 0, 1, 0, 0);
layerCtx.clearRect(0, 0, layer.width, layer.height);
layerCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
layerCtx.fillStyle = glowColor;
layerCtx.strokeStyle = glowColor;
layerCtx.lineWidth = strokeWidth;
layerCtx.lineCap = "round";
let lit = false;
for (let fade = 0; fade < FADES; fade++) {
for (let tone = 1; tone < TONES; tone++) {
const group = buckets[fade * TONES + tone];
if (!group.length) continue;
layerCtx.globalAlpha = (tone / (TONES - 1)) * fadeAlpha(fade);
drawGroup(layerCtx, group, sizeBase, s.forma);
lit = true;
}
}
layerCtx.globalAlpha = 1;
if (lit) {
ctx.shadowColor = glowColor;
ctx.shadowBlur = (3 + sizeBase * 1.5) * dpr;
ctx.globalAlpha = particleAlpha;
ctx.drawImage(layer, 0, 0, width, height);
ctx.shadowBlur = 0;
ctx.shadowColor = "transparent";
ctx.globalAlpha = 1;
}
}
for (let fade = 0; fade < FADES && particleAlpha > 0; fade++) {
ctx.globalAlpha = fadeAlpha(fade) * particleAlpha;
for (let tone = 0; tone < TONES; tone++) {
const group = buckets[fade * TONES + tone];
if (!group.length) continue;
if (lines) ctx.strokeStyle = palette[tone];
else ctx.fillStyle = palette[tone];
drawGroup(ctx, group, sizeBase, s.forma);
}
}
ctx.globalAlpha = 1;
if (solid > 0 && solidText.font) {
ctx.globalAlpha = solid;
ctx.font = solidText.font;
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
ctx.fillStyle = palette[0];
ctx.fillText(solidText.content, solidText.x, solidText.y);
ctx.globalAlpha = 1;
}
const scanning = drawScan(now, s);
return gathering || moving || repel || drift || bursting || scanning || melting;
};
const frame = (now: number) => {
raf = 0;
if (!alive) return;
const s = settingsRef.current;
if (s) {
const key = layoutKey(s);
if (key !== sampledKey) {
queueSample(s.texto !== sampledText);
sampledKey = key;
sampledText = s.texto;
}
}
const busy = step(now);
if (busy && visible && alive) raf = requestAnimationFrame(frame);
};
function wake() {
if (raf || !visible || !alive) return;
last = 0;
raf = requestAnimationFrame(frame);
}
wakeRef.current = wake;
const sampleText = async () => {
const s = settingsRef.current;
if (!s) return;
const currentBuild = ++buildId;
const rect = container.getBoundingClientRect();
const nextWidth = Math.floor(rect.width);
const nextHeight = Math.floor(rect.height);
requestedSize = sizeKey();
sampledKey = layoutKey(s);
sampledText = s.texto;
if (nextWidth <= 0 || nextHeight <= 0) return;
const nextDpr = Math.min(window.devicePixelRatio || 1, 2);
const resolvedFamily = family(s);
let resolvedSize = resolveFontSize(s.tamanhoFonte, container, s.pesoFonte, resolvedFamily);
let font = `${s.pesoFonte} ${resolvedSize}px ${resolvedFamily}`;
await waitForFonts(font);
if (currentBuild !== buildId || !alive) return;
const offscreen = document.createElement("canvas");
const offCtx = offscreen.getContext("2d", { willReadFrequently: true });
if (!offCtx) return;
const content = String(s.texto || " ");
const maxTextWidth = nextWidth * 0.92;
offCtx.font = font;
let metrics = offCtx.measureText(content);
const measuredWidth = Math.max(1, metrics.width);
if (measuredWidth > maxTextWidth) {
resolvedSize = Math.max(18, resolvedSize * (maxTextWidth / measuredWidth));
font = `${s.pesoFonte} ${resolvedSize}px ${resolvedFamily}`;
await waitForFonts(font);
if (currentBuild !== buildId || !alive) return;
offCtx.font = font;
metrics = offCtx.measureText(content);
}
const left = Math.ceil(metrics.actualBoundingBoxLeft || 0);
const right = Math.ceil(metrics.actualBoundingBoxRight || metrics.width);
const ascent = Math.ceil(metrics.actualBoundingBoxAscent || resolvedSize * 0.78);
const descent = Math.ceil(metrics.actualBoundingBoxDescent || resolvedSize * 0.22);
const padding = Math.max(12, Math.ceil(resolvedSize * 0.08));
const textWidth = Math.max(1, left + right);
const textHeight = Math.max(1, ascent + descent);
offscreen.width = textWidth + padding * 2;
offscreen.height = textHeight + padding * 2;
offCtx.clearRect(0, 0, offscreen.width, offscreen.height);
offCtx.font = font;
offCtx.textAlign = "left";
offCtx.textBaseline = "alphabetic";
offCtx.fillStyle = "#ffffff";
offCtx.fillText(content, padding - left, padding + ascent);
solidText.font = font;
solidText.content = content;
solidText.x = nextWidth / 2 - offscreen.width / 2 + padding - left;
solidText.y = nextHeight / 2 - offscreen.height / 2 + padding + ascent;
const imageData = offCtx.getImageData(0, 0, offscreen.width, offscreen.height);
const targets: { x: number; y: number; alpha: number }[] = [];
const sampleStep = Math.max(2, Math.floor(s.densidade));
for (let y = 0; y < offscreen.height; y += sampleStep) {
for (let x = 0; x < offscreen.width; x += sampleStep) {
const alpha = imageData.data[(y * offscreen.width + x) * 4 + 3];
if (alpha > 40) {
targets.push({
x: nextWidth / 2 - offscreen.width / 2 + x,
y: nextHeight / 2 - offscreen.height / 2 + y,
alpha: alpha / 255,
});
}
}
}
const maxParticles = Math.max(900, Math.min(5200, Math.floor((nextWidth * nextHeight) / 90)));
const stride = Math.max(1, Math.ceil(targets.length / maxParticles));
const selected = targets.filter((_, index) => index % stride === 0);
const intro = introPending && !reducedMotion;
introPending = false;
width = nextWidth;
height = nextHeight;
dpr = nextDpr;
canvas.width = Math.max(1, Math.floor(width * dpr));
canvas.height = Math.max(1, Math.floor(height * dpr));
layer.width = canvas.width;
layer.height = canvas.height;
bounds.left = Infinity;
bounds.right = -Infinity;
bounds.top = Infinity;
bounds.bottom = -Infinity;
for (const target of selected) {
bounds.left = Math.min(bounds.left, target.x);
bounds.right = Math.max(bounds.right, target.x);
bounds.top = Math.min(bounds.top, target.y);
bounds.bottom = Math.max(bounds.bottom, target.y);
}
if (!selected.length) {
bounds.left = bounds.right = width / 2;
bounds.top = bounds.bottom = height / 2;
}
particles = selected.map((target, index) => {
const seed = ((index * 9301 + 49297) % 233280) / 233280;
const depth = 0.45 + (((index * 233 + 97) % 1000) / 1000) * 0.9;
return {
x: target.x,
y: target.y,
startX: target.x,
startY: target.y,
targetX: target.x,
targetY: target.y,
weight: target.alpha,
tone: clamp(target.x / Math.max(1, width) + (seed - 0.5) * 0.35, 0, 1),
seed,
depth,
delay: 0,
ox: 0,
oy: 0,
vx: 0,
vy: 0,
angle: REST_ANGLE,
length: 0,
radius: 0,
heading: 0,
startRadius: 0,
swirl: 0,
};
});
bursting = false;
if (!pointer.active) {
pointer.x = width / 2;
pointer.y = height / 2;
pointer.smoothX = pointer.x;
pointer.smoothY = pointer.y;
}
if (intro) startIntro(true);
else gathering = false;
step(performance.now());
wake();
};
function queueSample(intro: boolean) {
if (intro) introPending = true;
if (sampleFrame) cancelAnimationFrame(sampleFrame);
sampleFrame = requestAnimationFrame(() => {
sampleFrame = 0;
sampleText();
});
}
const locate = (event: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
pointer.x = event.clientX - rect.left;
pointer.y = event.clientY - rect.top;
};
const handlePointerMove = (event: PointerEvent) => {
locate(event);
if (!pointer.active) {
pointer.smoothX = pointer.x;
pointer.smoothY = pointer.y;
}
pointer.active = true;
wake();
};
const handlePointerLeave = () => {
pointer.active = false;
wake();
};
const handlePointerEnter = (event: PointerEvent) => {
handlePointerMove(event);
if (settingsRef.current?.gatilho === "passar") startIntro(false);
};
const handlePointerDown = (event: PointerEvent) => {
const s = settingsRef.current;
if (!s || s.gatilho === "clicar" || !s.explodirAoClicar || reducedMotion) return;
const rect = canvas.getBoundingClientRect();
burst(event.clientX - rect.left, event.clientY - rect.top);
};
const handleClick = () => {
if (settingsRef.current?.gatilho === "clicar") startIntro(false);
};
const handleReduceMotionChange = (event: MediaQueryListEvent) => {
reducedMotion = event.matches;
if (reducedMotion) {
gathering = false;
bursting = false;
particles.forEach((particle) => {
particle.x = particle.targetX;
particle.y = particle.targetY;
particle.ox = 0;
particle.oy = 0;
particle.vx = 0;
particle.vy = 0;
});
}
wake();
};
motionQuery?.addEventListener?.("change", handleReduceMotionChange);
canvas.addEventListener("pointerenter", handlePointerEnter);
canvas.addEventListener("pointermove", handlePointerMove);
canvas.addEventListener("pointerleave", handlePointerLeave);
canvas.addEventListener("pointerdown", handlePointerDown);
canvas.addEventListener("click", handleClick);
const resizeObserver = new ResizeObserver(() => {
if (sizeKey() !== requestedSize) queueSample(false);
});
resizeObserver.observe(container);
const intersectionObserver = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
wake();
});
intersectionObserver.observe(container);
// Troca de tema muda a cor herdada: acorda para repintar
const themeObserver = new MutationObserver(wake);
for (const node of [document.documentElement, document.body]) {
if (node) themeObserver.observe(node, { attributes: true, attributeFilter: ["class", "style", "data-theme"] });
}
const schemeQuery = window.matchMedia?.("(prefers-color-scheme: dark)");
schemeQuery?.addEventListener?.("change", wake);
sampleText();
return () => {
alive = false;
buildId += 1;
wakeRef.current = () => {};
resizeObserver.disconnect();
intersectionObserver.disconnect();
themeObserver.disconnect();
schemeQuery?.removeEventListener?.("change", wake);
motionQuery?.removeEventListener?.("change", handleReduceMotionChange);
canvas.removeEventListener("pointerenter", handlePointerEnter);
canvas.removeEventListener("pointermove", handlePointerMove);
canvas.removeEventListener("pointerleave", handlePointerLeave);
canvas.removeEventListener("pointerdown", handlePointerDown);
canvas.removeEventListener("click", handleClick);
if (raf) cancelAnimationFrame(raf);
if (sampleFrame) cancelAnimationFrame(sampleFrame);
};
}, []);
return (
<div
ref={containerRef}
// Na miniatura, uma largura fixa: o canvas preenche o contêiner e não tem tamanho próprio
className={`relative isolate block h-full min-h-60 w-full touch-pan-y overflow-hidden ${miniatura ? "min-w-[560px]" : ""} ${className}`}
style={style}
>
<canvas ref={canvasRef} aria-hidden="true" className="absolute inset-0 block size-full" />
<span className="sr-only">{texto}</span>
</div>
);
}
import { defineStories } from "@/registry/stories";
import { TextoDeParticulas } from "./TextoDeParticulas";
export default defineStories({
args: {
texto: "React Bits",
entrada: "juntar",
forma: "circulo",
destaque: "movimento",
corDestaque: "#ff5a36",
tamanhoParticula: 2,
densidade: 4,
repulsao: 40,
raioRepulsao: 120,
flutuacao: 0.7,
explodirAoClicar: true,
gatilho: "montar",
brilho: true,
final: "particulas",
},
controls: {
texto: { type: "text" },
entrada: { type: "select", options: ["juntar", "chuva", "espiral", "varredura"] },
forma: { type: "select", options: ["circulo", "quadrado", "linha"] },
destaque: { type: "select", options: ["movimento", "gradiente"] },
corDestaque: { type: "color" },
tamanhoParticula: { type: "number", label: "tamanhoParticula (px)", min: 1, max: 6, step: 0.5 },
densidade: { type: "number", label: "densidade (px entre pontos)", min: 2, max: 10, step: 1 },
repulsao: { type: "number", label: "repulsao (px)", min: 0, max: 120, step: 5 },
raioRepulsao: { type: "number", label: "raioRepulsao (px)", min: 20, max: 300, step: 10 },
flutuacao: { type: "number", label: "flutuacao (px)", min: 0, max: 4, step: 0.1 },
explodirAoClicar: { type: "boolean" },
gatilho: { type: "select", options: ["montar", "passar", "clicar"] },
brilho: { type: "boolean" },
final: { type: "select", options: ["particulas", "texto"] },
},
render: (a) => (
<div className="h-64 w-full max-w-3xl">
<TextoDeParticulas
// Recomeça a entrada quando muda o tipo de entrada
key={a.entrada}
texto={a.texto}
entrada={a.entrada as "juntar"}
forma={a.forma as "circulo"}
destaque={a.destaque as "movimento"}
corDestaque={a.corDestaque}
tamanhoParticula={a.tamanhoParticula}
densidade={a.densidade}
repulsao={a.repulsao}
raioRepulsao={a.raioRepulsao}
flutuacao={a.flutuacao}
explodirAoClicar={a.explodirAoClicar}
gatilho={a.gatilho as "montar"}
brilho={a.brilho}
final={a.final as "particulas"}
/>
</div>
),
stories: [
{ name: "Padrão" },
{ name: "Chuva", args: { texto: "Chuva", entrada: "chuva", corDestaque: "#38bdf8" } },
{ name: "Espiral em linhas", args: { texto: "Espiral", entrada: "espiral", forma: "linha", corDestaque: "#a78bfa" } },
{ name: "Termina em texto", args: { texto: "Sólido", final: "texto" } },
{ name: "Varredura em gradiente", args: { texto: "Scanner", entrada: "varredura", destaque: "gradiente", forma: "quadrado" } },
],
});
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);
}