Aller au contenu
O-UI

Recherche

Trouvez un composant ou une page de documentation.

ParticleText

BêtaComposant

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

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

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

  1. 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), fontSize est 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).
  2. Écrivez les retours à la ligne dans text, avec \n. Le texte ne passe jamais à la ligne de lui-même, même avec fit.
  3. 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: hidden sur le composant ou ses ancêtres, et gardez de l'espace avec les éléments voisins.
  4. 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, ou flex-1 min-w-0 à côté d'autres éléments. Sans min-w-0, sa largeur minimale est celle du texte, et fit ne 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

  • interaction choisit la réaction : repel repousse les particules, attract les attire, vortex les fait tourner et none coupe toute réaction au pointeur.
  • strength règle sa force et radius sa portée, en pixels CSS. Une portée plus large déplace une plus grande part du texte.
  • trail entraîne les particules dans le sens du mouvement du pointeur, comme une main dans l'eau. Il reste actif avec strength={0}, pour un sillage seul.
  • turbulence ajoute des tourbillons aux particules perturbées pendant leur retour, sans brouiller le texte au repos.
  • parallax incline le volume vers le pointeur ; à 0, la projection reste immobile.
  • Le retour suit un ressort : returnDuration en donne le rythme, en secondes, et damping le rebond (sous 1, le retour dépasse un peu sa place ; à 1 et 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é. spacing règle l'écart entre les particules : plus petit, le nuage est plus dense. layers ajoute des couches dans la profondeur. maxParticles plafonne leur nombre : au-delà, la densité baisse uniformément, sans couper le texte.
  • Particules. particleSize règle leur diamètre et sizeVariation sa variation. glow ajoute un halo doux et opacity règle l'opacité de l'ensemble.
  • Volume. depth donne l'épaisseur du nuage ; depthOfField agrandit et adoucit les particules éloignées du plan du texte. Une perspective plus grande aplatit le relief.
  • Liant. cohesion fait suivre à chaque particule une part du mouvement de ses voisines, pour une matière plus liée. connectionOpacity et connectionDistance rè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, fit ajuste la taille avant la première peinture. Avec un rendu serveur, le texte HTML apparaît d'abord à fontSize, puis fit le réduit au chargement du script. Sur un petit écran, un grand fontSize peut donc passer sur plusieurs lignes, puis se resserrer : le contenu qui suit remonte. fontSize est 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 paused vaut true. paused sert à 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 avec Object.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, fit peut 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 :

ValeurSignification
loadingle nuage se prépare (polices, premier échantillonnage)
activele nuage bouge
idlele nuage est au repos (rien ne bouge, la boucle est arrêtée)
pauseden pause (prop paused ou pause globale)
reducedmouvement réduit : nuage immobile
disabledprop disabled : le texte HTML s'affiche
fallbackWebGL2 indisponible, contexte perdu ou mode contraste élevé : le texte HTML s'affiche

API

PropTypeDéfautDescription
textstring"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.
fontSizenumber160Taille du texte, en pixels CSS, de 12 à 512. Avec `fit`, c'est une taille maximale.
fontFamilystring"inherit"Famille de police CSS, variables comprises ; `inherit` reprend la police environnante.
fontWeightnumber700Graisse de la police, de 100 à 900.
letterSpacingnumber0Approche entre les lettres, en pixels CSS, de −8 à 32. Négative, elle resserre un grand titre.
lineHeightnumber1.1Interligne, relatif à la taille du texte, de 0,8 à 2. Sous 1, les lignes se rapprochent.
alignParticleTextAlign"center"Alignement des lignes : `left`, `center` ou `right`.
fitbooleantrueBooléen, vrai par défaut : réduit le texte pour qu'il tienne dans la largeur disponible, sans jamais l'agrandir.
spacingnumber2.5Écart entre les particules, en pixels CSS, de 1 à 12. Plus petit, le nuage est plus dense et plus lourd à calculer.
layersnumber3Nombre de couches dans la profondeur, entier de 1 à 6. Plus de couches donnent plus de volume.
depthnumber36Épaisseur du nuage, en pixels CSS, de 0 (plat) à 160.
maxParticlesnumber24000Nombre maximal de particules, entier de 1 000 à 60 000. Au-delà, la densité baisse uniformément. Baissez-le sur un matériel modeste.
particleSizenumber1.6Diamètre des particules, en pixels CSS, de 0,5 à 8. Petit, il garde des bords nets ; grand, il donne une matière plus douce.
sizeVariationnumber0.5Variation aléatoire du diamètre, de 0 (toutes égales) à 1 (très variées).
glownumber0.35Halo doux autour des particules, de 0 à 1. Plus marqué sur un fond sombre.
opacitynumber0.9Opacité de l'ensemble du nuage, de 0 à 1.
colorstring"currentColor"Couleur CSS du début du dégradé horizontal ; `currentColor` suit la couleur du texte environnant.
colorEndstring"currentColor"Couleur CSS de la fin du dégradé ; la même valeur que `color` donne une couleur unie.
depthOfFieldnumber0.4Profondeur de champ, de 0 à 1 : les particules éloignées du plan du texte deviennent plus grandes et plus douces.
cohesionnumber0.2Part du mouvement de ses voisines que suit chaque particule, de 0 à 0,8. Plus haute, la matière bouge d'un bloc.
connectionOpacitynumber0.15Opacité des filaments entre particules voisines, de 0 (aucun) à 1. Ils se voient surtout quand le nuage est perturbé.
connectionDistancenumber20Longueur au-delà de laquelle un filament disparaît, en pixels CSS, de 2 à 80.
driftnumber1.5Amplitude de la respiration, en pixels CSS, de 0 (immobile) à 30.
speednumber0.5Rythme de la respiration, de 0 (immobile) à 3 ; 1 est deux fois plus rapide que le défaut (0,5).
turbulencenumber0.6Tourbillons des particules perturbées avant leur retour, de 0 à 2. Le texte au repos n'en a pas.
intronumber1.6Duré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.
interactionParticleTextInteraction"repel"Réaction au pointeur : `repel` repousse, `attract` attire, `vortex` fait tourner, `none` coupe toute réaction, entraînement compris.
strengthnumber0.7Force de la réaction au pointeur, de 0 à 2. Au-delà de 1, le texte se défait nettement sous le pointeur.
radiusnumber100Portée du pointeur, en pixels CSS, de 0 à 500. Plus large, elle déplace une plus grande part du texte.
trailnumber0.6Entraînement des particules dans le sens du pointeur, de 0 à 2, comme une main dans l'eau. À 0, aucun sillage.
parallaxnumber0.15Inclinaison du volume vers le pointeur, de 0 à 1. À 0, la projection reste immobile.
returnDurationnumber0.9Rythme 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é.
dampingnumber0.7Amortissement du retour, de 0,3 (rebond) à 1,5. À 1 et au-delà, les particules ne dépassent pas leur place.
perspectivenumber900Distance de la caméra, en pixels CSS, de 300 à 2000. Plus grande, elle aplatit le relief.
pixelRationumber1.5Densité de pixels maximale du rendu, de 0,5 à 2, limitée aussi à celle de l'écran. Baissez-la sur un matériel modeste.
pausedbooleanfalseBoolé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.
disabledbooleanfalseBooléen : affiche le texte HTML à la place du nuage et libère le GPU.
reducedMotionReducedMotion"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 ni tabindex.
  • 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 setAnimationsPaused de 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 opacity ré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 maxParticles et pixelRatio.
  • 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} ou speed={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 (prop disabled).
  • Le nuage ne bouge pas. La page a appelé setAnimationsPaused(true), la prop paused est vraie, le système demande de réduire les animations, ou drift ou speed vaut 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 grand radius ou une grande depth).
  • Le texte ne rétrécit pas dans une ligne flex. Donnez au composant min-w-0, ou une colonne minmax(0, 1fr) dans une grille.
  • Le titre change de taille au chargement. Avec un rendu serveur, fit ne s'applique qu'au chargement du script. Choisissez un fontSize qui tient sur mobile, ou réservez la hauteur avec un min-height sur 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 spacing et particleSize.
  • 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.