Text Effects
Animated and decorative text treatments — staged reveals, typing, particle and cursor-repel effects, and clipped gradients.
Preview
We build
<p class="text-3xl font-bold text-foreground">
We build <x-ui.typewriter :words="['websites', 'apps', 'brands']" class="text-primary" />
</p>
Installation
php artisan ui:add typewriter
Note
This component ships an Alpine behavior module at
resources/js/ui/typewriter.js. Import it once from your bundle so it registers on alpine:init:
import './typewriter.js';
Registry contract
php artisan ui:add typewriter
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/typewriter.blade.php -
resources/js/ui/typewriter.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: Typewriter (`typewriter`)
Types and deletes through one or more words with a blinking caret, looping by default and respecting reduced-motion.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add typewriter
```
## Usage
```blade
<p class="text-3xl font-bold text-foreground">
We build <x-ui.typewriter :words="['websites', 'apps', 'brands']" class="text-primary" />
</p>
```
## Props
- `words` (array, default `[]`) — Word or words to type and delete through, in order; a single string is also accepted.
- `loop` (bool, default `true`) — Restarts from the first word after the last one deletes; false stops after the last word types.
- `typeSpeed` (int, default `80`) — Milliseconds per typed character.
- `deleteSpeed` (int, default `40`) — Milliseconds per deleted character.
- `pauseMs` (int, default `1500`) — Milliseconds to pause on a fully typed word before deleting it.
- `cursor` (bool, default `true`) — Shows a blinking caret after the text.
- `reserve` (bool, default `true`) — Reserves the width of the longest word so surrounding content never shifts as words grow and shrink.
## Use when
- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Typing and deleting through one or more words with a blinking caret, for a hero tagline or rotating headline.
- Wanting the text column width reserved so surrounding content does not shift as words grow and shrink.
## Avoid when
- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- Typing long paragraphs of body copy; the effect is best for short words or phrases.
- Needing a one-shot reveal instead of an endless type/delete loop; use text-animate or 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:typewriter>` tag (or `<x-ui.typewriter>`) 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/typewriter
- Registry JSON (files, props, contract): https://brokui.dev/r/open/typewriter.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 |
|---|---|---|---|
| words | array | [] | Word or words to type and delete through, in order; a single string is also accepted. |
| loop | bool | true | Restarts from the first word after the last one deletes; false stops after the last word types. |
| typeSpeed | int | 80 | Milliseconds per typed character. |
| deleteSpeed | int | 40 | Milliseconds per deleted character. |
| pauseMs | int | 1500 | Milliseconds to pause on a fully typed word before deleting it. |
| cursor | bool | true | Shows a blinking caret after the text. |
| reserve | bool | true | Reserves the width of the longest word so surrounding content never shifts as words grow and shrink. |
Slots
default— Unused; the component has no visible default slot content.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- typewriter.js types each word character by character, pauses for pauseMs, then deletes it before moving to the next word; loop controls whether it restarts after the last word.
- The server-rendered markup shows the first word immediately, so the text is correct before Alpine hydrates; a zero-width space keeps the line height stable between words.
- 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.
- Typing and deleting through one or more words with a blinking caret, for a hero tagline or rotating headline.
- Wanting the text column width reserved so surrounding content does not shift as words grow and shrink.
Avoid when
- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- Typing long paragraphs of body copy; the effect is best for short words or phrases.
- Needing a one-shot reveal instead of an endless type/delete loop; use text-animate or 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" with an aria-label listing every word, so screen readers get the full set of words at once instead of the animated typing.
- The animated text and caret are aria-hidden, since the aria-label already conveys the content.
- 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="typewriter-{{ $record->id }}">
<p class="text-3xl font-bold text-foreground">
We build <x-ui.typewriter :words="['websites', 'apps', 'brands']" class="text-primary" />
</p>
</div>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
'words' => [],
'loop' => true,
'typeSpeed' => 80,
'deleteSpeed' => 40,
'pauseMs' => 1500,
'cursor' => true,
// Keep the width of the longest word, so the text after the typewriter
// (and a centred line around it) never shifts as words grow and shrink.
'reserve' => true,
])
@php
// Normalize `words` to an array so a single string works too.
$wordsArray = is_array($words) ? array_values($words) : [$words];
$wordsArray = array_values(array_filter($wordsArray, fn ($w) => $w !== null && $w !== ''));
if (empty($wordsArray)) {
$wordsArray = [''];
}
// SSR fallback shows the first word so the text is correct before hydration.
$firstWord = $wordsArray[0];
$longest = array_reduce($wordsArray, fn ($carry, $w) => mb_strlen($w) > mb_strlen($carry) ? $w : $carry, '');
$reserve = filter_var($reserve, FILTER_VALIDATE_BOOLEAN);
$config = [
'words' => $wordsArray,
'loop' => (bool) $loop,
'typeSpeed' => (int) $typeSpeed,
'deleteSpeed' => (int) $deleteSpeed,
'pauseMs' => (int) $pauseMs,
];
@endphp
<span
data-slot="typewriter"
x-data="uiTypewriter(@js($config))"
role="img"
aria-label="{{ implode(', ', $wordsArray) }}"
{{ $attributes->merge(['class' => 'inline-grid items-baseline text-foreground '.($reserve ? 'text-start' : '')]) }}
>
{{-- The longest word, invisible, holds the column width; the live text sits
on top of it. A zero-width space keeps the line box while the word is
empty, so the height never collapses between words. --}}
@if ($reserve)
<span aria-hidden="true" class="invisible col-start-1 row-start-1 inline-flex items-baseline whitespace-nowrap">
<span>{{ $longest }}</span>
@if ($cursor)<span class="ms-0.5 inline-block w-px"></span>@endif
</span>
@endif
<span aria-hidden="true" class="col-start-1 row-start-1 inline-flex items-baseline whitespace-nowrap">
<span x-text="display || '\u200b'">{{ $firstWord }}</span>
@if ($cursor)
<span
class="ms-0.5 inline-block h-[1em] w-px translate-y-[0.1em] self-baseline bg-current motion-safe:animate-[ui-caret-blink_1s_steps(2)_infinite]"
></span>
@endif
</span>
</span>
/**
* Typewriter behavior.
*
* Types through a list of words one character at a time, pauses at the full
* word, then deletes and advances to the next. The full set of words is exposed
* to assistive tech via `aria-label` on the root, so the animated `display`
* span is `aria-hidden`. Honors reduced-motion by rendering the first word
* statically. Self-registers on `alpine:init` so import order does not matter.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiTypewriter', (config = {}) => ({
words: config.words ?? [],
loop: config.loop ?? true,
typeSpeed: config.typeSpeed ?? 80,
deleteSpeed: config.deleteSpeed ?? 40,
pauseMs: config.pauseMs ?? 1500,
display: '',
_wordIndex: 0,
_charIndex: 0,
_deleting: false,
_timer: null,
init() {
if (!this.words.length) {
return;
}
// Reduced motion: render the first word and skip all animation.
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
this.display = this.words[0];
return;
}
this.display = '';
this._charIndex = 0;
this.tick();
},
tick() {
const word = this.words[this._wordIndex] ?? '';
if (!this._deleting) {
// Typing forward.
this._charIndex++;
this.display = word.slice(0, this._charIndex);
if (this._charIndex >= word.length) {
// Reached full word. If this is the last word and we are not
// looping, stop here.
const isLast = this._wordIndex === this.words.length - 1;
if (isLast && !this.loop) {
return;
}
this._deleting = true;
this._timer = setTimeout(() => this.tick(), this.pauseMs);
return;
}
this._timer = setTimeout(() => this.tick(), this.typeSpeed);
return;
}
// Deleting.
this._charIndex--;
this.display = word.slice(0, Math.max(0, this._charIndex));
if (this._charIndex <= 0) {
this._deleting = false;
this._charIndex = 0;
this._wordIndex = (this._wordIndex + 1) % this.words.length;
this._timer = setTimeout(() => this.tick(), this.typeSpeed);
return;
}
this._timer = setTimeout(() => this.tick(), this.deleteSpeed);
},
destroy() {
if (this._timer) {
clearTimeout(this._timer);
this._timer = null;
}
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.2.1 - 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