Skip to content
Brok UI

Loading…

No results

Text Effects

Open source

Animated and decorative text treatments — staged reveals, typing, particle and cursor-repel effects, and clipped gradients.

Version
v1.0.3
Stability
stable
License
MIT
Related
Text Animate
Typewriter
Kinetic Text Reveal
Letter Cascade
Text Repel
Text Gradient
Circular Text
Encrypted Text
5 of 9

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.

Preview

previews.components.particle-typography.default.blade.php Blade
<x-ui.particle-typography text="Pixels" class="text-primary" />

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/particle-typography.blade.php
  • js resources/js/ui/particle-typography.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: 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.

particle-typography.md
# 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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

particle-typography

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

Expressive and showcase effect

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
root
Theming hooks
particle-typography

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:ignore

Add wire:ignore to the component root because its behavior owns rendered DOM.

livewire-component.blade.php Blade
<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.

resources/views/components/ui/particle-typography.blade.php Blade
@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 }}>
resources/js/ui/particle-typography.js JS
/**
 * 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