Text Effects
Animated and decorative text treatments — staged reveals, typing, particle and cursor-repel effects, and clipped gradients.
Preview
<x-ui.letter-cascade text="Hover me" class="text-3xl font-bold text-foreground" />
Installation
php artisan ui:add letter-cascade
Note
This component ships an Alpine behavior module at
resources/js/ui/letter-cascade.js. Import it once from your bundle so it registers on alpine:init:
import './letter-cascade.js';
Registry contract
php artisan ui:add letter-cascade
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/letter-cascade.blade.php -
resources/js/ui/letter-cascade.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: Letter Cascade (`letter-cascade`)
A 3D per-letter flip where each character tilts back and flips up into place, cascading across the text on hover, click or mount.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add letter-cascade
```
## Usage
```blade
<x-ui.letter-cascade text="Hover me" class="text-3xl font-bold text-foreground" />
```
## Props
- `text` (string, default ``) — The text to animate; falls back to the default slot when empty.
- `trigger` (string, default `hover`) — When the cascade plays: hover, click, or mount.
- `stagger` (int, default `40`) — Milliseconds between each letter's flip.
- `staggerFrom` (string, default `first`) — Stagger origin along the letters: first, last, or center.
- `as` (string, default `span`) — Root tag: span, div, h1 through h4, or p.
## Use when
- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Adding a 3D per-letter flip reveal to a short heading or label, triggered on hover, click, or mount.
- Wanting each character to cascade into place with a configurable stagger origin (first, last, center).
## Avoid when
- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- Revealing long paragraphs of body copy; per-letter flips are best for short headlines or labels.
- Needing a directional slide-and-blur reveal by word or line instead of a per-character 3D flip; use kinetic-text-reveal instead.
## Anti-patterns
- Heavy effects in forms or task-dense workflows
- Motion that interferes with reading or focus
## Rules
- Use the `<brok:letter-cascade>` tag (or `<x-ui.letter-cascade>`) 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/letter-cascade
- Registry JSON (files, props, contract): https://brokui.dev/r/open/letter-cascade.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. | |
| trigger | string | hover | When the cascade plays: hover, click, or mount. |
| stagger | int | 40 | Milliseconds between each letter's flip. |
| staggerFrom | string | first | Stagger origin along the letters: first, last, or center. |
| as | string | span | Root tag: span, div, h1 through h4, or p. |
Slots
default— Fallback text source when the text prop is empty.
Renders as span, div, h1, h2, h3, h4, p.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Each character renders as a two-face flip unit; triggering the cascade tilts the front face back while the back face flips up into its place, staggered per letter.
- Spaces are preserved as whitespace-pre flip units so word boundaries stay intact.
- 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 3D per-letter flip reveal to a short heading or label, triggered on hover, click, or mount.
- Wanting each character to cascade into place with a configurable stagger origin (first, last, center).
Avoid when
- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- Revealing long paragraphs of body copy; per-letter flips are best for short headlines or labels.
- Needing a directional slide-and-blur reveal by word or line instead of a per-character 3D flip; use kinetic-text-reveal 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 full text, so screen readers get the whole word or phrase at once instead of per letter.
- Every individual letter span is aria-hidden since the label already conveys the text.
- 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="letter-cascade-{{ $record->id }}">
<x-ui.letter-cascade text="Hover me" class="text-3xl font-bold text-foreground" />
</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' => '',
'trigger' => 'hover',
'stagger' => 40,
'staggerFrom' => 'first',
'as' => 'span',
])
@php
// The visible text comes from the `text` prop or the default slot.
$content = trim((string) $text) !== '' ? (string) $text : trim($slot);
// Split into characters, preserving spaces. We keep the full string for the
// accessible label and render each character as an aria-hidden flip unit.
$chars = $content === '' ? [] : preg_split('//u', $content, -1, PREG_SPLIT_NO_EMPTY);
$tag = in_array($as, ['span', 'div', 'h1', 'h2', 'h3', 'h4', 'p'], true) ? $as : 'span';
$config = [
'trigger' => in_array($trigger, ['hover', 'click', 'mount'], true) ? $trigger : 'hover',
'stagger' => (int) $stagger,
'staggerFrom' => in_array($staggerFrom, ['first', 'last', 'center'], true) ? $staggerFrom : 'first',
'count' => count($chars),
];
@endphp
<{{ $tag }}
data-slot="letter-cascade"
x-data="uiLetterCascade(@js($config))"
x-ref="root"
role="img"
aria-label="{{ $content }}"
{{ $attributes->merge(['class' => 'inline-block leading-tight text-foreground']) }}
>
@foreach ($chars as $i => $char)
@php $isSpace = trim($char) === ''; @endphp
<span
aria-hidden="true"
data-letter
@class([
'ui-letter-cascade__unit inline-block',
'whitespace-pre' => $isSpace,
])
>
{{-- Front face: the resting letter that tilts backward. --}}
<span class="ui-letter-cascade__face">{{ $isSpace ? ' ' : $char }}</span>
{{-- Back face: the duplicate letter that flips up into place. --}}
<span class="ui-letter-cascade__face ui-letter-cascade__back" aria-hidden="true">{{ $isSpace ? ' ' : $char }}</span>
</span>
@endforeach
</{{ $tag }}>
/**
* Letter Cascade behavior.
*
* Splits text into per-letter 3D flip units (rendered server-side). On trigger
* each letter tilts backward while a duplicate flips up from below into place,
* cascading across the text with a per-letter stagger. The spring-ish easing
* and the 3D/perspective rules live in ui.css, keyed on
* `[data-slot="letter-cascade"]`; this file only computes per-letter delays and
* (re)starts the animation on the configured trigger.
*
* The full string is exposed to assistive tech via `aria-label` on the root, so
* every letter unit is `aria-hidden`. Honors reduced-motion by never playing the
* cascade (CSS leaves the letters upright and fully legible).
*
* Self-registers on `alpine:init` so import order does not matter.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiLetterCascade', (config = {}) => ({
trigger: config.trigger ?? 'hover',
stagger: config.stagger ?? 40,
staggerFrom: config.staggerFrom ?? 'first',
count: config.count ?? 0,
_letters: [],
_reduced: false,
_onEnter: null,
_onClick: null,
init() {
this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
this._letters = Array.from(this.$refs.root.querySelectorAll('[data-letter]'));
// Pre-compute and assign each letter's stagger delay once.
this._letters.forEach((el, i) => {
el.style.animationDelay = `${this.delayFor(i)}ms`;
});
if (this._reduced) {
// Letters remain upright/static and fully readable; no listeners.
return;
}
if (this.trigger === 'mount') {
// Defer one frame so the initial styles are committed first.
requestAnimationFrame(() => this.play());
return;
}
if (this.trigger === 'click') {
this._onClick = () => this.play();
this.$refs.root.addEventListener('click', this._onClick);
return;
}
// Default: hover re-triggers the cascade each time.
this._onEnter = () => this.play();
this.$refs.root.addEventListener('mouseenter', this._onEnter);
},
// Stagger origin: distance (in steps) of letter `i` from the origin.
delayFor(i) {
const n = this.count;
let step;
if (this.staggerFrom === 'last') {
step = n - 1 - i;
} else if (this.staggerFrom === 'center') {
step = Math.abs(i - (n - 1) / 2);
} else {
step = i;
}
return Math.round(step * this.stagger);
},
play() {
if (this._reduced) return;
// Restart the keyframe by clearing the playing class, forcing a
// reflow, then re-adding it so a re-hover re-runs the cascade.
this._letters.forEach((el) => el.classList.remove('is-cascading'));
void this.$refs.root.offsetWidth;
this._letters.forEach((el) => el.classList.add('is-cascading'));
},
destroy() {
if (this._onEnter) this.$refs.root.removeEventListener('mouseenter', this._onEnter);
if (this._onClick) this.$refs.root.removeEventListener('click', this._onClick);
this._onEnter = null;
this._onClick = 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