# Water Ripple Showcase

> Scroll-driven work showcase with interactive water-ripple photos, pinned one-project-per-screen scrolling, and a thumbnail rail.

**Definition:** Water Ripple Showcase is a components UI animation from collections.html. It is built with HTML, CSS, JavaScript, and GSAP, ships with a live preview, and is free to copy into personal and commercial projects.

## Facts
- Category: Components
- Stack: HTML, CSS, JavaScript, and GSAP
- Added: 2026-09-06
- Canonical page: https://html.bosconvertpro.com/collection/water-ripple-showcase
- Full-page preview: https://html.bosconvertpro.com/collection/water-ripple-showcase/full
- Live demo: https://html.bosconvertpro.com/demos/water-ripple-showcase/index.html
- Markdown mirror: https://html.bosconvertpro.com/collection/water-ripple-showcase/index.md
- License: https://html.bosconvertpro.com/terms

## How to use
1. Open https://html.bosconvertpro.com/collection/water-ripple-showcase/full to preview the animation in full screen.
2. Copy index.html, styles.css, and script.js from the code tabs on https://html.bosconvertpro.com/collection/water-ripple-showcase.
3. Paste the files into your project and adjust timing, easing, and content as needed.

## FAQ
### What is Water Ripple Showcase?

Water Ripple Showcase is a components UI animation from collections.html. Scroll-driven work showcase with interactive water-ripple photos, pinned one-project-per-screen scrolling, and a thumbnail rail.

### What is Water Ripple Showcase built with?

Water Ripple Showcase is built with HTML, CSS, JavaScript, and GSAP. The source for every file is shown on the animation page and can be copied straight into your project.

### How do I use Water Ripple Showcase in my project?

Open the animation page (https://html.bosconvertpro.com/collection/water-ripple-showcase), preview it live, then copy index.html, styles.css, and script.js from the code tabs. Paste the files into your project and adjust timing, easing, and content as needed. A standalone demo runs at https://html.bosconvertpro.com/demos/water-ripple-showcase/index.html.

### Is Water Ripple Showcase free to use?

Yes. The Water Ripple Showcase demo code may be used in personal and commercial projects. You may not resell or redistribute the collection itself as a competing template pack. Full terms: https://html.bosconvertpro.com/terms.

## Source — index.html
````html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Water Ripple Showcase — Scroll Animation with Interactive Water</title>
<meta name="description" content="Water Ripple Showcase folder: a scroll-driven showcase with interactive water-ripple photos, pinned one-project-per-screen scrolling, odometer counter, thumbnail rail and mobile swipe carousel, built with HTML, CSS, JS and GSAP." />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;600;700&family=Archivo:wght@400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="styles.css" />
</head>
<body>

<section id="showcase" class="showcase" style="--total:6" aria-label="Featured work">

  <div class="desktop-view" aria-hidden="false">
    <div class="pinned-stage">

      <div class="photo-zone">
        <div class="photo-stage" id="stage">
          <canvas id="water-canvas" aria-hidden="true"></canvas>

          <figure class="photo-slide is-active" data-index="0">
            <div class="photo-mask"><img src="https://images.unsplash.com/photo-1477959858617-67f85cf4f1df?auto=format&fit=crop&w=1600&q=80" alt="Luminous Drift" draggable="false" /></div>
            <span class="photo-shine" aria-hidden="true"></span>
          </figure>
          <figure class="photo-slide" data-index="1">
            <div class="photo-mask"><img src="https://images.unsplash.com/photo-1449824913935-59a10b8d2000?auto=format&fit=crop&w=1600&q=80" alt="Velvet Horizon" loading="lazy" draggable="false" /></div>
            <span class="photo-shine" aria-hidden="true"></span>
          </figure>
          <figure class="photo-slide" data-index="2">
            <div class="photo-mask"><img src="https://images.unsplash.com/photo-1514565131-fce0801e5785?auto=format&fit=crop&w=1600&q=80" alt="Prism Bloom" loading="lazy" draggable="false" /></div>
            <span class="photo-shine" aria-hidden="true"></span>
          </figure>
          <figure class="photo-slide" data-index="3">
            <div class="photo-mask"><img src="https://images.unsplash.com/photo-1519501025264-65ba15a82390?auto=format&fit=crop&w=1600&q=80" alt="Mono No Aware" loading="lazy" draggable="false" /></div>
            <span class="photo-shine" aria-hidden="true"></span>
          </figure>
          <figure class="photo-slide" data-index="4">
            <div class="photo-mask"><img src="https://images.unsplash.com/photo-1480714378408-67cf0d13bc1b?auto=format&fit=crop&w=1600&q=80" alt="Glass Garden" loading="lazy" draggable="false" /></div>
            <span class="photo-shine" aria-hidden="true"></span>
          </figure>
          <figure class="photo-slide" data-index="5">
            <div class="photo-mask"><img src="https://images.unsplash.com/photo-1444723121867-7a241cacace9?auto=format&fit=crop&w=1600&q=80" alt="Silent Echo" loading="lazy" draggable="false" /></div>
            <span class="photo-shine" aria-hidden="true"></span>
          </figure>
          <div class="photo-grain" aria-hidden="true"></div>
        </div>
      </div>

      <div class="overlay-text">
        <header class="top-bar">
          <span class="slide-count"><span id="desk-count">01</span><i>/</i>06</span>
        </header>

        <div class="title-stack">
          <h3 class="slide-title is-active" data-index="0"><span class="word">Luminous</span> <span class="word">Drift</span></h3>
          <h3 class="slide-title" data-index="1"><span class="word">Velvet</span> <span class="word">Horizon</span></h3>
          <h3 class="slide-title" data-index="2"><span class="word">Prism</span> <span class="word">Bloom</span></h3>
          <h3 class="slide-title" data-index="3"><span class="word">Mono</span> <span class="word">No</span> <span class="word">Aware</span></h3>
          <h3 class="slide-title" data-index="4"><span class="word">Glass</span> <span class="word">Garden</span></h3>
          <h3 class="slide-title" data-index="5"><span class="word">Silent</span> <span class="word">Echo</span></h3>
        </div>

        <div class="project-tags">
          <ul class="tag-list is-active" data-index="0"><li>Brand Identity</li><li>Art Direction</li><li>Motion Design</li><li>Production</li></ul>
          <ul class="tag-list" data-index="1"><li>Visual Direction</li><li>Set Design</li><li>Photography</li></ul>
          <ul class="tag-list" data-index="2"><li>Creative Direction</li><li>CGI Art</li><li>Concept Design</li></ul>
          <ul class="tag-list" data-index="3"><li>Direction</li><li>Film Production</li><li>Sound Design</li></ul>
          <ul class="tag-list" data-index="4"><li>Experiential</li><li>Installation</li><li>Lighting Design</li></ul>
          <ul class="tag-list" data-index="5"><li>Web Experience</li><li>Interaction</li><li>Development</li></ul>
        </div>
      </div>

      <aside class="thumb-rail" aria-label="Projects">
        <div class="rail-track" id="rail-track">
          <span class="rail-marker" id="rail-marker" aria-hidden="true"></span>
          <button class="rail-thumb is-active" data-to="0" aria-label="Go to project 1" aria-current="true"><img src="https://images.unsplash.com/photo-1477959858617-67f85cf4f1df?auto=format&fit=crop&w=200&q=70" alt="" /></button>
          <button class="rail-thumb" data-to="1" aria-label="Go to project 2"><img src="https://images.unsplash.com/photo-1449824913935-59a10b8d2000?auto=format&fit=crop&w=200&q=70" alt="" loading="lazy" /></button>
          <button class="rail-thumb" data-to="2" aria-label="Go to project 3"><img src="https://images.unsplash.com/photo-1514565131-fce0801e5785?auto=format&fit=crop&w=200&q=70" alt="" loading="lazy" /></button>
          <button class="rail-thumb" data-to="3" aria-label="Go to project 4"><img src="https://images.unsplash.com/photo-1519501025264-65ba15a82390?auto=format&fit=crop&w=200&q=70" alt="" loading="lazy" /></button>
          <button class="rail-thumb" data-to="4" aria-label="Go to project 5"><img src="https://images.unsplash.com/photo-1480714378408-67cf0d13bc1b?auto=format&fit=crop&w=200&q=70" alt="" loading="lazy" /></button>
          <button class="rail-thumb" data-to="5" aria-label="Go to project 6"><img src="https://images.unsplash.com/photo-1444723121867-7a241cacace9?auto=format&fit=crop&w=200&q=70" alt="" loading="lazy" /></button>
        </div>
      </aside>
    </div>
  </div>

  <div class="mobile-view">
    <div class="mobile-top">
      <div class="step-lines" aria-hidden="true">
        <span class="step-line is-active"></span><span class="step-line"></span><span class="step-line"></span><span class="step-line"></span><span class="step-line"></span><span class="step-line"></span>
      </div>
    </div>

    <div class="picker-bar">
      <span class="mini-count"><span id="mob-count">01</span> / 06</span>
      <div class="mini-thumbs" role="tablist" aria-label="Projects">
        <button class="mini-thumb is-active" data-to="0" aria-label="Go to project 1"><img src="https://images.unsplash.com/photo-1477959858617-67f85cf4f1df?auto=format&fit=crop&w=200&q=70" alt="" /></button>
        <button class="mini-thumb" data-to="1" aria-label="Go to project 2"><img src="https://images.unsplash.com/photo-1449824913935-59a10b8d2000?auto=format&fit=crop&w=200&q=70" alt="" loading="lazy" /></button>
        <button class="mini-thumb" data-to="2" aria-label="Go to project 3"><img src="https://images.unsplash.com/photo-1514565131-fce0801e5785?auto=format&fit=crop&w=200&q=70" alt="" loading="lazy" /></button>
        <button class="mini-thumb" data-to="3" aria-label="Go to project 4"><img src="https://images.unsplash.com/photo-1519501025264-65ba15a82390?auto=format&fit=crop&w=200&q=70" alt="" loading="lazy" /></button>
        <button class="mini-thumb" data-to="4" aria-label="Go to project 5"><img src="https://images.unsplash.com/photo-1480714378408-67cf0d13bc1b?auto=format&fit=crop&w=200&q=70" alt="" loading="lazy" /></button>
        <button class="mini-thumb" data-to="5" aria-label="Go to project 6"><img src="https://images.unsplash.com/photo-1444723121867-7a241cacace9?auto=format&fit=crop&w=200&q=70" alt="" loading="lazy" /></button>
      </div>
    </div>

    <div class="card-view" id="card-view">
      <div class="card-track" id="card-track">
        <div class="city-card is-active" data-index="0"><a href="#" onclick="return false" aria-label="Luminous Drift"><img src="https://images.unsplash.com/photo-1477959858617-67f85cf4f1df?auto=format&fit=crop&w=1200&q=80" alt="Luminous Drift" /></a></div>
        <div class="city-card" data-index="1"><a href="#" onclick="return false" aria-label="Velvet Horizon"><img src="https://images.unsplash.com/photo-1449824913935-59a10b8d2000?auto=format&fit=crop&w=1200&q=80" alt="Velvet Horizon" loading="lazy" /></a></div>
        <div class="city-card" data-index="2"><a href="#" onclick="return false" aria-label="Prism Bloom"><img src="https://images.unsplash.com/photo-1514565131-fce0801e5785?auto=format&fit=crop&w=1200&q=80" alt="Prism Bloom" loading="lazy" /></a></div>
        <div class="city-card" data-index="3"><a href="#" onclick="return false" aria-label="Mono No Aware"><img src="https://images.unsplash.com/photo-1519501025264-65ba15a82390?auto=format&fit=crop&w=1200&q=80" alt="Mono No Aware" loading="lazy" /></a></div>
        <div class="city-card" data-index="4"><a href="#" onclick="return false" aria-label="Glass Garden"><img src="https://images.unsplash.com/photo-1480714378408-67cf0d13bc1b?auto=format&fit=crop&w=1200&q=80" alt="Glass Garden" loading="lazy" /></a></div>
        <div class="city-card" data-index="5"><a href="#" onclick="return false" aria-label="Silent Echo"><img src="https://images.unsplash.com/photo-1444723121867-7a241cacace9?auto=format&fit=crop&w=1200&q=80" alt="Silent Echo" loading="lazy" /></a></div>
      </div>
    </div>

    <div class="mobile-caption">
      <div class="caption-box">
        <div class="caption-titles">
          <h3 class="caption-title is-active" data-index="0">Luminous Drift</h3>
          <h3 class="caption-title" data-index="1">Velvet Horizon</h3>
          <h3 class="caption-title" data-index="2">Prism Bloom</h3>
          <h3 class="caption-title" data-index="3">Mono No Aware</h3>
          <h3 class="caption-title" data-index="4">Glass Garden</h3>
          <h3 class="caption-title" data-index="5">Silent Echo</h3>
        </div>
        <div class="caption-tags">
          <ul class="caption-tag-list is-active" data-index="0"><li>Brand Identity</li><li>Art Direction</li><li>Motion Design</li><li>Production</li></ul>
          <ul class="caption-tag-list" data-index="1"><li>Visual Direction</li><li>Set Design</li><li>Photography</li></ul>
          <ul class="caption-tag-list" data-index="2"><li>Creative Direction</li><li>CGI Art</li><li>Concept Design</li></ul>
          <ul class="caption-tag-list" data-index="3"><li>Direction</li><li>Film Production</li><li>Sound Design</li></ul>
          <ul class="caption-tag-list" data-index="4"><li>Experiential</li><li>Installation</li><li>Lighting Design</li></ul>
          <ul class="caption-tag-list" data-index="5"><li>Web Experience</li><li>Interaction</li><li>Development</li></ul>
        </div>
        <nav class="card-arrows">
          <button id="card-prev" class="navbtn" aria-label="Previous project"><svg viewBox="0 0 12 12" width="12" height="12"><path d="M7.5 1 3.5 6l4 5" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
          <button id="card-next" class="navbtn" aria-label="Next project"><svg viewBox="0 0 12 12" width="12" height="12"><path d="M4.5 1l4 5-4 5" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
        </nav>
      </div>
    </div>
  </div>
</section>

<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollTrigger.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollToPlugin.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="script.js"></script>
</body>
</html>
````

## Source — styles.css
````css
:root{
  --paper:#f1efe9;
  --navy:#051236;
  --ease-smooth:cubic-bezier(0.165,0.84,0.44,1);
  --font-main:"Inter Tight","Archivo",Helvetica,Arial,sans-serif;
  --card-width:min(37.5rem,88%);
  --card-gap:.8rem;
}
*{margin:0;padding:0;box-sizing:border-box}
html{font-size:max(2.6666666667vw,1px);-webkit-text-size-adjust:100%}
@media(min-width:768px){html{font-size:max(1.3020833333vw,1px)}}
@media(min-width:1200px){html{font-size:max(.5208333333vw,1px)}}
html,body{background:var(--navy)}
body{
  --padding-x:1.6rem;
  font-family:var(--font-main),sans-serif;
  background-color:var(--navy);
  color:var(--paper);
  overflow-x:clip;
  -webkit-font-smoothing:antialiased;
}
@media(min-width:768px){body{--padding-x:3.2rem}}
@media(min-width:1200px){body{--padding-x:4.8rem}}
img{display:block;max-width:100%}
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
a{color:inherit;text-decoration:none}

.showcase{position:relative;background-color:var(--paper);background:#f1efe9;color:#f1efe9;width:100vw;overflow:clip;isolation:isolate}

.arrow-btn{
  width:4.8rem;height:4.8rem;border:.1rem solid rgba(241,239,233,.5);display:grid;place-items:center;color:#f1efe9;
  transition:background .3s var(--ease-smooth),color .3s var(--ease-smooth),opacity .3s;pointer-events:auto;background:rgba(0,0,0,0);
}
.arrow-btn:hover{background:#f1efe9;color:#000}
.arrow-btn:disabled{opacity:.3;pointer-events:none}

.desktop-view{display:none}
@media(min-width:1200px){
  .showcase{height:calc(var(--total) * 100svh)}
  .desktop-view{display:block;height:100%}
  .mobile-view{display:none}
  .pinned-stage{position:sticky;top:0;height:100svh;height:100dvh;overflow:hidden;isolation:isolate;background:#f1efe9}
  .photo-zone{position:absolute;inset:0;display:grid;place-items:center;z-index:1}
  .photo-stage{
    position:relative;width:calc(100vw / 2.5);max-width:76rem;min-width:44rem;aspect-ratio:1.28 / 1;
  }
  #water-canvas{position:absolute;inset:0;width:100%;height:100%;display:none;cursor:pointer}
  .photo-stage.water-on #water-canvas{display:block}
  .photo-stage.water-on .photo-slide{visibility:hidden !important;opacity:0 !important}
  .photo-stage.water-on .photo-grain{display:none}
  .photo-slide{position:absolute;inset:0;visibility:hidden;opacity:0;--mx:50%;--my:50%;--r:150%}
  .photo-slide.is-active{visibility:visible;opacity:1;--r:150%}
  .photo-mask{
    position:absolute;inset:0;overflow:hidden;background:transparent;
    clip-path:circle(var(--r) at var(--mx) var(--my));
    will-change:clip-path;
  }
  .photo-mask img{width:100%;height:100%;object-fit:cover;transform:scale(1.14);will-change:transform,filter}
  .photo-slide.is-active .photo-mask img{transform:scale(1)}

  .photo-shine{
    position:absolute;inset:-1px;pointer-events:none;opacity:0;
    background:radial-gradient(circle at var(--mx) var(--my), transparent 0%, transparent calc(var(--r) - 12%), rgba(241,239,233,.22) calc(var(--r) - 2%), transparent var(--r));
    mix-blend-mode:screen;
  }
  .photo-grain{
    position:absolute;inset:-20%;pointer-events:none;opacity:.09;
    background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
  }

  .overlay-text{position:absolute;inset:0;z-index:5;mix-blend-mode:difference;color:#f1efe9;pointer-events:none}
  .overlay-text .arrow-btn{pointer-events:auto}
  .top-bar{
    position:absolute;top:0;left:0;right:0;display:flex;justify-content:flex-end;align-items:center;
    padding:2.4rem var(--padding-x);font-size:1.3rem;
  }
  .slide-count{font-size:1.3rem;letter-spacing:.14em;display:flex;gap:.8rem;align-items:baseline;color:#f1efe9}
  .slide-count i{font-style:normal;opacity:.6}
  .title-stack{
    position:absolute;left:12%;left:clamp(12rem,15vw,28.8rem);top:50%;transform:translateY(-50%);
    max-width:64rem;display:grid;overflow:visible;z-index:6;
  }
  .slide-title{
    grid-area:1/1;font-weight:600;font-size:3.2rem;line-height:1.02;letter-spacing:-.096rem;font-family:var(--font-main);
    color:#f1efe9;overflow:visible;white-space:normal;overflow-wrap:break-word;
    opacity:0;visibility:hidden;filter:blur(10px);
  }
  @media(min-width:768px){.slide-title{font-size:4.8rem}}
  @media(min-width:1200px){.slide-title{font-size:8.8rem}}
  .slide-title.is-active{opacity:1 !important;visibility:visible !important;filter:blur(0) !important}
  .slide-title .word{display:inline-block;will-change:transform,opacity,filter}
  .project-tags{
    position:absolute;right:clamp(9rem,10vw,16rem);top:50%;transform:translateY(-50%);width:23.9rem;display:grid;z-index:6;
  }
  .tag-list{grid-area:1/1;list-style:none;display:flex;flex-direction:column;gap:.4rem;font-size:1.4rem;line-height:1.4em;font-weight:400;font-family:var(--font-main);color:#f1efe9;opacity:0;visibility:hidden}
  @media(min-width:768px){.tag-list{font-size:1.6rem}}
  @media(min-width:1200px){.tag-list{font-size:1.8rem}}
  .tag-list.is-active{opacity:1 !important;visibility:visible !important}
  .tag-list li{overflow:hidden}
  .tag-list li span{display:block;will-change:transform}
  .thumb-rail{position:absolute;right:var(--padding-x);top:50%;transform:translateY(-50%);pointer-events:auto;z-index:6;mix-blend-mode:normal}
  .rail-track{position:relative;display:flex;flex-direction:column;gap:.8rem;align-items:center;padding-left:3.2rem}
  .rail-marker{position:absolute;left:0;top:50%;width:2rem;height:.2rem;background-color:#111;opacity:1}
  .rail-thumb{
    width:4.2rem;height:2.4rem;border-radius:.2rem;overflow:hidden;opacity:.28;transition:opacity .4s var(--ease-smooth),transform .4s var(--ease-smooth);
    background:transparent;flex:none;
  }
  .rail-thumb img{width:100%;height:100%;object-fit:cover}
  .rail-thumb.is-active{opacity:1}
  .rail-thumb:hover{opacity:.9}
  

}

.mobile-view{background:transparent;background-color:#f1efe9;color:#f1efe9;padding:11.9rem 0 11.3rem;display:flex;flex-direction:column;position:relative;isolation:isolate}
.mobile-top{display:flex;align-items:center;justify-content:flex-end;padding:0 var(--padding-x);min-height:2.8rem;mix-blend-mode:difference;color:#f1efe9;position:relative;z-index:3}
.step-lines{display:flex;gap:.6rem;align-items:center}
.step-line{width:.1rem;width:1px;height:1.6rem;background:rgba(241,239,233,.4);transition:background .2s}
.step-line.is-active{background:#f1efe9}
.picker-bar{margin-top:2.4rem;display:flex;align-items:center;justify-content:space-between;padding:0 var(--padding-x);position:relative;z-index:2}
.mini-count{font-size:1.2rem;letter-spacing:.16em;color:#111}
.mini-thumbs{display:flex;gap:.6rem}
.mini-thumb{width:3.5rem;height:2rem;border-radius:.2rem;overflow:hidden;opacity:.35;transition:opacity .3s;background:transparent}
.mini-thumb img{width:100%;height:100%;object-fit:cover}
.mini-thumb.is-active{opacity:1}
.card-view{margin-top:1.6rem;overflow:hidden;touch-action:pan-y pinch-zoom;position:relative;z-index:2}
.card-track{display:flex;gap:var(--card-gap);padding-inline:calc((100% - var(--card-width)) / 2);will-change:transform;cursor:grab}
.card-track.is-dragging{cursor:grabbing}
.city-card{flex:0 0 var(--card-width);aspect-ratio:319 / 250;overflow:hidden;background:transparent;border-radius:0}
.city-card img{width:100%;height:100%;object-fit:cover;pointer-events:none;transform:scale(1.02)}
.mobile-caption{mix-blend-mode:difference;color:#f1efe9;position:relative;z-index:3}
.caption-box{margin-top:2.6rem;display:flex;flex-direction:column;align-items:center;text-align:center}
.caption-titles{display:grid;width:100%;padding:0 var(--padding-x)}
.caption-title{grid-area:1/1;font-size:3.2rem;line-height:1.05;font-weight:600;letter-spacing:-.096rem;font-family:var(--font-main);color:#f1efe9;overflow:visible;opacity:0;visibility:hidden;transform:translateY(.8rem);transition:opacity .35s var(--ease-smooth),transform .35s var(--ease-smooth)}
.caption-title.is-active{opacity:1 !important;visibility:visible !important;transform:none}
.caption-tags{display:grid;width:100%;margin-top:1.2rem;padding:0 var(--padding-x)}
.caption-tag-list{grid-area:1/1;list-style:none;display:flex;flex-direction:column;gap:.1rem;font-size:1.4rem;line-height:140%;font-weight:400;font-family:var(--font-main);color:#f1efe9;opacity:0;visibility:hidden;transform:translateY(.8rem);transition:opacity .35s var(--ease-smooth),transform .35s var(--ease-smooth)}
.caption-tag-list.is-active{opacity:1 !important;visibility:visible !important;transform:none}
.card-arrows{margin-top:2.8rem;width:100%;display:flex;align-items:center;justify-content:space-between;padding:0 var(--padding-x)}
.card-arrows .arrow-btn{border:.1rem solid rgba(241,239,233,.6);color:#f1efe9}

.number-roll{display:inline-flex;overflow:hidden;line-height:1;font-variant-numeric:tabular-nums}
.roll-digit{display:inline-block;height:1em;overflow:hidden;line-height:1}
.roll-strip{display:flex;flex-direction:column;will-change:transform}
.roll-num{display:block;height:1em;line-height:1}

@media(min-width:768px) and (max-width:1199px){
  :root{--card-width:min(52rem,85%);--card-gap:1.6rem}
  .mobile-view{padding:15.5rem 0 3.2rem}
  .mobile-top{min-height:4rem}
  .caption-title{font-size:4.8rem}
  .caption-tag-list{font-size:1.6rem}
  .card-arrows{margin-top:4rem}
  .city-card{aspect-ratio:520 / 406}
}
@media(prefers-reduced-motion:reduce){
  *{animation:none!important;transition:none!important}
}
````

## Source — script.js
````javascript
gsap.registerPlugin(ScrollTrigger, ScrollToPlugin);

(function () {
  const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  const DUR_IMG = reduceMotion ? 0.01 : 1.4;
  const EASE_IMG = "power3.out";
  const EASE_Q = "expo.out";

  const pad2 = (n) => String(n + 1).padStart(2, "0");

  function makeRoller(el) {
    if (!el) return null;
    const raw = (el.textContent || "01").trim();
    el.innerHTML = "";
    el.classList.add("number-roll");
    const strips = [];
    raw.split("").forEach((ch) => {
      const d = document.createElement("span");
      d.className = "roll-digit";
      const s = document.createElement("span");
      s.className = "roll-strip";
      for (let i = 0; i < 10; i++) {
        const n = document.createElement("span");
        n.className = "roll-num";
        n.textContent = i;
        s.appendChild(n);
      }
      d.appendChild(s);
      el.appendChild(d);
      strips.push({ digit: d, strip: s, val: parseInt(ch, 10) || 0 });
    });
    const roller = {
      set(numStr, animate) {
        const chars = String(numStr).padStart(strips.length, "0").split("");
        strips.forEach((st, i) => {
          const target = parseInt(chars[i], 10) || 0;
          const h = st.digit.clientHeight || parseFloat(getComputedStyle(el).fontSize) || 12;
          const y = -target * h;
          if (!animate || reduceMotion || target === st.val) gsap.set(st.strip, { y });
          else gsap.to(st.strip, { y, duration: 0.75, ease: "expo.out", overwrite: true });
          st.val = target;
        });
        el.setAttribute("aria-label", numStr);
      },
      refresh() {
        strips.forEach((st) => {
          const h = st.digit.clientHeight || parseFloat(getComputedStyle(el).fontSize) || 12;
          gsap.set(st.strip, { y: -st.val * h });
        });
      }
    };
    roller.set(raw, false);
    return roller;
  }
  let deskRoll = null;
  let mobRoll = null;

  const showcase = document.getElementById("showcase");
  const slides = Array.from(document.querySelectorAll(".photo-slide"));
  const titles = Array.from(document.querySelectorAll(".slide-title"));
  const svcs = Array.from(document.querySelectorAll(".project-tags .tag-list"));
  const thumbs = Array.from(document.querySelectorAll(".thumb-rail .rail-thumb"));
  const bar = document.getElementById("rail-marker");
  const cur = document.getElementById("desk-count");
  const stage = document.getElementById("stage");
  const total = slides.length;
  let active = 0;
  let animating = false;

  svcs.forEach((ul) => {
    Array.from(ul.querySelectorAll("li")).forEach((li) => {
      if (!li.querySelector("span")) {
        const s = document.createElement("span");
        s.textContent = li.textContent;
        li.textContent = "";
        li.appendChild(s);
      }
    });
  });

  gsap.set(slides, { autoAlpha: 0, zIndex: 1 });
  gsap.set(slides[0], { autoAlpha: 1, zIndex: 2 });
  gsap.set(".photo-slide .photo-mask img", { scale: 1.14 });
  gsap.set(slides[0].querySelector("img"), { scale: 1 });

  titles.forEach((t, i) => {
    const words = t.querySelectorAll(".word");
    if (i === 0) gsap.set(words, { autoAlpha: 1, y: 0, filter: "blur(0px)" });
    else gsap.set(words, { autoAlpha: 0, y: 14, filter: "blur(10px)" });
  });
  svcs.forEach((ul, i) => {
    const spans = ul.querySelectorAll("li span");
    if (i === 0) gsap.set(spans, { yPercent: 0, autoAlpha: 1 });
    else gsap.set(spans, { yPercent: 105, autoAlpha: 0 });
  });
  moveBar(0, true);

  function moveBar(idx, instant) {
    if (!bar) return;

    let y = (idx - (total - 1) / 2) * 32;
    const track = document.getElementById("rail-track");
    const thumb = thumbs[idx];
    if (track && thumb) {
      y = thumb.offsetTop + thumb.offsetHeight / 2 - track.clientHeight / 2;
    }
    if (instant) gsap.set(bar, { y });
    else gsap.to(bar, { y, duration: reduceMotion ? 0.01 : 0.9, ease: EASE_Q, overwrite: true });
  }

  function setChrome(idx) {
    if (deskRoll) deskRoll.set(pad2(idx), true);
    else if (cur) cur.textContent = pad2(idx);
    thumbs.forEach((b, i) => {
      b.classList.toggle("is-active", i === idx);
      if (i === idx) b.setAttribute("aria-current", "true");
      else b.removeAttribute("aria-current");
    });
    const prev = document.getElementById("dPrev");
    const next = document.getElementById("dNext");
    if (prev) prev.disabled = idx === 0;
    if (next) next.disabled = idx === total - 1;
    moveBar(idx, false);
  }

  function showTitle(nextIdx, prevIdx) {
    const prev = titles[prevIdx];
    const next = titles[nextIdx];
    if (prev) {
      prev.classList.remove("is-active");
      gsap.to(prev, { autoAlpha: 0, filter: "blur(8px)", duration: reduceMotion ? 0.01 : 0.35, ease: "power2.in", overwrite: true });
      gsap.to(prev.querySelectorAll(".word"), { autoAlpha: 0, y: -14, filter: "blur(8px)", duration: reduceMotion ? 0.01 : 0.35, stagger: 0.01, ease: "power2.in", overwrite: true });
    }
    if (next) {
      next.classList.add("is-active");
      gsap.set(next, { autoAlpha: 1, filter: "blur(0px)" });
      gsap.fromTo(
        next.querySelectorAll(".word"),
        { autoAlpha: 0, y: 18, filter: "blur(8px)" },
        { autoAlpha: 1, y: 0, filter: "blur(0px)", duration: reduceMotion ? 0.01 : 0.9, stagger: 0.02, delay: 0.12, ease: EASE_IMG, overwrite: true, clearProps: "filter" }
      );
    }
  }

  function showServices(nextIdx, prevIdx) {
    const prev = svcs[prevIdx];
    const next = svcs[nextIdx];
    if (prev) {
      prev.classList.remove("is-active");
      gsap.to(prev.querySelectorAll("li span"), { yPercent: -20, autoAlpha: 0, y: -10, duration: reduceMotion ? 0.01 : 0.35, stagger: 0.02, ease: "power2.in", overwrite: true });
      gsap.to(prev, { autoAlpha: 0, duration: 0.25, overwrite: true });
    }
    if (next) {
      next.classList.add("is-active");
      gsap.set(next, { autoAlpha: 1 });
      gsap.fromTo(
        next.querySelectorAll("li span"),
        { yPercent: 105, y: 0, autoAlpha: 0 },
        { yPercent: 0, y: 0, autoAlpha: 1, duration: reduceMotion ? 0.01 : 0.8, stagger: 0.04, delay: 0.12, ease: EASE_IMG, overwrite: true, clearProps: "transform" }
      );
    }
  }

  const MAX_DROPS = 24;
  const water = {
    ready: false, renderer: null, mat: null, dropVecs: [],
    drops: [], clock: 0, lastT: 0, cur: 0, transTween: null,
    lastMX: -1, lastMY: -1, lastMT: 0
  };

  function waterAddDrop(u, v, amp) {
    if (!water.ready) return;
    water.drops.push({ u, v, t0: water.clock, amp });
    if (water.drops.length > MAX_DROPS) water.drops.shift();
    for (let i = 0; i < MAX_DROPS; i++) {
      const d = water.drops[i];
      const vec = water.dropVecs[i];
      if (d) vec.set(d.u, d.v, d.t0, d.amp);
      else vec.set(0, 0, -10, 0);
    }
  }

  function waterResize() {
    if (!water.ready || !stage) return;
    const w = stage.clientWidth || 2;
    const h = stage.clientHeight || 2;
    const dpr = Math.min(window.devicePixelRatio || 1, 1.75);
    water.renderer.setPixelRatio(dpr);
    water.renderer.setSize(w, h, false);
    water.mat.uniforms.uRes.value.set(w, h);
  }

  function waterLoop() {
    if (!water.ready) return;
    requestAnimationFrame(waterLoop);
    if (document.hidden || window.innerWidth < 1200) return;
    const r = stage.getBoundingClientRect();
    if (r.bottom < 0 || r.top > window.innerHeight) return;
    const now = performance.now() / 1000;
    water.clock += Math.min(0.05, now - (water.lastT || now));
    water.lastT = now;

    let dirty = false;
    for (let i = water.drops.length - 1; i >= 0; i--) {
      if (water.clock - water.drops[i].t0 > 3.5) { water.drops.splice(i, 1); dirty = true; }
    }
    if (dirty) {
      for (let i = 0; i < MAX_DROPS; i++) {
        const d = water.drops[i];
        const vec = water.dropVecs[i];
        if (d) vec.set(d.u, d.v, d.t0, d.amp);
        else vec.set(0, 0, -10, 0);
      }
    }
    water.mat.uniforms.uTime.value = water.clock;
    water.renderer.render(water.scene, water.camera);
  }

  function waterMouse(u, v) {
    if (!water.ready) return;
    const now = performance.now();
    const du = u - water.lastMX;
    const dv = v - water.lastMY;
    if (water.lastMX >= 0 && (du * du + dv * dv < 0.00012) && now - water.lastMT < 60) return;
    water.lastMX = u; water.lastMY = v; water.lastMT = now;
    waterAddDrop(u, v, 0.55);
  }

  function waterGo(nextIdx, prevIdx, mx, my) {
    if (!water.ready) { crossfadeImage(nextIdx, prevIdx, mx, my); return; }
    if (water.transTween) water.transTween.kill();
    const u = water.mat.uniforms;
    u.tB.value = water.textures[nextIdx];
    u.uSizeB.value.set(water.sizes[nextIdx].x, water.sizes[nextIdx].y);
    const cu = mx / 100;
    const cv = 1 - my / 100;
    u.uCenter.value.set(cu, cv);
    waterAddDrop(cu, cv, 1.5);
    waterAddDrop(Math.min(0.98, cu + 0.03), Math.max(0.02, cv - 0.02), 0.9);
    const o = { p: 0 };
    water.transTween = gsap.to(o, {
      p: 1, duration: reduceMotion ? 0.01 : 1.4, ease: "power3.out", overwrite: true,
      onUpdate: () => { u.uProgress.value = o.p; },
      onComplete: () => {
        u.tA.value = water.textures[nextIdx];
        u.uSizeA.value.set(water.sizes[nextIdx].x, water.sizes[nextIdx].y);
        u.uProgress.value = 0;
        water.cur = nextIdx;
        animating = false;
      }
    });
  }

  function waterInit() {
    if (water.ready || !window.THREE || !stage) return;
    const canvas = document.getElementById("water-canvas");
    if (!canvas) return;
    let renderer;
    try {
      renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: false, powerPreference: "high-performance" });
    } catch (e) { return; }
    water.renderer = renderer;
    water.scene = new THREE.Scene();
    water.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
    for (let i = 0; i < MAX_DROPS; i++) water.dropVecs.push(new THREE.Vector4(0, 0, -10, 0));

    const mat = new THREE.ShaderMaterial({
      uniforms: {
        tA: { value: null },
        tB: { value: null },
        uSizeA: { value: new THREE.Vector2(16, 9) },
        uSizeB: { value: new THREE.Vector2(16, 9) },
        uRes: { value: new THREE.Vector2(640, 500) },
        uCenter: { value: new THREE.Vector2(0.5, 0.5) },
        uProgress: { value: 0 },
        uTime: { value: 0 },
        uStrength: { value: 0.035 },
        uDrops: { value: water.dropVecs }
      },
      vertexShader: [
        "varying vec2 vUv;",
        "void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }"
      ].join("\n"),
      fragmentShader: [
        "precision highp float;",
        "varying vec2 vUv;",
        "uniform sampler2D tA;",
        "uniform sampler2D tB;",
        "uniform vec2 uSizeA;",
        "uniform vec2 uSizeB;",
        "uniform vec2 uRes;",
        "uniform vec2 uCenter;",
        "uniform float uProgress;",
        "uniform float uTime;",
        "uniform float uStrength;",
        "uniform vec4 uDrops[24];",
        "vec2 coverUv(vec2 uv, vec2 ts, vec2 ps){",
        "  float pa = ps.x / ps.y;",
        "  float ta = ts.x / ts.y;",
        "  vec2 s = vec2(1.0);",
        "  if (ta > pa) { s.x = pa / ta; } else { s.y = ta / pa; }",
        "  return (1.0 - s) * 0.5 + uv * s;",
        "}",
        "vec2 waterNormal(vec2 uv){",
        "  vec2 g = vec2(0.0);",
        "  float t = uTime;",
        "  g += 0.02 * vec2(sin(uv.y * 14.0 + t * 1.3) + 0.5 * sin(uv.y * 23.0 - t * 0.9),",
        "                   cos(uv.x * 13.0 - t * 1.1) + 0.5 * cos(uv.x * 27.0 + t * 0.7));",
        "  float aspect = uRes.x / uRes.y;",
        "  for (int i = 0; i < 24; i++) {",
        "    vec4 d = uDrops[i];",
        "    float age = t - d.z;",
        "    if (d.w > 0.0005 && age >= 0.0 && age <= 3.5) {",
        "      vec2 dv = uv - d.xy;",
        "      dv.x *= aspect;",
        "      float dist = length(dv);",
        "      float radius = age * 0.5;",
        "      float x = (dist - radius) / 0.14;",
        "      float envelope = exp(-x * x) * exp(-age * 1.6) * exp(-dist * 2.2);",
        "      float wave = sin((dist - radius) * 55.0);",
        "      vec2 dir = dist > 0.0001 ? dv / dist : vec2(0.0);",
        "      g += dir * (wave * envelope * d.w);",
        "    }",
        "  }",
        "  return g;",
        "}",
        "void main(){",
        "  vec2 n = waterNormal(vUv);",
        "  vec2 off = n * uStrength;",
        "  vec3 a = texture2D(tA, coverUv(vUv + off, uSizeA, uRes)).rgb;",
        "  vec3 col = a;",
        "  if (uProgress > 0.0005) {",
        "    vec3 b = texture2D(tB, coverUv(vUv + off, uSizeB, uRes)).rgb;",
        "    if (uProgress >= 0.9995) { col = b; }",
        "    else {",
        "      vec2 dv = vUv - uCenter;",
        "      dv.x *= uRes.x / uRes.y;",
        "      float dist = length(dv);",
        "      float r = uProgress * 1.25;",
        "      float wob = sin((dist - r) * 45.0) * 0.5 + 0.5;",
        "      float m = smoothstep(r - 0.16, r + 0.03, dist + wob * 0.07 * (1.0 - uProgress));",
        "      col = mix(b, a, m);",
        "    }",
        "  }",
        "  float lum = length(n);",
        "  col += (n.x * 0.5 + n.y * 0.5) * 0.10;",
        "  col += smoothstep(0.12, 0.55, lum) * 0.05;",
        "  gl_FragColor = vec4(col, 1.0);",
        "}"
      ].join("\n")
    });
    water.mat = mat;
    water.scene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), mat));

    const loader = new THREE.TextureLoader();
    loader.setCrossOrigin("anonymous");
    const srcs = slides.map((s) => {
      const img = s.querySelector("img");
      return img.currentSrc || img.src;
    });
    Promise.all(srcs.map((src) => new Promise((res, rej) => {
      loader.load(src, res, undefined, rej);
    }))).then((textures) => {
      textures.forEach((t) => { t.minFilter = THREE.LinearFilter; t.generateMipmaps = false; });
      water.textures = textures;
      water.sizes = textures.map((t) => ({ x: (t.image && t.image.naturalWidth) || 16, y: (t.image && t.image.naturalHeight) || 9 }));
      const u = mat.uniforms;
      u.tA.value = textures[0];
      u.tB.value = textures[0];
      u.uSizeA.value.set(water.sizes[0].x, water.sizes[0].y);
      u.uSizeB.value.set(water.sizes[0].x, water.sizes[0].y);
      water.ready = true;
      water.cur = active;
      stage.classList.add("water-on");
      gsap.set(slides, { autoAlpha: 0 });
      waterResize();
      water.lastT = performance.now() / 1000;
      waterLoop();

      setInterval(() => {
        if (!water.ready || document.hidden || window.innerWidth < 1200) return;
        const r = stage.getBoundingClientRect();
        if (r.bottom < 0 || r.top > window.innerHeight) return;
        waterAddDrop(0.15 + Math.random() * 0.7, 0.15 + Math.random() * 0.7, 0.35);
      }, 2400);
    }).catch(() => {

      water.ready = false;
    });
  }

  let imageTl = null;

  function crossfadeImage(nextIdx, prevIdx, mx = 50, my = 50) {
    const prevSlide = slides[prevIdx];
    const nextSlide = slides[nextIdx];
    if (!prevSlide || !nextSlide || prevIdx === nextIdx) {
      animating = false;
      return;
    }
    if (imageTl) imageTl.kill();

    slides.forEach((s, k) => {
      if (k !== prevIdx && k !== nextIdx) gsap.set(s, { autoAlpha: 0, zIndex: 1 });
    });
    const nextImg = nextSlide.querySelector("img");
    const prevImg = prevSlide.querySelector("img");
    const ripple = nextSlide.querySelector(".photo-shine");

    gsap.set(nextSlide, { autoAlpha: 1, zIndex: 3, "--mx": mx + "%", "--my": my + "%", "--r": "0%" });
    gsap.set(prevSlide, { autoAlpha: 1, zIndex: 1 });
    gsap.set(nextImg, { scale: 1.18 });
    if (ripple) gsap.set(ripple, { opacity: 1, "--mx": mx + "%", "--my": my + "%", "--r": "0%" });

    imageTl = gsap.timeline({
      defaults: { ease: EASE_IMG, overwrite: "auto" },
      onComplete: () => {
        gsap.set(prevSlide, { autoAlpha: 0, zIndex: 1 });
        gsap.set(nextSlide, { zIndex: 2 });
        if (ripple) gsap.set(ripple, { opacity: 0 });
        animating = false;
      }
    });

    imageTl.to(nextSlide, { "--r": "150%", duration: DUR_IMG }, 0);
    imageTl.to(nextImg, { scale: 1, duration: DUR_IMG }, 0);
    if (ripple) imageTl.to(ripple, { "--r": "150%", opacity: 0, duration: DUR_IMG }, 0);
    imageTl.to(prevImg, { scale: 1.08, duration: DUR_IMG }, 0);
  }

  function goDesktop(nextIdx, opts = {}) {
    nextIdx = Math.max(0, Math.min(total - 1, nextIdx));
    if (nextIdx === active) {
      setChrome(active);
      return;
    }
    const prevIdx = active;
    active = nextIdx;
    animating = true;
    const mx = opts.mx ?? 50;
    const my = opts.my ?? 50;
    slides[nextIdx].classList.add("is-active");
    slides[prevIdx].classList.remove("is-active");
    if (stage && stage.classList.contains("water-on")) {
      if (imageTl) imageTl.kill();
      gsap.set(slides, { autoAlpha: 0, zIndex: 1 });
      waterGo(nextIdx, prevIdx, mx, my);
    } else {
      crossfadeImage(nextIdx, prevIdx, mx, my);
    }
    showTitle(nextIdx, prevIdx);
    showServices(nextIdx, prevIdx);
    setChrome(nextIdx);
  }

  function desktopScrollTarget(i) {
    const top = showcase.getBoundingClientRect().top + window.scrollY;
    return top + i * window.innerHeight + (i === 0 ? 1 : 0);
  }
  function scrollToDesktop(i) {
    i = Math.max(0, Math.min(total - 1, i));
    gsap.to(window, { scrollTo: desktopScrollTarget(i), duration: reduceMotion ? 0.01 : 1.2, ease: "power3.inOut", overwrite: true });
  }

  thumbs.forEach((b) => {
    b.addEventListener("click", (e) => {
      const r = slides[Number(b.dataset.to)].getBoundingClientRect();
      const mx = ((e.clientX - r.left) / r.width) * 100 || 50;
      const my = ((e.clientY - r.top) / r.height) * 100 || 50;
      const i = Number(b.dataset.to);
      if (window.innerWidth >= 1200) {

        scrollToDesktop(i);

        if (Math.abs(i - active) > 0) goDesktop(i, { mx, my });
      } else {
        goMobile(i);
      }
    });
  });
  document.getElementById("dPrev")?.addEventListener("click", () => scrollToDesktop(active - 1));
  document.getElementById("dNext")?.addEventListener("click", () => scrollToDesktop(active + 1));

  const mm = gsap.matchMedia();
  mm.add("(min-width: 1200px)", () => {
    waterInit();
    const st = ScrollTrigger.create({
      trigger: showcase,
      start: "top top",
      end: "bottom bottom",
      scrub: true,
      invalidateOnRefresh: true,
      onUpdate: (self) => {
        const p = self.progress;
        let idx = Math.round(p * (total - 1));
        idx = Math.max(0, Math.min(total - 1, idx));
        if (idx !== active) goDesktop(idx);
      }
    });
    return () => st.kill();
  });

  if (stage) {
    stage.addEventListener("mousemove", (e) => {
      if (window.innerWidth < 1200) return;
      const r = stage.getBoundingClientRect();
      const px = (e.clientX - r.left) / r.width - 0.5;
      const py = (e.clientY - r.top) / r.height - 0.5;
      waterMouse((e.clientX - r.left) / r.width, 1 - (e.clientY - r.top) / r.height);
      gsap.to(stage, { x: px * 14, y: py * 14, duration: 0.8, ease: EASE_Q, overwrite: "auto" });
      const cur = slides[active]?.querySelector("img");
      if (cur) gsap.to(cur, { x: px * -10, y: py * -10, duration: 0.8, ease: EASE_Q, overwrite: "auto" });
    });
    stage.addEventListener("mouseleave", () => {
      gsap.to(stage, { x: 0, y: 0, duration: 0.9, ease: EASE_Q, overwrite: "auto" });
      const cur = slides[active]?.querySelector("img");
      if (cur) gsap.to(cur, { x: 0, y: 0, duration: 0.9, ease: EASE_Q, overwrite: "auto" });
    });

    stage.addEventListener("click", (e) => {
      if (window.innerWidth < 1200 || reduceMotion) return;
      const r = stage.getBoundingClientRect();
      const mx = ((e.clientX - r.left) / r.width) * 100;
      const my = ((e.clientY - r.top) / r.height) * 100;
      const slide = slides[active];
      const imgwrap = slide.querySelector(".photo-mask");
      const sx = window.innerWidth / r.width;
      const sy = window.innerHeight / r.height;
      const tl = gsap.timeline();
      tl.to(imgwrap, { scale: 1.04, duration: 0.18, ease: "power2.out", transformOrigin: `${mx}% ${my}%` }, 0)
        .to(stage, { scaleX: sx * 0.96, scaleY: sy * 0.96, duration: 0.8, ease: "power3.inOut" }, 0.1)
        .to(stage, { scaleX: 1, scaleY: 1, duration: 0.8, ease: "power3.inOut" }, 0.95)
        .to(imgwrap, { scale: 1, duration: 0.4 }, 1.1);
    });
  }

  const cardView = document.getElementById("card-view");
  const cardTrack = document.getElementById("card-track");
  const mobSlides = Array.from(document.querySelectorAll(".city-card"));
  const mobTitles = Array.from(document.querySelectorAll(".caption-title"));
  const mobTags = Array.from(document.querySelectorAll(".caption-tag-list"));
  const mobLines = Array.from(document.querySelectorAll(".step-line"));
  const mobThumbs = Array.from(document.querySelectorAll(".mini-thumb"));
  const mobCur = document.getElementById("mob-count");
  let mobActive = 0;
  let mobStep = 0;

  function measureMobile() {
    if (!cardTrack || !mobSlides.length) return;
    const gap = parseFloat(getComputedStyle(cardTrack).columnGap || getComputedStyle(cardTrack).gap) || 8;
    const w = mobSlides[0].getBoundingClientRect().width;
    mobStep = w + gap;
    goMobile(mobActive, true);
  }

  function goMobile(i, instant = false) {
    i = Math.max(0, Math.min(mobSlides.length - 1, i));
    mobActive = i;
    const x = -i * mobStep;
    if (cardTrack) {
      if (instant) gsap.set(cardTrack, { x });
      else gsap.to(cardTrack, { x, duration: reduceMotion ? 0.01 : 0.8, ease: EASE_Q, overwrite: true });
    }
    mobSlides.forEach((s, k) => s.classList.toggle("is-active", k === i));
    mobTitles.forEach((t, k) => t.classList.toggle("is-active", k === i));
    mobTags.forEach((s, k) => s.classList.toggle("is-active", k === i));
    mobLines.forEach((l, k) => l.classList.toggle("is-active", k === i));
    mobThumbs.forEach((b, k) => b.classList.toggle("is-active", k === i));
    if (mobRoll) mobRoll.set(pad2(i), !instant);
    else if (mobCur) mobCur.textContent = pad2(i);
    const prevBtn = document.getElementById("card-prev");
    const nextBtn = document.getElementById("card-next");
    if (prevBtn) prevBtn.disabled = i === 0;
    if (nextBtn) nextBtn.disabled = i === mobSlides.length - 1;
  }

  window.goMobile = goMobile;

  document.getElementById("card-prev")?.addEventListener("click", () => goMobile(mobActive - 1));
  document.getElementById("card-next")?.addEventListener("click", () => goMobile(mobActive + 1));
  mobThumbs.forEach((b) => b.addEventListener("click", () => goMobile(Number(b.dataset.to))));

  if (cardView && cardTrack) {
    let dragging = false, startX = 0, baseX = 0, curX = 0, moved = 0;
    const getX = () => gsap.getProperty(cardTrack, "x");
    cardView.addEventListener("pointerdown", (e) => {
      if (window.innerWidth >= 1200) return;
      dragging = true; moved = 0;
      startX = e.clientX; baseX = getX();
      cardTrack.classList.add("is-dragging");
      cardView.setPointerCapture?.(e.pointerId);
    });
    cardView.addEventListener("pointermove", (e) => {
      if (!dragging) return;
      const dx = e.clientX - startX;
      moved = Math.max(moved, Math.abs(dx));
      let x = baseX + dx;

      const min = -(mobSlides.length - 1) * mobStep, max = 0;
      if (x > max) x = max + (x - max) * 0.35;
      if (x < min) x = min + (x - min) * 0.35;
      curX = x;
      gsap.set(cardTrack, { x });
    });
    const end = (e) => {
      if (!dragging) return;
      dragging = false;
      cardTrack.classList.remove("is-dragging");
      const dx = (e?.clientX ?? startX) - startX;
      let idx = Math.round(-curX / mobStep);

      if (Math.abs(dx) > 40 && moved > 10) {
        if (dx < 0) idx = Math.floor(-baseX / mobStep) + 1;
        else idx = Math.ceil(-baseX / mobStep) - 1;
      }
      if (moved < 6) return;
      goMobile(idx);
    };
    cardView.addEventListener("pointerup", end);
    cardView.addEventListener("pointercancel", end);

    cardTrack.addEventListener("dragstart", (e) => e.preventDefault());
  }

  window.addEventListener("resize", () => { measureMobile(); waterResize(); if (deskRoll) deskRoll.refresh(); if (mobRoll) mobRoll.refresh(); ScrollTrigger.refresh(); });
  window.addEventListener("load", () => { measureMobile(); waterResize(); if (deskRoll) deskRoll.refresh(); if (mobRoll) mobRoll.refresh(); ScrollTrigger.refresh(); });
  measureMobile();
  deskRoll = makeRoller(cur);
  mobRoll = makeRoller(mobCur);
  setChrome(0);
  goMobile(0, true);
  waterInit();
})();
````
