← Catalogue

Scroll Cards

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

#scroll#gsap#cards#horizontal#pin

Aperçu

Scroll Cards

Code source

HTML
<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>
CSS
/* Scroll Cards — des cartes traversent l'écran en diagonale 3D au fil du
scroll, 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 pour
permettre 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;
}
JS
(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-droite
const rotZ = t * 10; // suit la pente
const rotY = t * 42; // 3D : couchée aux bords, droite au centre
const 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);
}
})();