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:
@@ -1,7 +1,14 @@
|
|||||||
|
import { motion, useReducedMotion } from "motion/react";
|
||||||
import { cn } from "@/lib/utils";
|
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({
|
export function Barra({
|
||||||
percentuale,
|
percentuale,
|
||||||
className,
|
className,
|
||||||
@@ -13,12 +20,22 @@ export function Barra({
|
|||||||
trackClassName?: string;
|
trackClassName?: string;
|
||||||
altezza?: 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 (
|
return (
|
||||||
<div className={cn("overflow-hidden rounded-full bg-secondary", altezza, trackClassName)}>
|
<div
|
||||||
<div
|
className={cn("overflow-hidden rounded-full bg-secondary", altezza, trackClassName)}
|
||||||
className={cn("anim-barra h-full rounded-full bg-accent-grad", className)}
|
role="progressbar"
|
||||||
style={{ width: `${larghezza}%` }}
|
aria-valuenow={valore}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className={cn("h-full w-full origin-left rounded-full bg-accent-grad", className)}
|
||||||
|
initial={{ scaleX: 0 }}
|
||||||
|
animate={{ scaleX: valore / 100 }}
|
||||||
|
transition={ridotto ? { duration: 0.2 } : molla.ui}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 (
|
return (
|
||||||
<span className="tabular-nums">
|
<motion.span className="tabular-nums" aria-label={`${valore}${suffisso}`}>
|
||||||
{n}
|
{testo}
|
||||||
{suffisso}
|
</motion.span>
|
||||||
</span>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { cn } from "@/lib/utils";
|
||||||
|
import { molla } from "@/lib/molla";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Comparsa graduale con leggero slide dal basso (~300 ms).
|
* Comparsa graduale quando l'elemento entra davvero nel viewport.
|
||||||
* `indice` sfalsa l'animazione tra elementi vicini.
|
*
|
||||||
|
* 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({
|
export function Reveal({
|
||||||
children,
|
children,
|
||||||
@@ -15,15 +24,23 @@ export function Reveal({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
indice?: number;
|
indice?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: CSSProperties;
|
style?: MotionStyle;
|
||||||
as?: "div" | "section" | "li" | "article";
|
as?: "div" | "section" | "li" | "article";
|
||||||
}) {
|
}) {
|
||||||
|
const ridotto = useReducedMotion();
|
||||||
|
const Componente = motion[Tag];
|
||||||
|
const ritardo = Math.min(indice, 5) * 0.04;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tag
|
<Componente
|
||||||
className={cn("anim-reveal", className)}
|
className={cn(className)}
|
||||||
style={{ animationDelay: `${Math.min(indice, 8) * 60}ms`, ...style }}
|
{...(style ? { style } : {})}
|
||||||
|
initial={ridotto ? { opacity: 0 } : { opacity: 0, y: 10 }}
|
||||||
|
whileInView={ridotto ? { opacity: 1 } : { opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, amount: 0.15, margin: "0px 0px -10% 0px" }}
|
||||||
|
transition={ridotto ? { duration: 0.2, delay: ritardo } : { ...molla.ui, delay: ritardo }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Tag>
|
</Componente>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -1,8 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Sistema di micro-animazioni: solo API standard del browser (nessuna
|
* Quel che resta del motion "fatto a mano": il rilevamento del movimento
|
||||||
* dipendenza da Lovable). Funziona in qualsiasi progetto React + Vite.
|
* 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. */
|
/** True se l'utente ha chiesto meno movimento o il device è poco performante. */
|
||||||
export function useMotoRidotto() {
|
export function useMotoRidotto() {
|
||||||
@@ -21,62 +25,6 @@ export function useMotoRidotto() {
|
|||||||
return ridotto;
|
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. */
|
/** Coriandoli leggeri, caricati solo al momento del bisogno. */
|
||||||
export async function coriandoli(ridotto = false) {
|
export async function coriandoli(ridotto = false) {
|
||||||
if (ridotto || typeof window === "undefined") return;
|
if (ridotto || typeof window === "undefined") return;
|
||||||
|
|||||||
Reference in New Issue
Block a user