Des cartes traversent l'écran en diagonale 3D au fil du scroll, dans une section épinglée. GSAP + ScrollTrigger.

<section class="wtl-scroll-cards" id="wtlScrollCards" aria-label="Cartes au scroll"><div class="wtl-scroll-cards__head"><h2>Une destination par carte.</h2><p>Faites défiler : les cartes traversent l'écran en diagonale, pilotées par le scroll.</p></div><div class="wtl-scroll-cards__pin"><!-- Les cartes sont générées en JS depuis le tableau PLACES (script.js). --><div class="wtl-scroll-cards__track" id="wtlScrollCardsTrack"></div></div><div class="wtl-scroll-cards__hint">Défilez pour explorer</div></section>
/* Scroll Cards — des cartes traversent l'écran en diagonale 3D au fil duscroll, dans une section épinglée. Police Geist.GSAP + ScrollTrigger sont chargés automatiquement par script.js. */.wtl-scroll-cards {--wtl-sc-card-w: clamp(220px, 22vw, 320px);position: relative;background: #151313;color: #fff;font-family: "Geist", system-ui, sans-serif;}/* Le conteneur d'aperçu centre le body : on rétablit un flux normal pourpermettre le défilement (sans effet en intégration : body est déjà block). */body {display: block;overflow-x: hidden;}.wtl-scroll-cards__pin {height: 100vh;overflow: hidden;display: flex;align-items: center;position: relative;}/* En-tête superposé */.wtl-scroll-cards__head {position: absolute;top: clamp(2.5rem, 6vh, 5rem);left: 0;right: 0;text-align: center;z-index: 3;pointer-events: none;padding: 0 1.5rem;}.wtl-scroll-cards__head h2 {margin: 0;font-size: clamp(2rem, 1.2rem + 3vw, 3.6rem);letter-spacing: -0.045em;line-height: 1;}.wtl-scroll-cards__head p {margin: 0.9rem auto 0;max-width: 46ch;color: rgba(255, 255, 255, 0.6);font-weight: 500;font-size: clamp(0.95rem, 0.9rem + 0.3vw, 1.15rem);}/* Piste + cartes */.wtl-scroll-cards__track {display: flex;gap: clamp(60px, 7vw, 130px);padding-left: 4vw;will-change: transform;}.wtl-scroll-card {flex: 0 0 auto;width: var(--wtl-sc-card-w);will-change: transform;filter: drop-shadow(0 24px 40px rgba(0, 0, 0, 0.45));}.wtl-scroll-card svg {width: 100%;height: auto;display: block;}.wtl-scroll-card__name {font-family: "Geist", system-ui, sans-serif;font-weight: 700;}/* Indice de scroll */.wtl-scroll-cards__hint {position: absolute;bottom: 1.8rem;left: 50%;transform: translateX(-50%);z-index: 3;font-family: "Geist", system-ui, sans-serif;text-transform: uppercase;letter-spacing: 0.18em;font-size: 0.72rem;color: rgba(255, 255, 255, 0.45);}/* Repli « mouvement réduit » ou GSAP absent : liste défilable horizontale */.wtl-scroll-cards.is-static .wtl-scroll-cards__pin {height: auto;overflow-x: auto;padding: 8rem 0;}.wtl-scroll-cards.is-static .wtl-scroll-cards__track {padding: 0 6vw;}
(function () {"use strict";/* --- Données : une entrée = une carte. Remplace par tes propres lieux/visuels. --- */// Images cross-origin : utiliser une source CORS (Unsplash) — les services// qui redirigent (picsum…) sont bloqués par l'ORB dans une iframe sandbox.const Q = "?w=640&h=900&fit=crop&q=70";const PLACES = [{ name: "Lisbonne", color: "#048fc2", ink: "#06222c", img: "https://images.unsplash.com/photo-1585208798174-6cedd86e019a" + Q },{ name: "Porto", color: "#f2b705", ink: "#2a1f00", img: "https://images.unsplash.com/photo-1555881400-74d7acaacd8b" + Q },{ name: "Séville", color: "#e8552b", ink: "#2a0c02", img: "https://images.unsplash.com/photo-1559386081-325882507af7" + Q },{ name: "Bordeaux", color: "#1f9d6b", ink: "#06281b", img: "https://images.unsplash.com/photo-1506744038136-46273834b3fb" + Q },{ name: "Nantes", color: "#e94f6b", ink: "#2c0712", img: "https://images.unsplash.com/photo-1431274172761-fca41d930114" + Q },{ name: "Lyon", color: "#c0e84f", ink: "#1e2a02", img: "https://images.unsplash.com/photo-1524396309943-e03f5249f002" + Q },{ name: "Paris", color: "#163d31", ink: "#eafff5", img: "https://images.unsplash.com/photo-1502602898657-3e91760cbb34" + Q },];const GSAP_CDN = "https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js";const ST_CDN = "https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/ScrollTrigger.min.js";function loadScript(src) {return new Promise((resolve, reject) => {const s = document.createElement("script");s.src = src;s.onload = resolve;s.onerror = reject;document.head.appendChild(s);});}async function ensureGsap() {if (!window.gsap) await loadScript(GSAP_CDN);if (window.gsap && !window.ScrollTrigger) await loadScript(ST_CDN);}function buildCards(track) {const VB_W = 300;const VB_H = 416;// Ordre inversé : avec un défilement gauche → droite, la carte la plus à// droite mène la danse → la première du tableau apparaît en premier.PLACES.slice().reverse().forEach((c, i) => {const nm = c.name.toUpperCase();const fs = nm.length > 8 ? 30 : 36;const card = document.createElement("article");card.className = "wtl-scroll-card";// La photo est un <img> HTML dans un <foreignObject> : un <image> SVG// cross-origin est bloqué par l'ORB de Chrome, pas un <img> HTML.card.innerHTML = `<svg viewBox="0 0 ${VB_W} ${VB_H}" role="img" aria-label="${c.name}"><defs><clipPath id="wtl-sc-dome-${i}"><path d="M14 40 Q14 14 40 14 H260 Q286 14 286 40 V298 Q150 350 14 298 Z"/></clipPath><path id="wtl-sc-arc-${i}" d="M44 356 Q150 398 256 356"/></defs><rect x="0" y="0" width="${VB_W}" height="${VB_H}" rx="30" fill="${c.color}"/><foreignObject x="14" y="14" width="272" height="284" clip-path="url(#wtl-sc-dome-${i})"><img src="${c.img}" alt=""style="width:100%;height:100%;object-fit:cover;display:block" /></foreignObject><text class="wtl-scroll-card__name" fill="${c.ink}" font-size="${fs}"text-anchor="middle" textLength="200" lengthAdjust="spacingAndGlyphs"><textPath href="#wtl-sc-arc-${i}" startOffset="50%">${nm}</textPath></text></svg>`;track.appendChild(card);});}function init() {const track = document.getElementById("wtlScrollCardsTrack");const section = document.getElementById("wtlScrollCards");if (!track || !section) return;buildCards(track);const cards = Array.from(track.children);const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;// Repli : pas d'animation → liste défilable horizontale simple.if (reduce || !window.gsap || !window.ScrollTrigger) {section.classList.add("is-static");return;}gsap.registerPlugin(ScrollTrigger);let vw = 0;let centers = [];let startX = 0;let endX = 0;let trackW = 0;function measure() {vw = window.innerWidth;centers = cards.map((c) => c.offsetLeft + c.offsetWidth / 2);trackW = track.scrollWidth;const cw = cards[0].offsetWidth;const first = centers[0];const last = centers[centers.length - 1];startX = vw * 0.2 - last; // au départ : carte de tête à gauche// En fin de course, la dernière carte sort complètement par la droite// (son centre dépasse le bord droit d'une largeur de carte).endX = vw + cw - first;}const clamp = (v, a, b) => Math.max(a, Math.min(b, v));function render(p) {const x = startX + (endX - startX) * p;gsap.set(track, { x });for (let i = 0; i < cards.length; i++) {const screenCenter = x + centers[i];const t = clamp((screenCenter - vw / 2) / vw, -1.3, 1.3);const y = -t * 200 + t * t * 70; // diagonale bas-gauche → haut-droiteconst rotZ = t * 10; // suit la penteconst rotY = t * 42; // 3D : couchée aux bords, droite au centreconst sc = Math.max(0.72, 1.1 - Math.abs(t) * 0.18);gsap.set(cards[i], {x: 0,y,rotationZ: rotZ,rotationY: rotY,scale: sc,zIndex: Math.round((1.3 - Math.abs(t)) * 100),transformPerspective: 1100,transformOrigin: "50% 50%",force3D: true,});}}// Le scroll alimente une cible ; un lerp dans le ticker lisse le rendu// → glissement fluide quelle que soit la granularité de la molette.let targetP = 0;let currentP = 0;ScrollTrigger.create({trigger: section,start: "top top",end: () => "+=" + Math.round(trackW * 1.05 + vw * 0.5),pin: ".wtl-scroll-cards__pin",invalidateOnRefresh: true,onRefresh: measure,onUpdate: (self) => {targetP = self.progress;},});gsap.ticker.add(() => {currentP += (targetP - currentP) * 0.1;render(currentP);});measure();render(0);}// Mouvement réduit : inutile de charger GSAP, on part direct sur le repli.if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {init();} else {ensureGsap().catch(() => {}).finally(init);}})();