Aller au contenu
O-UI

Recherche

Trouvez un composant ou une page de documentation.

Magnetic

BêtaEffet

Attire 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
Paramètres

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.json

Magnetic 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 :

  1. 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. Un aria-label se pose sur le bouton, pas sur Magnetic.
  2. 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é).
  3. Placez les classes de mise en page sur Magnetic. Sa racine, un <span> en inline-block, devient l'élément de la ligne flex ou de la grille : w-full, flex-1 ou col-span-2 vont donc sur Magnetic, avec className ou class. L'élément enveloppé peut alors prendre w-full pour remplir l'enveloppe. Entre les deux, une enveloppe intérieure en block (data-slot="magnetic-target") porte le déplacement.
  4. 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 en overflow: hidden le 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.

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 aussi disabled à Magnetic.
  • À l'inverse, disabled sur Magnetic coupe seulement l'effet : le bouton reste utilisable.
  • Ici, le bouton utilise aria-disabled plutôt que disabled : 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.
  • 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} et bounce={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} et bounce={0.75}, pour un seul élément mis en avant.

Les options se répartissent en deux groupes :

  • Où agit l'effet. strength règle l'amplitude : la part du décalage du pointeur que suit l'élément. radius règle la distance à laquelle l'attraction commence, au-delà du bord.
  • Comment il bouge. duration règle la vitesse du suivi, returnDuration celle du retour et bounce le 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 ref ou 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, une ref de 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 block ou inline-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

PropTypeDéfautDescription
strengthnumber0.35Part 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.
radiusnumber60Porté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.
durationnumber0.35Temps 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.
returnDurationnumber0.4Temps 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.
bouncenumber0.5Rebond 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.
disabledbooleanfalseBoolé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.
reducedMotionReducedMotion"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 (strength va 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-change n'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, disabled est 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 en block ou inline-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. Mettez w-full (ou flex-1) sur Magnetic, puis w-full sur le bouton.
  • Un bouton désactivé bouge encore. Magnetic ne lit pas l'attribut disabled du bouton : passez-lui aussi disabled.
  • 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 transform en 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és translate, rotate et scale, que Magnetic ne touche pas (ce sont celles des classes translate-*, rotate-* et scale-* de Tailwind v4).

Notes et limites

  • Magnetic demande IntersectionObserver et matchMedia, 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 leur display. 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é.