Aller au contenu
O-UI

Recherche

Trouvez un composant ou une page de documentation.

Paper

BêtaComposant

Une feuille de papier qui se froisse, se plie ou s’envole en avion, et garde la mémoire de ses plis.

Frameworks
ReactVueVanilla
Technologies
WebGLCanvas 2DCSS
Déclenché par
Piloté par l'application
Mis à jour le
Paramètres

Paper donne à un contenu HTML l’apparence d’une feuille mate. La feuille peut se froisser en boule, se plier en deux, en quatre ou en lettre, ou se plier en avion. Elle garde la mémoire de ce qu’elle a subi : défroissée, elle reste marquée ; dépliée, elle garde ses plis.

Au repos, quelle que soit sa forme, la feuille n’utilise aucun contexte WebGL. Le GPU ne travaille que pendant un geste. Le même composant habille aussi une section d’un papier froissé ou plié, sans aucun geste.

Installation

Installez l'élément et ses dépendances en une commande :

pnpm dlx shadcn@latest add https://o-ui.hd987.dev/r/paper.json

Le composant utilise WebGL2, sans dépendance supplémentaire. Un repli CSS prend le relais quand WebGL n’est pas disponible.

Utilisation

Donnez une hauteur à la racine et composez votre contenu normalement. À plat, le texte, les images et les champs restent du HTML natif.

import { Paper } from "@/components/o-ui/paper/paper";

export function Example() {
  return (
    <Paper className="h-80 w-full max-w-sm">
      <article className="h-full p-7 text-stone-800">
        <h3>Field notes</h3>
        <p>Leave room for the unexpected.</p>
      </article>
    </Paper>
  );
}
  • Choisissez des couleurs de texte adaptées à paperColor. Le contenu ne change pas de thème pendant un geste.
  • Gardez les actions principales hors de la feuille si elles doivent rester utilisables pendant un geste.
  • Laissez le débordement visible. Pendant un geste, la feuille et son ombre dépassent de la boîte ; le composant limite ce débord aux bords du document.

Exemples

Envoyer un message

Le message s’écrit dans un textarea natif posé sur la feuille. À l’envoi, fly() plie la feuille en avion, la valeur du champ imprimée dessus, puis la fait s’envoler. Quand la promesse renvoie true, la démo annonce l’envoi, vide le champ et appelle reset() pour faire revenir une feuille neuve.

Suppression d’une note

onComplete retire la note seulement après le froissage. La démo annonce le résultat et propose une restauration : ces deux responsabilités appartiennent à votre application.

Ouvrir une invitation

La carte arrive pliée en quatre : shape="quarter" l’affiche directement au montage. Le bouton passe shape à flat ; la carte s’ouvre et garde ses plis, puis se replie au clic suivant.

Une section en papier

wrinkles et creases habillent une section sans aucun geste : son fond est calculé une fois, puis plus rien ne tourne.

Progression précise

La prop progress affiche directement une pose entre la feuille à plat et shape. Elle convient à une glissière, à un geste ou à une progression de défilement.

Intégration

Les formes

shape vaut flat, crumpled, half, quarter, letter ou plane. Un changement de shape joue le geste ; entre deux formes non plates, la feuille se rouvre d’abord. Au montage, une forme non plate s’affiche directement, sans animation.

Les pliages s’orientent sur le grand côté de la feuille : half plie le bas sur le haut d’une feuille en portrait, letter la plie en trois, et le nez de l’avion suit le grand côté.

Piloter les gestes

Les actions sont accessibles par actionsRef en React et par la référence du composant en Vue. Chacune renvoie une promesse : true quand la cible est atteinte, false si une autre action l’a interrompue.

"use client";

import { useRef } from "react";
import { Paper, type PaperActions } from "@/components/o-ui/paper/paper";

export function PaperActionsExample() {
  const paper = useRef<PaperActions>(null);

  return (
    <>
      <Paper actionsRef={paper} className="h-80 w-full max-w-sm">
        <p className="p-7 text-stone-800">A draft worth keeping.</p>
      </Paper>
      <button type="button" onClick={() => void paper.current?.to("quarter")}>
        Fold
      </button>
      <button type="button" onClick={() => void paper.current?.to("flat")}>
        Open
      </button>
    </>
  );
}
  • to(shape) joue le geste vers une forme. crumple() et flatten() sont des raccourcis.
  • fly() plie la feuille en avion si besoin, puis la fait s’envoler. La boîte garde sa taille, son contenu devient inerte et masqué jusqu’à reset().
  • reset() fait revenir une feuille neuve, sans les marques des gestes passés.
  • setProgress(progress) affiche une pose fixe entre la feuille à plat et shape, comme la prop progress, sans passer par un rendu de React ou de Vue.
  • getState() lit la forme, la progression et le mouvement en cours.

La référence Vue expose aussi el, la racine HTML. onStart et onComplete (en Vue, @start et @complete) reçoivent la forme visée, ou "flown" pour le vol.

Le vol

L’avion décolle de la feuille, s’éloigne en rapetissant et sort par le bord de la zone de vol, dans la direction de flightDirection. Il vole dans une couche fixe placée au-dessus de la page, qui ne capte pas le pointeur. La boîte de la feuille garde sa place pendant le vol.

Par défaut, la zone de vol est la fenêtre. Passez un élément à flightBounds pour garder le vol dans une carte, une modale ou une colonne : seule sa partie visible sert de zone de vol.

const [card, setCard] = useState<HTMLElement | null>(null);

<section ref={setCard} className="rounded-xl border p-8">
  <Paper actionsRef={paper} flightBounds={card ?? undefined} className="h-80 w-64">
    …
  </Paper>
</section>;

En Vue, passez l’élément d’une référence de template : :flight-bounds="card ?? undefined". Dans un <dialog> ouvert avec showModal(), l’avion vole dans la couche supérieure du dialogue. Sous mouvement réduit, la feuille disparaît en fondu au lieu de s’envoler.

Marquer une feuille à plat

wrinkles et creases marquent une feuille à plat : un papier froissé puis lissé, ou une lettre pliée puis rouverte. L’image est calculée une fois ; aucun contexte WebGL ne reste ouvert.

<Paper wrinkles={0.6} creases="letter" className="min-h-96 p-10">
  …
</Paper>

Avec memory (actif par défaut), les gestes laissent aussi leurs marques sur la feuille rouverte.

Interrompre ou inverser un geste

Une nouvelle action part de la pose visible et reprend le chemin en sens inverse si besoin. Pendant le vol, une nouvelle action attend la fin du vol ; reset() l’interrompt.

API

PropTypeDéfautDescription
shapePaperShape"flat"Forme visée : flat, crumpled, half, quarter, letter ou plane. Un changement joue le geste ; au montage, une forme non plate s’affiche directement.
progressnumberundefinedPose fixe entre la feuille à plat (0) et shape (1). Chaque changement annule le geste en cours ; retirez la prop pour revenir au mode non piloté. Sans effet quand shape vaut flat.
speednumber1Multiplie le rythme naturel de chaque geste, de 0,25 à 4. Un froissage dure environ 1,8 s, un pliage en quatre 1,7 s et l’avion 2,9 s, plus 2,1 s de vol.
delaynumber0Attente avant un geste, en secondes, de 0 à 3. Elle ne s’applique pas à progress.
seednumber7Graine entière de 0 à 10000 pour les plis, le grain et la variation du rythme. Changez-la pour obtenir une autre feuille reproductible.
wrinklesnumber0Froissure de la feuille à plat, de 0 à 1 : un papier froissé puis lissé. L’image est calculée une fois, sans contexte au repos.
creasesPaperCreases"none"Plis marqués d’une feuille rouverte : none, half, quarter ou letter. Les plis apparaissent en creux ou en bosse selon la face.
memorybooleantrueLes gestes laissent leurs marques : un froissage ajoute de la froissure, un pliage rouvert ajoute ses plis. reset() les efface.
crumpleSizenumber0.46Diamètre de la boule par rapport à la hauteur de la feuille, de 0,2 à 1.
crumpleRoughnessnumber0.55Irrégularité de la boule et de ses arêtes, de 0 à 1. Les valeurs élevées donnent un papier plus fortement froissé.
flightDirectionPaperFlightDirection"up-right"Direction de sortie de l’avion : up-right, up-left ou up.
flightBounds"viewport" | HTMLElement"viewport"Zone où l’avion vole : la fenêtre (viewport) ou un élément, par exemple une carte ou une modale. Seule la partie visible de l’élément est utilisée ; retiré du document, le vol revient à la fenêtre.
paperColorstring"#f4f0e8"Couleur CSS du recto, résolue dans le document. Choisissez-la avec le contraste du contenu à plat.
backColorstring"#ded8cc"Couleur CSS du verso. Une teinte légèrement plus sombre que le recto aide à lire les plis.
texturenumber0.35Intensité du grain et des fibres, de 0 à 1, identique à plat et pendant les gestes. À 0, le fond reste une couleur unie.
lightingnumber0.8Contraste du relief sous la lumière, de 0 à 1,5. La feuille à plat garde exactement sa couleur.
shadownumber0.45Opacité des ombres portées sur la page, de 0 à 1. Réduisez-la sur un fond déjà contrasté.
shadowColorstring"#1c1917"Couleur CSS des ombres portées sur la page.
cornerRadiusnumber3Arrondi de la feuille à plat en pixels CSS, de 0 à 64. Il disparaît pendant un geste.
perspectivenumber900Distance de caméra en pixels CSS, de 300 à 2400. Une petite distance accentue la profondeur.
contentMode"fade" | "print""print"print imprime le texte, les valeurs des champs et les surfaces simples sur la feuille ; fade fait disparaître le HTML sans le capturer.
contentFadenumber0.16Intervalle de progression du fondu HTML en mode fade, de 0,02 à 0,5.
renderContent((context: CanvasRenderingContext2D, size: PaperContentSize) => void)undefinedDessine vous-même le contenu imprimé dans un canvas 2D, à la taille de la feuille en pixels CSS. Utile pour un contenu que la capture ne reproduit pas. Il est rappelé au début de chaque geste.
qualityPaperQuality"auto"Densité du maillage, taille des ombres et densité de pixels : auto, low, medium ou high. auto choisit low sur un appareil modeste.
pausedbooleanfalseFige le geste en cours sans changer sa pose ; la feuille garde son image, pas son contexte WebGL (sauf pendant le vol). La reprise continue depuis la même pose. La pause commune du site, elle, affiche directement l’état final.
disabledbooleanfalseDésactive l’effet : feuille HTML unie, sans marques ni canvas. Les promesses en cours renvoient false.
reducedMotionReducedMotion"user"« user » suit le système, « always » affiche chaque état directement avec un court fondu, « never » conserve le mouvement après un choix explicite de l’utilisateur.
onStart((shape: PaperEventShape) => void)undefinedRappel React appelé au début effectif d’un geste, après son délai, avec la forme visée ou flown. Hors de l’écran, il est appelé aussitôt, l’état étant appliqué sans animation. En Vue, utilisez l’événement start.
onComplete((shape: PaperEventShape) => void)undefinedRappel appelé quand le geste atteint sa cible, avec la forme atteinte ou flown après le vol. En Vue, l’événement complete porte la même valeur.

Les nombres finis hors plage sont bornés. Une valeur invalide conserve la valeur courante ; les props retirées retrouvent leur défaut dans les adaptateurs. actionsRef est propre à React ; la ref ordinaire reste dirigée vers la racine.

Accessibilité

  • Le contenu initial reste lisible sans JavaScript. Le canvas, l’image figée et l’ombre sont décoratifs et ne reçoivent aucun événement.
  • Le sous-arbre HTML devient inert et aria-hidden dès que la feuille 3D le remplace ; il est restauré au retour à plat. Le focus présent dans la feuille passe à la racine sans défilement, puis revient à son élément.
  • Après le vol, le contenu reste inerte et masqué jusqu’à reset(). Annoncez le résultat (« Message envoyé ») et placez le focus sur une action pertinente, comme le fait la démo « Envoyer un message ».
  • Sous mouvement réduit, chaque état s’affiche directement avec un fondu de 0,2 s. Le changement de préférence système est suivi en direct.
  • La pause commune du site (setAnimationsPaused) arrête le mouvement, pas les actions : un geste lancé ou en cours affiche directement son état final, comme en mouvement réduit. Les promesses et les événements restent les mêmes.
  • Si une forme non plate reste affichée, gardez le texte significatif dans un équivalent accessible placé hors de la feuille. La démo « Progression précise » le fait.
  • Les marques restent discrètes, mais vérifiez le contraste de votre texte avec wrinkles à 1.

Performances

Le composant n’ajoute aucune dépendance npm et ne charge aucun asset. Son cœur pèse environ 27 Ko gzip, primitive frame-loop comprise.

Au repos, quelle que soit sa forme, la feuille n’utilise aucun contexte WebGL : à plat, son fond est une image calculée une fois ; dans une autre forme, la dernière image du geste reste figée. Un contexte n’existe que pendant un geste, et huit au plus en même temps ; les suivants passent au repli CSS.

Les pliages se calculent sur le GPU. Le froissage prépare son étude dans les temps morts, dès que la feuille approche de l’écran. Les shaders se compilent en arrière-plan quand le navigateur le permet : le geste démarre à l’image où ils sont prêts. Réduisez quality sur les petits appareils si nécessaire.

Les feuilles de même couleur, texture et graine partagent leur tuile de grain. Une feuille figée dans une autre forme ne se redessine qu’après 150 ms sans changement de taille, de couleur ou de contenu.

Problèmes fréquents

  • La feuille reste plate. Changez shape, appelez une action, ou faites varier progress avec une shape non plate.
  • Le contenu imprimé est incomplet. Les vidéos, SVG et effets CSS complexes ne sont pas capturés. Passez à contentMode="fade", ou dessinez-le avec renderContent.
  • Les plis ou l’ombre sont coupés. Vérifiez les ancêtres en overflow: hidden et laissez de l’espace autour de la feuille.
  • L’avion survole un en-tête ou une autre zone. Limitez le vol à un conteneur avec flightBounds.
  • Le geste est remplacé par un changement direct. Vérifiez la préférence système de mouvement réduit et la prop reducedMotion.

Notes et limites

  • WebGL2 est requis pour le rendu en 3D. Sans WebGL, en couleurs forcées ou après une perte de contexte, le repli CSS conserve les gestes et leurs états finaux, sans marques.
  • L’impression reconstruit le texte, les valeurs des champs, les fonds unis, les bordures simples et les images de même origine déjà chargées. Ce n’est pas une capture exhaustive du navigateur.
  • Deux plis qui se croisent ne peuvent pas rester entrouverts tous les deux sans déformer le papier : une feuille rouverte revient à plat et garde ses plis par leur marque.
  • Le froissage conserve la surface du papier et résout des contacts entre sommets ; des intersections restent possibles en compression extrême.