ViewProgress
BêtaPrimitiveLa progression d'un élément dans la zone visible, de 0 à 1 : pour une barre de lecture, une apparition au défilement ou un carrousel.
- Frameworks
- ReactVueVanilla
- Mis à jour le
- Construit sur
- FrameLoop
- Utilisée par
- BlurPhaseDriverProjectedTextScrollTint
ViewProgress mesure la progression d'un élément à travers la zone visible, de 0 à 1, et y lie une animation ou une fonction. Il sert aux apparitions au défilement, aux barres de lecture, aux parallaxes, aux carrousels et aux récits pas à pas. Il reprend les plages de view() en CSS (cover, entry…) et donne le même rendu dans tous les navigateurs actuels, Firefox compris. Pour une animation purement CSS dans Chrome et Safari, animation-timeline: view() suffit.
Dans l'aperçu, faites défiler la zone encadrée : chaque ligne donne la progression de la carte pour une plage. La plage choisie remplit la carte. Le contrôle « Taille du sujet » montre ce qui change quand la carte est plus grande que la vue.
Installation
Installez l'élément et ses dépendances en une commande :
pnpm dlx shadcn@latest add https://o-ui.hd987.dev/r/view-progress.jsonViewProgress s'appuie sur FrameLoop, installé avec lui dans components/o-ui/frame-loop/. Les exemples utilisent Tailwind CSS v4, comme tous les éléments d'O-UI (prérequis).
Utilisation
Une section monte à sa place pendant qu'elle entre dans la zone visible, au rythme du défilement :
"use client";
import { type ReactNode, useRef } from "react";
import { useViewAnimation } from "@/components/o-ui/view-progress/view-progress";
export function Reveal({ children }: { children: ReactNode }) {
const sectionRef = useRef<HTMLElement>(null);
// While the section crosses the bottom edge of the view.
useViewAnimation(
sectionRef,
[
{ translate: "0 40px", scale: "0.96" },
{ translate: "0 0", scale: "1" },
],
{ range: "entry" },
);
// With reduced motion, `!important` beats the animation: the section stays in place.
return (
<section ref={sectionRef} className="motion-reduce:translate-none! motion-reduce:scale-none!">
{children}
</section>
);
}Les keyframes sont celles de Web Animations, comme pour element.animate() : une liste d'objets, avec les propriétés en camelCase (backgroundColor) et un offset facultatif entre 0 et 1. La première keyframe vaut pour le début de la plage, la dernière pour sa fin.
Pour lire la valeur plutôt que d'animer, useViewProgress appelle une fonction à chaque changement. Ici, un chapitre affiche où en est sa lecture :
"use client";
import { type ReactNode, useRef } from "react";
import { useViewProgress } from "@/components/o-ui/view-progress/view-progress";
export function Chapter({ title, children }: { title: string; children: ReactNode }) {
const chapterRef = useRef<HTMLElement>(null);
const percentRef = useRef<HTMLSpanElement>(null);
// From the chapter's top at the top of the view to its end at the bottom.
useViewProgress(
chapterRef,
(progress) => {
if (percentRef.current) percentRef.current.textContent = `${Math.round(progress * 100)} %`;
},
{ range: "contain" },
);
return (
<section ref={chapterRef}>
<header className="flex items-baseline justify-between gap-4">
<h2>{title}</h2>
{/* A visual cue: hidden from screen readers, which would hear it change. */}
<span ref={percentRef} aria-hidden="true">
0 %
</span>
</header>
{children}
</section>
);
}En React, les hooks sont dans view-progress.tsx et le reste dans view-progress-core.ts. En Vue, tout est exporté par @/components/o-ui/view-progress, l'index du dossier installé.
Animation ou fonction
useViewAnimation(animateViewProgress) pour tout ce qui bouge ou change de style. Dans Chrome, Edge et Safari, l'animation suit le défilement sans aucun JavaScript.useViewProgress(observeViewProgress) quand vous avez besoin du nombre : un texte, ou une décision comme l'étape en cours. La fonction est appelée à chaque changement, sans nouveau rendu. Écrivez dans le DOM, ou ne mettez à jour un état que quand une valeur qui en dérive change (l'étape, pas la progression), comme dans le récit pas à pas.
Les deux renvoient un handle, à lire au moment voulu : voir Lire la progression à la demande.
Les règles
- Préférez une animation à une fonction, pour la raison ci-dessus. Dans une fonction, ne mesurez rien vous-même (
getBoundingClientRect,offsetTop) : la mesure est déjà faite, et une nouvelle lecture force le navigateur à recalculer la mise en page à chaque image. - Animez
opacity,translate,scaleoutransform. Ces propriétés ne recalculent pas la mise en page. Pour une couleur, animez une propriété personnalisée enregistrée (@property), comme le fait ScrollTint. - Choisissez la plage avec
range, commeanimation-rangeen CSS :range: "entry"parcourt toute la plageentry, etrange: "entry 25% cover 50%"va d'un point à un autre.rangeStartetrangeEndmodifient un seul bout. Attention :rangeStartseul garde la fin par défaut,cover 100%. - Adoucissez dans les keyframes. L'animation suit la progression de façon linéaire : un
easingdans une keyframe ("ease-out") donne une courbe au mouvement qui la suit. - Laissez le contenu visible sans JavaScript. N'écrivez pas l'état de départ d'un contenu en CSS (
opacity-0) : l'animation le pose elle-même dès la première image. Seul un indicateur, comme une barre vide (scale-x-0), peut partir de son état de départ. - Neutralisez le mouvement réduit en CSS. Une déclaration
!importantl'emporte sur une animation, en direct :motion-reduce:translate-none!,motion-reduce:scale-none!oumotion-reduce:transform-none!avec Tailwind v4. Une barre de progression est un retour d'état : gardez-la. - Évitez les fondus partiels sur du texte. Un texte à moitié transparent n'a plus assez de contraste quand le défilement s'arrête. Préférez un déplacement ou une échelle, ou réservez le fondu aux décors.
- Un appel par élément. Pour une liste, créez un composant par entrée, qui appelle le hook ou le composable pour lui-même (comme les exemples).
Les plages
Pour un défilement vertical, « la vue » étant la zone visible :
| Plage | 0 % | 100 % |
|---|---|---|
cover | Le haut de l'élément atteint le bas de la vue | Le bas de l'élément quitte le haut de la vue |
contain | L'élément est entièrement visible (plus grand que la vue : son haut atteint le haut de la vue) | Il commence à sortir par le haut (plus grand : son bas atteint le bas de la vue) |
entry | Le haut de l'élément atteint le bas de la vue | L'élément est entièrement entré (plus grand : il couvre toute la vue) |
exit | Il commence à sortir par le haut (plus grand : il ne couvre plus toute la vue) | Le bas de l'élément quitte le haut de la vue |
entry-crossing | Le haut de l'élément atteint le bas de la vue | Le bas de l'élément atteint le bas de la vue |
exit-crossing | Le haut de l'élément atteint le haut de la vue | Le bas de l'élément atteint le haut de la vue |
- Pour un élément plus petit que la vue,
entryetentry-crossingse confondent, commeexitetexit-crossing. Ils ne diffèrent que pour un élément plus grand que la vue. - La progression vaut 0 avant la plage et 1 après. Un pourcentage précise un point (
"entry 50%"), même hors de 0 à 100 ; une longueur ("entry 80px") n'est pas acceptée, et une plage mal écrite est ignorée : la précédente reste,coverpar défaut. - Sur l'axe horizontal, lisez « gauche » pour le haut et « droite » pour le bas (l'inverse de droite à gauche).
Les hooks et les composables
- React : la cible,
subjectetsourcesont des refs ou des éléments, relus après chaque rendu (montage conditionnel, nouvel élément). Une option retirée, ou passée àundefined, revient à sa valeur par défaut. - Vue : appelez les composables dans
setup. La cible,subjectetsourcesont des éléments, desrefde template ou des getters ; les options, un objet, unerefou un getter. Un composant mis en cache par<KeepAlive>s'arrête quand il est désactivé. - Les keyframes sont lues quand l'animation est construite. Pour en changer, remontez le composant qui appelle le hook ou le composable (une nouvelle
keysur ce composant). Pour des keyframes qui dépendent des tailles, passez une fonction (voir plus bas).
Exemples
Apparition au défilement
Chaque entrée d'un journal des versions monte à sa place pendant qu'elle entre dans la vue (range: "entry"), au rythme du défilement : remonter la fait redescendre. Un composant par entrée suit sa propre progression. L'easing de la première keyframe ralentit le mouvement à l'arrivée.
Barre de lecture
La barre de l'en-tête suit la lecture d'un article : vide quand son titre atteint le haut de la vue, pleine quand sa fin atteint le bas. L'élément animé est la barre, et l'article la mène par subject, avec la plage contain. L'animation porte sur scale : la barre ne déplace rien autour d'elle.
Image en parallaxe
L'illustration défile plus lentement que le texte, à l'intérieur de son cadre arrondi. La figure mène l'animation (subject) sur toute la plage par défaut, cover, et l'illustration, plus haute de 30 %, se déplace dans les deux sens. La figure est hors du cadre qui coupe l'illustration (overflow-hidden) : la progression suit le cadre visible, et le chemin natif reste possible. Avec l'illustration comme sujet, elle suivrait une scène plus grande que le cadre, et le cadre, qui coupe sans défiler, imposerait le chemin mesuré.
Carrousel horizontal
Dans une zone qui défile à l'horizontale (axis: "x"), chaque carte grandit en passant au centre. Les trois keyframes d'échelle se répartissent sur cover : celle du milieu tombe à 50 %, quand la carte est au centre. ViewProgress trouve seul la zone de défilement, focalisable pour défiler aussi au clavier.
Récit pas à pas
Le panneau reste en place pendant que les étapes défilent. useViewProgress lit la progression de la section sur contain, le temps où le panneau est épinglé, et en déduit l'étape en cours : le composant ne se rend de nouveau qu'au changement d'étape. La barre du panneau suit la même progression en continu, par une animation.
Intégration
Lire la progression à la demande
Les hooks et les composables renvoient un handle : progress, la progression actuelle, et native pour une animation. Il n'est pas réactif : lisez-le dans un gestionnaire d'évènement, pas au rendu. Ici, un bouton de partage indique jusqu'où le lecteur est allé :
const reading = useViewAnimation(barRef, [{ scale: "0 1" }, { scale: "1 1" }], {
subject: articleRef,
range: "contain",
});
function share() {
track("share", { read: Math.round(reading.progress * 100) });
}Avant le montage et avant la première image, progress vaut 0 et native vaut false. Le cœur expose les mêmes propriétés sur ses instances.
Le chemin natif et le chemin mesuré
Quand le navigateur a ViewTimeline et que la zone de défilement de CSS est aussi celle de ViewProgress, l'animation suit le défilement sans JavaScript. Sinon (Firefox, un ancêtre en overflow: hidden, une zone qui défile de droite à gauche à l'horizontale), sa position est réglée à chaque image de défilement, après une mesure groupée avec celles des autres éléments. Le rendu est le même ; native indique le chemin pris (false jusqu'à la première image). Comme view(), les deux chemins ignorent les transformations de l'élément lui-même : une apparition qui le déplace ne fausse pas sa propre progression.
La zone de défilement
Par défaut (source: "auto"), c'est le premier ancêtre qui défile vraiment sur l'axe : overflow à scroll, ou à auto avec un contenu qui déborde. Sinon, c'est la fenêtre. Un ancêtre qui ne fait que couper son contenu est ignoré : une carte arrondie en overflow: hidden, ou un conteneur de mise en page en overflow-x: hidden. La zone est cherchée de nouveau quand un ancêtre plus proche se met à défiler, quand l'élément ou sa zone change de taille, et au redimensionnement de la fenêtre. Passez source: "viewport" ou un ancêtre précis pour l'imposer.
Des styles qui partent avec l'animation (onStart)
onStart est appelée dans l'image où l'animation démarre, juste avant elle. Écrivez-y les styles dont elle dépend, pour qu'ils apparaissent avec elle et jamais avant :
animateViewProgress(card, [{ "--tint": "0" }, { "--tint": "1" }], {
// `--tint` is registered with `@property` (a <number>), so it animates.
onStart: () => {
card.style.background = "color-mix(in oklab, teal calc(var(--tint) * 100%), navy)";
},
});Des keyframes qui dépendent des tailles
Une fonction reçoit la taille du sujet et celle de la zone visible, en pixels CSS, et elle est rappelée quand elles changent. Ici, une bande traverse la carte sur toute la plage cover :
animateViewProgress(
band,
({ subject, port }) => [{ translate: `0 ${-port}px` }, { translate: `0 ${subject}px` }],
{ subject: card },
);Next.js et Nuxt
Les hooks commencent par "use client" et n'agissent qu'au montage ; les composables, dans onMounted. Au rendu serveur, rien n'est mesuré ni animé, et le HTML ne porte aucun style ajouté. Un élément déjà dans sa plage au chargement prend sa place animée à la première image.
La pause de FrameLoop
Une animation liée au défilement n'avance que si l'utilisateur défile : elle ne suit pas la pause commune de FrameLoop (setAnimationsPaused), qui ne concerne que les animations autonomes.
API
Cœur (view-progress-core.ts)
| Export | Rôle |
|---|---|
animateViewProgress(target, keyframes, options) | Lie une animation de target à la progression. Renvoie { progress, native, update, destroy }. |
observeViewProgress(subject, onProgress, options) | Appelle onProgress(progress) au départ, puis à chaque changement. Renvoie { progress, update, destroy }. |
supportsViewTimeline() | Indique si le navigateur a ViewTimeline. |
resolveViewSource(subject, axis) | La zone de défilement que source: "auto" trouve pour subject (null pour la fenêtre). |
viewProgressOf(start, subject, port, from, to) | La progression calculée à partir de tailles connues : start est la position du sujet dans la zone, subject et port leurs tailles, en pixels CSS. |
VIEW_PROGRESS_DEFAULTS | Les valeurs par défaut des options. |
update(options) applique de nouvelles options ; une valeur absente ou invalide garde la valeur courante. destroy() retire l'animation et tous les observateurs ; il peut être appelé plusieurs fois. Un élément invalide (une recherche qui n'a rien trouvé) donne une instance sans effet.
Hooks et composables
| Fonction | Rôle |
|---|---|
useViewAnimation(target, keyframes, options) | animateViewProgress pendant le montage, reconstruite quand la cible ou le sujet change. Renvoie { progress, native }. |
useViewProgress(target, onProgress, options) | observeViewProgress pendant le montage ; la fonction du dernier rendu est appelée. Renvoie { progress }. |
Options
| Prop | Type | Défaut | Description |
|---|---|---|---|
axis | ViewAxis | "y" | L'axe du défilement : "y" (vertical) ou "x" (horizontal). |
range | ViewRangeShorthand | "cover" | La plage, comme animation-range en CSS : un nom ("entry", de son 0 % à son 100 %), un point ("entry 25%", jusqu'à entry 100 %) ou deux points ("entry 25% cover 50%"). rangeStart et rangeEnd l'emportent sur le bout qu'ils précisent. |
rangeStart | ViewRange | "cover 0%" | Où la progression vaut 0, comme animation-range-start : un nom de plage, seul (son 0 %) ou suivi d'un pourcentage. Seul, il garde la fin courante (cover 100 % par défaut) : préférez range. |
rangeEnd | ViewRange | "cover 100%" | Où la progression vaut 1, comme animation-range-end. Un nom seul vaut son 100 %. |
source | ViewSource | "auto" | La zone de défilement : "auto" (le premier ancêtre qui défile vraiment sur l'axe, sinon la fenêtre), "viewport", ou un ancêtre précis. Un élément qui n'est pas un ancêtre est ignoré. Les hooks acceptent aussi une ref. |
disabled | boolean | false | Arrête la mesure et retire l'animation, sans détruire l'instance. |
Pour une animation, deux options s'ajoutent :
| Prop | Type | Défaut | Description |
|---|---|---|---|
subject | Element | the animated element | L'élément dont la position mène l'animation, si ce n'est pas l'élément animé (un article pour sa barre de lecture). Les hooks acceptent une ref ; en Vue, une ref de template ou un getter, et null attend le montage. |
onStart | (() => void) | — | Appelée dans l'image où l'animation démarre, juste avant elle, et à chaque reconstruction : écrivez-y les styles dont l'animation dépend, pour qu'ils apparaissent avec elle. |
Le handle (ViewAnimationHandle)
| Prop | Type | Défaut | Description |
|---|---|---|---|
native | boolean | — | true quand une ViewTimeline native mène l'animation, false sur le chemin mesuré et avant la première image. |
progress | number | — | La progression actuelle, de 0 à 1 ; 0 avant la première image. useViewProgress ne renvoie que celle-ci. |
La géométrie (ViewGeometry)
Une fonction de keyframes reçoit la géométrie, en pixels CSS de mise en page :
| Prop | Type | Défaut | Description |
|---|---|---|---|
subject | number | — | La taille du sujet sur l'axe (sa boîte de bordure). |
port | number | — | La taille de la zone visible sur l'axe : la zone de défilement sans bordures ni barre de défilement. |
reversed | boolean | — | La zone défile de droite à gauche (rtl, axe horizontal) : inversez les keyframes physiques (un déplacement en x). |
Les types des plages sont ViewRangeName ("cover", "contain", "entry", "exit", "entry-crossing", "exit-crossing"), ViewRange (un nom, seul ou suivi d'un pourcentage, pour rangeStart et rangeEnd) et ViewRangeShorthand (un ou deux ViewRange, pour range).
Accessibilité
- Ce que fait ViewProgress : il ne rend rien et ne change pas le focus. Une animation liée au défilement ne bouge que si l'utilisateur défile : elle n'a pas besoin de pause (WCAG 2.2.2).
- Ce que vous faites :
- le mouvement réduit, le contenu visible sans JavaScript et le contraste : règles 5 à 7 ;
- une zone qui défile sans contenu focalisable reçoit
tabindex="0", un rôleregionet un nom, pour défiler au clavier (le carrousel) ; - un indicateur qui change en continu (un pourcentage) reste visuel :
aria-hidden="true", pour qu'un lecteur d'écran ne l'annonce pas à chaque image.
- Contraste élevé : un indicateur dessiné en fond (une barre) disparaît en
forced-colors; donnez-lui une couleur système (forced-colors:bg-[CanvasText]), comme les exemples.
Performances
- Chemin natif : aucune mesure ni écriture pendant le défilement ;
opacityet les transformations sont animées par le compositeur. - Chemin mesuré : une seule mesure groupée par image de défilement pour tous les éléments de la page, avant toute écriture. Seuls les éléments proches de leur zone visible sont mesurés, et les autres une fois à la fin du geste, pour rattraper un saut (touche Fin, ancre).
observeViewProgressn'appelle sa fonction que quand la valeur change.- Une erreur reste locale : une exception dans une fonction ou des keyframes invalides sont signalées dans la console, et les autres éléments continuent.
- Poids : environ 4 ko compressés, en plus de FrameLoop.
Problèmes fréquents
- La progression ne bouge pas. La zone de défilement trouvée n'est pas celle attendue, par exemple un conteneur en
overflow: hiddenqu'un script fait défiler (un carrousel en JavaScript) :source: "auto"ignore un ancêtre qui ne fait que couper. Passezsourceexplicitement. - La plage va jusqu'à
cover 100%alors que vous visiezentry.rangeStartseul garde la fin par défaut : passezrange: "entry". - Sur le chemin mesuré, la valeur ne suit pas un déplacement sans défilement. Un accordéon qui s'ouvre au-dessus de l'élément le déplace sans défilement ni changement de taille de l'élément : la valeur se met à jour au prochain défilement. Le chemin natif n'a pas cette limite.
- L'animation ne prend pas l'easing voulu. La progression est linéaire : mettez l'
easingdans les keyframes (règle 4), pas dans les options.
Notes et limites
- Navigateurs : Chrome, Edge et Safari récents ont
ViewTimeline; ailleurs (Firefox à ce jour), le chemin mesuré prend le relais avec le même rendu. - Transformations de l'élément mesuré : sans
ViewTimeline, une rotation ou untranslateencalc()sur l'élément lui-même empêchent de retrouver sa place, et sa progression suit alors sa boîte dessinée. Animez un enfant, ou passez l'élément qui ne bouge pas ensubject. Les transformations de ses ancêtres (Magnetic) sont mesurées telles qu'elles sont dessinées. - Une zone qui cesse de déborder sans changer de taille (une hauteur fixe dont le contenu rétrécit) reste la zone tant que rien d'autre ne change : passez
sourcesi le contenu varie beaucoup. Sur le chemin natif, l'élément garde alors son style de base jusqu'à la reconstruction suivante. - De droite à gauche sur l'axe horizontal, le chemin mesuré est toujours pris : Chromium y place les plages autrement que la définition de CSS.
- Modes d'écriture verticaux : non pris en charge.
- Ne déplacez pas un dossier d'O-UI seul. ViewProgress importe
../frame-loop/frame-loop-core: déplacezframe-loop/avec lui.