Text Effects
Animated and decorative text treatments — staged reveals, typing, particle and cursor-repel effects, and clipped gradients.
Text Animate — A wrapper that reveals its text with a chosen animation (fade-up, fade, blur-in, slide-up, slide-left, slide-right, scale-in) when scrolled into view, optionally split and staggered per word or character, and able to re-trigger on every re-entry (once=false).
Preview
<x-ui.text-animate effect="fade-up" split-by="word" class="text-2xl font-semibold text-foreground">Animate your text on scroll</x-ui.text-animate>
Installation
php artisan ui:add text-animate
Note
This component ships an Alpine behavior module at
resources/js/ui/text-animate.js. Import it once from your bundle so it registers on alpine:init:
import './text-animate.js';
Registry contract
php artisan ui:add text-animate
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-animate.blade.php -
resources/js/ui/text-animate.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 Animate (`text-animate`)
A wrapper that reveals its text with a chosen animation (fade-up, fade, blur-in, slide-up, slide-left, slide-right, scale-in) when scrolled into view, optionally split and staggered per word or character, and able to re-trigger on every re-entry (once=false).
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add text-animate
```
## Usage
```blade
<x-ui.text-animate effect="fade-up" split-by="word" class="text-2xl font-semibold text-foreground">Animate your text on scroll</x-ui.text-animate>
```
## Props
- `effect` (string, default `fade-up`) — Reveal animation: fade-up, fade, blur-in, slide-up, slide-left, slide-right, or scale-in.
- `splitBy` (string, default `none`) — Segmentation for the stagger: none (whole block animates together), word, or char.
- `stagger` (int, default `60`) — Milliseconds between each segment's reveal when splitBy is word or char.
- `delay` (int, default `0`) — Milliseconds before the reveal starts.
- `as` (string, default `div`) — Root tag: div, span, p, or h1 through h6.
- `once` (bool, default `true`) — Plays the reveal only the first time it scrolls into view; false re-triggers it on every entry.
## Use when
- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Revealing a heading or block of text with a chosen scroll-triggered animation (fade-up, fade, blur-in, slide-up, slide-left, slide-right, scale-in).
- Wanting the reveal split and staggered per word or character, and optionally re-triggered on every scroll re-entry (once=false).
## Avoid when
- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- Wanting a fixed directional slide with more stagger-origin control (start/end/center/edges/random); use kinetic-text-reveal instead.
- Needing a scramble/decrypt look instead of a fade or slide reveal; use encrypted-text instead.
## Anti-patterns
- Heavy effects in forms or task-dense workflows
- Motion that interferes with reading or focus
## Rules
- Use the `<brok:text-animate>` tag (or `<x-ui.text-animate>`) 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-animate
- Registry JSON (files, props, contract): https://brokui.dev/r/open/text-animate.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 |
|---|---|---|---|
| effect | string | fade-up | Reveal animation: fade-up, fade, blur-in, slide-up, slide-left, slide-right, or scale-in. |
| splitBy | string | none | Segmentation for the stagger: none (whole block animates together), word, or char. |
| stagger | int | 60 | Milliseconds between each segment's reveal when splitBy is word or char. |
| delay | int | 0 | Milliseconds before the reveal starts. |
| as | string | div | Root tag: div, span, p, or h1 through h6. |
| once | bool | true | Plays the reveal only the first time it scrolls into view; false re-triggers it on every entry. |
Slots
default— The text to reveal.
Renders as div, span, p, h1, h2, h3, h4, h5, h6.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- text-animate.js splits the text into per-character or per-word spans (or leaves it whole) and reveals them with the chosen keyframe effect when the element scrolls into view.
- once=false replays the reveal animation every time the element re-enters the viewport, instead of only on first scroll-in.
- 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.
- Revealing a heading or block of text with a chosen scroll-triggered animation (fade-up, fade, blur-in, slide-up, slide-left, slide-right, scale-in).
- Wanting the reveal split and staggered per word or character, and optionally re-triggered on every scroll re-entry (once=false).
Avoid when
- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- Wanting a fixed directional slide with more stagger-origin control (start/end/center/edges/random); use kinetic-text-reveal instead.
- Needing a scramble/decrypt look instead of a fade or slide reveal; use encrypted-text 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 as one sentence regardless of how it is split for animation.
- Splitting the text into spans is purely visual; the accessible name always reflects the complete, unsplit string.
- 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.text-animate effect="fade-up" split-by="word" class="text-2xl font-semibold text-foreground">Animate your text on scroll</x-ui.text-animate>
</div>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
'effect' => 'fade-up',
'splitBy' => 'none',
'stagger' => 60,
'delay' => 0,
'as' => 'div',
'once' => true,
])
@php
// Whitelist the effect so the keyframe name (ui-text-<effect>) is always valid.
$allowedEffects = ['fade-up', 'fade', 'blur-in', 'slide-up', 'slide-left', 'slide-right', 'scale-in'];
$effect = in_array($effect, $allowedEffects, true) ? $effect : 'fade-up';
$allowedSplit = ['none', 'word', 'char'];
$splitBy = in_array($splitBy, $allowedSplit, true) ? $splitBy : 'none';
// Restrict the root tag to a safe, known set of inline/block containers.
$allowedTags = ['div', 'span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
$tag = in_array($as, $allowedTags, true) ? $as : 'div';
$text = trim((string) ($slot ?? ''));
$config = [
'effect' => $effect,
'splitBy' => $splitBy,
'once' => (bool) $once,
];
@endphp
<{{ $tag }}
data-slot="text-animate"
x-data="uiTextAnimate(@js($config))"
{{-- The behaviour layer splits the text into per-character spans; role=img +
aria-label keeps it announced as one phrase and makes the label valid on
this otherwise-generic element (a11y: aria-prohibited-attr). --}}
role="img"
aria-label="{{ $text }}"
style="--delay: {{ (int) $delay }}; --stagger: {{ (int) $stagger }}"
{{ $attributes->merge(['class' => 'text-foreground']) }}
>{{ $text }}</{{ $tag }}>
/**
* Text Animate behavior.
*
* Reveals a block of text with a chosen CSS keyframe (ui-text-<effect>) once it
* scrolls into view (via IntersectionObserver, threshold ~0.2). The text can be
* split into one <span> per word or per character; each token carries an `--i`
* index so the central keyframe can stagger its reveal via animation-delay
* `calc((var(--delay) + var(--i) * var(--stagger)) * 1ms)`.
*
* The reveal itself is CSS-driven: this module only toggles a `data-shown`
* attribute that the stylesheet keys off. Each token span is aria-hidden and
* the root carries an aria-label with the full original text, so screen readers
* read the phrase exactly once. Reduced-motion is handled entirely in CSS — the
* `motion-safe`/`no-preference` gate means tokens are only hidden when motion is
* allowed, so reduced-motion users always see fully visible text.
*
* Self-registers on `alpine:init` so import order does not matter.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiTextAnimate', (config = {}) => ({
effect: config.effect ?? 'fade-up',
splitBy: config.splitBy ?? 'none',
once: config.once ?? true,
shown: false,
_observer: null,
init() {
this.split();
this._observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.shown = true;
this.$el.setAttribute('data-shown', '');
if (this.once) {
this._observer.disconnect();
this._observer = null;
}
} else if (!this.once) {
this.shown = false;
this.$el.removeAttribute('data-shown');
}
});
}, { threshold: 0.2 });
this._observer.observe(this.$el);
},
// Capture the original text and rebuild it as token spans. Each token
// gets `--i` (its stagger index) and is aria-hidden; spaces are kept as
// their own (non-animated) text so wrapping/spacing is preserved.
split() {
const text = this.$el.textContent;
const effectClass = 'ui-text-token--' + this.effect;
// splitBy === 'none' → one span around the whole phrase.
if (this.splitBy === 'none') {
this.$el.textContent = '';
this.$el.appendChild(this.token(text, 0, effectClass));
return;
}
// Split into words (keeping the spaces) or individual characters.
const tokens = this.splitBy === 'char'
? Array.from(text)
: text.match(/\S+|\s+/g) || [];
this.$el.textContent = '';
let i = 0;
tokens.forEach((tok) => {
// Whitespace stays as a plain text node so it never animates and
// never consumes a stagger index.
if (/^\s+$/.test(tok)) {
this.$el.appendChild(document.createTextNode(tok));
return;
}
this.$el.appendChild(this.token(tok, i, effectClass));
i += 1;
});
},
token(content, index, effectClass) {
const span = document.createElement('span');
span.className = 'ui-text-token ' + effectClass;
span.setAttribute('aria-hidden', 'true');
span.style.setProperty('--i', String(index));
// char/word tokens are inline-block so transforms (translateY/scale)
// apply without affecting line layout.
if (this.splitBy !== 'none') {
span.style.display = 'inline-block';
}
span.textContent = content;
return span;
},
destroy() {
if (this._observer) {
this._observer.disconnect();
this._observer = null;
}
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.1.2 - 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