GSAP & Scroll Motion
3D Curved Portfolio Showcase Fan with Smooth Hover Elevation
Project tiles sit on an arc (rotation and depth from each tile's distance to the centre); the hovered tile lifts and its neighbours ease outward with quickTo, so nothing re-lays out.
Works in Webflow Framer React Zero CLS
Customize before you copy
Showing the Webflow version
You are a creative developer. Build a "curved fan" portfolio showcase.
Layout:
- 5–9 project tiles (image + title) in a row, fanned on an arc like a hand of cards.
- For tile i with offset d = i - centreIndex: rotation = d * 7deg, translateY = d² * 10px, z-index = 100 - |d|. transform-origin: 50% 120% so the arc pivots below the row.
- The row has perspective: 1200px; each tile gets a subtle rotateY = d * -4deg for depth.
Interaction:
- On pointer enter of a tile: lift it (y -28px, scale 1.06, rotation 0, on top), push neighbours outward by 18px / |d| along x, and dim the rest to opacity 0.75.
- On pointer leave of the row: everything eases back to its arc pose.
- Rapid hovers must never stack tweens (retarget the running motion instead); duration 0.8s, ease "expo.out".
- Keyboard: tiles are links; focus-visible triggers the same lift.
Constraints:
- Transforms and opacity only; tile images sized with width/height attributes and aspect-ratio (no CLS).
- On touch / coarse pointers, skip the fan and render a horizontal scroll-snap row instead.
- Reduced motion: keep the static arc, no hover motion.
Deliver the structure, CSS and the script with a cleanup.
Target platform: Webflow.
- Build the structure as native Webflow Designer elements with Client-First class names (section_[name], [name]_component, [name]_item). No div soup from an HTML embed unless I ask for one.
- Hook every behaviour to custom attributes (data-gsap="…"), never to class names, so designers can restyle without breaking the script.
- Load GSAP through Webflow's built-in GSAP integration (Site settings) or the jsDelivr CDN tags, and put the init script in Page settings → Before </body> tag, wrapped in Webflow.push(() => { … }).
- Say which parts go in Designer styles, which in a page-head <style>, and which in the body script.
- Designer: work_fan (data-gsap="fan") > work_fan-tile Link Blocks (data-gsap="fan-tile"), ideally a CMS list limited to 5–9 items.
Motion timing: "Editorial". Use a base duration of 0.8s, ease "expo.out" (CSS: cubic-bezier(0.16, 1, 0.3, 1)), stagger 0.08s, scrub smoothing 0.8, and springs of stiffness 220 / damping 22, everywhere a duration, ease or spring appears.<!-- Designer: div.work_fan [data-gsap="fan"] > a.work_fan-tile [data-gsap="fan-tile"] ×5–9 -->
<!-- Page settings > Inside <head> -->
<style>
[data-gsap="fan"] { display: flex; justify-content: center; perspective: 1200px; padding: 80px 0; }
[data-gsap="fan-tile"] { flex: 0 0 auto; width: 180px; margin: 0 -26px; transform-origin: 50% 120%;
border-radius: 12px; overflow: hidden; }
[data-gsap="fan-tile"] img { display: block; width: 100%; height: auto; aspect-ratio: 4 / 5; object-fit: cover; }
@media (pointer: coarse) {
[data-gsap="fan"] { justify-content: flex-start; overflow-x: auto; scroll-snap-type: x mandatory; perspective: none; }
[data-gsap="fan-tile"] { margin: 0 8px; scroll-snap-align: center; }
}
</style>
<!-- Page settings > Before </body> tag -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>
window.Webflow ||= [];
window.Webflow.push(() => {
const row = document.querySelector('[data-gsap="fan"]');
if (!row || !matchMedia("(pointer: fine)").matches) return;
const tiles = gsap.utils.toArray('[data-gsap="fan-tile"]', row);
const mid = (tiles.length - 1) / 2;
const pose = (d) => ({ x: 0, y: d * d * 10, rotation: d * 7, rotationY: d * -4, scale: 1 });
tiles.forEach((t, i) => gsap.set(t, { ...pose(i - mid), zIndex: 100 - Math.abs(i - mid) }));
if (matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const opts = { duration: 0.8, ease: "expo.out" };
const to = tiles.map((t) => ({
x: gsap.quickTo(t, "x", opts), y: gsap.quickTo(t, "y", opts),
r: gsap.quickTo(t, "rotation", opts), s: gsap.quickTo(t, "scale", opts),
}));
const lift = (active) => tiles.forEach((t, i) => {
const d = i - mid, gap = i - active, p = pose(d);
const on = i === active;
to[i].x(on ? 0 : Math.sign(gap) * 18 / Math.abs(gap));
to[i].y(on ? -28 : p.y); to[i].r(on ? 0 : p.rotation); to[i].s(on ? 1.06 : 1);
gsap.set(t, { zIndex: on ? 200 : 100 - Math.abs(d) });
gsap.to(t, { opacity: on ? 1 : 0.75, duration: 0.8 * 0.6, overwrite: "auto" });
});
const reset = () => tiles.forEach((t, i) => {
const p = pose(i - mid);
to[i].x(0); to[i].y(p.y); to[i].r(p.rotation); to[i].s(1);
gsap.to(t, { opacity: 1, duration: 0.8 * 0.6, overwrite: "auto" });
gsap.set(t, { zIndex: 100 - Math.abs(i - mid) });
});
tiles.forEach((t, i) => { t.addEventListener("pointerenter", () => lift(i)); t.addEventListener("focus", () => lift(i)); });
row.addEventListener("pointerleave", reset);
row.addEventListener("focusout", (e) => { if (!row.contains(e.relatedTarget)) reset(); });
});
</script>3-step implementation guide
Step 1
DOM & attribute setup
Build a single row of 5–9 linked tiles with fixed-ratio images (width and height set). Give the row perspective and let each tile overlap its neighbours slightly with negative margins.
Step 2
Prompt Claude or drop in the snippet
Paste the prompt for your platform, or drop in the snippet. It places every tile on the arc from its distance to the centre and retargets one running motion per tile on hover, so fast mouse sweeps never queue tweens.
Step 3
Mobile & Core Web Vitals check
On a phone, check you get a swipeable scroll-snap row, not a tiny fan. On desktop, tab through the tiles: each should lift on focus. Lighthouse should show no CLS from the tiles.
Building a full site like this?
Skip the manual setup — I ship complete Figma-to-Webflow & Framer builds in 5–10 business days, or can wire this exact interaction into your live site in 48 hours ($250).