Text Effects
Animated and decorative text treatments — staged reveals, typing, particle and cursor-repel effects, and clipped gradients.
Preview
<div class="py-6">
<x-ui.text-repel text="Push the letters around" class="text-3xl font-bold text-foreground" />
</div>
Installation
php artisan ui:add text-repel
Note
This component ships an Alpine behavior module at
resources/js/ui/text-repel.js. Import it once from your bundle so it registers on alpine:init:
import './text-repel.js';
Registry contract
php artisan ui:add text-repel
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/text-repel.blade.php -
resources/js/ui/text-repel.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: Text Repel (`text-repel`)
A magnetic force-field on text: individual letters spring away from (or toward) the cursor with real-time physics, returning to rest when it leaves.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add text-repel
```
## Usage
```blade
<div class="py-6">
<x-ui.text-repel text="Push the letters around" class="text-3xl font-bold text-foreground" />
</div>
```
## Props
- `text` (string, default ``) — The text to animate; falls back to the default slot when empty.
- `mode` (string, default `repel`) — Force direction: repel (letters spring away from the cursor) or attract (letters spring toward it).
- `radius` (int, default `120`) — Pointer influence radius in pixels.
- `strength` (int, default `40`) — Maximum displacement force applied to a letter.
- `stiffness` (float, default `0.15`) — Spring stiffness pulling a displaced letter back to rest.
- `damping` (float, default `0.75`) — Spring damping that controls how quickly the letter settles.
- `as` (string, default `div`) — Root tag: div, span, p, or h1 through h4.
## Use when
- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Adding a playful magnetic-field effect where individual letters spring away from (or toward) the cursor.
- Wanting real-time spring physics per letter with tunable radius, strength, stiffness, and damping.
## Avoid when
- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- Needing readable, stable text while the pointer is nearby; the letters visibly displace under the cursor.
- Needing a cursor-driven particle effect instead of letter displacement; use particle-typography instead.
## Anti-patterns
- Heavy effects in forms or task-dense workflows
- Motion that interferes with reading or focus
## Rules
- Use the `<brok:text-repel>` tag (or `<x-ui.text-repel>`) 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/text-repel
- Registry JSON (files, props, contract): https://brokui.dev/r/open/text-repel.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 | The text to animate; falls back to the default slot when empty. | |
| mode | string | repel | Force direction: repel (letters spring away from the cursor) or attract (letters spring toward it). |
| radius | int | 120 | Pointer influence radius in pixels. |
| strength | int | 40 | Maximum displacement force applied to a letter. |
| stiffness | float | 0.15 | Spring stiffness pulling a displaced letter back to rest. |
| damping | float | 0.75 | Spring damping that controls how quickly the letter settles. |
| as | string | div | Root tag: div, span, p, or h1 through h4. |
Slots
default— Fallback text source when the text prop is empty.
Renders as div, span, p, h1, h2, h3, h4.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- text-repel.js tracks the pointer window-wide and applies a spring force to each letter within radius, pushing it away in repel mode or pulling it in in attract mode.
- Letters spring back to their resting position via stiffness and damping once the pointer moves away or leaves the window.
- 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 a playful magnetic-field effect where individual letters spring away from (or toward) the cursor.
- Wanting real-time spring physics per letter with tunable radius, strength, stiffness, and damping.
Avoid when
- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- Needing readable, stable text while the pointer is nearby; the letters visibly displace under the cursor.
- Needing a cursor-driven particle effect instead of letter displacement; use particle-typography instead.
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, so screen readers get the whole phrase at once instead of scattered letters.
- Individual letter spans are aria-hidden and select-none since the label already conveys the text and the displaced letters are not meant to be selected.
- 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 a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="text-repel-{{ $record->id }}">
<div class="py-6">
<x-ui.text-repel text="Push the letters around" class="text-3xl font-bold text-foreground" />
</div>
</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' => '',
'mode' => 'repel',
'radius' => 120,
'strength' => 40,
'stiffness' => 0.15,
'damping' => 0.75,
'as' => 'div',
])
@php
// Resolve the text from the `text` prop or the default slot.
$content = trim((string) ($text !== '' ? $text : $slot));
// Restrict the wrapper element to a small, safe allow-list.
$tag = in_array($as, ['div', 'span', 'p', 'h1', 'h2', 'h3', 'h4'], true) ? $as : 'div';
$mode = $mode === 'attract' ? 'attract' : 'repel';
// Split into per-letter pieces. Whitespace runs become non-breaking gaps so
// letters keep their layout while individually transformed.
$letters = preg_split('//u', $content, -1, PREG_SPLIT_NO_EMPTY) ?: [];
$config = [
'mode' => $mode,
'radius' => (float) $radius,
'strength' => (float) $strength,
'stiffness' => (float) $stiffness,
'damping' => (float) $damping,
];
@endphp
<{{ $tag }}
data-slot="text-repel"
x-data="uiTextRepel(@js($config))"
x-ref="root"
@pointermove.window="onPointerMove($event)"
@pointerleave.window="onPointerLeave()"
aria-label="{{ $content }}"
role="img"
{{ $attributes->merge(['class' => 'inline-block text-foreground select-none']) }}
>
@foreach ($letters as $ch)
@if (trim($ch) === '')
<span aria-hidden="true" class="inline-block"> </span>
@else
<span aria-hidden="true" data-letter class="inline-block will-change-transform">{{ $ch }}</span>
@endif
@endforeach
</{{ $tag }}>
/**
* Text Repel behavior.
*
* Turns text into a magnetic force-field: each letter is an inline-block span
* that springs away from (mode "repel") or toward (mode "attract") the cursor
* when it enters the letter's influence radius, and springs back to rest when
* the cursor leaves. A single rAF loop integrates a simple spring per letter
* (offset + velocity), applying the result with an inline `transform`. The loop
* only runs while there is work to do — it parks itself once every letter has
* settled at rest, and wakes on the next pointer move.
*
* Accessibility: the full text lives in the root `aria-label`; the per-letter
* spans are `aria-hidden`. Reduced-motion disables the pointer reaction and rAF
* entirely, leaving the text static.
*
* Self-registers on `alpine:init` so import order does not matter.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiTextRepel', (config = {}) => ({
mode: config.mode === 'attract' ? 'attract' : 'repel',
radius: config.radius ?? 120,
strength: config.strength ?? 40,
stiffness: config.stiffness ?? 0.15,
damping: config.damping ?? 0.75,
_reduced: false,
_raf: null,
_letters: [],
// Cursor position in viewport coordinates; null when the pointer is away.
_px: null,
_py: null,
init() {
this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (this._reduced) {
return;
}
// Cache each letter span with its live spring state.
const root = this.$refs.root ?? this.$el;
this._letters = Array.from(root.querySelectorAll('[data-letter]')).map((el) => ({
el,
x: 0,
y: 0,
vx: 0,
vy: 0,
}));
},
onPointerMove(event) {
if (this._reduced) {
return;
}
this._px = event.clientX;
this._py = event.clientY;
this._wake();
},
onPointerLeave() {
if (this._reduced) {
return;
}
// Drop the cursor: targets fall to 0 and letters spring home.
this._px = null;
this._py = null;
this._wake();
},
_wake() {
if (this._raf === null) {
this._raf = requestAnimationFrame(() => this._frame());
}
},
_frame() {
const sign = this.mode === 'attract' ? -1 : 1;
const radius = this.radius;
let active = false;
// Read pass: measure every letter's *rest* center BEFORE any transform
// is written this frame. Reading getBoundingClientRect() and writing
// style.transform in the same per-letter step forced a synchronous
// reflow per letter (each write invalidated the next read); batching
// all reads first collapses that to a single reflow per frame. The
// rect is still read every frame so the field stays correct under
// scroll/resize (viewport-relative, like the cursor coords).
if (this._px !== null) {
for (const letter of this._letters) {
const rect = letter.el.getBoundingClientRect();
letter.cx = rect.left + rect.width / 2 - letter.x;
letter.cy = rect.top + rect.height / 2 - letter.y;
}
}
for (const letter of this._letters) {
let tx = 0;
let ty = 0;
if (this._px !== null) {
// Push/pull relative to the letter's *rest* center, so the
// target is stable regardless of the current offset.
const dx = letter.cx - this._px;
const dy = letter.cy - this._py;
const dist = Math.hypot(dx, dy);
if (dist < radius && dist > 0.0001) {
// Linear falloff: full strength at the cursor, zero at the
// radius edge.
const force = (1 - dist / radius) * this.strength * sign;
tx = (dx / dist) * force;
ty = (dy / dist) * force;
}
}
// Spring integration toward the target (tx, ty).
letter.vx = (letter.vx + (tx - letter.x) * this.stiffness) * this.damping;
letter.vy = (letter.vy + (ty - letter.y) * this.stiffness) * this.damping;
letter.x += letter.vx;
letter.y += letter.vy;
const moving =
Math.abs(letter.vx) > 0.01 ||
Math.abs(letter.vy) > 0.01 ||
Math.abs(letter.x - tx) > 0.01 ||
Math.abs(letter.y - ty) > 0.01;
if (moving) {
active = true;
letter.el.style.transform = `translate(${letter.x.toFixed(2)}px, ${letter.y.toFixed(2)}px)`;
} else if (letter.x !== 0 || letter.y !== 0) {
// Snap to rest and clear the transform so we stop touching it.
letter.x = 0;
letter.y = 0;
letter.vx = 0;
letter.vy = 0;
letter.el.style.transform = '';
}
}
if (active || this._px !== null) {
this._raf = requestAnimationFrame(() => this._frame());
} else {
// Everything is at rest and the cursor is gone: park the loop.
this._raf = null;
}
},
destroy() {
if (this._raf !== null) {
cancelAnimationFrame(this._raf);
this._raf = null;
}
},
}));
});
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