ParticleText
BêtaComposantForme un texte avec un nuage de particules en volume qui réagit au pointeur.
- Frameworks
- ReactVueVanilla
- Technologies
- WebGLCanvas 2D
- Déclenché par
- PointeurContinu
- Mis à jour le
- Construit sur
- FrameLoopPointerTrackerWebGLSurface
ParticleText dessine un texte avec des milliers de particules en volume. Le nuage respire doucement, s'écarte ou tourbillonne au passage du pointeur, puis revient former les lettres. Le vrai texte HTML reste dans la page, pour les lecteurs d'écran, le référencement et en repli. Utilisez-le pour un grand titre, quelques mots tout au plus : sous 24 px environ, le texte devient difficile à lire, et il ne convient pas au texte courant.
Installation
Installez l'élément et ses dépendances en une commande :
pnpm dlx shadcn@latest add https://o-ui.hd987.dev/r/particle-text.jsonParticleText n'a aucune dépendance : il dessine avec WebGL2, disponible dans tous les navigateurs récents. Sans WebGL2, le texte HTML s'affiche à la place du nuage. Les prérequis communs à tous les composants (Tailwind CSS v4, React 19 ou Vue 3.5) sont décrits dans Installation.
Utilisation
Placez ParticleText dans un titre : le texte vient de la prop text, et le titre garde ainsi sa sémantique.
import { ParticleText } from "@/components/o-ui/particle-text/particle-text";
export function Hero() {
return (
<h1>
<ParticleText text="Make space." fontSize={120} />
</h1>
);
}ParticleText est un composant client : cet extrait fonctionne tel quel dans un Server Component de Next.js. Le contenu vient de text ; children n'est pas pris en charge. Avec un rendu serveur, voir aussi la taille au chargement.
Quelques règles font la différence :
- Le texte HTML donne sa hauteur au composant ; le parent donne sa largeur. Aucune hauteur CSS n'est nécessaire. Avec
fit(activé par défaut),fontSizeest une taille maximale : le texte rétrécit quand la largeur manque, sans jamais être agrandi. Dans un parent flex, donnez-lui la largeur explicitement (voir Taille et mise en page). - Écrivez les retours à la ligne dans
text, avec\n. Le texte ne passe jamais à la ligne de lui-même, même avecfit. - Laissez de la place autour. Les particules sortent du texte quand le pointeur les pousse : environ 100 px au-dessus et au-dessous avec les réglages par défaut. Évitez
overflow: hiddensur le composant ou ses ancêtres, et gardez de l'espace avec les éléments voisins. - Prévoyez une pause pour toute la page. Le nuage respire sans fin : un seul interrupteur suffit à le mettre en pause partout (voir Mettre en pause).
Exemples
Signature de studio
Un grand titre dans un h1, aligné à gauche, avec une respiration discrète (drift={0.5}, speed={0.3}), une répulsion douce (strength={0.35}) et peu de relief (depth={24}, parallax={0.06}).
Composition éditoriale
Un titre sur deux lignes (text={"Shape\nthe noise"}), aligné à gauche, avec un interligne serré (lineHeight={0.95}) et un dégradé lié au thème (color="var(--primary)", colorEnd="var(--muted-foreground)"). La grille place le texte courant à côté sur grand écran et empile les deux blocs sur petit écran.
Échelles et matières
Trois tailles et trois matières pour un même composant :
- FORM : un texte compact et dense, aux petites particules (
spacing={1.25},particleSize={0.8}), qui garde des bords nets ; - GLOW : un halo (
glow={0.75}), une profondeur de champ (depthOfField={0.65}) et un dégradé violet-sarcelle ; - FLOW : un tourbillon ample (
interaction="vortex",radius={100}), avec entraînement, turbulence et filaments visibles.
Chaque nuage est dans un bloc à hauteur minimale (min-h-28), centré verticalement, pour que les trois s'alignent malgré leurs tailles.
Personnalisation
Taille et mise en page
align règle l'alignement des lignes (left, center ou right) ; lineHeight et letterSpacing ajustent une composition sur plusieurs lignes.
<ParticleText
text={"Shape\nthe noise"}
fontSize={104}
align="left"
lineHeight={0.95}
letterSpacing={-2}
/>Dans un parent flex, le composant prend sinon la largeur de son texte :
- dans une ligne (
flex-row) :w-full min-w-0, ouflex-1 min-w-0à côté d'autres éléments. Sansmin-w-0, sa largeur minimale est celle du texte, etfitne le réduit qu'une fois toute la ligne dépassée ; - dans une colonne centrée (
flex-col items-center) :w-full, comme le conteneur des exemples ; - dans une grille : une colonne
minmax(0, 1fr).
Pour centrer le titre dans un bloc de hauteur fixe :
<div className="flex h-80 items-center">
<ParticleText className="w-full min-w-0" text="Make space." fontSize={120} />
</div>Réaction au pointeur
interactionchoisit la réaction :repelrepousse les particules,attractles attire,vortexles fait tourner etnonecoupe toute réaction au pointeur.strengthrègle sa force etradiussa portée, en pixels CSS. Une portée plus large déplace une plus grande part du texte.trailentraîne les particules dans le sens du mouvement du pointeur, comme une main dans l'eau. Il reste actif avecstrength={0}, pour un sillage seul.turbulenceajoute des tourbillons aux particules perturbées pendant leur retour, sans brouiller le texte au repos.parallaxincline le volume vers le pointeur ; à0, la projection reste immobile.- Le retour suit un ressort :
returnDurationen donne le rythme, en secondes, etdampingle rebond (sous1, le retour dépasse un peu sa place ; à1et au-delà, il ne la dépasse pas).
Sur un écran tactile, la réaction au pointeur et la parallaxe sont coupées ; la respiration et la formation restent. Comparez les quatre modes, avec ou sans entraînement :
Matière et rendu
- Densité.
spacingrègle l'écart entre les particules : plus petit, le nuage est plus dense.layersajoute des couches dans la profondeur.maxParticlesplafonne leur nombre : au-delà, la densité baisse uniformément, sans couper le texte. - Particules.
particleSizerègle leur diamètre etsizeVariationsa variation.glowajoute un halo doux etopacityrègle l'opacité de l'ensemble. - Volume.
depthdonne l'épaisseur du nuage ;depthOfFieldagrandit et adoucit les particules éloignées du plan du texte. Uneperspectiveplus grande aplatit le relief. - Liant.
cohesionfait suivre à chaque particule une part du mouvement de ses voisines, pour une matière plus liée.connectionOpacityetconnectionDistancerèglent les filaments entre voisines, visibles surtout quand le nuage est perturbé ; àconnectionOpacity={0}, ils disparaissent.
Les réglages de « GLOW » et « FLOW » (onglet « Code » de l'exemple Échelles et matières) donnent une version lumineuse et une version expressive. Pour une version calme : une respiration réduite, peu de halo et un retour sans rebond.
<ParticleText
text="Quiet"
drift={0.5}
speed={0.3}
strength={0.3}
trail={0.15}
turbulence={0.2}
parallax={0.05}
glow={0.15}
damping={1}
/>Couleurs et thème
Par défaut, color et colorEnd valent currentColor : le nuage prend la couleur du texte environnant et suit les changements de thème en direct. Une classe comme text-foreground suffit pour une couleur adaptée au clair et au sombre.
color et colorEnd sont le début et la fin d'un dégradé horizontal sur toute la largeur du texte ; donnez-leur la même valeur pour une couleur unie. Toute couleur CSS convient : une variable du thème (var(--primary)) s'adapte d'elle-même, une couleur fixe doit rester lisible sur vos deux thèmes, comme les teintes moyennes de l'exemple « GLOW ». Vérifiez le contraste si vous baissez opacity ou montez le halo.
<ParticleText text="Spectrum" color="var(--primary)" colorEnd="var(--muted-foreground)" />Apparition
intro règle, en secondes, la formation du texte depuis un nuage dispersé. Elle joue la première fois que le texte devient visible ; intro={0} la désactive. Un changement de texte ensuite se fait en fondu court, sans rejouer la formation. Pour la rejouer, remontez le composant avec une nouvelle key, comme le bouton « Replay » de cet exemple.
Intégration
Next.js et Nuxt
Le rendu serveur produit le texte HTML ; le nuage le remplace visuellement au chargement du script.
- Next.js : ParticleText est un composant client, utilisable dans un Server Component. Un fichier qui ajoute un état ou un gestionnaire d'événement doit commencer par
"use client". - Nuxt : aucun
<ClientOnly>n'est nécessaire. Importez ParticleText explicitement : l'import automatique de Nuxt préfixe le nom d'un composant par son dossier. - Taille au chargement. Côté client,
fitajuste la taille avant la première peinture. Avec un rendu serveur, le texte HTML apparaît d'abord àfontSize, puisfitle réduit au chargement du script. Sur un petit écran, un grandfontSizepeut donc passer sur plusieurs lignes, puis se resserrer : le contenu qui suit remonte.fontSizeest un nombre, pas une valeur responsive : pour limiter ce saut, choisissez un compromis entre ordinateur et mobile, et acceptez ce léger ajustement au chargement.
Mettre en pause toutes les animations
Le nuage respire 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 ParticleText, met en pause tous les nuages 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>
);
}- En pause, le nuage se pose sur ses lettres, toujours lisible, et ne réagit plus au pointeur ; la boucle de rendu s'arrête.
- La pause de la page l'emporte : un nuage est en pause si la page l'est ou si sa prop
pausedvauttrue.pausedsert à une seule instance, par exemple tant qu'un panneau est replié. - Le libellé reste fixe : avec
aria-pressed, un lecteur d'écran annonce « Pause animations, enfoncé ». Pour garder le choix du visiteur d'une page à l'autre, suivez la recette de FrameLoop.
Polices web
Par défaut, fontFamily vaut inherit : le nuage reprend la police du texte environnant, y compris une police chargée par next/font ou @nuxt/fonts. Vous pouvez aussi passer une famille CSS, variables comprises (fontFamily="var(--font-heading)"). Le premier dessin attend les polices de la page, une seconde au plus ; une police arrivée plus tard redessine le nuage.
Vanilla : un texte prêt dès le premier rendu
Pour que le texte ne bouge pas au démarrage, écrivez dès le HTML le texte et sa typographie, dans un élément data-slot="particle-text-label", enfant direct de l'élément : le cœur le reprend au lieu d'en créer un, et y applique la typographie de ses options. Écrivez donc dans le HTML les mêmes valeurs que dans les options, et passez toujours text : avec un label fourni, le cœur ne lit pas son texte, et dessinerait sinon le texte par défaut.
<h1 id="headline">
<span
data-slot="particle-text-label"
style="display: block; white-space: pre-wrap; overflow-wrap: break-word; font-size: 120px; font-weight: 700; letter-spacing: 0px; line-height: 1.1; text-align: center"
>Make space.</span
>
</h1>import { createParticleText } from "./particle-text-core";
const element = document.querySelector<HTMLElement>("#headline");
// The same text and typography as in the HTML above.
const particles = element
? createParticleText(element, { text: "Make space.", fontSize: 120 })
: undefined;getParticleTextLabelStyle(options), exporté par le cœur, donne ces styles pour vos options (un objet à appliquer avecObject.assign(label.style, …)).- Le cœur ne réécrit jamais le texte d'un label fourni : si vous changez
text, changez aussi son contenu. - Sur un écran étroit,
fitpeut encore réduire le texte au chargement du script (voir la taille au chargement).
Dans votre propre composant Vue
Si vous enveloppez ParticleText dans un composant qui déclare ses props avec defineProps<ParticleTextOptions>(), Vue donne false aux booléens absents : fit serait alors coupé. Déclarez-les avec withDefaults(defineProps<ParticleTextOptions>(), PARTICLE_TEXT_DEFAULTS), comme le playground.
Accéder à l'élément
La ref donne la racine de ParticleText, un <span> en bloc. En Vue, la ref posée sur le composant expose { el } :
<script setup lang="ts">
import { useTemplateRef } from "vue";
import { ParticleText } from "@/components/o-ui/particle-text";
const particles = useTemplateRef("particles");
// particles.value?.el is the root element.
</script>
<template>
<ParticleText ref="particles" text="Make space." />
</template>ParticleText n'a pas d'API impérative : pilotez-le par ses props.
L'attribut data-state de la racine indique son état, pour le style ou le débogage :
| Valeur | Signification |
|---|---|
loading | le nuage se prépare (polices, premier échantillonnage) |
active | le nuage bouge |
idle | le nuage est au repos (rien ne bouge, la boucle est arrêtée) |
paused | en pause (prop paused ou pause globale) |
reduced | mouvement réduit : nuage immobile |
disabled | prop disabled : le texte HTML s'affiche |
fallback | WebGL2 indisponible, contexte perdu ou mode contraste élevé : le texte HTML s'affiche |
API
| Prop | Type | Défaut | Description |
|---|---|---|---|
text | string | "O-UI" | Texte du nuage, 240 caractères au plus. Les retours à la ligne (`\n`) sont conservés ; il n'y en a pas d'automatique. |
fontSize | number | 160 | Taille du texte, en pixels CSS, de 12 à 512. Avec `fit`, c'est une taille maximale. |
fontFamily | string | "inherit" | Famille de police CSS, variables comprises ; `inherit` reprend la police environnante. |
fontWeight | number | 700 | Graisse de la police, de 100 à 900. |
letterSpacing | number | 0 | Approche entre les lettres, en pixels CSS, de −8 à 32. Négative, elle resserre un grand titre. |
lineHeight | number | 1.1 | Interligne, relatif à la taille du texte, de 0,8 à 2. Sous 1, les lignes se rapprochent. |
align | ParticleTextAlign | "center" | Alignement des lignes : `left`, `center` ou `right`. |
fit | boolean | true | Booléen, vrai par défaut : réduit le texte pour qu'il tienne dans la largeur disponible, sans jamais l'agrandir. |
spacing | number | 2.5 | Écart entre les particules, en pixels CSS, de 1 à 12. Plus petit, le nuage est plus dense et plus lourd à calculer. |
layers | number | 3 | Nombre de couches dans la profondeur, entier de 1 à 6. Plus de couches donnent plus de volume. |
depth | number | 36 | Épaisseur du nuage, en pixels CSS, de 0 (plat) à 160. |
maxParticles | number | 24000 | Nombre maximal de particules, entier de 1 000 à 60 000. Au-delà, la densité baisse uniformément. Baissez-le sur un matériel modeste. |
particleSize | number | 1.6 | Diamètre des particules, en pixels CSS, de 0,5 à 8. Petit, il garde des bords nets ; grand, il donne une matière plus douce. |
sizeVariation | number | 0.5 | Variation aléatoire du diamètre, de 0 (toutes égales) à 1 (très variées). |
glow | number | 0.35 | Halo doux autour des particules, de 0 à 1. Plus marqué sur un fond sombre. |
opacity | number | 0.9 | Opacité de l'ensemble du nuage, de 0 à 1. |
color | string | "currentColor" | Couleur CSS du début du dégradé horizontal ; `currentColor` suit la couleur du texte environnant. |
colorEnd | string | "currentColor" | Couleur CSS de la fin du dégradé ; la même valeur que `color` donne une couleur unie. |
depthOfField | number | 0.4 | Profondeur de champ, de 0 à 1 : les particules éloignées du plan du texte deviennent plus grandes et plus douces. |
cohesion | number | 0.2 | Part du mouvement de ses voisines que suit chaque particule, de 0 à 0,8. Plus haute, la matière bouge d'un bloc. |
connectionOpacity | number | 0.15 | Opacité des filaments entre particules voisines, de 0 (aucun) à 1. Ils se voient surtout quand le nuage est perturbé. |
connectionDistance | number | 20 | Longueur au-delà de laquelle un filament disparaît, en pixels CSS, de 2 à 80. |
drift | number | 1.5 | Amplitude de la respiration, en pixels CSS, de 0 (immobile) à 30. |
speed | number | 0.5 | Rythme de la respiration, de 0 (immobile) à 3 ; 1 est deux fois plus rapide que le défaut (0,5). |
turbulence | number | 0.6 | Tourbillons des particules perturbées avant leur retour, de 0 à 2. Le texte au repos n'en a pas. |
intro | number | 1.6 | Durée de la formation du texte à sa première apparition, en secondes, de 0 (aucune) à 4. Un fondu la remplace avec le mouvement réduit. |
interaction | ParticleTextInteraction | "repel" | Réaction au pointeur : `repel` repousse, `attract` attire, `vortex` fait tourner, `none` coupe toute réaction, entraînement compris. |
strength | number | 0.7 | Force de la réaction au pointeur, de 0 à 2. Au-delà de 1, le texte se défait nettement sous le pointeur. |
radius | number | 100 | Portée du pointeur, en pixels CSS, de 0 à 500. Plus large, elle déplace une plus grande part du texte. |
trail | number | 0.6 | Entraînement des particules dans le sens du pointeur, de 0 à 2, comme une main dans l'eau. À 0, aucun sillage. |
parallax | number | 0.15 | Inclinaison du volume vers le pointeur, de 0 à 1. À 0, la projection reste immobile. |
returnDuration | number | 0.9 | Rythme du retour des particules à leur place, en secondes, de 0,15 à 3. C'est une durée caractéristique, pas un arrêt chronométré. |
damping | number | 0.7 | Amortissement du retour, de 0,3 (rebond) à 1,5. À 1 et au-delà, les particules ne dépassent pas leur place. |
perspective | number | 900 | Distance de la caméra, en pixels CSS, de 300 à 2000. Plus grande, elle aplatit le relief. |
pixelRatio | number | 1.5 | Densité de pixels maximale du rendu, de 0,5 à 2, limitée aussi à celle de l'écran. Baissez-la sur un matériel modeste. |
paused | boolean | false | Booléen : le nuage se pose sur ses lettres et ne réagit plus ; la boucle de rendu s'arrête. Pour toute la page, voir `setAnimationsPaused` de FrameLoop. |
disabled | boolean | false | Booléen : affiche le texte HTML à la place du nuage et libère le GPU. |
reducedMotion | ReducedMotion | "user" | `"user"` suit le réglage du système, `"always"` garde un nuage immobile, `"never"` garde le mouvement : à réserver au cas où la personne a réactivé les animations dans les réglages de votre site. |
Les nombres hors plage sont ramenés dans la plage, et un texte de plus de 240 caractères est tronqué. Une valeur invalide (NaN, mauvais type, couleur CSS invalide, valeur inconnue) garde la valeur courante, ou la valeur par défaut à la création. Le playground ne règle pas les couleurs : essayez color et colorEnd dans votre code. Le texte, la typographie, spacing, layers et maxParticles redessinent le nuage, en 6 à 10 ms : ne les changez pas à chaque image. Les autres options changent en direct.
Les adaptateurs transmettent les attributs natifs à la racine, ainsi que className en React et class en Vue. Le cœur exporte aussi getParticleTextLabelStyle(options). Pour toute la page, la pause commune setAnimationsPaused(paused) de FrameLoop met en pause tous les nuages (voir Mettre en pause).
Accessibilité
Ce que fait ParticleText
- Le vrai texte HTML existe dès le rendu serveur et reste lisible sans JavaScript. Il fournit toujours le contenu aux lecteurs d'écran, même quand le nuage le cache visuellement.
- Le canvas est décoratif (
aria-hidden="true") et ne capte ni les clics ni le focus. ParticleText n'ajoute ni rôle nitabindex. - Le mouvement s'arrête hors de l'écran et dans un onglet masqué.
- Sur un écran tactile, la réaction au pointeur et la parallaxe sont coupées.
Ce que vous devez faire
- Placer ParticleText dans un titre (
h1,h2) de la bonne hiérarchie. - Permettre de mettre le mouvement en pause (WCAG 2.2.2) : un seul interrupteur pour toute la page, avec
setAnimationsPausedde FrameLoop (voir Mettre en pause). Le mouvement réduit du système ne le remplace pas : il ne concerne que les personnes qui l'ont activé. - Garder un contraste suffisant, surtout avec des couleurs personnalisées, une
opacityréduite ou un halo marqué.
Mouvement réduit. Le nuage reste immobile : ni respiration, ni réaction au pointeur, ni parallaxe. La formation devient un simple fondu. Un changement du réglage système s'applique en direct.
Contraste élevé. En mode forced-colors, le nuage disparaît et le texte HTML s'affiche, avec les couleurs du système.
Performances
- Le cœur et ses fichiers annexes pèsent environ 15,3 kB minifiés et compressés (41 kB minifiés), sans dépendance. Avec FrameLoop et PointerTracker, partagés avec les autres éléments d'O-UI, l'ensemble pèse 16,5 kB.
- La simulation et le rendu tournent sur le GPU (WebGL2). Le texte est échantillonné avec Canvas 2D seulement quand il change : environ 6 ms aux réglages par défaut, 10 ms pour un grand titre sur deux lignes.
- Sur un ordinateur portable récent, le texte par défaut (« O-UI », environ 8 400 particules, sous le plafond de 24 000) tourne à 120 images par seconde, au repos comme sous le pointeur ; la mesure tient avec 60 000 particules et tous les effets au maximum. Le composant n'a pas encore été mesuré sur un téléphone d'entrée de gamme : sur un matériel modeste, baissez
maxParticlesetpixelRatio. - Toutes les instances de la page partagent une seule boucle et un seul jeu d'écouteurs de pointeur, ceux de FrameLoop et de PointerTracker, avec les autres éléments d'O-UI. À chaque image, tous mesurent, puis tous écrivent.
- La boucle s'arrête hors de l'écran, dans un onglet masqué, en pause, avec le mouvement réduit, et au repos sans respiration (
drift={0}ouspeed={0}). - Chaque instance utilise son propre contexte WebGL, libéré quand elle s'éloigne de plus de 360 px de l'écran. Seules les instances proches de l'écran comptent donc dans la limite de contextes du navigateur. Gardez peu d'instances visibles à la fois : une ou deux en général. Si le navigateur retire un contexte, le texte HTML prend la place du nuage, qui revient quand l'instance s'éloigne puis se rapproche de l'écran.
Problèmes fréquents
- Le nuage n'apparaît pas, le texte HTML s'affiche. Regardez
data-state:fallback(WebGL2 indisponible, contexte perdu ou mode contraste élevé),disabled(propdisabled). - Le nuage ne bouge pas. La page a appelé
setAnimationsPaused(true), la proppausedest vraie, le système demande de réduire les animations, oudriftouspeedvaut 0. - Le nuage est coupé pendant l'interaction. Le composant ou un ancêtre a
overflow: hidden. Laissez environ 100 px de marge au-dessus et au-dessous avec les réglages par défaut (davantage avec un grandradiusou une grandedepth). - Le texte ne rétrécit pas dans une ligne flex. Donnez au composant
min-w-0, ou une colonneminmax(0, 1fr)dans une grille. - Le titre change de taille au chargement. Avec un rendu serveur,
fitne s'applique qu'au chargement du script. Choisissez unfontSizequi tient sur mobile, ou réservez la hauteur avec unmin-heightsur le parent. - Le texte ne passe pas à la ligne. Les retours à la ligne doivent être écrits dans
text, avec\n.
Notes et limites
- Sous environ 24 px de taille effective, le texte devient difficile à lire. Pour un petit texte, baissez
spacingetparticleSize. - Les emoji sont dessinés comme des silhouettes, sans leurs couleurs.
- Les rotations 3D d'un ancêtre (
rotateX,rotateY) ne sont pas prises en compte. - Changer
textà chaque image redessine le nuage à chaque fois : réservez-le à des changements ponctuels.