Magnetic
BêtaEffetAttire un élément vers le pointeur, puis le relâche avec un retour élastique.
- Frameworks
- ReactVueVanilla
- Technologies
- CSS
- Déclenché par
- Pointeur
- Mis à jour le
- Construit sur
- FrameLoopPointerTracker
Magnetic attire un élément vers le pointeur quand celui-ci s'approche, puis le laisse revenir à sa place avec un léger rebond. Utilisez-le pour l'action principale d'une page, des boutons d'icônes ou une navigation, en version discrète. L'effet est décoratif : sur un écran tactile, au clavier ou avec le mouvement réduit, l'élément reste immobile et fonctionne comme d'habitude. Évitez-le dans une interface dense, comme un tableau de bord, où tout se mettrait à bouger.
Installation
Installez l'élément et ses dépendances en une commande :
pnpm dlx shadcn@latest add https://o-ui.hd987.dev/r/magnetic.jsonMagnetic n'a aucune dépendance ; ses adaptateurs utilisent seulement cn de @/lib/utils. Les prérequis communs à tous les composants (Tailwind CSS v4, React 19 ou Vue 3.5) sont décrits dans Installation.
Utilisation
Enveloppez l'élément interactif : un bouton, un lien, une icône cliquable.
import { Magnetic } from "@/components/o-ui/magnetic/magnetic";
export function GetStarted() {
return (
<Magnetic>
<button
type="button"
className="rounded-full bg-primary px-6 py-3 text-primary-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-hidden"
>
Get started
</button>
</Magnetic>
);
}Quelques règles font la différence :
- Enveloppez l'élément interactif lui-même. Magnetic n'ajoute ni rôle ni
tabindex, et ne change pas le nom de l'élément. Unaria-labelse pose sur le bouton, pas sur Magnetic. - Gardez le style de focus de l'élément. Magnetic ne dessine rien ; au focus clavier, il remet l'élément au repos (voir Accessibilité).
- Placez les classes de mise en page sur Magnetic. Sa racine, un
<span>eninline-block, devient l'élément de la ligne flex ou de la grille :w-full,flex-1oucol-span-2vont donc sur Magnetic, avecclassNameouclass. L'élément enveloppé peut alors prendrew-fullpour remplir l'enveloppe. Entre les deux, une enveloppe intérieure enblock(data-slot="magnetic-target") porte le déplacement. - Laissez de la place autour. L'élément se déplace d'environ
strength× la moitié de sa taille, davantage avec une grande portée : une vingtaine de pixels pour un bouton de 120 px avec les réglages par défaut. Un ancêtre proche enoverflow: hiddenle couperait.
Les éléments voisins ne se gênent pas : quand leurs zones d'attraction se chevauchent, seul l'élément le plus proche du pointeur est attiré. Vous réglez donc radius selon la sensation voulue, sans tenir compte de l'espacement. Des Magnetic imbriqués (un libellé dans un bouton, des icônes dans une carte) bougent ensemble avec celui qui les contient.
Exemples
Appel à l'action
Le cas le plus simple : seul le bouton principal est enveloppé, avec une attraction un peu plus marquée (strength={0.4}). L'action secondaire reste fixe, pour qu'un seul élément attire le regard.
Navigation
Des liens rapprochés, avec le réglage discret de la personnalisation (strength={0.2} et bounce={0.2}), qui convient à du texte. La portée est réduite à 40 px, à l'échelle de ces petits éléments. Leurs zones d'attraction se chevauchent : seul le lien le plus proche du pointeur bouge.
Boutons d'icônes
Des boutons ronds de 44 px, à l'attraction plus marquée (strength={0.5}), avec une portée réduite à 36 px. Chaque bouton porte son aria-label : on nomme l'élément interactif, pas Magnetic.
Formulaire
disabled suit l'état du formulaire : le bouton ne s'anime qu'une fois l'adresse valide. S'il est désactivé pendant un survol, il revient à sa place en douceur.
- Magnetic ne lit pas l'état de l'élément enveloppé : un
<button disabled>bouge encore. Passez aussidisabledà Magnetic. - À l'inverse,
disabledsur Magnetic coupe seulement l'effet : le bouton reste utilisable. - Ici, le bouton utilise
aria-disabledplutôt quedisabled: il reste focalisable, et le formulaire ignore l'envoi tant que l'adresse est invalide.
Profondeur
Un second Magnetic enveloppe le libellé, à l'intérieur du bouton. Le libellé suit le pointeur un peu plus loin que le bouton, ce qui donne du relief.
- Donnez au Magnetic du libellé une portée qui couvre toute la zone du bouton. Sa portée se mesure depuis le bord du libellé, alors que le bouton est attiré jusqu'à sa propre portée au-delà de son bord : il faut donc la portée du bouton, plus la plus grande marge entre le libellé et le bord du bouton.
- Ici : un bouton rond de 160 px et une portée de 80 px ; un libellé d'environ 90 × 20 px, soit une marge verticale de (160 − 20) / 2 = 70 px. D'où
radius={150}. - Pour un bouton de 180 × 48 px, de portée 60 px, avec un libellé de 100 × 20 px : marges de 40 px en largeur et 14 px en hauteur, d'où 60 + 40 = 100 px.
- Ici : un bouton rond de 160 px et une portée de 80 px ; un libellé d'environ 90 × 20 px, soit une marge verticale de (160 − 20) / 2 = 70 px. D'où
- Le focus clavier du bouton immobilise les deux.
- Deux Magnetic imbriqués bougent toujours ensemble : la règle du plus proche ne s'applique qu'entre éléments voisins.
Personnalisation
- Discrète :
strength={0.2}etbounce={0.2}, pour des liens ou un menu. - Par défaut : aucune prop, pour la plupart des boutons.
- Élastique :
strength={0.5},duration={0.5},returnDuration={0.6}etbounce={0.75}, pour un seul élément mis en avant.
Les options se répartissent en deux groupes :
- Où agit l'effet.
strengthrègle l'amplitude : la part du décalage du pointeur que suit l'élément.radiusrègle la distance à laquelle l'attraction commence, au-delà du bord. - Comment il bouge.
durationrègle la vitesse du suivi,returnDurationcelle du retour etbouncele rebond à l'arrivée.
Le mouvement reste continu quand le pointeur arrive, repart ou revient pendant un rebond. Chaque option peut changer en cours de mouvement, sans saut.
Intégration
Next.js et Nuxt
Le rendu serveur produit l'enveloppe et son contenu ; l'effet s'active à l'hydratation.
- Next.js : Magnetic est un composant client, utilisable dans un Server Component. Un fichier qui ajoute un état, une
refou un gestionnaire d'événement, comme le formulaire ci-dessus, doit lui-même commencer par"use client". Les exemples portent tous la directive, pour se copier tels quels où que ce soit. - Nuxt : aucun
<ClientOnly>n'est nécessaire. Importez Magnetic explicitement, comme dans les exemples : l'import automatique de Nuxt préfixe le nom d'un composant par son dossier.
Avec un Button ou un lien de navigation
Enveloppez le Button de shadcn/ui comme un bouton natif : il garde ses variantes et son anneau de focus. Un Link de Next.js, un NuxtLink ou un RouterLink de Vue Router s'enveloppe de la même façon. C'est l'enveloppe intérieure de Magnetic, en block, qui bouge : un lien en ligne fonctionne donc tel quel.
import Link from "next/link";
import { Magnetic } from "@/components/o-ui/magnetic/magnetic";
import { Button } from "@/components/ui/button";
export function Actions() {
return (
<div className="flex items-center gap-4">
<Magnetic strength={0.4}>
<Button size="lg">Get started</Button>
</Magnetic>
<Magnetic strength={0.2} bounce={0.2} radius={40}>
<Link
href="/pricing"
className="rounded-md px-3 py-2 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-hidden"
>
Pricing
</Link>
</Magnetic>
</div>
);
}Accéder à l'élément
La ref donne la racine de Magnetic, la zone de survol qui ne bouge pas, et non l'élément enveloppé.
const magnetic = useRef<HTMLSpanElement>(null);
// magnetic.current is the root element.
<Magnetic ref={magnetic}>…</Magnetic>;Magnetic n'a pas d'API impérative : pilotez-le par ses props, comme disabled dans le formulaire.
Utiliser sans enveloppe
Pour rendre magnétique un élément de votre propre composant, sans le <span> que pose Magnetic, utilisez le hook useMagnetic en React, ou le composable du même nom en Vue. L'élément que vous passez est la zone de survol et ne bouge jamais ; c'est son premier enfant qui se déplace, ou l'élément target.
"use client";
import { type ReactNode, useRef } from "react";
import { useMagnetic } from "@/components/o-ui/magnetic/magnetic";
export function IconLink({ href, label, children }: { href: string; label: string; children: ReactNode }) {
const linkRef = useRef<HTMLAnchorElement>(null);
const iconRef = useRef<HTMLSpanElement>(null);
// The link is the hover area; only the icon moves.
useMagnetic(linkRef, { target: iconRef, strength: 0.4, radius: 40 });
return (
<a
ref={linkRef}
href={href}
aria-label={label}
className="inline-flex size-11 items-center justify-center rounded-full focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-hidden"
>
<span ref={iconRef} className="block">
{children}
</span>
</a>
);
}- Les options sont celles du composant, plus
target: une ref ou un élément en React ; un élément, unerefde template ou un getter en Vue. Elles s'appliquent en direct, et une option retirée revient à sa valeur par défaut. - L'élément déplacé doit être en
blockouinline-block: une transformation n'a pas d'effet sur un élément en ligne. - Le hook et le composable ne renvoient rien. Le comportement est celui du composant : focus clavier, mouvement réduit, écrans tactiles.
API
| Prop | Type | Défaut | Description |
|---|---|---|---|
strength | number | 0.35 | Part du décalage du pointeur que suit l'élément, de 0 à 1. À 1, son centre rejoint le pointeur. Entre 0,3 et 0,5 pour un bouton, autour de 0,2 pour un lien. |
radius | number | 60 | Portée de l'attraction au-delà du bord de l'élément, en pixels CSS, de 0 à 1000. L'attraction s'estompe en douceur jusqu'à cette distance. Réduisez-la pour un petit élément (36 à 40 px pour un lien ou une icône), augmentez-la pour un grand bouton. À 0, seul le survol de l'élément l'attire. |
duration | number | 0.35 | Temps que met l'élément pour rejoindre la position où le pointeur l'attire, en secondes, de 0,05 à 2. Plus court, il colle au pointeur ; plus long, il suit avec douceur. Il ne dépasse jamais cette position. |
returnDuration | number | 0.4 | Temps du retour au repos une fois l'élément relâché, en secondes, de 0,05 à 2. Avec du rebond, l'élément oscille encore un peu autour de sa place. |
bounce | number | 0.5 | Rebond du retour, de 0 à 0,9. À 0, l'élément revient sans dépasser sa place ; vers 0,9, il oscille franchement. Un retour long rebondit moins : quels que soient les réglages, l'élément est à sa place moins de 4 secondes après le départ du pointeur. |
disabled | boolean | false | Booléen : coupe l'effet sans démonter le composant ni désactiver l'élément enveloppé. Un élément déplacé revient à sa place avec le retour habituel. Pour un bouton désactivé, passez-le aussi à Magnetic. |
reducedMotion | ReducedMotion | "user" | `"user"` suit le réglage du système, `"always"` coupe l'effet, `"never"` le garde même si la personne a demandé moins d'animations : à réserver au cas où elle les a réactivées dans les réglages de votre site. |
Les nombres hors plage sont ramenés dans la plage. Une valeur invalide (NaN, mauvais type, valeur inconnue) garde la valeur courante, ou la valeur par défaut à la création. Le playground se limite aux valeurs utiles ; les props acceptent toute la plage ci-dessus.
Les adaptateurs transmettent les attributs natifs à la racine, un <span> en inline-block, ainsi que className en React et class en Vue. En React, le type MagneticProps décrit toutes les props, pour écrire un composant qui enveloppe Magnetic. Le cœur ajoute l'option target, l'élément à déplacer (voir l'onglet Vanilla) ; le hook et le composable useMagnetic aussi (voir « Utiliser sans enveloppe »).
Accessibilité
Ce que fait Magnetic
- Magnetic est purement visuel : le rôle, le nom et le comportement de l'élément enveloppé ne changent pas.
- Au focus clavier de l'élément, ou d'un élément qu'il contient, l'élément revient aussitôt au repos, sans rebond, et y reste tant que le focus est là. L'anneau de focus ne bouge donc jamais. Un libellé imbriqué dans le bouton focalisé s'immobilise aussi.
- Un clic à la souris ne l'immobilise pas, mais une touche pressée ensuite, qui rend le focus visible, le fait. Un conteneur focalisé, comme un
<main tabindex="-1">après un lien d'évitement, ne fige pas les Magnetic qu'il contient. - Seuls une souris ou un pavé tactile déclenchent l'effet. Sur un écran tactile, rien ne bouge et rien n'est bloqué ; sur un appareil hybride, un toucher relâche l'élément.
- Le mouvement naît uniquement du pointeur : une fois le pointeur parti, l'élément est à sa place en moins de 4 secondes, quels que soient les réglages. Il ne demande donc aucun contrôle de pause.
- Sous un pointeur immobile, l'élément reste sous lui (
strengthva au plus jusqu'à 1) : le clic tombe sur l'élément.
Ce que vous devez faire
- Nommer l'élément interactif enveloppé, pas Magnetic.
- Lui garder un focus visible, par exemple
focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-hidden. - Lui garder son état de survol habituel : le mouvement s'y ajoute, il ne le remplace pas.
Mouvement réduit. Quand la personne demande moins d'animations, l'effet est coupé et l'élément reste à sa place. Un changement du réglage système s'applique en direct : un élément déplacé revient alors à sa place sans animation. La prop reducedMotion permet de forcer ce comportement ou de l'ignorer.
Contraste élevé. Magnetic ne dessine rien. Préférez focus-visible:outline-hidden à outline-none sur l'élément : le mode forced-colors rend alors son contour visible.
Performances
- Le cœur et ses fichiers annexes pèsent environ 2,6 kB minifiés et compressés (5,6 kB minifiés), sans dépendance. Avec FrameLoop et PointerTracker, partagés avec les autres éléments d'O-UI, l'ensemble pèse 3,7 kB.
- L'effet ne change que la transformation de l'élément : le navigateur la confie au GPU, sans recalcul de mise en page ni repeinture.
will-changen'est posé que pendant le mouvement : un élément immobile sous le pointeur n'en garde pas. - Toutes les instances de la page partagent une seule boucle et un seul jeu d'écouteurs, ceux de FrameLoop et de PointerTracker, avec les autres éléments d'O-UI. À chaque image, tous mesurent, puis tous écrivent : une seule mise en page au plus.
- Rien ne tourne au repos : la boucle s'arrête dès que plus rien ne bouge. Une instance éloignée de l'écran n'écoute ni ne mesure rien.
- Une grille entière reste légère. Sur un Apple M5, avec Chrome, 60 boutons visibles coûtent environ 0,3 ms par image pendant que le pointeur bouge, et 200 boutons environ 0,7 ms, sur un budget de 16 ms.
Problèmes fréquents
- L'élément ne bouge pas. L'écran est tactile, le système demande de réduire les animations,
disabledest actif, ou l'élément a le focus clavier (l'effet reprend quand le focus part). En vanilla, vérifiez aussi que l'élément déplacé est enblockouinline-block, et qu'il est bien un descendant de la zone. - L'élément est coupé pendant son déplacement. Un ancêtre proche a
overflow: hidden. Laissez de la marge autour, ou placez cette règle plus haut. - Le bouton ne prend pas toute la largeur. La racine de Magnetic est en
inline-block. Mettezw-full(ouflex-1) sur Magnetic, puisw-fullsur le bouton. - Un bouton désactivé bouge encore. Magnetic ne lit pas l'attribut
disableddu bouton : passez-lui aussidisabled. - De deux éléments voisins, un seul bouge. C'est voulu : seul le plus proche du pointeur est attiré. Pour que deux éléments bougent ensemble, imbriquez-les, comme dans l'exemple « Profondeur ».
- Le libellé cesse de suivre alors que le bouton bouge encore. Sa portée est trop courte : elle doit couvrir toute la zone du bouton (voir « Profondeur »).
- En vanilla, l'élément perd sa transformation CSS pendant le mouvement. Magnetic écrit
transformen ligne. Une transformation en ligne est conservée, mais une transformation venue d'une feuille de style est remplacée. Déplacez-la sur un élément enfant, ou utilisez les propriétéstranslate,rotateetscale, que Magnetic ne touche pas (ce sont celles des classestranslate-*,rotate-*etscale-*de Tailwind v4).
Notes et limites
- Magnetic demande
IntersectionObserveretmatchMedia, disponibles dans tous les navigateurs récents. Sans eux, l'effet reste coupé et l'élément fonctionne normalement. - Si la page se réorganise sans défilement ni redimensionnement (un accordéon qui s'ouvre, un contenu chargé au-dessus) alors que le pointeur est immobile, l'élément garde son décalage jusqu'au prochain mouvement du pointeur.
- La racine de Magnetic est un
<span>, qui ne peut contenir en HTML que du contenu « en ligne » : boutons, liens, images, texte, quel que soit leurdisplay. Une carte en<div>y donnerait du HTML invalide. Pour une carte magnétique, appliquez le cœur à votre propre<div>(onglet Vanilla). - Un ancêtre mis à l'échelle (
scale) est pris en compte. Un ancêtre tourné (rotate) ne l'est pas : l'élément se déplace alors dans une direction tournée. - Pendant un mouvement très rapide du pointeur, l'élément le suit avec un léger retard : un clic donné à cet instant, près du bord, peut tomber à côté.