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;