AnimatedHeadline
componentA headline whose words are revealed through a clip that closes to a caret and opens on the next word. One word is revealed once, then stands still; several are played through once and stop on the first, within five seconds; with loop, they keep cycling and a pause/play button stops them.
Preview
We make sites, apps, games
Code
<twig:AnimatedHeadline text="We make" :words="['sites', 'apps', 'games']" />
We make sites, apps, games
Code
<twig:AnimatedHeadline text="We make" :words="['sites', 'apps', 'games']" />
Try it
Change the props; the example is rendered again by the server.
We make sites, apps, games
<twig:AnimatedHeadline text="We make" :words="['sites', 'apps', 'games']" />
<twig:AnimatedHeadline text="We make" :words="['sites', 'apps', 'games']" />
Variants
loop
Whether the words keep cycling, with a pause/play button, instead of playing through once.
We make sites, apps, games
We make sites, apps, games
We make sites, apps, games
We make sites, apps, games
Usage
Studio
Code
<twig:AnimatedHeadline words="Studio" />
Installation
php bin/console ux:install animated-headline --kit=github.com/parler-haut-interagir-librement/ux-kit
Props
| Prop | Type | Default | Description |
|---|---|---|---|
text |
string|null |
null |
Static text before the words. |
suffix |
string|null |
null |
Static text after the words. |
level |
int |
1 |
The level of the heading, from 1 to 6. |
label |
string|null |
null |
What screen readers read instead of the text, the words joined and the suffix. |
separator |
string |
', ' |
What joins the words in what screen readers read. |
pause |
int |
2500 |
Milliseconds a word rests before the next one. |
duration |
int |
600 |
Milliseconds the clip takes to close, and as many to open. |
loop |
bool |
false |
Whether the words keep cycling, with a pause/play button, instead of playing through once. |
pauseLabel |
string |
'Pause animation' |
The button's text while the words cycle, with loop. |
playLabel |
string |
'Play animation' |
The button's text while they are paused, with loop. |
Examples
Words cycled with a pause button
<twig:AnimatedHeadline text="Made for" :words="['writers', 'players', 'teams']" :loop="true" :pause="2000"
:pauseLabel="'animation.pause'|trans" :playLabel="'animation.play'|trans" />
Another level
<twig:AnimatedHeadline :words="['Soon', 'Bientôt']" :level="2" label="Soon, bientôt" />
Accessibility
- Screen readers read the whole headline at once — the text, the words joined by
separator, the suffix, orlabel—, from a visually hidden copy. The animated words are hidden from them and the clip isaria-live="off": nothing is announced while they change. - Under
prefers-reduced-motion, nothing moves: the first word stands still, and the controller stops if the reader turns the preference on while the page is open. Without JavaScript, the first word stands still too. - Pause, Stop, Hide (WCAG 2.2.2): without
loop, the words play through once and stop on the first one, the rest and the moves shortened so that the whole run ends within five seconds. Withloop, the template renders a<button>,hiddenuntil the controller shows it, whose visible text is its name:pauseLabelwhile the words cycle,playLabelonce paused, both translatable, in English by default. The heading is then named by its visually hidden copy alone, througharia-labelledby, so the button's text is not part of it. One word never cycles, and has no button,loopor not. - The cycle also holds while the headline is hovered or holds the focus, and while the document is hidden; a held headline shows its current word fully open. Pressing play goes on at once, even under the pointer or the focus.
- A move of the clip ends on
transitionend, or afterdurationif the event never comes: the clip is never left closed. - The controller
animated-headlinedrives the classesisRevealing,isPulsingandisCurrent, and the custom property--phil-reveal-size, which phil-ux reads; the button is phil-ux'sisRevealToggle.
Files to copy
assets/controllers/animated_headline_controller.js
import { Controller } from '@hotwired/stimulus';
/**
* The longest run, in milliseconds, of motion that starts by itself without a way to pause it: WCAG 2.2.2 (Pause,
* Stop, Hide) asks for one beyond five seconds, so a headline played through once ends within this budget.
*/
export const BUDGET = 4800;
/**
* Reveals the words of a headline through a clip that closes to a caret and opens on the next word.
*
* One word is revealed once, then stands still. Several words are played through once, back to the word the server
* marked as current, the whole run fitted within `BUDGET`; with `loop`, they keep cycling, and the template renders a
* pause/play button, hidden until the controller shows and drives it. The cycle also holds while the headline is
* hovered or holds the focus, and while the document is hidden; a held headline shows its current word fully open.
*
* Under prefers-reduced-motion, nothing moves: the current word stays, and the controller stops if the reader turns
* the preference on while the page is open. The words are hidden from screen readers by the template, which gives them
* the whole headline at once, so nothing is announced as it changes.
*
* Each move of the clip ends on `transitionend`, or after `duration` if the event never comes (an interrupted
* transition, a hidden tab), so the clip is never left closed.
*
* The state lives in classes the stylesheet reads: `isRevealing` on the clip while the controller drives it,
* `isPulsing` while a word rests between two moves, `isCurrent` on the word shown; the clip's width is
* `--phil-reveal-size`.
*
* @value pause Milliseconds a word rests before the clip closes on it.
* @value duration Milliseconds the clip takes to close, and as many to open.
* @value loop Whether the words keep cycling, with a pause/play button, instead of playing through once.
* @value pauseLabel The button's text while the words cycle.
* @value playLabel The button's text while the reader has paused them.
* @target reveal The clip around the stacked words.
* @target word One of the words, in the order they are shown.
* @target toggle The pause/play button, rendered hidden with `loop`.
*/
export default class extends Controller {
static values = {
pause: { type: Number, default: 2500 },
duration: { type: Number, default: 600 },
loop: { type: Boolean, default: false },
pauseLabel: { type: String, default: 'Pause animation' },
playLabel: { type: String, default: 'Play animation' },
};
static targets = ['reveal', 'word', 'toggle'];
connect() {
this.home = Math.max(0, this.wordTargets.findIndex((word) => word.classList.contains('isCurrent')));
this.index = this.home;
this.timer = null;
this.ending = null;
this.running = false;
this.opened = false;
this.reasons = new Set(document.hidden ? ['hidden'] : []);
this.motion = window.matchMedia('(prefers-reduced-motion: reduce)');
this.onMotionChange = () => (this.motion.matches ? this.stop() : this.start());
this.onResize = () => this.running && this.opened && this.size(this.width(this.index));
this.onVisibility = () => this.hold('hidden', document.hidden);
this.onEnter = () => this.hold('hover', true);
this.onLeave = () => this.hold('hover', false);
this.onFocusIn = () => this.hold('focus', true);
this.onFocusOut = (event) => this.hold('focus', this.element.contains(event.relatedTarget ?? null));
this.motion.addEventListener('change', this.onMotionChange);
window.addEventListener('resize', this.onResize);
document.addEventListener('visibilitychange', this.onVisibility);
this.element.addEventListener('mouseenter', this.onEnter);
this.element.addEventListener('mouseleave', this.onLeave);
this.element.addEventListener('focusin', this.onFocusIn);
this.element.addEventListener('focusout', this.onFocusOut);
if (!this.motion.matches) {
this.start();
}
}
disconnect() {
this.stop();
// Back to the server's word, so that a reconnection, or a snapshot of the page, starts from it.
if (this.wordTargets.length > 0) {
this.show(this.home);
}
this.motion.removeEventListener('change', this.onMotionChange);
window.removeEventListener('resize', this.onResize);
document.removeEventListener('visibilitychange', this.onVisibility);
this.element.removeEventListener('mouseenter', this.onEnter);
this.element.removeEventListener('mouseleave', this.onLeave);
this.element.removeEventListener('focusin', this.onFocusIn);
this.element.removeEventListener('focusout', this.onFocusOut);
}
/**
* The button's action: pauses the cycle, or plays it again at once, even under the pointer or the focus that
* pressed it.
*/
toggle() {
if (this.reasons.has('user')) {
this.reasons.delete('hover');
this.reasons.delete('focus');
this.hold('user', false);
} else {
this.hold('user', true);
}
this.label();
}
start() {
if (!this.hasRevealTarget || 0 === this.wordTargets.length) {
return;
}
const count = this.wordTargets.length;
const cycles = this.loopValue && count > 1;
const { pause, duration } = timing(count, this.pauseValue, this.durationValue, cycles);
this.restTime = pause;
this.moveTime = duration;
this.moves = count < 2 ? 0 : cycles ? Infinity : count;
this.running = true;
if (cycles && this.hasToggleTarget) {
this.toggleTarget.hidden = false;
this.label();
}
this.revealTarget.style.setProperty('--phil-reveal-duration', `${this.moveTime}ms`);
this.revealTarget.classList.add('isRevealing');
this.show(this.home);
if (this.reasons.size > 0) {
this.openFully();
return;
}
this.size(0);
// Reading the width commits the closed clip, so that the next width is a transition, not a jump.
void this.revealTarget.offsetWidth;
this.open();
}
stop() {
this.cancel();
this.running = false;
this.opened = false;
if (this.hasToggleTarget) {
this.toggleTarget.hidden = true;
}
if (this.hasRevealTarget) {
this.revealTarget.classList.remove('isRevealing', 'isPulsing');
this.revealTarget.style.removeProperty('--phil-reveal-size');
this.revealTarget.style.removeProperty('--phil-reveal-duration');
}
}
/**
* Adds or removes one reason to hold the current word still; the cycle goes on once none is left.
*/
hold(reason, on) {
const held = this.reasons.size > 0;
on ? this.reasons.add(reason) : this.reasons.delete(reason);
if (!this.running || held === this.reasons.size > 0) {
return;
}
if (held) {
this.rest();
} else {
this.openFully();
}
}
open() {
this.size(this.width(this.index));
this.afterMove(() => this.rest());
}
/**
* The current word fully open and nothing scheduled: what a held headline shows.
*/
openFully() {
this.cancel();
this.revealTarget.classList.remove('isPulsing');
this.size(this.width(this.index));
this.opened = true;
}
rest() {
this.opened = true;
if (this.moves <= 0) {
this.stop();
return;
}
this.revealTarget.classList.add('isPulsing');
this.later(this.restTime, () => this.close());
}
close() {
this.opened = false;
this.revealTarget.classList.remove('isPulsing');
this.size(0);
this.afterMove(() => {
this.moves -= 1;
this.show(next(this.index, this.wordTargets.length));
this.open();
});
}
show(index) {
this.wordTargets.forEach((word, position) => word.classList.toggle('isCurrent', position === index));
this.index = index;
}
size(pixels) {
this.revealTarget.style.setProperty('--phil-reveal-size', 0 === pixels ? '0px' : `calc(${pixels}px + var(--phil-reveal-caret, 0px))`);
}
width(index) {
return this.wordTargets[index].offsetWidth;
}
label() {
if (this.hasToggleTarget) {
this.toggleTarget.textContent = this.reasons.has('user') ? this.playLabelValue : this.pauseLabelValue;
}
}
/**
* Calls back once the clip's transition ends, or after the move's duration if `transitionend` never fires.
*/
afterMove(callback) {
this.cancel();
const reveal = this.revealTarget;
const done = () => {
this.cancel();
callback();
};
const onEnd = (event) => event.target === reveal && done();
reveal.addEventListener('transitionend', onEnd);
this.ending = () => reveal.removeEventListener('transitionend', onEnd);
this.timer = setTimeout(done, this.moveTime);
}
later(delay, callback) {
this.cancel();
this.timer = setTimeout(callback, delay);
}
cancel() {
clearTimeout(this.timer);
this.timer = null;
this.ending?.();
this.ending = null;
}
}
/**
* The word after `index` among `count`, back to the first after the last; null when there is none to show.
*/
export function next(index, count) {
return count < 2 ? null : (index + 1) % count;
}
/**
* The rest and move times of a run: as given when it cycles, its pause button at hand; otherwise both scaled down so
* that the entrance, then a rest, a close and an open per word back to the first, fit within `budget`.
*/
export function timing(count, pause, duration, cycles, budget = BUDGET) {
if (cycles) {
return { pause, duration };
}
const moves = count < 2 ? 0 : count;
const total = (1 + 2 * moves) * duration + moves * pause;
const scale = total > budget ? budget / total : 1;
return { pause: Math.floor(pause * scale), duration: Math.floor(duration * scale) };
}
templates/components/AnimatedHeadline.html.twig
{#-
# A headline whose words are revealed through a clip, one after the other.
#
# Screen readers read the whole headline at once, from a visually hidden copy; the animated words are hidden from
# them, so nothing is announced while they change. Without JavaScript, or under prefers-reduced-motion, the first
# word stands still. One word is revealed once; several are played through once, back to the first, within five
# seconds; with `loop`, they keep cycling and a pause/play button, shown by the controller, stops them.
#}
{%- props
## list<string>|string The word, or the words to cycle through; one word is revealed once.
words,
## string|null Static text before the words.
text = null,
## string|null Static text after the words.
suffix = null,
## int The level of the heading, from 1 to 6.
level = 1,
## string|null What screen readers read instead of the text, the words joined and the suffix.
label = null,
## string What joins the words in what screen readers read.
separator = ', ',
## int Milliseconds a word rests before the next one.
pause = 2500,
## int Milliseconds the clip takes to close, and as many to open.
duration = 600,
## bool Whether the words keep cycling, with a pause/play button, instead of playing through once.
loop = false,
## string The button's text while the words cycle, with `loop`.
pauseLabel = 'Pause animation',
## string The button's text while they are paused, with `loop`.
playLabel = 'Play animation'
-%}
{%- set _words = words is iterable ? words : [words] -%}
{%- set _level = max(1, min(6, level)) -%}
{%- set _cycles = loop and _words|length > 1 -%}
{%- set _id = 'animated-headline-' ~ random() -%}
{%- set _label = label ?? ([text, _words|join(separator), suffix]|filter(part => part is not null and part is not same as(''))|join(' ')) -%}
<h{{ _level }} data-animated-headline-pause-value="{{ pause }}" data-animated-headline-duration-value="{{ duration }}" data-animated-headline-loop-value="{{ _cycles ? 'true' : 'false' }}"{% if _cycles %} data-animated-headline-pause-label-value="{{ pauseLabel }}" data-animated-headline-play-label-value="{{ playLabel }}" aria-labelledby="{{ _id }}"{% endif %}{{ attributes.defaults({class: 'isHeadline', 'data-controller': 'animated-headline'}) }}>
<span{% if _cycles %} id="{{ _id }}"{% endif %} class="isVisuallyHidden">{{ _label }}</span>
<span aria-hidden="true">
{%- if text is not null and text is not same as('') %}{{ text }} {% endif -%}
<span class="isReveal" aria-live="off" data-animated-headline-target="reveal">
{%- for word in _words -%}
<span{% if loop.first %} class="isCurrent"{% endif %} data-animated-headline-target="word">{{ word }}</span>
{%- endfor -%}
</span>
{%- if suffix is not null and suffix is not same as('') %} {{ suffix }}{% endif -%}
</span>
{%- if _cycles %}
<button type="button" class="isRevealToggle" hidden data-animated-headline-target="toggle" data-action="animated-headline#toggle">{{ pauseLabel }}</button>
{%- endif %}
</h{{ _level }}>