Le Livre

Dossier

Trois fiches dans une chemise qui s'entrouvre au survol et s'ouvre au clic.

Réglages
Couleur

Survolez : les fiches se soulèvent. Cliquez : le dossier s’ouvre.

Fiche

Chapitre
Objets
Nom courant
Folder
Publié le
1er septembre 2026
Fichiers
Folder.tsx, Folder.css
Dépendances
Aucune

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.

Folder.tsxL106–110
const DELAYS: Record<"rest" | "hover" | "open", [number, number, number]> = {
  rest: [0, 0, 0],
  hover: [120, 60, 0],
  open: [100, 50, 0],
};
Trois nombres. La fiche du dessus part la dernière : c'est ce qui fait lire un éventail plutôt qu'un bloc collé.

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.

Folder.tsxL187–200
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)`;
  }
Dix transformations écrites à la main dans le DOM, à chaque image. Aucun état React ne bouge pendant le mouvement.

Ce qui coince

  • Porté sans bibliothèque d'animation. La source utilisait motion/react, Tailwind et un utilitaire cn. 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.
    Folder.tsxL147–153
    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.
    Folder.tsxL273–277
    <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.
    Folder.tsxL207
    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. Une transition sur la même propriété se battrait avec lui, et le mouvement deviendrait mou sans qu'on comprenne pourquoi.
    Folder.cssL55–62
    /* 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>
  );
}

Ailleurs dans le Livre

Sedan, disponible pour de nouveaux projetsObjets · publié le 1er septembre 2026