Passa le animazioni a molle interrompibili

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 12:42:01 +02:00
co-authored by Claude Opus 5
parent 18c6205e06
commit e7863ab950
5 changed files with 115 additions and 90 deletions
+31
View File
@@ -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);
}
+7 -59
View File
@@ -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;