FrameLoop
BêtaPrimitiveUne 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
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.jsonFrameLoop 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 :
- Mesurez dans
read, écrivez danswrite. 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, quewriterelit (exemple « Indicateur d'onglet »). - Renvoyez
falsedè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. - 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. - 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. - Une animation autonome teste
frame.paused. Déclarez-lacontinuous: 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 renvoiefalsetant queframe.pausedvauttrue. Une animation déclenchée par l'utilisateur, comme la barre ci-dessus, ignore la pause. - Hors écran, désabonnez une animation continue. La boucle ne sait pas ce qui est visible : passez
active: falsequand unIntersectionObserversignale que l'élément est sorti de l'écran (exemple « Interrupteur de pause »). - Écrivez un état complet. Au retour sur l'onglet, chaque membre reçoit une frame, même endormi :
writedoit pouvoir être rappelé sans que rien ne change.
Les hooks et les composables diffèrent sur un point :
- React :
useFrameLooplit les rappels du dernier rendu. Inutile de mémoïser le membre, et les props se lisent directement. - Vue : appelez
useFrameLoopdanssetup. Le membre est capturé une fois : lisezprops.valuedans les rappels, pas une valeur déstructurée.activeaccepte un booléen, unerefou 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 avecsubscribeAnimationsPaused. - Pour afficher l'état dans votre interface :
useAnimationsPaused(), un booléen en React et uneRefen 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)
| Fonction | Rô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
| Fonction | Rô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.
| Prop | Type | Défaut | Description |
|---|---|---|---|
continuous | boolean | false | Une 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)
| Prop | Type | Défaut | Description |
|---|---|---|---|
time | number | — | 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. |
dt | number | — | 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. |
paused | boolean | — | 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: truesur toute animation autonome, et figez-la quandframe.pausedvauttrue; - respectez
prefers-reduced-motiondans 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énementchangede laMediaQueryList), comme le font le playground et l'interrupteur de pause.
- posez un interrupteur de pause visible dès qu'une animation continue dure plus de cinq secondes : un libellé fixe et
- Contraste élevé : FrameLoop n'affiche rien. Dans les exemples, le focus reste visible en
forced-colors(outline-hiddende Tailwind y garde un contour).
Performances
- Une seule
requestAnimationFramepar 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.tsdans un même projet feraient deux boucles et deux pauses : gardez un seul dossiercomponents/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éplacezframe-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 (
requestAnimationFrameetAbortController).