PointerTracker
BêtaPrimitiveUn seul jeu d'écouteurs pour toute la page : la position du pointeur, sa présence et sa vitesse lissée.
- Frameworks
- ReactVueVanilla
- Mis à jour le
- Construit sur
- FrameLoop
PointerTracker suit le pointeur pour toute la page avec un seul jeu d'écouteurs : sa position dans la fenêtre, s'il survole la page, et sa vitesse lissée. Cent éléments qui réagissent au pointeur ne posent donc pas cent écouteurs ; Magnetic, GlowBorder et ParticleText le partagent. Utilisez-le pour des effets qui dépendent de la position ou de la vitesse (un halo, un aperçu qui suit, une inclinaison) ; pour un simple survol d'un élément, :hover ou pointerenter suffisent. Seuls la souris et le stylet fournissent une position ; les contacts et le type d’appareil incluent aussi le toucher.
Dans l'aperçu, le cercle suit le pointeur, le trait montre sa vitesse et la ligne du dessous affiche l'état. « Actif » correspond à l'option active du hook ; « Vecteur vitesse » ne règle que l'aperçu. Avec le mouvement réduit, le relevé reste le même : c'est une mesure, pas une animation.
Installation
Installez l'élément et ses dépendances en une commande :
pnpm dlx shadcn@latest add https://o-ui.hd987.dev/r/pointer-tracker.jsonPointerTracker s'appuie sur FrameLoop, installé avec lui dans components/o-ui/frame-loop/. Les exemples utilisent Tailwind CSS v4 et les couleurs de shadcn, comme tous les éléments d'O-UI (prérequis).
Utilisation
Le schéma tient en deux pièces. L'écouteur de PointerTracker est appelé à chaque événement et ne fait que réveiller un membre de FrameLoop. Ce membre mesure dans read, écrit dans write, puis renvoie true pour recevoir la frame suivante ou false pour s'endormir jusqu'au prochain réveil.
Ici, une carte s'éclaire d'un halo qui suit le pointeur, tant qu'il la survole. Loin d'elle, le membre n'écrit rien.
"use client";
import { type ReactNode, useRef } from "react";
import { useFrameLoop } from "@/components/o-ui/frame-loop/frame-loop";
import { usePointerTracker } from "@/components/o-ui/pointer-tracker/pointer-tracker";
import { getPointer } from "@/components/o-ui/pointer-tracker/pointer-tracker-core";
let reduceQuery: MediaQueryList | undefined;
export function SpotlightCard({ children }: { children: ReactNode }) {
const cardRef = useRef<HTMLDivElement>(null);
const boxRef = useRef<DOMRect | null>(null);
const litRef = useRef(false);
const wake = useFrameLoop({
read() {
boxRef.current = cardRef.current?.getBoundingClientRect() ?? null;
},
write() {
const card = cardRef.current;
const box = boxRef.current;
if (!card || !box) return false;
// Viewport coordinates: compared with the card's box, then made relative to it.
const { x, y, present } = getPointer();
const over = present && x >= box.left && x <= box.right && y >= box.top && y <= box.bottom;
// Away from the card, one write turns the light off, then nothing.
if (!over && !litRef.current) return false;
litRef.current = over;
card.style.setProperty("--spot", over ? "1" : "0");
// With reduced motion, the light stays centered and only fades.
reduceQuery ??= window.matchMedia("(prefers-reduced-motion: reduce)");
if (over && !reduceQuery.matches) {
card.style.setProperty("--x", `${x - box.left}px`);
card.style.setProperty("--y", `${y - box.top}px`);
}
return false;
},
});
// A move, a leave, or the page moving under the pointer: the member gets a frame.
usePointerTracker(wake);
return (
<div ref={cardRef} className="relative overflow-hidden rounded-xl border bg-card p-6">
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-[radial-gradient(240px_circle_at_var(--x,50%)_var(--y,50%),color-mix(in_oklab,var(--color-primary)_15%,transparent),transparent)] opacity-[var(--spot,0)] transition-opacity forced-colors:hidden"
/>
<div className="relative">{children}</div>
</div>
);
}En React, le hook est dans pointer-tracker.tsx et le reste (dont le type TrackedPointer) dans pointer-tracker-core.ts. En Vue, tout est exporté par @/components/o-ui/pointer-tracker.
Quelques règles font la différence :
- L'écouteur réveille, le membre lit. L'écouteur est appelé à chaque événement, souvent plusieurs fois par frame : n'y mesurez rien et n'y écrivez rien. Le membre lit
getPointer()dans sa frame, enreadou enwrite. - Comparez au rectangle mesuré dans
read.xetysont des coordonnées de la fenêtre : un survol se teste contre la boîte de l'élément, et une position dans l'élément s'obtient en soustrayant son coin. Ce test ignore les éléments superposés et les coins arrondis. - Testez
present. Il vautfalseavant le premier mouvement, quand le pointeur quitte la fenêtre ou entre dans une iframe, quand la fenêtre perd le focus et après un toucher : retirez l'effet.xetygardent alors la dernière position connue (0avant le premier mouvement). - Restez éveillé tant que la vitesse compte. Après le dernier mouvement, la vitesse décroît jusqu'à zéro sans notifier personne : un membre qui l'utilise renvoie
truetant qu'elle n'est pas nulle, ou tant qu'elle dépasse un seuil qui lui suffit. - Un membre par groupe. Pour une grille de cartes, un seul membre mesure toutes les cartes, puis écrit toutes les cartes (exemple « Cartes éclairées »).
- En coupant l'écoute, retirez l'effet. Avec
active: false, plus aucune notification n'arrive : réveillez le membre une dernière fois pour qu'il éteigne l'effet, comme le fait le playground ci-dessus. - Ne déclenchez pas de rendu par mouvement. Écrivez dans le DOM depuis
write, pas dans un état React ou Vue.
Les hooks et les composables diffèrent sur un point :
- React :
usePointerTrackerappelle l'écouteur du dernier rendu, sans se réabonner. Il ne renvoie rien. - Vue : appelez
usePointerTrackerdanssetup. L'écouteur est capturé une fois : lisez les props dans son corps.activeaccepte un booléen, unerefou un getter. Un composant mis en cache par<KeepAlive>cesse d'écouter quand il est désactivé.
Exemples
Cartes éclairées
Un halo suit le pointeur sur toute une grille de cartes et traverse les espaces entre elles. Un seul membre mesure toutes les cartes dans read, puis écrit leurs variables CSS dans write ; loin de la grille, il n'écrit plus rien. Le fondu du halo est une transition CSS : le membre n'a pas besoin de rester éveillé. Avec le mouvement réduit, le halo reste centré sur chaque carte et n'apparaît qu'en fondu.
Aperçu au survol
Une liste de projets : un aperçu suit le pointeur avec un léger retard et s'incline selon sa vitesse horizontale. Il reste dans la liste, du côté où il a la place, et Échap le masque jusqu'au prochain mouvement. Avec le mouvement réduit, il reste immobile au milieu de la liste et apparaît en fondu. Il est décoratif (aria-hidden) : chaque ligne nomme déjà son projet.
Intégration
Avec les effets d'O-UI
Magnetic, GlowBorder, ParticleText et vos propres effets partagent le même jeu d'écouteurs et la même boucle : ajouter un effet au pointeur n'ajoute ni écouteur ni requestAnimationFrame.
Next.js et Nuxt
Le hook commence par "use client" et ne s'abonne qu'au montage ; le composable n'agit que dans onMounted. Au rendu serveur, rien ne s'abonne, et getPointer() renvoie un pointeur absent (present: false).
API
Cœur (pointer-tracker-core.ts)
| Export | Rôle |
|---|---|
subscribePointer(listener) | Appelle listener(pointer) quand le pointeur bouge ou part, et quand la page défile ou change de taille sous lui. Renvoie la fonction qui le désabonne. |
subscribePointerContact(listener) | Appelle listener(pressed) à chaque appui, relâchement ou annulation, et quand une sortie, perte de focus ou un onglet masqué termine des contacts. Partage les écouteurs de pression, sans pointermove, coordonnées ni frame de vélocité. Renvoie un désabonnement idempotent. |
getPointer() | L'état partagé du pointeur : le même objet à chaque appel, mis à jour sur place. Lisez-le dans une frame, ne le modifiez pas. |
getPointerType() | L'appareil du dernier événement de pointeur : "mouse", "pen" ou "touch", ou null tant qu'aucun n'est connu (avant le premier événement, sur le serveur, après le dernier désabonnement). |
getPointerContact() | true tant qu'un pointeur, de n'importe quel appareil, est enfoncé : de son pointerdown à son pointerup ou pointercancel, plusieurs pouvant se chevaucher. Remis à false quand la fenêtre perd le focus, quand l'onglet est masqué et au dernier désabonnement. |
POINTER_TRACKER_DEFAULTS | Le défaut du hook et du composable : active: true. |
Hook et composable
| Fonction | Rôle |
|---|---|
usePointerTracker(listener, { active }) | Abonne l'écouteur pendant le montage, tant que active vaut true (par défaut). Ne renvoie rien. |
L'état du pointeur (TrackedPointer)
| Prop | Type | Défaut | Description |
|---|---|---|---|
x | number | — | La position horizontale du pointeur dans la fenêtre, en pixels CSS (clientX). 0 avant le premier mouvement. |
y | number | — | La position verticale du pointeur dans la fenêtre, en pixels CSS (clientY). 0 avant le premier mouvement. |
present | boolean | — | Un pointeur qui survole (souris, stylet) est sur la page. false avant le premier mouvement, hors de la fenêtre, dans une iframe, quand la fenêtre perd le focus et après un toucher. x et y gardent alors la dernière position connue. |
velocityX | number | — | La vitesse horizontale lissée, en pixels CSS par seconde : de quelques centaines à quelques milliers pour un geste ordinaire. Elle répond en 50 ms environ, tient 40 ms après le dernier mouvement, puis décroît jusqu'à zéro en une demi-seconde environ. Un défilement sous un pointeur immobile ne crée pas de vitesse. |
velocityY | number | — | La vitesse verticale lissée, en pixels CSS par seconde. |
Accessibilité
- Ce que fait PointerTracker : il ne rend rien et ne change pas le focus. Il ne suit pas les positions tactiles : sur mobile, un effet de survol ne s’active pas au doigt. Les appuis tactiles restent observables par le canal de contact.
- Ce que vous faites :
- gardez l'effet décoratif (
aria-hidden), ou donnez-lui un équivalent au clavier, par exemple au focus ; - un contenu qui apparaît au survol et en cache un autre se ferme avec Échap (WCAG 1.4.13), comme dans l'aperçu au survol ;
- avec le mouvement réduit, ce qui suivait le pointeur reste immobile et l'effet passe par un fondu (le halo des exemples reste centré, l'aperçu reste au milieu de la liste) ;
- un élément déplacé par le pointeur revient au repos au focus clavier et y reste, pour que l'anneau de focus ne bouge pas ;
- pour un effet qui déplace un élément, ajoutez la condition
(hover: hover) and (pointer: fine), comme Magnetic : PointerTracker écarte le toucher, pas les écrans sans survol ; - masquez en
forced-colorsles dégradés et les halos, qui n'y ont pas de sens (forced-colors:hidden, comme dans les exemples).
- gardez l'effet décoratif (
Performances
- Deux canaux, des écouteurs partagés. Les pressions, relâchements, annulations, sorties, pertes de focus et onglets masqués ont un seul jeu d’écouteurs passifs pour les deux canaux. Ils restent tant qu’au moins un canal est abonné.
pointermove, le défilement, le redimensionnement et le suivi de vitesse existent uniquement avec un abonné aux coordonnées. Le dernier désabonnement aux coordonnées oublie la présence et la vitesse, mais conserve les identifiants pressés et le type d’appareil si des abonnés aux contacts restent. Le dernier désabonnement des deux canaux efface aussi les contacts et l’appareil. - Le canal de contact seul ne fait aucun travail au mouvement : il ne lit pas les coordonnées et ne demande aucune frame, même pendant un appui. Les relâchements sont notifiés même si leur appui a précédé l’abonnement ; un appui commencé avant le premier abonnement est inconnu.
- La vitesse est calculée une fois par frame, dans la phase
preparede FrameLoop, avant que les membres la lisent. Le suivi s'endort dès qu'elle est nulle. - Sans pointeur, rien ne se réveille : le défilement et le redimensionnement ne notifient que si un pointeur survole la page. Sur un écran tactile, un effet abonné ne coûte rien.
- Les écouteurs sont appelés à chaque événement : gardez-les légers (un réveil). Avec beaucoup d'éléments hors écran, gardez l'abonnement mais sortez de la frame sans mesurer tant qu'un
IntersectionObserverles signale hors de vue : couper l'écoute du dernier abonné ferait oublier la présence du pointeur. - Poids : moins de 1 ko compressé, en plus de FrameLoop.
Problèmes fréquents
- L'effet reste allumé après
active: false. Plus rien ne réveille le membre : réveillez-le une dernière fois et retirez l'effet danswrite(règle 6). - L'effet est décalé après un changement de mise en page (un accordéon qui s'ouvre, une police qui se charge). Sans défilement ni redimensionnement, rien ne notifie. Observez le conteneur dont la taille change (ou
document.body) avec unResizeObserverqui appellewake(): un élément déplacé sans être redimensionné ne déclenche pas le sien. - L'effet est décalé dans un conteneur mis à l'échelle.
getBoundingClientRectinclut la mise à l'échelle des ancêtres, alors que les variables CSS s'appliquent dans le repère de l'élément : divisez par l'échelle (box.width / element.offsetWidth).
Notes et limites
- Un état par document. Une iframe a son propre suivi, en coordonnées de sa propre fenêtre.
- Un seul dossier
components/o-ui/pointer-tracker/. Deux copies du fichier feraient deux jeux d'écouteurs et deux états qui s'ignorent. - Ne déplacez pas un dossier d'O-UI seul. PointerTracker importe
../frame-loop/frame-loop-core, et les effets l'importent par../pointer-tracker/pointer-tracker-core. - La position du toucher n’est jamais suivie. Un stylet compte, qu'il survole l'écran ou le touche. Pour un effet au doigt, écoutez les événements
pointerdownetpointermovede l'élément lui-même. - Navigateurs : tous les navigateurs actuels (Pointer Events).