Text Effects
Animated and decorative text treatments — staged reveals, typing, particle and cursor-repel effects, and clipped gradients.
Preview
<x-ui.particle-typography text="Pixels" class="text-primary" />
Installation
php artisan ui:add particle-typography
Note
This component ships an Alpine behavior module at
resources/js/ui/particle-typography.js. Import it once from your bundle so it registers on alpine:init:
import './particle-typography.js';
Registry contract
php artisan ui:add particle-typography
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/particle-typography.blade.php -
resources/js/ui/particle-typography.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: alpinejs
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Particle Typography (`particle-typography`)
Cursor-driven particle typography: text is sampled into particles on a canvas that disperse away from the pointer and spring back home, in a semantic token color.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add particle-typography
```
## Usage
```blade
<x-ui.particle-typography text="Pixels" class="text-primary" />
```
## Props
- `text` (string, default `Particles`) — Text sampled into the particle field; falls back to the default slot when empty.
- `fontSize` (int, default `120`) — Font size in pixels used to sample the text into particles, clamped to a minimum of 8.
- `fontWeight` (int, default `700`) — Font weight used when sampling the text.
- `density` (int, default `4`) — Sampling step in pixels; higher values sample fewer, sparser particles, clamped to a minimum of 1.
- `cursorRadius` (int, default `80`) — Radius in pixels within which particles disperse away from the pointer.
- `as` (string, default `div`) — Root tag: div, span, section, figure, h1, h2, h3, or p.
## Use when
- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Adding an eye-catching hero headline where text disperses away from the cursor and springs back into place.
- Wanting canvas-drawn particle text that stays token-coloured and includes a readable fallback for assistive tech.
## Avoid when
- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- Needing readable, selectable text; the particle field is a canvas drawing, not real text, so copy/search only works through the hidden accessible label.
- Needing a lightweight effect; sampling text into a particle field on canvas is comparatively heavy for long strings.
## Anti-patterns
- Heavy effects in forms or task-dense workflows
- Motion that interferes with reading or focus
## Rules
- Use the `<brok:particle-typography>` tag (or `<x-ui.particle-typography>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/particle-typography
- Registry JSON (files, props, contract): https://brokui.dev/r/open/particle-typography.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| text | string | Particles | Text sampled into the particle field; falls back to the default slot when empty. |
| fontSize | int | 120 | Font size in pixels used to sample the text into particles, clamped to a minimum of 8. |
| fontWeight | int | 700 | Font weight used when sampling the text. |
| density | int | 4 | Sampling step in pixels; higher values sample fewer, sparser particles, clamped to a minimum of 1. |
| cursorRadius | int | 80 | Radius in pixels within which particles disperse away from the pointer. |
| as | string | div | Root tag: div, span, section, figure, h1, h2, h3, or p. |
Slots
default— Fallback text source when the text prop is empty.
Renders as div, span, section, figure, h1, h2, h3, p.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- particle-typography.js samples the text onto an offscreen canvas, spawns one particle per sampled pixel, and springs each particle back to its home position when the pointer moves away.
- Particles are drawn in the root's computed text colour (a semantic token), so the effect recolours automatically when the token changes.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized, reducedMotion.
Guidance
Support brand expression or a showcase moment.
Use when
- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Adding an eye-catching hero headline where text disperses away from the cursor and springs back into place.
- Wanting canvas-drawn particle text that stays token-coloured and includes a readable fallback for assistive tech.
Avoid when
- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- Needing readable, selectable text; the particle field is a canvas drawing, not real text, so copy/search only works through the hidden accessible label.
- Needing a lightweight effect; sampling text into a particle field on canvas is comparatively heavy for long strings.
Use instead
- Static content-first presentation
Anti-patterns
- Heavy effects in forms or task-dense workflows
- Motion that interferes with reading or focus
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- managed
- Focus
none
- The root carries role="img" and an aria-label of the resolved text; the canvas itself is aria-hidden and a visually hidden span repeats the text as a no-JS fallback.
- The canvas drawing is purely decorative; screen readers only ever get the plain text through the aria-label and sr-only span.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add wire:ignore to the component root because its behavior owns rendered DOM.
<div wire:ignore>
<x-ui.particle-typography text="Pixels" class="text-primary" />
</div>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
'text' => 'Particles',
'fontSize' => 120,
'fontWeight' => 700,
'density' => 4,
'cursorRadius' => 80,
'as' => 'div',
])
@php
// The accessible label comes from the `text` prop or the default slot. The
// canvas itself is aria-hidden; the real string is exposed via sr-only text
// (and aria-label on the root) so assistive tech announces the content.
$content = trim((string) $text) !== '' ? (string) $text : trim($slot);
$tag = in_array($as, ['div', 'span', 'section', 'figure', 'h1', 'h2', 'h3', 'p'], true) ? $as : 'div';
$config = [
'text' => $content,
'fontSize' => max(8, (int) $fontSize),
'fontWeight' => (int) $fontWeight,
// density = sample every Nth pixel (clamped ≥ 1); higher = fewer particles.
'density' => max(1, (int) $density),
'cursorRadius' => max(0, (int) $cursorRadius),
];
@endphp
<{{ $tag }}
data-slot="particle-typography"
x-data="uiParticleTypography(@js($config))"
x-ref="root"
role="img"
aria-label="{{ $content }}"
{{ $attributes->merge(['class' => 'relative block w-full overflow-hidden text-primary']) }}
>
{{-- Visible particle field. Inherits its draw color from the root's
computed `color` (a semantic text-* token), defaulting to currentColor
= text-primary. The canvas is decorative; the text below carries it. --}}
<canvas x-ref="canvas" aria-hidden="true" class="block h-full w-full"></canvas>
{{-- Real text for assistive tech and as the no-JS / fallback content. --}}
<span class="sr-only">{{ $content }}</span>
</{{ $tag }}>
/**
* Particle Typography behavior (cursor-driven particle text).
*
* The text is drawn once to a hidden offscreen canvas, its pixels are read back
* with getImageData, and every Nth opaque pixel (step = `density`) becomes a
* particle with a home position. On the visible canvas a requestAnimationFrame
* loop draws each particle: when the pointer comes within `cursorRadius` the
* particle is pushed radially outward (force ∝ 1 − dist/radius), and it always
* springs back toward home (Hooke-ish stiffness + velocity damping). So moving
* the cursor disperses the text, which then reassembles.
*
* Color is a SEMANTIC token: we read the computed `color` of the root element
* (default currentColor = text-primary) so the particles follow the design
* system / theme rather than a hard-coded value.
*
* Sizing: the visible canvas is sized from the rendered text bounds and scaled
* for devicePixelRatio. A ResizeObserver re-samples when the box width changes
* (e.g. responsive layout) so particle homes stay aligned.
*
* a11y: the canvas is aria-hidden; the Blade exposes the real string via an
* sr-only element and aria-label on the root. Reduced-motion: the text is
* rendered as STATIC particles at their home positions in a single frame, with
* no pointer dispersion and no animation loop.
*
* Self-registers on `alpine:init` so import order does not matter.
*
* Note: this file is plain JS served by the registry — no Blade syntax allowed.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiParticleTypography', (config = {}) => ({
text: config.text ?? 'Particles',
fontSize: config.fontSize ?? 120,
fontWeight: config.fontWeight ?? 700,
density: Math.max(1, config.density ?? 4),
cursorRadius: config.cursorRadius ?? 80,
// Hard cap on particle count so dense text / small density stays smooth.
_maxParticles: 9000,
_particles: [],
_ctx: null,
_dpr: 1,
_color: 'currentColor',
_cursor: { x: -9999, y: -9999, active: false },
_raf: null,
_ro: null,
_onMove: null,
_onLeave: null,
_reduced: false,
_viewObserver: null,
_offscreen: false,
init() {
const canvas = this.$refs.canvas;
this._ctx = canvas.getContext('2d');
this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
this.sample();
this.draw();
if (this._reduced) {
// Static frame only: no listeners, no rAF.
return;
}
this._onMove = (e) => {
const rect = canvas.getBoundingClientRect();
this._cursor.x = e.clientX - rect.left;
this._cursor.y = e.clientY - rect.top;
this._cursor.active = true;
};
this._onLeave = () => { this._cursor.active = false; };
this.$refs.root.addEventListener('pointermove', this._onMove);
this.$refs.root.addEventListener('pointerleave', this._onLeave);
// Re-sample on resize (debounced to a frame) so homes track layout.
let pending = false;
this._ro = new ResizeObserver(() => {
if (pending) return;
pending = true;
requestAnimationFrame(() => {
pending = false;
this.sample();
});
});
this._ro.observe(this.$refs.root);
this._raf = requestAnimationFrame(() => this.loop());
// Pause the rAF loop while fully scrolled out of view (same-tab;
// backgrounded tabs are already throttled by the browser) so
// off-screen instances don't burn GPU/CPU. Resume on re-entry.
// Static/reduced-motion renders never set _raf, so the `&& this._raf`
// guard leaves them untouched.
if (typeof IntersectionObserver !== 'undefined') {
this._viewObserver = new IntersectionObserver((entries) => {
const visible = entries.some((e) => e.isIntersecting);
if (!visible && this._raf) {
cancelAnimationFrame(this._raf);
this._raf = null;
this._offscreen = true;
} else if (visible && this._offscreen) {
this._offscreen = false;
this._raf = requestAnimationFrame(() => this.loop());
}
});
this._viewObserver.observe(this.$refs.root);
}
},
// Read the visible particle color from the root's computed `color`. This
// resolves a semantic text-* token (default currentColor = text-primary).
resolveColor() {
const c = getComputedStyle(this.$refs.root).color;
this._color = c && c !== '' ? c : getComputedStyle(document.body).color;
},
// Draw the text to a hidden canvas, then sample opaque pixels into homes.
sample() {
const canvas = this.$refs.canvas;
const root = this.$refs.root;
this._dpr = Math.max(1, window.devicePixelRatio || 1);
this.resolveColor();
// Font family follows the rendered element so it matches the theme.
const cs = getComputedStyle(root);
const family = cs.fontFamily || 'system-ui, sans-serif';
const font = `${this.fontWeight} ${this.fontSize}px ${family}`;
// Measure on a scratch context to size the visible box.
const scratch = document.createElement('canvas');
const sctx = scratch.getContext('2d');
sctx.font = font;
const metrics = sctx.measureText(this.text);
const ascent = metrics.actualBoundingBoxAscent || this.fontSize * 0.8;
const descent = metrics.actualBoundingBoxDescent || this.fontSize * 0.2;
const pad = Math.round(this.fontSize * 0.25);
// CSS pixel box for the field. Width prefers the container so the
// canvas is responsive; height is driven by the glyph metrics.
const cssW = Math.max(Math.ceil(metrics.width) + pad * 2, root.clientWidth || 0);
const cssH = Math.ceil(ascent + descent) + pad * 2;
// Hidden canvas drawn at DPR for crisp pixel sampling.
const off = document.createElement('canvas');
off.width = Math.max(1, Math.round(cssW * this._dpr));
off.height = Math.max(1, Math.round(cssH * this._dpr));
const octx = off.getContext('2d', { willReadFrequently: true });
octx.scale(this._dpr, this._dpr);
octx.font = font;
octx.textBaseline = 'middle';
octx.textAlign = 'center';
octx.fillStyle = this._color;
octx.fillText(this.text, cssW / 2, cssH / 2);
// Read pixels and grid-sample every `density` px (in device pixels).
const step = Math.max(1, Math.round(this.density * this._dpr));
let data;
try {
data = octx.getImageData(0, 0, off.width, off.height).data;
} catch (e) {
// Defensive: if pixels are unreadable, keep any prior particles.
data = null;
}
if (data) {
const particles = [];
for (let y = 0; y < off.height && particles.length < this._maxParticles; y += step) {
for (let x = 0; x < off.width; x += step) {
const alpha = data[(y * off.width + x) * 4 + 3];
if (alpha > 128) {
// Store homes in CSS px; rendering scales by DPR.
const hx = x / this._dpr;
const hy = y / this._dpr;
particles.push({ homeX: hx, homeY: hy, x: hx, y: hy, vx: 0, vy: 0 });
if (particles.length >= this._maxParticles) break;
}
}
}
this._particles = particles;
}
// Size the visible canvas (backing store at DPR, CSS box in px).
const vis = canvas;
vis.width = off.width;
vis.height = off.height;
vis.style.height = `${cssH}px`;
this._ctx.setTransform(this._dpr, 0, 0, this._dpr, 0, 0);
// After a re-sample under reduced motion, repaint the static frame.
if (this._reduced) this.draw();
},
loop() {
const r = this.cursorRadius;
const r2 = r * r;
for (const p of this._particles) {
if (this._cursor.active) {
const dx = p.x - this._cursor.x;
const dy = p.y - this._cursor.y;
const d2 = dx * dx + dy * dy;
if (d2 < r2 && d2 > 0.01) {
const dist = Math.sqrt(d2);
const force = (1 - dist / r) * 4; // push strength
p.vx += (dx / dist) * force;
p.vy += (dy / dist) * force;
}
}
// Spring back home + damping.
p.vx += (p.homeX - p.x) * 0.08;
p.vy += (p.homeY - p.y) * 0.08;
p.vx *= 0.82;
p.vy *= 0.82;
p.x += p.vx;
p.y += p.vy;
}
this.draw();
this._raf = requestAnimationFrame(() => this.loop());
},
draw() {
const ctx = this._ctx;
if (!ctx) return;
const w = this.$refs.canvas.width / this._dpr;
const h = this.$refs.canvas.height / this._dpr;
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = this._color;
const size = 1.6;
for (const p of this._particles) {
ctx.fillRect(p.x, p.y, size, size);
}
},
destroy() {
if (this._viewObserver) { this._viewObserver.disconnect(); this._viewObserver = null; }
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
if (this._ro) { this._ro.disconnect(); this._ro = null; }
if (this._onMove) this.$refs.root.removeEventListener('pointermove', this._onMove);
if (this._onLeave) this.$refs.root.removeEventListener('pointerleave', this._onLeave);
this._onMove = null;
this._onLeave = null;
this._particles = [];
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.0.3 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0