Aller au contenu
O-UI

Recherche

Trouvez un composant ou une page de documentation.

FrameLoop

BêtaPrimitive

Une seule boucle d'animation pour toute la page : toutes les mesures, puis toutes les écritures, et un interrupteur de pause pour les animations continues.

Frameworks
ReactVueVanilla
Mis à jour le
Paramètres

FrameLoop fait tourner une seule boucle requestAnimationFrame pour toute la page : chaque animation s'y abonne comme membre, et dans une frame, tous les membres mesurent la page avant que le premier écrive. La boucle s'endort quand rien ne bouge, et elle porte un interrupteur de pause commun à toutes les animations continues. Les effets et composants d'O-UI l'utilisent ; utilisez-la pour vos animations écrites à la main, qui partageront la même boucle et la même pause. Pour une simple transition, gardez le CSS ; GSAP et Motion ont leur propre boucle.

Installation

Installez l'élément et ses dépendances en une commande :

pnpm dlx shadcn@latest add https://o-ui.hd987.dev/r/frame-loop.json

FrameLoop n'a aucune dépendance. Les éléments d'O-UI qui l'utilisent l'installent d'eux-mêmes, dans components/o-ui/frame-loop/.

Utilisation

Un membre décrit ce qu'il fait à chaque frame. Ici, une barre rejoint sa valeur (une fraction de 0 à 1), puis la boucle s'endort.

"use client";

import { useEffect, useRef, useState } from "react";
import { useFrameLoop } from "@/components/o-ui/frame-loop/frame-loop";

let reduceQuery: MediaQueryList | undefined;

export function Progress({ value }: { value: number }) {
  const barRef = useRef<HTMLDivElement>(null);
  const currentRef = useRef(value);
  // The first render already shows the value: nothing jumps at hydration.
  const [initial] = useState(value);
  const wake = useFrameLoop({
    write(frame) {
      reduceQuery ??= window.matchMedia("(prefers-reduced-motion: reduce)");
      // Eases toward the value (at once with reduced motion), then sleeps once there.
      const ease = reduceQuery.matches ? 1 : 1 - Math.exp(-frame.dt / 0.15);
      const next = currentRef.current + (value - currentRef.current) * ease;
      currentRef.current = Math.abs(value - next) < 0.001 ? value : next;
      barRef.current?.style.setProperty("transform", `scaleX(${currentRef.current})`);
      return currentRef.current !== value;
    },
  });
  // A new value wakes the member.
  useEffect(() => {
    wake();
  }, [value, wake]);
  return (
    <div
      role="progressbar"
      aria-label="Upload"
      aria-valuenow={Math.round(value * 100)}
      className="h-2 overflow-hidden rounded-full bg-muted"
    >
      <div
        ref={barRef}
        className="h-full origin-left bg-primary"
        style={{ transform: `scaleX(${initial})` }}
      />
    </div>
  );
}

En React, les hooks sont dans frame-loop.tsx et le reste dans frame-loop-core.ts. En Vue, tout est exporté par @/components/o-ui/frame-loop.

Quelques règles font la différence :

  1. Mesurez dans read, écrivez dans write. Toutes les lectures de la page passent avant toutes les écritures : la mise en page n'est calculée qu'une fois par frame, quel que soit le nombre d'animations. Gardez la mesure dans une ref ou une variable, que write relit (exemple « Indicateur d'onglet »).
  2. Renvoyez false dès que rien ne bouge. Le membre s'endort jusqu'au prochain réveil : wake() en React et en Vue, wakeFrame(member) en JavaScript natif. Une page au repos ne fait aucun travail.
  3. Animez avec frame.dt. C'est le temps écoulé depuis la frame précédente, en secondes : le mouvement est ainsi le même à 60 Hz qu'à 120 Hz. Il est borné à 0,05 s, pour qu'une animation reprenne sans sauter après une frame bloquée.
  4. Ne déclenchez pas de rendu par frame. En React comme en Vue, écrivez dans le DOM depuis write (une ref, style, textContent), pas dans un état réactif : 60 rendus par seconde coûteraient bien plus que l'animation.
  5. Une animation autonome teste frame.paused. Déclarez-la continuous: true : la boucle la réveille à chaque changement de la pause, mais ne l'arrête pas d'elle-même. Elle se fige et renvoie false tant que frame.paused vaut true. Une animation déclenchée par l'utilisateur, comme la barre ci-dessus, ignore la pause.
  6. Hors écran, désabonnez une animation continue. La boucle ne sait pas ce qui est visible : passez active: false quand un IntersectionObserver signale que l'élément est sorti de l'écran (exemple « Interrupteur de pause »).
  7. Écrivez un état complet. Au retour sur l'onglet, chaque membre reçoit une frame, même endormi : write doit pouvoir être rappelé sans que rien ne change.

Les hooks et les composables diffèrent sur un point :

  • React : useFrameLoop lit les rappels du dernier rendu. Inutile de mémoïser le membre, et les props se lisent directement.
  • Vue : appelez useFrameLoop dans setup. Le membre est capturé une fois : lisez props.value dans les rappels, pas une valeur déstructurée. active accepte un booléen, une ref ou un getter. Un composant mis en cache par <KeepAlive> se désabonne quand il est désactivé.

Exemples

Prix animé

Une animation ponctuelle : au changement de période, le prix rejoint sa nouvelle valeur en une demi-seconde environ, puis le membre renvoie false et la boucle s'endort. Avec le mouvement réduit, la valeur change d'un coup.

Indicateur d'onglet

Le trait mesure l'onglet choisi dans read (sa position et sa largeur), puis glisse jusqu'à lui dans write. Un ResizeObserver réveille le membre quand la mise en page change, par exemple au chargement d'une police. Le trait fait 100 px et il est mis à l'échelle : on anime une transformation, jamais une largeur. Les onglets suivent le motif WAI-ARIA (flèches, Home et End).

Interrupteur de pause

Le bouton « Pause animations » d'un site, et un fond qui dérive en continu. Le fond est un membre continu : il teste frame.paused et se fige, et un IntersectionObserver le désabonne hors écran. Le bouton garde un libellé fixe et indique son état par aria-pressed.

Intégration

La pause commune

Une animation automatique de plus de cinq secondes doit pouvoir être mise en pause (WCAG 2.2.2). Un seul contrôle suffit pour toute la page :

import { setAnimationsPaused } from "@/components/o-ui/frame-loop/frame-loop-core";

// In your site's pause switch:
setAnimationsPaused(true);
  • Les membres continus de la page se figent, y compris ceux des effets d'O-UI (GlowBorder, ParticleText) et ceux créés ensuite.
  • Un membre continu reçoit une frame à chaque changement, dans laquelle il lit frame.paused. Pour réagir autrement au changement (relancer un dessin, par exemple), abonnez-vous avec subscribeAnimationsPaused.
  • Pour afficher l'état dans votre interface : useAnimationsPaused(), un booléen en React et une Ref en lecture seule en Vue.

Gardez le choix du visiteur d'une page à l'autre, par exemple dans localStorage, et rétablissez-le au montage du bouton :

"use client";

import { useEffect } from "react";
import { useAnimationsPaused } from "@/components/o-ui/frame-loop/frame-loop";
import { setAnimationsPaused } from "@/components/o-ui/frame-loop/frame-loop-core";

const KEY = "animations-paused";

export function PauseAnimationsButton() {
  const paused = useAnimationsPaused();
  // Restores the visitor's choice, in the browser only.
  useEffect(() => {
    setAnimationsPaused(localStorage.getItem(KEY) === "true");
  }, []);
  return (
    <button
      type="button"
      aria-pressed={paused}
      onClick={() => {
        const next = !paused;
        setAnimationsPaused(next);
        localStorage.setItem(KEY, String(next));
      }}
    >
      Pause animations
    </button>
  );
}

Les animations continues déjà lancées se figent à la frame suivante.

Next.js et Nuxt

Les hooks commencent par "use client" et ne s'abonnent qu'au montage ; les composables n'agissent que dans onMounted. Au rendu serveur, rien ne s'abonne et useAnimationsPaused() vaut false.

Avec GSAP ou Motion

GSAP et Motion font tourner leur propre boucle : leurs animations ne passent pas par FrameLoop et ne suivent pas sa pause. Pour qu'un même interrupteur les arrête, abonnez-vous à la pause et appelez leur propre méthode :

import gsap from "gsap";
import { subscribeAnimationsPaused } from "@/components/o-ui/frame-loop/frame-loop-core";

subscribeAnimationsPaused((paused) => {
  if (paused) gsap.globalTimeline.pause();
  else gsap.globalTimeline.resume();
});

API

Cœur (frame-loop-core.ts)

FonctionRôle
subscribeFrame(member)Abonne un membre et lui donne sa première frame. Renvoie la fonction qui le désabonne.
wakeFrame(member)Demande une frame pour un membre abonné. Sans effet sur un membre qui ne l'est pas.
setAnimationsPaused(paused)Met en pause (true) ou relance (false) les animations continues de la page.
getAnimationsPaused()Indique si la pause est active.
subscribeAnimationsPaused(listener)Appelle listener(paused) à chaque changement. Renvoie la fonction qui le désabonne.

Hooks et composables

FonctionRôle
useFrameLoop(member, { active })Abonne le membre pendant le montage, tant que active vaut true (par défaut). Renvoie la fonction qui le réveille.
useAnimationsPaused()L'état de la pause, qui ne change qu'au basculement : un booléen en React, une Ref en lecture seule en Vue.

Un membre (FrameMember)

Les phases sont facultatives. Dans une frame, la boucle appelle prepare de tous les membres réveillés, puis read de tous, puis write de tous.

PropTypeDéfautDescription
continuousbooleanfalseUne animation autonome (une dérive, une boucle). La boucle la réveille à chaque changement de la pause commune ; elle teste frame.paused et se fige. Laissez-la à false pour une animation déclenchée par l'utilisateur.
prepare(frame: FrameState) => void—Met à jour un état que d'autres membres lisent dans la même frame (une vitesse lissée, par exemple), sans toucher au DOM.
read(frame: FrameState) => void—Lit la mise en page (getBoundingClientRect, tailles). Tous les membres lisent avant qu'un seul écrive.
write(frame: FrameState) => boolean—Écrit la frame (styles, transformations, texte). Renvoie true pour recevoir la frame suivante ; false endort le membre jusqu'au prochain réveil. Sans write, le membre reçoit une frame par réveil.
suspend() => void—Appelée quand l'onglet est masqué et que les frames s'arrêtent : retirez ici un will-change posé pendant l'animation.

Une frame (FrameState)

PropTypeDéfautDescription
timenumber—L'horodatage de la frame, en millisecondes (l'horloge de requestAnimationFrame). Il avance aussi pendant le sommeil de la boucle : pour un mouvement qui reprend là où il s'est arrêté, cumulez dt.
dtnumber—Le temps écoulé depuis la frame précédente, en secondes, de 0 à 0,05. Il vaut 1/60 s à la première frame après un sommeil de la boucle ou au retour sur l'onglet.
pausedboolean—La pause commune des animations continues (setAnimationsPaused). Un membre continu se fige quand elle vaut true.

Accessibilité

  • Ce que fait FrameLoop : il fournit la pause commune, qui couvre la WCAG 2.2.2 pour toutes les animations continues de la page avec un seul contrôle. Il ne rend rien et ne change pas le focus.
  • Ce que vous faites :
    • posez un interrupteur de pause visible dès qu'une animation continue dure plus de cinq secondes : un libellé fixe et aria-pressed, comme dans l'exemple ;
    • déclarez continuous: true sur toute animation autonome, et figez-la quand frame.paused vaut true ;
    • respectez prefers-reduced-motion dans chaque membre : l'état final ou un fondu court plutôt qu'un déplacement. Si le membre s'endort sous le mouvement réduit, réveillez-le quand la préférence change (l'événement change de la MediaQueryList), comme le font le playground et l'interrupteur de pause.
  • Contraste élevé : FrameLoop n'affiche rien. Dans les exemples, le focus reste visible en forced-colors (outline-hidden de Tailwind y garde un contour).

Performances

  • Une seule requestAnimationFrame par frame, quel que soit le nombre de membres.
  • Rien ne tourne quand rien ne bouge : un membre au repos ne coûte rien, et la boucle s'endort quand aucun membre ne demande de frame.
  • Rien ne tourne dans un onglet masqué : la frame en attente est annulée, puis chaque membre reçoit une frame au retour.
  • Hors écran, c'est à vous de désabonner une animation continue (règle 6) : la boucle ne connaît pas la visibilité des éléments.
  • Une erreur reste locale : une exception d'un membre est signalée dans la console, le membre fautif s'endort jusqu'à son prochain réveil, et les autres continuent.
  • Poids : moins de 1 ko compressé, sans dépendance.

Notes et limites

  • Un état par document. Deux copies du fichier frame-loop-core.ts dans un même projet feraient deux boucles et deux pauses : gardez un seul dossier components/o-ui/frame-loop/, que partagent tous les éléments d'O-UI.
  • Ne déplacez pas un dossier d'O-UI seul. Ses voisins importent FrameLoop par ../frame-loop/frame-loop-core : déplacez frame-loop/ avec eux, ou ces imports casseront.
  • Un membre abonné deux fois par subscribeFrame : seule la dernière inscription compte. Les hooks et les composables abonnent chacun leur propre membre.
  • Navigateurs : tous les navigateurs actuels (requestAnimationFrame et AbortController).