Dossier
Trois fiches dans une chemise qui s'entrouvre au survol et s'ouvre au clic.
Survolez : les fiches se soulèvent. Cliquez : le dossier s’ouvre.
Fiche
Pourquoi
Le ressort est ici légitime, et c'est la seule raison pour laquelle il est là. Une chemise en carton a une masse : quand on la soulève elle dépasse un peu, puis revient. L'œil connaît ce mouvement, et il le reconnaît. Le même ressort sur un bouton ou un menu ferait jouet : il raconterait une inertie qu'un rectangle de pixels n'a pas.
La règle n'est donc pas « pas de ressort », elle est « un ressort seulement quand l'objet en aurait un dans la vraie vie ». Un dossier, une carte qu'on tire, un tiroir : oui. Une bascule, un onglet, une infobulle : jamais.
Les trois fiches ne partent pas ensemble. Celle du dessus attend cent vingt millisecondes, la deuxième soixante, la dernière part tout de suite. Sans ce décalage, les trois se déplacent comme un bloc collé ; avec, l'œil lit un éventail. C'est trois nombres, et c'est toute la différence entre une image qui bouge et un objet qu'on manipule.
const DELAYS: Record<"rest" | "hover" | "open", [number, number, number]> = {
rest: [0, 0, 0],
hover: [120, 60, 0],
open: [100, 50, 0],
};
Le mouvement est calculé hors de React : une seule boucle d'animation écrit les dix transformations directement dans le DOM, et elle s'arrête d'elle-même dès que tout est au repos. Aucun rendu React n'est déclenché pendant le geste.
const paint = useCallback(() => {
springs.current.forEach((s, i) => {
const el = cardRefs.current[i];
if (!el) return;
el.style.transform = `translate(-50%, -50%) translate(${s.x.cur.toFixed(
2
)}px, ${s.y.cur.toFixed(2)}px) rotate(${s.r.cur.toFixed(2)}deg)`;
});
const flap = flapRef.current;
if (flap) {
flap.style.transform = `translate(-50%, -50%) rotateX(${flapSpring.current.cur.toFixed(
2
)}deg)`;
}
Ce qui coince
- Porté sans bibliothèque d'animation. La source utilisait
motion/react, Tailwind et un utilitairecn. Ici, l'intégrateur est un oscillateur amorti aux mêmes constantes (raideur 120, amortissement 13, masse 1), et les classes sont devenues une feuille de style. Le rendu et les poses sont ceux de la source.while (t > 0) { const h = t < SUBSTEP ? t : SUBSTEP; const a = (-STIFFNESS * (s.cur - s.to) - DAMPING * s.vel) / MASS; s.vel += a * h; s.cur += s.vel * h; t -= h; }L'intégrateur, en sept lignes et à pas fixe : le mouvement est identique à 60, 120 et 144 Hz. - C'était une div cliquable. Donc inaccessible au clavier : impossible d'ouvrir le dossier sans souris. C'est un bouton, avec
aria-expanded, et le focus vaut survol.<button type="button" className="fld__box" aria-expanded={open} aria-label={label} - Le pas de temps est plafonné. À 64 ms. Un onglet remis au premier plan renvoie un écart de plusieurs secondes : sans plafond, l'intégrateur diverge et les fiches partent hors de l'écran.
const dt = last.current ? Math.min((now - last.current) / 1000, 0.064) : SUBSTEP; - Pas de transition CSS sur les fiches. Le ressort écrit
transformà chaque image. Unetransitionsur la même propriété se battrait avec lui, et le mouvement deviendrait mou sans qu'on comprenne pourquoi./* Les fiches. Le `transform` complet est écrit par le ressort à chaque image. D'où l'absence de `transition` : les deux se battraient. */ .fld__card { position: absolute; top: 50%; left: 50%; will-change: transform; }
Le code
Les fichiers en entier, lus sur le disque au moment du build, dans le composant même qui tourne au-dessus. Les extraits cités plus haut en sont des lignes exactes : les numéros correspondent. Copiez-les, le composant n'a besoin de rien d'autre.
"use client";
/* ------------------------------------------------------------------
Dossier.
Un objet figuratif : trois fiches glissées dans une chemise qui
s'entrouvre au survol et s'ouvre au clic.
Le ressort est ici légitime, et c'est la seule raison pour laquelle il
est là. Une chemise en carton a une masse : quand on la soulève, elle
dépasse un peu et revient. Sur un bouton ou un menu, le même ressort
ferait jouet : il raconterait une inertie qui n'existe pas. La règle
n'est pas « pas de ressort », elle est « un ressort seulement quand
l'objet en aurait un dans la vraie vie ».
Rendu sans bibliothèque d'animation : l'intégrateur ci-dessous est un
oscillateur amorti standard, aux mêmes constantes que la source
(raideur 120, amortissement 13, masse 1). Il se met à jour hors de
React : aucun rendu déclenché pendant le mouvement, une seule boucle
pour les dix valeurs animées.
------------------------------------------------------------------- */
import { useCallback, useEffect, useRef, useState } from "react";
/* ---------- Thèmes ---------- */
const themes = {
black: {
backFill: "black",
backInsetShadow: "inset 0 0 6px 2px rgba(255,255,255,0.37)",
flapFill: "#292929",
flapFillOpacity: 0.25,
flapStroke: "#979797",
flapInsetColor: "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0",
cardFill: "#F1F1F1",
cardStroke: "#E0E0E0",
cardLineFill: "#D4D4D4",
cardInsetColor: "0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0",
},
white: {
backFill: "#ffffff",
backInsetShadow: "inset 0 0 6px 2px rgba(178,178,178,0.25)",
flapFill: "#f5f5f5",
flapFillOpacity: 0.85,
flapStroke: "#d4d4d4",
flapInsetColor: "0 0 0 0 0.6 0 0 0 0 0.6 0 0 0 0 0.6 0 0 0 0.15 0",
cardFill: "#262626",
cardStroke: "#404040",
cardLineFill: "#737373",
cardInsetColor: "0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.15 0",
},
blue: {
backFill: "#50B1FD",
backInsetShadow: "inset 0 0 6px 2px rgba(255,255,255,0.35)",
flapFill: "#3a9ae8",
flapFillOpacity: 0.45,
flapStroke: "#7ec8ff",
flapInsetColor: "0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.12 0",
cardFill: "#F1F1F1",
cardStroke: "#E0E0E0",
cardLineFill: "#D4D4D4",
cardInsetColor: "0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0",
},
} as const;
const sizeScales = { sm: 0.65, md: 1, lg: 1.35 } as const;
export type FolderProps = Omit<React.ComponentProps<"div">, "color"> & {
color?: keyof typeof themes;
size?: keyof typeof sizeScales;
/** Libellé du bouton pour les lecteurs d'écran. */
label?: string;
};
const BASE_WIDTH = 321;
const BASE_HEIGHT = 270;
const FLAP_PATH =
"M0 25C0 11.1929 11.1929 0 25 0H136.084C143.044 0 149.689 2.90139 154.42 8.00608L178.08 33.5343C182.811 38.639 189.456 41.5404 196.416 41.5404H296C309.807 41.5404 321 52.7333 321 66.5404V216C321 229.807 309.807 241 296 241H25C11.1929 241 0 229.807 0 216V25Z";
/* ---------- Poses. Reprises telles quelles de la source. ---------- */
type Pose = { x: number; y: number; r: number };
const CARDS: Record<"rest" | "hover" | "open", [Pose, Pose, Pose]> = {
rest: [
{ x: 40, y: -10, r: 10 },
{ x: 3, y: -20, r: 2 },
{ x: -40, y: -22, r: -5 },
],
hover: [
{ x: 40, y: -30, r: 14 },
{ x: 3, y: -35, r: -1 },
{ x: -40, y: -44, r: -9 },
],
open: [
{ x: 70, y: -160, r: 18 },
{ x: 0, y: -180, r: -3 },
{ x: -65, y: -170, r: -14 },
],
};
const FLAP: Record<"rest" | "hover" | "open", number> = {
rest: -15,
hover: -45,
open: -55,
};
/* Décalages d'entrée, en millisecondes. La fiche du dessus part la
dernière : c'est ce qui fait lire un éventail plutôt qu'un bloc. */
const DELAYS: Record<"rest" | "hover" | "open", [number, number, number]> = {
rest: [0, 0, 0],
hover: [120, 60, 0],
open: [100, 50, 0],
};
/* ---------- Oscillateur amorti ---------- */
const STIFFNESS = 120;
const DAMPING = 13;
const MASS = 1;
const SUBSTEP = 1 / 240; /* pas fixe : le mouvement est identique à 60 et 120 Hz */
const REST_X = 0.01;
const REST_V = 0.02;
type Spring = { cur: number; vel: number; to: number; next: number | null; at: number };
const makeSpring = (v: number): Spring => ({ cur: v, vel: 0, to: v, next: null, at: 0 });
function aim(s: Spring, to: number, delay: number, now: number, snap: boolean) {
if (snap) {
s.to = to;
s.cur = to;
s.vel = 0;
s.next = null;
return;
}
if (delay <= 0) {
s.to = to;
s.next = null;
return;
}
s.next = to;
s.at = now + delay;
}
/** Avance un ressort de `dt` secondes. Rend `true` tant qu'il bouge. */
function step(s: Spring, dt: number, now: number): boolean {
if (s.next !== null && now >= s.at) {
s.to = s.next;
s.next = null;
}
let t = dt;
while (t > 0) {
const h = t < SUBSTEP ? t : SUBSTEP;
const a = (-STIFFNESS * (s.cur - s.to) - DAMPING * s.vel) / MASS;
s.vel += a * h;
s.cur += s.vel * h;
t -= h;
}
return (
s.next !== null || Math.abs(s.cur - s.to) > REST_X || Math.abs(s.vel) > REST_V
);
}
export default function Folder({
color = "black",
size = "md",
label = "Ouvrir le dossier",
className,
...rest
}: FolderProps) {
const theme = themes[color] ?? themes.black;
const scale = sizeScales[size] ?? 1;
const [open, setOpen] = useState(false);
const [lit, setLit] = useState(false);
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
const flapRef = useRef<HTMLDivElement>(null);
const raf = useRef<number | null>(null);
const last = useRef(0);
/* Dix ressorts : trois par fiche, plus le rabat. Créés une seule fois. */
const springs = useRef(
CARDS.rest.map((p) => ({
x: makeSpring(p.x),
y: makeSpring(p.y),
r: makeSpring(p.r),
}))
);
const flapSpring = useRef(makeSpring(FLAP.rest));
const paint = useCallback(() => {
springs.current.forEach((s, i) => {
const el = cardRefs.current[i];
if (!el) return;
el.style.transform = `translate(-50%, -50%) translate(${s.x.cur.toFixed(
2
)}px, ${s.y.cur.toFixed(2)}px) rotate(${s.r.cur.toFixed(2)}deg)`;
});
const flap = flapRef.current;
if (flap) {
flap.style.transform = `translate(-50%, -50%) rotateX(${flapSpring.current.cur.toFixed(
2
)}deg)`;
}
}, []);
const loop = useCallback(
(now: number) => {
/* Plafond : un onglet remis au premier plan renvoie un écart énorme,
et sans plafond le ressort explose au lieu de reprendre. */
const dt = last.current ? Math.min((now - last.current) / 1000, 0.064) : SUBSTEP;
last.current = now;
let moving = false;
for (const s of springs.current) {
moving = step(s.x, dt, now) || moving;
moving = step(s.y, dt, now) || moving;
moving = step(s.r, dt, now) || moving;
}
moving = step(flapSpring.current, dt, now) || moving;
paint();
if (moving) {
raf.current = requestAnimationFrame(loop);
} else {
raf.current = null;
last.current = 0;
}
},
[paint]
);
const run = useCallback(() => {
if (raf.current === null) {
last.current = 0;
raf.current = requestAnimationFrame(loop);
}
}, [loop]);
/* Une pose = un état. On vise, la boucle fait le reste. */
useEffect(() => {
const mode = open ? "open" : lit ? "hover" : "rest";
const snap =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const now = typeof performance !== "undefined" ? performance.now() : 0;
CARDS[mode].forEach((pose, i) => {
const s = springs.current[i];
const d = DELAYS[mode][i];
aim(s.x, pose.x, d, now, snap);
aim(s.y, pose.y, d, now, snap);
aim(s.r, pose.r, d, now, snap);
});
aim(flapSpring.current, FLAP[mode], 0, now, snap);
if (snap) paint();
else run();
}, [open, lit, paint, run]);
useEffect(() => {
paint();
return () => {
if (raf.current !== null) cancelAnimationFrame(raf.current);
};
}, [paint]);
return (
<div
data-slot="folder"
className={["fld", className].filter(Boolean).join(" ")}
{...rest}
>
{/* Un bouton, pas une div : sans ça le dossier ne s'ouvre qu'à la
souris. Le focus vaut survol, comme partout sur le site. */}
<button
type="button"
className="fld__box"
aria-expanded={open}
aria-label={label}
style={
{
"--fld-w": `${BASE_WIDTH * scale}px`,
"--fld-h": `${BASE_HEIGHT * scale}px`,
"--fld-scale": scale,
"--fld-persp": `${800 * scale}px`,
} as React.CSSProperties
}
onPointerEnter={(e) => {
if (e.pointerType === "touch") return;
setLit(true);
}}
onPointerLeave={() => {
setLit(false);
setOpen(false);
}}
onFocus={() => setLit(true)}
onBlur={() => {
setLit(false);
setOpen(false);
}}
onClick={() => setOpen((o) => !o)}
>
<span className="fld__scene">
<span
className="fld__back"
style={{
backgroundColor: theme.backFill,
boxShadow: theme.backInsetShadow,
}}
/>
{[0, 1, 2].map((i) => (
<div
key={i}
className="fld__card"
ref={(el) => {
cardRefs.current[i] = el;
}}
>
<Card id={i + 1} theme={theme} />
</div>
))}
<div className="fld__flap" ref={flapRef}>
<span
className="fld__glass"
style={{
clipPath: `path('${FLAP_PATH}')`,
WebkitClipPath: `path('${FLAP_PATH}')`,
}}
/>
<svg
className="fld__flapart"
width="321"
height="241"
viewBox="0 0 321 241"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<g filter="url(#fld-flap-inset)">
<path
d={FLAP_PATH}
fill={theme.flapFill}
fillOpacity={theme.flapFillOpacity}
/>
<path
d="M25 0.5H136.084C142.905 0.5 149.417 3.3431 154.054 8.3457L177.713 33.874C182.539 39.0808 189.317 42.04 196.416 42.04H296C309.531 42.04 320.5 53.0092 320.5 66.54V216C320.5 229.531 309.531 240.5 296 240.5H25C11.469 240.5 0.5 229.531 0.5 216V25C0.5 11.469 11.469 0.5 25 0.5Z"
stroke={theme.flapStroke}
/>
</g>
<defs>
<filter
id="fld-flap-inset"
x="-25.4"
y="-25.4"
width="371.8"
height="291.8"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset />
<feGaussianBlur stdDeviation="2.65" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix type="matrix" values={theme.flapInsetColor} />
<feBlend mode="normal" in2="shape" result="effect1_innerShadow" />
</filter>
</defs>
</svg>
</div>
</span>
</button>
</div>
);
}
export type { FolderProps as FolderComponentProps };
/* ---------- La fiche ---------- */
type Theme = (typeof themes)[keyof typeof themes];
/* Les dix-huit lignes de texte sont régulières : pas de 14,1184 px, deux
colonnes. On les engendre plutôt que de recopier dix-huit rectangles :
même géométrie, un cinquième du fichier. */
const ROWS = 9;
const ROW_STEP = 14.1184;
const COLS = [
{ x: 14.8253, y0: 60.9939, m: "1 -0.000409158 0.00201956 0.999998" },
{ x: 84.4303, y0: 60.9617, m: "1 -0.000461045 0.00179228 0.999998" },
];
function Card({ id, theme }: { id: number; theme: Theme }) {
const filterId = `fld-card-inset-${id}`;
return (
<div data-slot="folder-card">
<svg
width="164"
height="214"
viewBox="0 0 164 214"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<g filter={`url(#${filterId})`}>
<rect width="163.078" height="213.262" rx="20" fill={theme.cardFill} />
</g>
<rect
x="0.5"
y="0.5"
width="162.078"
height="212.262"
rx="19.5"
stroke={theme.cardStroke}
/>
{/* Le titre */}
<rect
x="14.1193"
y="31.2091"
width="134.84"
height="11.8892"
rx="5.94459"
fill={theme.cardLineFill}
/>
{/* Le corps */}
{COLS.flatMap((col, c) =>
Array.from({ length: ROWS }, (_, r) => (
<rect
key={`${c}-${r}`}
width="64.5183"
height="5.88276"
rx="2.94138"
transform={`matrix(${col.m} ${col.x} ${(col.y0 + r * ROW_STEP).toFixed(4)})`}
fill={theme.cardLineFill}
/>
))
)}
<defs>
<filter
id={filterId}
x="0"
y="0"
width="166.078"
height="218.262"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feMorphology
radius="2"
operator="erode"
in="SourceAlpha"
result={`erode_${id}`}
/>
<feOffset dx="3" dy="5" />
<feGaussianBlur stdDeviation="3.05" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix type="matrix" values={theme.cardInsetColor} />
<feBlend mode="normal" in2="shape" result={`inner_${id}`} />
</filter>
</defs>
</svg>
</div>
);
}
/* Dossier · src/components/book/kit/Folder.tsx */
.fld {
position: relative;
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
}
/* Un bouton, remis à plat : c'est la zone de survol et de clic. */
.fld__box {
position: relative;
width: var(--fld-w);
height: var(--fld-h);
padding: 0;
border: 0;
background: none;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.fld__box:focus-visible {
outline: 2px solid var(--fg, #1c1c1c);
outline-offset: 6px;
border-radius: 25px;
}
/* La scène porte l'échelle et la perspective. Les enfants travaillent
tous en coordonnées 321 × 270, quelle que soit la taille demandée. */
.fld__scene {
position: absolute;
top: 50%;
left: 50%;
display: block;
width: 321px;
height: 270px;
transform: translate(-50%, -50%) scale(var(--fld-scale));
perspective: var(--fld-persp);
}
.fld__back {
position: absolute;
top: 50%;
left: 50%;
display: block;
width: 321px;
height: 270px;
border-radius: 25px;
transform: translate(-50%, -50%);
}
/* Les fiches. Le `transform` complet est écrit par le ressort à chaque
image. D'où l'absence de `transition` : les deux se battraient. */
.fld__card {
position: absolute;
top: 50%;
left: 50%;
will-change: transform;
}
/* Le rabat pivote autour de son arête basse. Les 16 px de décalage
reproduisent le `mt-4` de la source. */
.fld__flap {
position: absolute;
top: 50%;
left: 50%;
width: 321px;
height: 241px;
margin-top: 16px;
transform-origin: bottom center;
transform-style: preserve-3d;
will-change: transform;
}
.fld__glass {
position: absolute;
inset: 0;
display: block;
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
transform: translateZ(0);
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
will-change: transform;
}
.fld__flapart {
position: absolute;
inset: 0;
}
/* Sans mouvement, les poses sont posées d'un coup par le composant :
il n'y a rien à neutraliser ici, mais le rabat ne doit pas rester
flou pendant un changement d'état instantané. */
@media (prefers-reduced-motion: reduce) {
.fld__card,
.fld__flap {
will-change: auto;
}
}