InfiniteWheel
BêtaComposantUne roue d'icônes horizontale ou verticale, en boucle continue, avec relief progressif, glissement, inertie et centrage doux.
- Frameworks
- ReactVueVanilla
- Technologies
- CSS
- Déclenché par
- PointeurGeste
- Mis à jour le
- Construit sur
- FrameLoopPointerTracker
InfiniteWheel présente des actions sur une surface courbe : un disque rond au premier plan et des voisins progressivement inclinés et ombrés. Le glissement, les flèches et le survol optionnel parcourent les éléments ; seul un clic, Entrée ou Espace déclenche leur action. Utilisez-la pour une navigation à icônes ou pour transformer des pas signés en une valeur numérique.
Installation
Installez l'élément et ses dépendances en une commande :
pnpm dlx shadcn@latest add https://o-ui.hd987.dev/r/infinite-wheel.jsonUtilisation
"use client";
import { useState } from "react";
import { InfiniteWheel, InfiniteWheelItem } from "@/components/o-ui/infinite-wheel/infinite-wheel";
export function Tools() {
const [opened, setOpened] = useState("Home");
return (
<div className="space-y-4">
<InfiniteWheel aria-label="Workspace views">
<InfiniteWheelItem onClick={() => setOpened("Home")} aria-label="Home">⌂</InfiniteWheelItem>
<InfiniteWheelItem onClick={() => setOpened("Search")} aria-label="Search">⌕</InfiniteWheelItem>
<InfiniteWheelItem onClick={() => setOpened("Library")} aria-label="Library">▤</InfiniteWheelItem>
<InfiniteWheelItem onClick={() => setOpened("Messages")} aria-label="Messages">✉</InfiniteWheelItem>
</InfiniteWheel>
<p aria-live="polite">Opened {opened}</p>
</div>
);
}- Placez les boutons directement dans la roue. Le cœur reconnaît
data-infinite-wheel-itemet observe les ajouts, suppressions et réordonnancements. - Donnez un nom à chaque action sur son bouton.
aria-labelsur la roue nomme le groupe. - Utilisez
orientation="vertical"pour une colonne. La version horizontale utilisetouch-action: pan-y pinch-zoomet laisse défiler la page verticalement. La verticale utilisepan-x pinch-zoom: les glissements verticaux commencés dans ce contrôle compact pilotent la roue ; le défilement horizontal natif et le zoom restent disponibles. - La taille de chaque cible native reste identique ; seule sa couche visuelle se réduit et s’incline. Les adaptateurs fournissent cette couche dès le rendu serveur. En vanilla, le cœur l’ajoute autour du contenu original et la retire à la destruction, sans copie des enfants ni de leurs identifiants.
Exemples
Parcourir un workspace
Le survol des bords facilite le parcours sur ordinateur ; le clic ou Entrée ouvre la vue et met à jour son aperçu. arc={1} dessine la trajectoire circulaire complète lorsque l’espace disponible le permet. La destination active reste distincte de l’icône actuellement au centre.
Choisir un outil de studio
Les glyphes et leurs libellés identifient les outils. aria-pressed indique l’outil réellement choisi ; les flèches déplacent le focus sans modifier cet état applicatif.
Régler une valeur en continu
Cette molette verticale contient seulement sept repères réutilisables. Chaque delta signé ajoute 0,1 EV à la valeur, même lorsque l’index revient au début ; les repères sont des positions dans la boucle, pas des valeurs d’exposition. Haut/Bas et les boutons −/+ offrent une alternative au geste, sans rôle spinbutton incomplet.
Personnalisation
curvature règle l’angle maximal de la surface, de 0 à 70 degrés, avec 55 degrés par défaut. À zéro, le relief devient plat ; minScale et minOpacity continuent de régler la mise en retrait des voisins. Le disque central utilise foreground, son contenu passe progressivement en contraste inversé et les voisins héritent de foreground, sans fond rectangulaire. Le groupe ne dessine ni rail ni bordure : placez-le sur une surface calme du thème.
arc règle la trajectoire des centres, de −1 à 1, avec 0 par défaut : les centres restent alignés en ligne ou en colonne, tandis que les icônes latérales s’inclinent, reculent et s’ombrent. À 1, quatre originaux ou plus suivent un arc de cercle, et −1 inverse la courbe. Les valeurs intermédiaires adoucissent sa flèche. Le signe positif abaisse les voisins en horizontal et les décale vers la droite en vertical ; le bouton au premier plan reste le sommet de la courbe.
depth règle le recul visuel indépendamment de cette trajectoire, de 0 à 1,5, avec 0,6 par défaut. Le recul au bord vaut itemSize × depth × curvature / 90 pixels. Pour accentuer le cercle, utilisez arc=1 ; pour garder le cercle sans recul, combinez arc=1 et depth=0. À curvature=0, la trajectoire et le recul deviennent plats, tandis que l’échelle et l’ombrage restent disponibles.
perspective règle la distance de vue, en pixels CSS de 160 à 1600, avec 448 px par défaut. Une distance courte accentue la perspective ; une distance longue l’adoucit. Ce réglage ne déplace pas les centres et reste indépendant de arc, de depth et de minScale. Pour de grandes cellules, augmentez la distance pour conserver une perspective mesurée.
shadowOpacity règle uniquement l’ombre douce du disque de premier plan, de 0 à 0,6, avec 0,12 par défaut. À zéro, elle disparaît. Sa couleur vient de foreground : elle peut devenir une légère lueur dans un thème sombre. Vous pouvez définir --infinite-wheel-shadow-color: var(--primary) sur le groupe pour choisir un autre jeton. Le rayon reste fixe et discret ; les paramètres d’ombre ne sont pas animés pendant la rotation.
minScale règle séparément l’échelle du contenu des voisins ; minOpacity règle leur ombrage progressif. Pour des libellés, conservez au moins minOpacity=0.75, comme dans la palette d’outils.
La dimension transversale réserve la flèche complète et la place des boutons et de leur focus dès le rendu serveur. Elle reste réservée lorsque la roue horizontale est rétrécie : la courbe se recentre dans cet espace sans changer la hauteur à chaque mouvement. Si un parent limite la largeur d’une roue verticale, l’arc s’atténue pour conserver les cibles natives ; prévoyez au moins itemSize + 12 pixels de largeur pour le bouton et son focus.
Trois recettes visuelles, utilisables dans les deux orientations :
| Style | Réglages | Résultat |
|---|---|---|
| Aligné avec relief | Aucun réglage : <InfiniteWheel> | Centres alignés, voisins inclinés et en retrait, ombrage progressif et disque net. |
| Arc circulaire | arc={1} | Trajectoire circulaire complète si la place le permet ; Navigation et Dial illustrent cette option. |
| Doux, presque plat | curvature={15} depth={0.15} perspective={1200} minScale={0.95} minOpacity={0.8} shadowOpacity={0.04} | Perspective légère et voisins plus présents, sans modifier les cibles natives. |
Pour une palette tactile, partez des réglages par défaut et réduisez visibleCount si la place manque. Une forte courbure comprime les cellules périphériques : augmentez gap plutôt que de serrer les cibles près du bord.
Les sept icônes du playground sont visibles sur une largeur suffisante. Sur un écran étroit, les éléments périphériques sortent du cadre ; le centre, le geste et le clavier restent utilisables. visibleCount règle la largeur souhaitée, et non le nombre d’actions originales créées.
Intégration
initialIndex choisit le départ. Il n'y a pas de prop d'index contrôlé : utilisez goTo(index) pour choisir une destination ou moveBy(steps) pour avancer de plusieurs tours. En React, ref pointe vers le DOM et apiRef vers l'instance. En Vue, la ref du composant expose el, goTo, moveBy, refresh et getIndex.
onStep({delta, index, source}) transmet les pas signés réellement parcourus, y compris aux passages de boucle. Il permet d'écrire setValue(value => value + delta). onIndexChange({index, source}) indique une destination choisie ou la sélection atteinte au repos. Si une commande annoncée ou un parcours libre déjà rendu est interrompu par masquage, sortie d’écran, changement d’orientation ou de direction, ou désactivation, cet événement corrige la destination avec l’index réellement arrêté lorsqu’il diffère. Aucun pas fictif ne rejoint la cible abandonnée. onSettle signale une fin normale de commande, même déjà centrée (Tab ou clic), et non son interruption. Vue émet step, indexChange et settle. index est l’index original normalisé ; source vaut drag, hover, keyboard, click ou programmatic. Aucun pas n'est émis à l'initialisation ni lors d'un changement d'enfants.
Pour une molette bornée, bornez votre valeur applicative dans le callback, sans renvoyer sa valeur à initialIndex et sans recréer la roue. Conservez des clés stables pour les repères : leur identité ne dépend pas de la valeur. delta peut dépasser un pas ; ajoutez-le intégralement au lieu de comparer deux index normalisés.
En React, une commande impérative peut rester aussi courte que ceci :
"use client";
import { useRef } from "react";
import { InfiniteWheel, InfiniteWheelItem } from "@/components/o-ui/infinite-wheel/infinite-wheel";
import type { InfiniteWheelInstance } from "@/components/o-ui/infinite-wheel/infinite-wheel-core";
export function BrowseViews() {
const wheel = useRef<InfiniteWheelInstance>(null);
return (
<div className="space-y-4">
<InfiniteWheel apiRef={wheel} aria-label="Workspace views">
<InfiniteWheelItem aria-label="Home">⌂</InfiniteWheelItem>
<InfiniteWheelItem aria-label="Search">⌕</InfiniteWheelItem>
<InfiniteWheelItem aria-label="Library">▤</InfiniteWheelItem>
<InfiniteWheelItem aria-label="Messages">✉</InfiniteWheelItem>
</InfiniteWheel>
<button type="button" onClick={() => wheel.current?.moveBy(1)}>Next</button>
</div>
);
}Le clic ou tap d'un voisin le centre doucement par le chemin le plus court et active son action une fois. Un tap qui interrompt un élan inertiel arrête d'abord la roue sans activer le bouton mobile. Pendant un centrage doux, un nouveau clic peut choisir et activer une autre action normalement. Un appui sans déplacement dans un espace vide interrompt la commande ; au relâchement, la roue rejoint le repère le plus proche si snap est actif, sans annoncer la fin de la commande abandonnée. aria-pressed et aria-current restent entièrement sous votre contrôle.
API
| Prop | Type | Défaut | Description |
|---|---|---|---|
orientation | "horizontal" | "vertical" | "horizontal" | Axe horizontal ou vertical ; le changement conserve l'élément choisi et annule le geste en cours. |
initialIndex | number | 0 | Index de départ entier, de −1 000 000 000 à 1 000 000 000, normalisé dans la liste. Lu uniquement à la création. |
itemSize | number | 56 | Taille des cellules en pixels CSS, de 44 à 160 ; 56 par défaut ; les cibles natives ne sont jamais réduites par le relief. |
gap | number | 16 | Espacement en pixels CSS, de 0 à 64. Deux éléments réservent au moins 24 px pour leur recyclage et leur focus. |
visibleCount | number | 7 | Nombre souhaité de cellules visibles, de 1 à 9, arrondi au nombre impair supérieur. La fenêtre rétrécit pour les listes courtes. |
friction | number | 5 | Freinage de l'élan, de 2 à 16 par seconde ; une valeur élevée arrête plus vite. |
duration | number | 0.35 | Durée du centrage doux en secondes, de 0,1 à 1. |
maxSpeed | number | 18 | Vitesse maximale d'un geste, de 1 à 30 cellules par seconde. |
snap | boolean | true | Centre un élément au repos ; désactivé, conserve un décalage libre. Les choix explicites centrent toujours l'action. |
draggable | boolean | true | Autorise le glissement tactile, souris et stylet. |
hoverEdge | boolean | false | Active l'assistance au survol, uniquement pour un pointeur fin capable de survoler. |
edgeRegion | number | 0.3 | Fraction de chaque bord sensible au survol, de 0,1 à 0,45. Le centre reste neutre. |
hoverSpeed | number | 2.5 | Vitesse maximale du survol, de 0,25 à 6 cellules par seconde, atteinte progressivement après un délai de 0,15 s. |
curvature | number | 55 | Angle maximal de la surface en degrés, de 0 à 70 ; 55 par défaut, atteint juste hors du cadre. Zéro neutralise l’inclinaison et le recul. Les deux orientations partagent ce réglage. |
arc | number | 0 | Force signée de la trajectoire circulaire, de −1 à 1 ; 0 par défaut, pour des centres alignés. Zéro aligne les centres, ±1 dessine un arc de cercle pour quatre originaux ou plus, si l’espace le permet. Le signe inverse la courbe transversale. |
depth | number | 0.6 | Facteur de recul, de 0 à 1,5 taille de cellule ; 0,6 par défaut. Le recul est multiplié par curvature / 90. Indépendant de l’arc ; zéro retire le recul. |
perspective | number | 448 | Distance de perspective en pixels CSS, de 160 à 1600 ; 448 par défaut. Une distance courte accentue la perspective, une distance longue l’adoucit. Indépendante de la trajectoire et du recul. |
shadowOpacity | number | 0.12 | Opacité de l’ombre douce du disque, de 0 à 0,6 ; 0,12 par défaut. Zéro retire l’ombre. Couleur adaptée au thème, personnalisable avec --infinite-wheel-shadow-color ; paramètres fixes pendant la rotation. |
minScale | number | 0.8 | Échelle du contenu visuel des voisins éloignés, de 0,6 à 1 ; les cibles natives restent à leur taille. |
minOpacity | number | 0.45 | Opacité des voisins éloignés, de 0,2 à 1. |
disabled | boolean | false | Arrête les flèches, le glissement, le survol et les commandes impératives. Les actions natives disponibles restent accessibles par Tab : leur focus clavier les révèle instantanément, avec les notifications de déplacement réelles. Pour tout désactiver, désactivez aussi les boutons. |
paused | boolean | false | Met uniquement le survol autonome en pause ; les gestes et les commandes directes restent disponibles. |
reducedMotion | "always" | "never" | "user" | "user" | user suit le système ; always centre immédiatement et supprime l'élan et le survol ; never ignore la préférence après un choix explicite de l'utilisateur. |
onStep | ((event: InfiniteWheelStep) => void) | undefined | Pas signés réellement parcourus, regroupés par frame, sans notification à l'initialisation ou au rafraîchissement. |
onIndexChange | ((event: InfiniteWheelSelection) => void) | undefined | Destination explicitement choisie ou sélection atteinte après un parcours libre. |
onSettle | ((event: InfiniteWheelSelection) => void) | undefined | Fin normale de commande, même sans déplacement ; aucune notification d’interruption. |
Les nombres hors plage sont bornés. Les valeurs invalides conservent le réglage courant. Retirer une prop dans React ou Vue rétablit son défaut ; update dans le cœur applique un changement partiel et undefined retire un callback.
Adaptateurs et instance
React accepte les attributs natifs du groupe, children, className, style, ref (élément DOM) et apiRef (instance). InfiniteWheelItem est un bouton natif : type="button" par défaut, attributs, onClick, disabled et ref du bouton sont transmis.
Pour consommer une touche avant la navigation de la roue en React, utilisez onKeyDownCapture et event.preventDefault() : le cœur écoute ensuite l’événement natif en phase de propagation. Un gestionnaire natif d’enfant peut également annuler la touche. Configurez normalement la roue avec ses props ; apiRef.current.update(...) est temporaire, car une mise à jour ultérieure des props réapplique leurs valeurs.
Vue accepte les mêmes options, les attributs natifs et class, avec un slot par défaut. Les callbacks du cœur deviennent les événements step, indexChange et settle ; InfiniteWheelItem transmet les attributs natifs et @click. La ref du composant expose el, goTo(index), moveBy(steps), refresh() et getIndex().
L’instance vanilla et apiRef React proposent également update(options) et destroy(). goTo normalise l’index et emprunte le chemin le plus court ; moveBy accepte des pas signés, bornés à ±1 000 000 par appel. refresh réconcilie le contenu sans pas fictif. getIndex renvoie l’index original le plus proche, ou −1 pour une liste vide. destroy est idempotent.
Les changements de dir sur la racine ou ses ancêtres, y compris <html>, actualisent automatiquement la symétrie horizontale et les flèches, même sans redimensionnement. Si la direction change par CSS (feuille de style, classe ou style d’un ancêtre), appelez refresh() pour relire les mesures et la direction. Ces changements ne modifient pas l’index logique et ne créent aucun pas de navigation.
Accessibilité
Nommez le groupe et chaque bouton ; masquez les SVG décoratifs avec aria-hidden="true". Gardez une cible native d’au moins 44 px, un focus visible et un retour d’état textuel. Portez aria-pressed ou aria-current sur l’action réellement choisie par votre application, jamais sur le seul élément centré. Pour désactiver une action, utilisez son attribut natif disabled : l’option de la roue arrête le parcours, mais laisse les actions natives disponibles.
Pour des libellés dans les cellules, augmentez minOpacity — par exemple à 0.75 comme dans la palette — et vérifiez le contraste dans vos thèmes. L’ombrage par défaut convient aux icônes ; du petit texte demande davantage de contraste.
En fonctionnement normal, un seul bouton disponible entre dans l'ordre de tabulation : le plus proche du centre physique, avec priorité au suivant en cas d'égalité. Les flèches gauche/droite suivent l'orientation horizontale et RTL ; haut/bas pilotent la verticale. Elles sautent les boutons nativement disabled ; Début/Fin rejoignent la première/dernière action disponible. Les cellules désactivées conservent leur index et comptent dans les pas signés : sauter une cellule produit deux pas, pas un. aria-disabled conserve un élément focusable et ne bloque pas son action native à votre place.
La roue utilise un groupe générique et des boutons natifs. Une barre d’outils peut fournir son propre role="toolbar" ; la molette numérique conserve le rôle de groupe. Dans la molette cyclique, Bas augmente et Haut diminue la valeur ; Début/Fin choisissent des repères de boucle, pas des bornes numériques.
Une action conditionnelle doit être retirée du DOM : rendu conditionnel React ou v-if Vue. Ne masquez pas une cellule conservée avec CSS ou v-show, car elle garde sa place physique dans la roue. Le disabled natif conserve volontairement cette place et son index.
Pendant un glissement direct et son élan, un focus déjà dans la roue suit l'action disponible la plus proche. Si les actions disponibles sont toutes hors du clip, notamment dans une fenêtre étroite, le groupe existant reçoit temporairement le focus avec un contour visible. Les flèches restent utilisables, Entrée sur le groupe n'active aucune action ; la fin normale du glissement centre une action disponible et lui rend le focus. Ce repli ne clone rien, ne modifie pas une destination impérative demandée et ne crée aucun événement de déplacement programmatique. Un focus extérieur reste extérieur.
Changer le disabled natif d'un bouton actualise son style et le point d'entrée sans modifier la sélection physique ni émettre d'événement. Si toutes les actions sont désactivées, il n'y a aucun point d'entrée clavier ; le parcours au pointeur peut toujours montrer ces cellules. Une seule action disponible ne provoque jamais un tour complet fictif avec une flèche. Si un fieldset ancêtre change d'état hors de la racine, appelez refresh() pour actualiser immédiatement le point d'entrée et le style.
Avec l'option disabled de la roue, toutes les actions natives disponibles restent dans l'ordre de tabulation. Le focus clavier révèle instantanément chacune d'elles et émet les pas/index réellement parcourus ; le focus au pointeur ne déplace pas le bouton avant son clic. Les flèches, le drag, le survol et l'API impérative restent inactifs. Réactiver la roue rétablit le point d'entrée unique sans déplacer un focus extérieur. Pour désactiver entièrement une commande numérique, désactivez également ses boutons.
Le mouvement réduit conserve le glissement direct, les changements progressifs d’échelle et d’opacité, supprime l’élan et centre immédiatement. La trajectoire redevient droite et centrée dans l’espace réservé ; l’inclinaison et le recul sont neutralisés ; le survol automatique est inactif. En contraste forcé, l’arc, le recul, l’inclinaison et la mise en retrait sont neutralisés ; les couleurs et le focus natifs restent visibles, sans disque, ombre ni filtre d’inversion. L’espace transversal réservé est conservé dans les deux modes pour éviter un changement de mise en page. Le survol s'arrête au focus clavier, sur écran tactile, hors écran et dans un onglet masqué. Une pause locale ou setAnimationsPaused de la primitive frame-loop permet d'arrêter toutes les assistances autonomes.
Si vous activez hoverEdge, reliez un interrupteur commun à setAnimationsPaused pour permettre l’arrêt de ce mouvement autonome. paused ne concerne que le survol ; les commandes directes restent utilisables. Le showroom fournit déjà cet interrupteur pour toutes les démos.
import { setAnimationsPaused } from "@/components/o-ui/frame-loop/frame-loop-core";
// In your page's shared pause control:
setAnimationsPaused(true);Sans JavaScript, les adaptateurs présentent toutes les actions grâce à la variante noscript: de Tailwind CSS 4.1 ou plus récent.
Performances
Aucune dépendance d’animation supplémentaire. Les instances partagent frame-loop et pointer-tracker, dorment au repos et suspendent l’animation hors écran ou dans un onglet masqué. Le DOM contient uniquement les boutons originaux, sans clones ; la molette numérique conserve ainsi sept repères quelle que soit sa valeur.
Mesure locale avec esbuild 0.28.2, cible ES2022 : le cœur vanilla et les primitives partagées représentent 27 885 octets minifiés, soit 10 073 octets gzip. Cette mesure exclut les adaptateurs, les runtimes React/Vue et le CSS ; le résultat dépend du bundler et du code conservé. Le composant ne virtualise pas les listes ; pour un grand catalogue, préférez une recherche ou une liste virtualisée, puis réservez la roue à un petit ensemble d’actions.
Notes et limites
Le composant est en bêta. Les cellules ont une taille identique et contiennent une action native directement marquée ; évitez d'y imbriquer plusieurs contrôles interactifs. Une liste vide est sûre et un élément seul reste statique. Avec deux éléments, la fenêtre montre principalement l'action centrale afin que le recyclage reste hors champ. Les listes plus longues réservent une courte zone entièrement transparente à la jonction de la boucle ; aucun original ne traverse visiblement le cadre lors de son recyclage.
Avec deux ou trois originaux, la fenêtre se resserre pour masquer la jonction et la trajectoire conserve une séparation régulière ; utilisez au moins quatre actions pour l’arc circulaire complet. visibleCount est une largeur souhaitée, limitée par la place disponible et le nombre d’originaux. Pour des contenus de tailles différentes, imposez itemSize et placez les libellés longs dans un aperçu externe.
Une roue verticale doit garder une hauteur finie : par exemple className="max-h-96" en React ou class="max-h-96" en Vue. Ne compensez pas le manque d’espace par un défilement interne ; réduisez plutôt visibleCount, itemSize ou gap. Si la dimension transversale est contrainte, la trajectoire s’atténue pour préserver les cibles et le focus.
Le moteur requiert les API navigateur modernes Pointer Events, ResizeObserver et IntersectionObserver. Sans initialisation cliente, le contenu natif reste disponible ; les adaptateurs assurent un rendu serveur identique au premier rendu client. Conservez les classes noscript: livrées pour retrouver toutes les actions sans JavaScript.
Les originaux retirés sont réconciliés dans la phase d’écriture partagée, même si la roue est hors écran ; leurs styles et attributs sont restaurés sans relancer son animation. Dans un onglet masqué, cette réconciliation attend le retour du document ou destroy().