Typography
Particle Typography
Headline rendered as thousands of cursor-reactive particles. Multi-line, per-word colour, fits itself to its box, waits for webfonts, keeps a real heading in the DOM.
Use this component
Copy it into your own project
Pick your coding agent and copy a complete integration prompt — source, dependencies, tokens and usage included — or grab the raw files.
1 file · no extra packages
Paste into the Claude Code terminal, or save as a .md and reference it.
## Integrate the <ParticleTypography /> component from 3dopenwebsite.com
You are helping integrate a React component from the 3dopenwebsite.com component library into an existing application. Work directly in this repository: create the files below at the given paths, install the dependencies, then wire the usage example into the page I name. Run the type-checker before you finish.
### Component: Particle Typography
### Variant: TypeScript + Tailwind CSS (React 18)
### Dependencies: none beyond React
### Files: src/components/ui/cursor-driven-particle-typography.tsx
Headline rendered as thousands of cursor-reactive particles. Multi-line, per-word colour, fits itself to its box, waits for webfonts, keeps a real heading in the DOM.
---
### Usage Example
```tsx
<CursorDrivenParticleTypography
segments={[
{ text: "Next Level", color: "hsl(16 100% 50%)" },
{ text: "of ", color: "hsl(16 100% 50%)" },
{ text: "Intelligence.", color: "#f0efed" },
]}
fontSize={96}
particleDensity={3}
align="left"
/>
```
### Props
- `text`
- `segments`
- `fontSize`
- `particleDensity`
- `particleSize`
- `bleed`
- `align`
### Design tokens
The component uses the 3dopenwebsite.com design tokens (`text-fire`, `bg-fire`, `bg-glass-bg`, `border-glass-border`, `text-foreground`, `text-muted-foreground`). Either map those classes to your own theme, or add the tokens:
```css
:root {
--background: 0 0% 0%;
--foreground: 0 0% 100%;
--muted-foreground: 0 0% 60%;
--radius: 1rem;
--glass-bg: 0 0% 100% / 0.05;
--glass-border: 0 0% 100% / 0.1;
--glass-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);
--fire-orange: 16 100% 50%;
--fire-orange-light: 25 100% 60%;
--fire-orange-gradient: linear-gradient(135deg, hsl(16 100% 50%), hsl(25 100% 60%));
}
```
```ts
// tailwind.config — theme.extend
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
"muted-foreground": "hsl(var(--muted-foreground))",
fire: { DEFAULT: "hsl(var(--fire-orange))", light: "hsl(var(--fire-orange-light))" },
glass: { bg: "hsl(var(--glass-bg))", border: "hsl(var(--glass-border))" },
},
borderRadius: { glass: "20px" },
boxShadow: { glass: "var(--glass-shadow)" },
```
The `cn` helper (`@/lib/utils`) is clsx + tailwind-merge:
```ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/** True in headless / driven browsers (prerender, tests): decorative canvases are skipped there. */
export const isAutomated = () => typeof navigator !== "undefined" && !!navigator.webdriver;
```
### Integration Instructions
1. Install the listed dependencies.
2. Create each file below at its path. Keep the `@/` alias pointing at `src/`, or rewrite the imports to your project's alias.
3. Add `src/lib/utils.ts` if the project has no `cn` helper.
4. Add the design tokens above (or remap the classes to your theme).
5. Import and render the component using the usage example; adjust props to fit.
### Full Component Source
#### src/components/ui/cursor-driven-particle-typography.tsx
```tsx
"use client";
import { cn } from "@/lib/utils";
import React, { useEffect, useRef } from "react";
/** A run of text within a line, optionally in its own colour. */
export interface TextRun {
text: string;
color?: string;
}
export interface CursorDrivenParticleTypographyProps {
/** Additional CSS classes */
className?: string;
/** The text to render. May contain "\n" for multiple lines. */
text: string;
/**
* Per-line runs, for headlines that change colour mid-sentence. Overrides
* `text` for rendering when set — keep passing `text` as the source string.
*/
segments?: TextRun[][];
/** Font size in pixels. Treated as a ceiling — type is fitted to the box. */
fontSize?: number;
/** Font family */
fontFamily?: string;
/** Size of each particle */
particleSize?: number;
/** Density of particles (lower number = more particles, minimum 1) */
particleDensity?: number;
/** How strongly the cursor pushes particles away */
dispersionStrength?: number;
/** Speed at which particles return to origin */
returnSpeed?: number;
/** Custom color for particles. Overrides inherited text color if set. */
color?: string;
/** Leading multiplier for multi-line text. */
lineHeight?: number;
/**
* Fraction of the canvas kept empty around the text (0-1). Particles are
* clipped at the canvas edge, so type fitted edge-to-edge visibly cuts off
* once the cursor disperses it. Raise this to give the effect room.
*/
bleed?: number;
/** Horizontal anchor for the text block within the canvas. */
align?: "center" | "left";
}
class Particle {
x: number;
y: number;
originX: number;
originY: number;
vx: number;
vy: number;
size: number;
dispersion: number;
returnSpd: number;
constructor(
x: number,
y: number,
size: number,
dispersion: number,
returnSpd: number
) {
this.x = x + (Math.random() - 0.5) * 10; // start with slight randomness
this.y = y + (Math.random() - 0.5) * 10;
this.originX = x;
this.originY = y;
this.vx = (Math.random() - 0.5) * 5;
this.vy = (Math.random() - 0.5) * 5;
this.size = size;
this.dispersion = dispersion;
this.returnSpd = returnSpd;
}
update(mouseX: number, mouseY: number) {
const dx = mouseX - this.x;
const dy = mouseY - this.y;
const distance = Math.sqrt(dx * dx + dy * dy);
// Physics interaction with mouse
const interactionRadius = 120; // 120px interaction radius
if (distance < interactionRadius && mouseX !== -1000 && mouseY !== -1000) {
const forceDirectionX = dx / distance;
const forceDirectionY = dy / distance;
const force = (interactionRadius - distance) / interactionRadius;
// Calculate repulsion
const repulsionX = forceDirectionX * force * this.dispersion;
const repulsionY = forceDirectionY * force * this.dispersion;
this.vx -= repulsionX;
this.vy -= repulsionY;
}
// Return to origin (spring physics)
this.vx += (this.originX - this.x) * this.returnSpd;
this.vy += (this.originY - this.y) * this.returnSpd;
// Friction
this.vx *= 0.85;
this.vy *= 0.85;
// Add subtle noise/jitter when close to origin
const distToOrigin = Math.sqrt(
Math.pow(this.x - this.originX, 2) + Math.pow(this.y - this.originY, 2)
);
if (distToOrigin < 1 && Math.random() > 0.95) {
this.vx += (Math.random() - 0.5) * 0.2;
this.vy += (Math.random() - 0.5) * 0.2;
}
this.x += this.vx;
this.y += this.vy;
}
/** Contributes to a shared path; the caller fills once per colour group. */
addToPath(ctx: CanvasRenderingContext2D, useRects: boolean) {
if (useRects) {
// At <=1.5px a square and a circle are indistinguishable, and rects
// build the path far faster — this is what makes dense mode viable.
const d = this.size * 2;
ctx.rect(this.x - this.size, this.y - this.size, d, d);
} else {
ctx.moveTo(this.x + this.size, this.y);
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
}
}
}
export function CursorDrivenParticleTypography({
className,
text,
segments,
fontSize = 120,
fontFamily = "Inter, sans-serif",
particleSize = 1.5,
particleDensity = 6,
dispersionStrength = 15,
returnSpeed = 0.08,
color,
lineHeight = 1.05,
bleed = 0.06,
align = "center",
}: CursorDrivenParticleTypographyProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// `segments` is a fresh array identity on every render when passed inline;
// compare by value so the effect doesn't rebuild the field each render.
const segmentsKey = segments ? JSON.stringify(segments) : "";
useEffect(() => {
if (
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
return;
}
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) return;
let animationFrameId: number;
// Particles bucketed by colour: one path fill per colour per frame,
// rather than a fillStyle + beginPath + fill per particle.
let groups: Array<{ color: string; items: Particle[] }> = [];
let mouseX = -1000;
let mouseY = -1000;
let containerWidth = 0;
let containerHeight = 0;
const init = () => {
const container = containerRef.current;
if (!container) return;
containerWidth = container.clientWidth;
containerHeight = container.clientHeight;
if (containerWidth === 0 || containerHeight === 0) return;
const dpr = window.devicePixelRatio || 1;
canvas.width = containerWidth * dpr;
canvas.height = containerHeight * dpr;
canvas.style.width = `${containerWidth}px`;
canvas.style.height = `${containerHeight}px`;
ctx.scale(dpr, dpr);
const computedStyle = window.getComputedStyle(container);
const defaultColor = color || computedStyle.color || "#000000";
ctx.clearRect(0, 0, containerWidth, containerHeight);
ctx.textBaseline = "middle";
ctx.textAlign = "left";
const lines: TextRun[][] =
segments && segments.length
? segments
: text.split("\n").map((line) => [{ text: line }]);
const measureLine = (runs: TextRun[]) =>
runs.reduce((w, run) => w + ctx.measureText(run.text).width, 0);
// All lines share one size, fitted to the WIDEST line — sizing each
// line on its own makes a multi-line headline render at two sizes.
const fit = Math.max(0.1, Math.min(1, 1 - bleed));
const maxWidth = containerWidth * fit;
ctx.font = `bold ${fontSize}px ${fontFamily}`;
const widestLine = Math.max(...lines.map(measureLine));
// Bound by height too, or tall glyphs get clipped by the container.
const heightBudget =
(containerHeight * fit) / (1.15 + (lines.length - 1) * lineHeight);
let effectiveFontSize = Math.floor(
Math.min(
fontSize,
widestLine > 0 ? fontSize * (maxWidth / widestLine) : fontSize,
heightBudget
)
);
ctx.font = `bold ${effectiveFontSize}px ${fontFamily}`;
// Verify against real metrics and shrink until it genuinely fits.
// Guards against the estimate being off for any reason — a swapped
// webfont, unusual metrics, a fallback stack that differs in width.
let guard = 0;
while (
effectiveFontSize > 8 &&
guard++ < 40 &&
Math.max(...lines.map(measureLine)) > maxWidth
) {
effectiveFontSize -= Math.max(1, Math.round(effectiveFontSize * 0.03));
ctx.font = `bold ${effectiveFontSize}px ${fontFamily}`;
}
// Draw the block centred, run by run so each keeps its own colour
const lineStep = effectiveFontSize * lineHeight;
const firstY = containerHeight / 2 - ((lines.length - 1) * lineStep) / 2;
// Left-anchored blocks still need the bleed margin as a gutter, or
// dispersed particles clip against the canvas edge.
const gutter = (containerWidth * (1 - fit)) / 2;
lines.forEach((runs, i) => {
const y = firstY + i * lineStep;
let x =
align === "left"
? gutter
: (containerWidth - measureLine(runs)) / 2;
runs.forEach((run) => {
ctx.fillStyle = run.color || defaultColor;
ctx.fillText(run.text, x, y);
x += ctx.measureText(run.text).width;
});
});
// Sample the rendered block. Reading RGB — not just alpha — is what
// carries per-run colour through to the particles.
const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height);
const byColor = new Map<string, Particle[]>();
const step = Math.max(1, Math.floor(particleDensity * dpr));
for (let y = 0; y < pixels.height; y += step) {
for (let x = 0; x < pixels.width; x += step) {
const index = (y * pixels.width + x) * 4;
const alpha = pixels.data[index + 3] || 0;
if (alpha <= 128) continue;
const key = `rgb(${pixels.data[index]},${pixels.data[index + 1]},${pixels.data[index + 2]})`;
let bucket = byColor.get(key);
if (!bucket) {
bucket = [];
byColor.set(key, bucket);
}
bucket.push(
new Particle(
x / dpr,
y / dpr,
particleSize,
dispersionStrength,
returnSpeed
)
);
}
}
groups = Array.from(byColor, ([c, items]) => ({ color: c, items }));
};
const useRects = particleSize <= 1.5;
const animate = () => {
ctx.clearRect(0, 0, containerWidth, containerHeight);
for (const group of groups) {
ctx.fillStyle = group.color;
ctx.beginPath();
for (let i = 0; i < group.items.length; i++) {
group.items[i].update(mouseX, mouseY);
group.items[i].addToPath(ctx, useRects);
}
ctx.fill();
}
animationFrameId = requestAnimationFrame(animate);
};
const handleMouseMove = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
mouseX = e.clientX - rect.left;
mouseY = e.clientY - rect.top;
};
const handleMouseLeave = () => {
mouseX = -1000;
mouseY = -1000;
};
const handleTouch = (e: TouchEvent) => {
const touch = e.touches[0];
if (!touch) return;
const rect = canvas.getBoundingClientRect();
mouseX = touch.clientX - rect.left;
mouseY = touch.clientY - rect.top;
};
// Fonts must be READY before measuring: text is measured once and
// baked into particle positions, so measuring against a fallback and
// drawing with a webfont that arrives later clips the headline.
let started = false;
let cancelled = false;
const boot = () => {
if (cancelled || started) return;
started = true;
init();
animate();
};
// Belt and braces: boot on fonts.ready, and on a timeout in case the
// font never resolves, so the headline can never fail to appear.
const timeoutId = setTimeout(boot, 600);
if (document.fonts?.ready) {
document.fonts.ready.then(() => {
clearTimeout(timeoutId);
boot();
});
} else {
clearTimeout(timeoutId);
setTimeout(boot, 100);
}
// A late-arriving face (or a second weight) changes metrics — re-fit.
const onFontsDone = () => {
if (started) init();
};
document.fonts?.addEventListener?.("loadingdone", onFontsDone);
const resizeObserver = new ResizeObserver(() => init());
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
// Re-sample when the theme changes (class changes on the html tag)
const themeObserver = new MutationObserver(() => init());
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
canvas.addEventListener("mousemove", handleMouseMove);
canvas.addEventListener("mouseleave", handleMouseLeave);
canvas.addEventListener("touchstart", handleTouch, { passive: true });
canvas.addEventListener("touchmove", handleTouch, { passive: true });
canvas.addEventListener("touchend", handleMouseLeave);
return () => {
cancelled = true;
clearTimeout(timeoutId);
document.fonts?.removeEventListener?.("loadingdone", onFontsDone);
resizeObserver.disconnect();
themeObserver.disconnect();
canvas.removeEventListener("mousemove", handleMouseMove);
canvas.removeEventListener("mouseleave", handleMouseLeave);
canvas.removeEventListener("touchstart", handleTouch);
canvas.removeEventListener("touchmove", handleTouch);
canvas.removeEventListener("touchend", handleMouseLeave);
cancelAnimationFrame(animationFrameId);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
text,
segmentsKey,
fontSize,
fontFamily,
particleSize,
particleDensity,
dispersionStrength,
returnSpeed,
color,
lineHeight,
bleed,
align,
]);
return (
<div
ref={containerRef}
className={cn(
"w-full h-full flex items-center justify-center relative touch-pan-y",
className
)}
>
<canvas ref={canvasRef} aria-hidden="true" className="block w-full h-full" />
</div>
);
}
```
---
Source: https://www.3dopenwebsite.com/components?c=particle-typography · Built by 3dopenwebsite.com — keep this line in the file header when you reuse it.- Source
- src/components/ui/cursor-driven-particle-typography.tsx
- Origin
- componentry, heavily adapted
- Key props
textsegmentsfontSizeparticleDensityparticleSizebleedalign