Aller au contenu

AnimatedHeadline

composant

A 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.

Aperçu

Clair

We make sites, apps, games

Code
<twig:AnimatedHeadline text="We make" :words="['sites', 'apps', 'games']" />

Sombre

We make sites, apps, games

Code
<twig:AnimatedHeadline text="We make" :words="['sites', 'apps', 'games']" />

Essayer

Changez les propriétés ; le serveur rend l’exemple de nouveau.

We make sites, apps, games

<twig:AnimatedHeadline text="We make" :words="['sites', 'apps', 'games']" />

Variantes

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

loop="false"

We make sites, apps, games

We make sites, apps, games

loop="true"

Usage

Usage

Studio

Code
<twig:AnimatedHeadline words="Studio" />

Installation

php bin/console ux:install animated-headline --kit=github.com/parler-haut-interagir-librement/ux-kit

Propriétés

PropriétéTypePar défautDescription
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, or label —, from a visually hidden copy. The animated words are hidden from them and the clip is aria-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. With loop, the template renders a <button>, hidden until the controller shows it, whose visible text is its name: pauseLabel while the words cycle, playLabel once paused, both translatable, in English by default. The heading is then named by its visually hidden copy alone, through aria-labelledby, so the button's text is not part of it. One word never cycles, and has no button, loop or 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 after duration if the event never comes: the clip is never left closed.
  • The controller animated-headline drives the classes isRevealing, isPulsing and isCurrent, and the custom property --phil-reveal-size, which phil-ux reads; the button is phil-ux's isRevealToggle.

Fichiers à copier

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 }}>