From e7863ab950360d40a1bdd0104827c60ca68f75fb Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Sat, 5 Sep 2026 12:42:01 +0200 Subject: [PATCH] Passa le animazioni a molle interrompibili MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Il movimento era fatto con `@keyframes` e transizioni a durata fissa: nessuna di quelle animazioni può essere afferrata a metà. Se l'utente scorre o tocca mentre una è in corso, quella va avanti per conto suo e per ripartire deve prima finire. `lib/molla.ts` raccoglie i parametri, che sono i due di Apple in «Designing Fluid Interfaces» — rimbalzo e durata, non massa/rigidità/smorzamento. `molla.ui` (nessun sorpasso) è il default; `molla.slancio` si usa solo dopo un gesto che portava già inerzia. `proietta()` calcola dove finirebbe un elemento lanciato, con la stessa decelerazione esponenziale dello scroll iOS. Nei tre componenti: - `Reveal` compare quando entra davvero nel viewport. Prima partiva al mount, quindi gli elementi sotto la piega consumavano l'animazione a vuoto e l'utente li trovava già fermi. Il ritardo massimo scende da 480 a 200 ms. - `Barra` anima `scaleX` invece di `width`: la larghezza rifà il layout a ogni frame, la trasformazione la gestisce il compositore. - `Numero` usa una molla al posto dell'interpolazione a durata fissa: se il dato cambia a metà conteggio il numero cambia rotta da dov'è invece di ripartire da capo. Sparisce la prop `durata`, che nessuno passava. `lib/motion.ts` resta solo per il rilevamento del movimento ridotto (che qui guarda anche `deviceMemory`, cosa che `useReducedMotion` non fa) e per i coriandoli. Co-Authored-By: Claude Opus 5 --- src/components/motion/Barra.tsx | 31 +++++++++++---- src/components/motion/Numero.tsx | 44 +++++++++++++-------- src/components/motion/Reveal.tsx | 33 ++++++++++++---- src/lib/molla.ts | 31 +++++++++++++++ src/lib/motion.ts | 66 ++++---------------------------- 5 files changed, 115 insertions(+), 90 deletions(-) create mode 100644 src/lib/molla.ts diff --git a/src/components/motion/Barra.tsx b/src/components/motion/Barra.tsx index 291f2fc..e4e23ef 100644 --- a/src/components/motion/Barra.tsx +++ b/src/components/motion/Barra.tsx @@ -1,7 +1,14 @@ +import { motion, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; -import { useRiempimento } from "@/lib/motion"; +import { molla } from "@/lib/molla"; -/** Barra di avanzamento che si riempie in ~700 ms all'apertura. */ +/** + * Barra di avanzamento a molla. + * + * Anima `scaleX` e non `width`: la larghezza rifà il layout a ogni frame, + * la trasformazione la gestisce il compositore. La molla riparte dal valore + * a schermo, quindi se il dato cambia a metà riempimento non c'è scatto. + */ export function Barra({ percentuale, className, @@ -13,12 +20,22 @@ export function Barra({ trackClassName?: string; altezza?: string; }) { - const larghezza = useRiempimento(Math.max(0, Math.min(100, Math.round(percentuale)))); + const ridotto = useReducedMotion(); + const valore = Math.max(0, Math.min(100, Math.round(percentuale))); + return ( -
-
+
); diff --git a/src/components/motion/Numero.tsx b/src/components/motion/Numero.tsx index b64b489..ff5f143 100644 --- a/src/components/motion/Numero.tsx +++ b/src/components/motion/Numero.tsx @@ -1,20 +1,32 @@ -import { useConteggio } from "@/lib/motion"; +import { useEffect } from "react"; +import { motion, useMotionValue, useSpring, useTransform, useReducedMotion } from "motion/react"; +import { molla } from "@/lib/molla"; + +/** + * Contatore animato per statistiche e percentuali. + * + * Usa una molla invece di un'interpolazione a durata fissa: se il valore + * cambia mentre il conteggio è in corso, il numero cambia rotta dal punto in + * cui si trova invece di ripartire da capo. + */ +export function Numero({ valore, suffisso = "" }: { valore: number; suffisso?: string }) { + const ridotto = useReducedMotion(); + const grezzo = useMotionValue(0); + const morbido = useSpring(grezzo, molla.ui); + const testo = useTransform(morbido, (n) => `${Math.round(n)}${suffisso}`); + + useEffect(() => { + if (ridotto) { + grezzo.jump(valore); + morbido.jump(valore); + return; + } + grezzo.set(valore); + }, [valore, ridotto, grezzo, morbido]); -/** Contatore animato per statistiche e percentuali. */ -export function Numero({ - valore, - durata = 700, - suffisso = "", -}: { - valore: number; - durata?: number; - suffisso?: string; -}) { - const n = useConteggio(valore, durata); return ( - - {n} - {suffisso} - + + {testo} + ); } diff --git a/src/components/motion/Reveal.tsx b/src/components/motion/Reveal.tsx index 34ce949..ca40e83 100644 --- a/src/components/motion/Reveal.tsx +++ b/src/components/motion/Reveal.tsx @@ -1,9 +1,18 @@ -import type { CSSProperties, ReactNode } from "react"; +import type { ReactNode } from "react"; +import { motion, useReducedMotion, type MotionStyle } from "motion/react"; import { cn } from "@/lib/utils"; +import { molla } from "@/lib/molla"; /** - * Comparsa graduale con leggero slide dal basso (~300 ms). - * `indice` sfalsa l'animazione tra elementi vicini. + * Comparsa graduale quando l'elemento entra davvero nel viewport. + * + * Prima partiva al mount: gli elementi sotto la piega consumavano + * l'animazione a vuoto e l'utente li trovava già fermi. Ora la molla è + * interrompibile e riparte dal valore corrente, quindi uno scroll a metà + * animazione non produce salti. + * + * `indice` sfalsa elementi vicini, con un tetto basso: oltre ~200 ms + * l'ultimo elemento di una lista sembra in ritardo rispetto al tocco. */ export function Reveal({ children, @@ -15,15 +24,23 @@ export function Reveal({ children: ReactNode; indice?: number; className?: string; - style?: CSSProperties; + style?: MotionStyle; as?: "div" | "section" | "li" | "article"; }) { + const ridotto = useReducedMotion(); + const Componente = motion[Tag]; + const ritardo = Math.min(indice, 5) * 0.04; + return ( - {children} - + ); } diff --git a/src/lib/molla.ts b/src/lib/molla.ts new file mode 100644 index 0000000..4d3dab4 --- /dev/null +++ b/src/lib/molla.ts @@ -0,0 +1,31 @@ +/** + * Parametri di molla condivisi, tarati sui valori che Apple usa in + * "Designing Fluid Interfaces": damping (rimbalzo) e response (durata) invece + * di massa/rigidità/smorzamento. + * + * L'API `bounce` + `duration` di `motion` mappa esattamente su quella coppia: + * `bounce: 0` è criticamente smorzata (nessun sorpasso), `bounce: 0.2` è la + * `damping 0.8` di Apple. + * + * Regola: `ui` ovunque; `slancio` SOLO dopo un gesto che portava già inerzia + * (un lancio, un trascinamento rilasciato). Un menu che compare da fermo non + * deve rimbalzare. + */ +export const molla = { + /** Default: sposta/riposiziona. damping 1.0, response 0.4. */ + ui: { type: "spring", bounce: 0, duration: 0.4 }, + /** Dopo un gesto con inerzia. damping ~0.8, response 0.4. */ + slancio: { type: "spring", bounce: 0.2, duration: 0.4 }, + /** Fogli e drawer. damping ~0.8, response 0.3. */ + foglio: { type: "spring", bounce: 0.2, duration: 0.3 }, +} as const; + +/** + * Proietta dove si fermerebbe un oggetto lanciato a `velocita` px/s, con la + * stessa decelerazione esponenziale dello scroll iOS. Serve a scegliere il + * punto di arrivo a partire da dove il gesto *stava andando*, non da dove il + * dito si è staccato. + */ +export function proietta(velocita: number, decelerazione = 0.998) { + return ((velocita / 1000) * decelerazione) / (1 - decelerazione); +} diff --git a/src/lib/motion.ts b/src/lib/motion.ts index bd3b716..2561723 100644 --- a/src/lib/motion.ts +++ b/src/lib/motion.ts @@ -1,8 +1,12 @@ /** - * Sistema di micro-animazioni: solo API standard del browser (nessuna - * dipendenza da Lovable). Funziona in qualsiasi progetto React + Vite. + * Quel che resta del motion "fatto a mano": il rilevamento del movimento + * ridotto (che qui tiene conto anche dei device deboli, cosa che + * `useReducedMotion` di motion non fa) e i coriandoli. + * + * Le animazioni di valore — conteggi, barre, comparse — sono passate a molle + * interrompibili: vedi `lib/molla.ts` e `components/motion/`. */ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; /** True se l'utente ha chiesto meno movimento o il device è poco performante. */ export function useMotoRidotto() { @@ -21,62 +25,6 @@ export function useMotoRidotto() { return ridotto; } -function easeOut(t: number) { - return 1 - Math.pow(1 - t, 3); -} - -/** - * Anima un numero da 0 (o dal valore precedente) fino a `valore`. - * Usa requestAnimationFrame: mai bloccante, zero chiamate di rete. - */ -export function useConteggio(valore: number, durata = 700) { - const ridotto = useMotoRidotto(); - const [corrente, setCorrente] = useState(valore); - const daRef = useRef(0); - - useEffect(() => { - if (ridotto) { - setCorrente(valore); - daRef.current = valore; - return; - } - const da = daRef.current; - if (da === valore) return; - const inizio = performance.now(); - let raf = 0; - const step = (ora: number) => { - const t = Math.min(1, (ora - inizio) / durata); - setCorrente(Math.round(da + (valore - da) * easeOut(t))); - if (t < 1) raf = requestAnimationFrame(step); - else daRef.current = valore; - }; - raf = requestAnimationFrame(step); - return () => cancelAnimationFrame(raf); - }, [valore, durata, ridotto]); - - return corrente; -} - -/** - * Restituisce la larghezza da applicare a una barra: parte da 0 al mount e - * raggiunge il target al frame successivo, lasciando animare la transizione CSS. - */ -export function useRiempimento(percentuale: number) { - const ridotto = useMotoRidotto(); - const [larghezza, setLarghezza] = useState(0); - - useEffect(() => { - if (ridotto) { - setLarghezza(percentuale); - return; - } - const raf = requestAnimationFrame(() => setLarghezza(percentuale)); - return () => cancelAnimationFrame(raf); - }, [percentuale, ridotto]); - - return larghezza; -} - /** Coriandoli leggeri, caricati solo al momento del bisogno. */ export async function coriandoli(ridotto = false) { if (ridotto || typeof window === "undefined") return;