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
+24 -7
View File
@@ -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 (
<div className={cn("overflow-hidden rounded-full bg-secondary", altezza, trackClassName)}>
<div
className={cn("anim-barra h-full rounded-full bg-accent-grad", className)}
style={{ width: `${larghezza}%` }}
<div
className={cn("overflow-hidden rounded-full bg-secondary", altezza, trackClassName)}
role="progressbar"
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>
);
+28 -16
View File
@@ -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 (
<span className="tabular-nums">
{n}
{suffisso}
</span>
<motion.span className="tabular-nums" aria-label={`${valore}${suffisso}`}>
{testo}
</motion.span>
);
}
+25 -8
View File
@@ -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 (
<Tag
className={cn("anim-reveal", className)}
style={{ animationDelay: `${Math.min(indice, 8) * 60}ms`, ...style }}
<Componente
className={cn(className)}
{...(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}
</Tag>
</Componente>
);
}