Marquee
BêtaComposantFait défiler en boucle des logos, des mots ou des cartes, à vitesse constante et sans couture visible.
- Frameworks
- ReactVueVanilla
- Technologies
- CSS
- Déclenché par
- DéfilementContinu
- Mis à jour le
- Construit sur
- FrameLoopPointerTracker
Marquee fait défiler son contenu en boucle : chaque enfant est un élément, et des copies remplissent le bandeau pour que la boucle n'ait jamais de trou. Utilisez-le pour des logos de partenaires, une barre d'annonce, un mur d'intégrations ou des colonnes de témoignages. Pour un contenu que l'on doit parcourir à son rythme, comme une galerie de produits, préférez un carrousel que l'on fait avancer soi-même.
Installation
Installez l'élément et ses dépendances en une commande :
pnpm dlx shadcn@latest add https://o-ui.hd987.dev/r/marquee.jsonMarquee n'a aucune dépendance. Il installe avec lui les primitives FrameLoop, la boucle d'animation partagée par les animations d'O-UI, et PointerTracker, qui suit le pointeur pour toute la page. Les prérequis communs à tous les composants (Tailwind CSS v4, React 19 ou Vue 3.5) sont décrits dans Installation.
Utilisation
Placez vos éléments dans Marquee : il les fait défiler vers la gauche, à 40 pixels par seconde, avec un fondu aux deux bords.
import { Marquee } from "@/components/o-ui/marquee/marquee";
export function Partners() {
return (
<Marquee gap={56}>
<img src="/logos/velnora.svg" alt="Velnora" width={120} height={32} />
<img src="/logos/orveth.svg" alt="Orveth" width={112} height={32} />
<img src="/logos/tavren.svg" alt="Tavren" width={128} height={32} />
<img src="/logos/kelvoro.svg" alt="Kelvoro" width={116} height={32} />
</Marquee>
);
}Quelques règles font la différence :
- Chaque enfant direct est un élément.
gaples sépare, et le même espace sépare la fin d'une copie du début de la suivante : la boucle n'a pas de couture. Pour un séparateur (un point, un trait), placez-le dans chaque élément, après son contenu. - En vertical, donnez une hauteur au bandeau (
h-96, ou la ligne d'une grille). Sans hauteur, il prend celle d'une copie de son contenu. - Le fondu masque aussi le fond et la bordure de Marquee. Pour un bandeau de couleur pleine, mettez le fond sur un parent, ou passez
fade={0}. - Laissez de la place aux ombres et aux anneaux de focus. Tout ce qui dépasse du bandeau est coupé, sur les quatre côtés : ajoutez une marge intérieure sur l'axe perpendiculaire au défilement (
py-2pour un bandeau horizontal,px-2pour un bandeau vertical). - Pour des éléments cliquables, ralentissez au survol (
hoverSpeed={0.25}, ou0pour l'arrêter) : la cible reste facile à atteindre à la souris. - Donnez aux images leurs dimensions, et gardez leur chargement par défaut. Une image en
loading="lazy"ne se charge qu'en entrant dans la partie visible du bandeau, et apparaîtrait en retard.
Exemples
Logos partenaires
Le cas le plus simple : des logos cliquables, avec le fondu par défaut et un espace plus large (gap={56}). hoverSpeed={0.25} ralentit la rangée sous la souris, pour qu'on clique sans viser une cible qui file. La marge intérieure py-3 laisse la place à l'anneau de focus de chaque lien. Au clavier, le bandeau s'arrête et amène le logo focalisé au centre.
Barre d'annonce
Un bandeau lent (speed={30}) en haut d'un site. hoverSpeed={0} l'arrête au survol, pour qu'on puisse lire le message et cliquer le lien. Le fond est posé sur le parent et le bandeau a des bords nets (fade={0}) : la couleur va ainsi d'un bord à l'autre.
Mur d'intégrations
Trois Marquee empilés, en sens alternés et chacun à sa vitesse (26, 34 et 22 px/s) : les rangées ne s'alignent jamais, et le mur paraît vivant sans être agité. Un fondu plus large (fade="15%") fond les tuiles dans la page.
Colonnes de témoignages
Des bandeaux verticaux (direction="up" et "down") dans une grille : la ligne de la grille leur donne leur hauteur. Ils ralentissent fortement au survol (hoverSpeed={0.15}) pour laisser lire une carte. Sur mobile, une seule colonne reste affichée.
Entraîné par le scroll
Faites défiler l'aperçu. Avec scrollSpeed={0.6}, une part de la vitesse du scroll s'ajoute à celle du bandeau : il accélère quand on descend, part en arrière quand on remonte vite, puis reprend son rythme quand le scroll s'arrête. Les deux bandeaux vont en sens opposés.
Personnalisation
Le rythme. Pour des logos, restez entre 30 et 50 px/s : on les reconnaît au passage. Pour de grands mots, 60 à 120 px/s donnent de l'énergie. La vitesse est en pixels par seconde : elle reste la même quel que soit l'écran ou la quantité de contenu.
Les changements de vitesse. inertia règle leur durée : démarrage, pause, survol, retour après un scroll, nouvelle vitesse ou nouveau sens. À 0, la vitesse change d'un coup, sans que le bandeau saute ; à 1.5, il prend son élan comme un tapis lourd. Changer direction sur le même axe le fait freiner, s'arrêter, puis repartir dans l'autre sens.
Le survol. hoverSpeed multiplie la vitesse sous la souris ou le stylet : 0 arrête le bandeau, 0.25 le ralentit, 1 (par défaut) l'ignore, 2 l'accélère. Il ne s'applique pas au doigt.
Le scroll. scrollMode choisit la réaction au scroll de la page :
"push"(par défaut) : le bandeau est poussé dans le sens du scroll, puis revient à son sens ;"follow": il prend le sens du scroll et le garde jusqu'au prochain changement ;"boost": tout scroll l'accélère dans son propre sens, sans jamais l'inverser.
Avec speed={0}, le bandeau ne bouge que pendant le scroll.
Les longueurs. gap et fade acceptent un nombre de pixels ou toute longueur CSS : gap="clamp(1.5rem, 5vw, 4rem)" s'adapte à la largeur de l'écran, fade={64} donne un fondu fixe. En pourcentage, fade se mesure sur la longueur visible du bandeau.
Un bandeau incliné. Une rotation posée sur un parent incline le bandeau sans gêner le défilement. Deux bandeaux croisés, en sens opposés, font un bon séparateur de sections :
<div className="-rotate-2 bg-primary py-3 text-primary-foreground">
<Marquee fade={0} speed={70} className="text-3xl font-semibold">
…
</Marquee>
</div>Intégration
Next.js et Nuxt
Le rendu serveur produit le bandeau et son contenu une seule fois, déjà en ligne avec ses espaces et son fondu : rien ne se décale au chargement. Les copies s'ajoutent à l'hydratation, avant que le bandeau ne bouge.
- Next.js : Marquee est un composant client, utilisable dans un Server Component. Les copies sont de vrais éléments : un
Linkde Next.js y fonctionne comme dans l'original. - Nuxt : aucun
<ClientOnly>n'est nécessaire. Importez Marquee explicitement, comme dans les exemples : l'import automatique de Nuxt préfixe le nom d'un composant par son dossier.
Mettre en pause toutes les animations
Le bandeau défile sans fin. Pour la WCAG 2.2.2, la page doit permettre de mettre ce mouvement en pause. La pause commune de FrameLoop, installé avec Marquee, arrête tous les bandeaux de la page, ceux créés ensuite compris, et les autres animations continues d'O-UI : un seul interrupteur suffit. Placez-le dans l'en-tête, le pied de page ou les réglages du site.
"use client";
import { useAnimationsPaused } from "@/components/o-ui/frame-loop/frame-loop";
import { setAnimationsPaused } from "@/components/o-ui/frame-loop/frame-loop-core";
export function MotionToggle() {
const paused = useAnimationsPaused();
return (
<button
type="button"
aria-pressed={paused}
onClick={() => {
setAnimationsPaused(!paused);
}}
>
Pause animations
</button>
);
}Les bandeaux freinent jusqu'à l'arrêt, puis repartent de là où ils étaient. Pour garder le choix du visiteur d'une page à l'autre, suivez la recette de FrameLoop.
Arrêter un bandeau depuis l'application
paused arrête un seul bandeau, en douceur, et le relance de là où il était : par exemple quand une fenêtre s'ouvre par-dessus.
<Marquee paused={dialogOpen}>…</Marquee>disabled immobilise le bandeau au début de son contenu, comme le mouvement réduit, sans le démonter.
Laisser le visiteur relancer le mouvement
En mouvement réduit, le bandeau reste immobile, et ce qui dépasse du bord reste caché. Si le contenu compte, proposez au visiteur de relancer le mouvement lui-même : reducedMotion="never" est alors légitime, puisque c'est son choix explicite. Placez ce contrôle près du bandeau ou dans les réglages du site.
"use client";
import { useState } from "react";
import { Marquee } from "@/components/o-ui/marquee/marquee";
export function Testimonials() {
const [play, setPlay] = useState(false);
return (
<>
<button
type="button"
aria-pressed={play}
onClick={() => {
setPlay(!play);
}}
>
Play animations
</button>
<Marquee reducedMotion={play ? "never" : "user"}>…</Marquee>
</>
);
}Pour n'afficher ce bouton qu'aux visiteurs concernés, testez window.matchMedia("(prefers-reduced-motion: reduce)") côté client, ou masquez-le avec la variante Tailwind motion-safe:hidden.
Accéder à l'élément
La ref donne la racine du bandeau. En Vue, la ref posée sur le composant expose cette racine dans el.
const band = useRef<HTMLDivElement>(null);
// band.current is the root element.
<Marquee ref={band}>…</Marquee>;API
| Prop | Type | Défaut | Description |
|---|---|---|---|
speed | number | 40 | Vitesse de croisière, en pixels par seconde, de 0 à 1000. À 0, le bandeau ne bouge qu'avec le scroll (`scrollSpeed`). |
direction | MarqueeDirection | "left" | Sens du défilement : `"left"`, `"right"`, `"up"` ou `"down"`. Sur le même axe, un changement fait freiner puis repartir le bandeau ; d'un axe à l'autre, il repart de l'arrêt, au début. |
gap | string | number | 32 | Espace entre les éléments, répété entre deux copies : un nombre de pixels CSS, de 0 à 1000, ou une longueur CSS (`"2rem"`, `"clamp(1rem, 4vw, 3rem)"`). |
fade | string | number | "10%" | Longueur du fondu à chaque bord : un nombre de pixels CSS, de 0 à 2000, ou une longueur CSS, un pourcentage étant mesuré sur la longueur visible. 0 donne des bords nets ; jamais plus de la moitié du bandeau. |
hoverSpeed | number | 1 | Facteur de vitesse sous la souris ou le stylet, de 0 à 2. 0 arrête le bandeau, 1 ignore le survol, 2 l'accélère. Sans effet au doigt. |
scrollSpeed | number | 0 | Part de la vitesse du scroll transmise au bandeau, de 0 à 2. 0 ignore le scroll ; vers 0,5, le bandeau suit nettement un scroll rapide. Le scroll est celui de la page, ou d'un ancêtre qui défile. |
scrollMode | MarqueeScrollMode | "push" | Réaction au scroll : `"push"` pousse le bandeau dans le sens du scroll (en arrière quand on remonte), `"follow"` lui fait prendre le sens du scroll jusqu'au prochain changement, `"boost"` ne fait que l'accélérer. |
inertia | number | 0.6 | Durée d'un changement de vitesse, en secondes, de 0 à 3 : démarrage, pause, survol, fin d'un scroll, nouvelle vitesse. À 0, la vitesse change d'un coup, sans saut de position. |
paused | boolean | false | Freine le bandeau jusqu'à l'arrêt, là où il est, puis le relance en douceur. Pour toutes les animations de la page, voir `setAnimationsPaused` (FrameLoop). |
disabled | boolean | false | Immobilise le bandeau au début de son contenu, comme en mouvement réduit, sans le démonter. |
reducedMotion | ReducedMotion | "user" | `"user"` suit le réglage du système, `"always"` immobilise toujours le bandeau, `"never"` ignore le réglage (à réserver à un choix explicite du visiteur). |
Un nombre hors de sa plage est ramené à la borne la plus proche. Une valeur invalide (NaN, mauvais type, valeur hors de la liste) est ignorée : l'option garde sa valeur courante. Pour gap et fade, une chaîne qui ne contient qu'un nombre (gap="32" dans un template Vue) compte en pixels ; une longueur CSS que le navigateur ne reconnaît pas est ignorée par lui.
En React, className, style et les autres attributs passent à la racine, et la ref la désigne. En Vue, class, style et les attributs passent aussi à la racine, et el est exposé.
Accessibilité
Ce que Marquee fait seul :
- Un contenu lu une seule fois. Les copies portent
aria-hidden="true": un lecteur d'écran ne lit que l'original. - Une seule tabulation par élément. Ce qui est focalisable dans les copies (liens, boutons) reçoit
tabindex="-1". Les copies restent cliquables à la souris et au doigt. - Le focus clavier arrête le bandeau. Quand un élément de l'original reçoit le focus au clavier, le bandeau freine et l'amène au centre (ou aligne son début, s'il est plus long que le bandeau), puis reste immobile tant que le focus est dedans. La page ne défile pas vers l'élément caché. Le bandeau repart quand le focus en sort. Un clic de souris ne l'arrête pas.
- Le mouvement réduit immobilise le bandeau au début de son contenu, avec le même aspect. Le focus clavier y amène directement l'élément au centre, sans glissement, et le scroll n'entraîne plus le bandeau. Un changement du réglage du système s'applique aussitôt, sans animation. Pour laisser le visiteur relancer le mouvement, voir la recette.
- Sans JavaScript, le contenu s'affiche une fois, sur une ligne : son début reste lisible.
Ce que l'intégrateur doit faire :
- Un interrupteur de pause sur la page (WCAG 2.2.2), voir Mettre en pause.
- Un texte de remplacement sur chaque logo (
alt), et un titre au-dessus du bandeau pour dire ce qu'il montre (« Trusted by product teams at »). - Ne réservez pas au bandeau une information indispensable. En mouvement réduit, ce qui dépasse du bord reste caché, sauf au focus clavier. Si le contenu compte, proposez un contrôle pour relancer le mouvement (voir la recette).
- Un anneau de focus sur chaque élément interactif, avec de la place autour (règle 4 de l'utilisation).
Performances
- Aucune dépendance. Le cœur et ses fichiers annexes pèsent environ 5,9 kB minifiés et compressés (13,9 kB minifiés). Avec FrameLoop et PointerTracker, partagés avec les autres éléments d'O-UI, l'ensemble pèse 6,9 kB.
- Aucun JavaScript par frame à vitesse constante. Le défilement est confié au compositeur du navigateur (Web Animations) : il reste fluide quand la page calcule autre chose, pendant l'hydratation par exemple. Seuls les changements de vitesse passent par la boucle d'animation partagée, le temps qu'ils durent.
- Rien hors de l'écran ni dans un onglet masqué. Le bandeau s'arrête et reprend à sa place quand il revient.
- Des copies au nombre minimal, recalculé quand la taille du bandeau ou de son contenu change. Les tailles viennent d'un
ResizeObserver, sans lecture forcée de la mise en page. - Un seul écouteur de scroll pour tous les bandeaux de la page, et seulement si l'un d'eux a un
scrollSpeed. Le survol passe par le pointeur partagé de la page, et seulement sihoverSpeedn'est pas 1. - Un calque du compositeur seulement pendant le mouvement (
will-change). À l'arrêt, le bandeau est calé sur un pixel de l'écran : le texte reste net.
Le coût tient surtout au contenu recopié : pour un contenu lourd (vidéos, grandes images), gardez peu d'éléments.
Problèmes fréquents
- Le bandeau vertical prend toute la hauteur de son contenu. Donnez-lui une hauteur (
h-96) ou placez-le dans une grille. - Le bandeau est étroit dans une rangée flex. Sa largeur ne dépend pas de ses copies : sans largeur à lui, il prend celle d'une copie. Donnez-lui
flex-1ouw-full. - Les ombres ou l'anneau de focus sont coupés. Le bandeau coupe ce qui dépasse de lui : ajoutez
py-2(horizontal) oupx-2(vertical). - Le fond du bandeau s'efface aux bords. Le fondu s'applique aussi au fond : posez-le sur un parent, ou passez
fade={0}. - Une image apparaît en retard en entrant. Retirez son
loading="lazy". - Un élément n'a pas le même état dans une copie. Chaque copie est une vraie instance de vos composants : un compteur ou un champ y a son propre état. Gardez au bandeau un contenu de présentation.
- Une
refposée sur un enfant désigne une copie, ou devientnull. Les enfants sont rendus une fois par copie : larefsuit la dernière rendue. Passez par larefdu bandeau, puisquerySelector('[data-slot="marquee-group"] …'), qui vise l'original.
Notes et limites
- Navigateurs : Chrome et Edge 120+, Safari 15.4+, Firefox 115+ (
mask-imagesans préfixe,contain: inline-size). Avant Safari 17, un bandeau sans largeur ni hauteur à lui (dans une rangée flex, ou vertical sans hauteur) ne prend pas la taille d'une copie : donnez-lui une taille. Sans Web Animations, le défilement passe par la boucle d'animation, sans autre différence. - Copies : chaque copie rend à nouveau vos enfants. Évitez les
idet lesrefsur le contenu : ils seraient dupliqués. Une vidéo jouerait autant de fois qu'il y a de copies. - Sens de lecture : un bandeau en
dir="rtl"est géré, et les sens restent physiques ("left"va toujours vers la gauche). Les modes d'écriture verticaux ne sont pas gérés. - Contenu minuscule : au-delà de 100 copies, un contenu de quelques pixels dans un très large bandeau laisserait un trou ; gardez au contenu une longueur raisonnable.