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
Particle Typography
Text Repel
Text Gradient
Circular Text
Encrypted Text
4 of 9

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.

Preview

previews.components.letter-cascade.default.blade.php Blade
<x-ui.letter-cascade text="Hover me" class="text-3xl font-bold text-foreground" />

Installation

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

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

  • blade resources/views/components/ui/letter-cascade.blade.php
  • js resources/js/ui/letter-cascade.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.

letter-cascade.md
# 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

manifest knowledge + registry-derived coverage

Props

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

letter-cascade

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

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 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
root
Theming hooks
letter-cascade

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

Add a stable wire:key when Livewire can reorder this interactive component.

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

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