<p class="indicator">
  <span>Scroll</span>
  <span></span>
</p>

<div class="fish-wrapper">
  <div class="fish">
    <div class="fish__skeleton"></div>
    <div class="fish__inner">
      <!--body-->
      <div class="fish__body"></div>
      <div class="fish__body"></div>
      <div class="fish__body"></div>
      <div class="fish__body"></div>

      <!--head-->
      <div class="fish__head"></div>
      <div class="fish__head fish__head--2"></div>
      <div class="fish__head fish__head--3"></div>
      <div class="fish__head fish__head--4"></div>

      <div class="fish__tail-main"></div>
      <div class="fish__tail-fork"></div>

      <div class="fish__fin"></div>
      <div class="fish__fin fish__fin--2"></div>
    </div>
  </div>
</div>

<div class="bubbles">
  <div class="bubbles__inner">
    <div class="bubbles__bubble"></div>
    <div class="bubbles__bubble"></div>
    <div class="bubbles__bubble"></div>
  </div>
</div>

<div class="rays"><div data-rays></div></div>

<div class="lights">
  <div class="lights__group" data-lights="1">
    <div class="lights__light"></div>
    <div class="lights__light"></div>
    <div class="lights__light"></div>
    <div class="lights__light"></div>
    <div class="lights__light"></div>
    <div class="lights__light"></div>
    <div class="lights__light"></div>
    <div class="lights__light"></div>
  </div>
</div>

<div class="content">
  <section>
    <div class="section__content">
      <p>In the deepest ocean</p>
    </div>
  </section>
  <section>
    <div class="section__content">
      <p>the bottom of the sea</p>
    </div>
  </section>
  <section>
    <div class="section__content">
      <p>Your eyes...</p>
    </div>
  </section>
  <section>
    <div class="section__content">
      <p>they turn me...</p>
    </div>
  </section>
  <section>
    <div class="section__content">
      <p>turn me on to phantoms</p>
    </div>
  </section>
  <section>
    <div class="section__content">
      <p>I follow to the edge of the earth</p>
    </div>
  </section>
  <section>
    <div class="section__content">
      <p>and fall off</p>
    </div>
  </section>
  <section>
    <div class="section__content">
      <p>I get eaten by the worms</p>
    </div>
  </section>
  <section>
    <div class="section__content">
      <p>and weird fishes</p>
    </div>
  </section>
  <section>
    <div class="section__content">
      <p>Hit the bottom and escape</p>
    </div>
  </section>
  <section>
    <div class="section__content">
      <p>escape</p>
    </div>
  </section>
</div>
@import url('https://fonts.googleapis.com/css2?family=Judson&display=swap');

* {
  box-sizing: border-box;
}

body {
  font-family: 'Judson', serif;
  background: linear-gradient(to bottom,
    rgba(99, 167, 191, 1),
    rgba(94, 86, 179, 1),
    rgba(72, 139, 163, 1),
    rgba(79, 124, 179, 1),
    rgba(52, 115, 140, 1),
    rgba(48, 92, 145, 1),
    rgba(39, 82, 145, 1),
    rgba(19, 58, 110, 1),
    rgba(10, 50, 80, 1),
    rgba(40, 41, 100, 1),
    rgba(14, 52, 94, 1),
    black);
  max-width: 100vw;
  min-height: 100vh;
  overflow-x: hidden;
  color: white;
  position: relative;
  margin: 0;
  
  &::after {
    position: fixed;
    content: '';
    pointer-events: none;
    top: 0;
    left: 0;
    width: 100%;
    height: 100vh;
    background: radial-gradient(circle at center, transparent, rgba(0, 0, 0, 0.5));
  }
  
  @media (min-width: 40em) {
    font-size: 2rem;
  }
}

.rays {
  --r: 10deg;
  --c: rgba(255, 251, 227, 0.2);
  --size: max(60vh, 80rem);
  --mask: radial-gradient(circle at center, black, transparent 50%);
  position: fixed;
  pointer-events: none;
  top: calc(var(--size) * -0.55);
  left: 50%;
  width: var(--size);
  height: var(--size);
  pointer-events: none;
  
  > div {
    width: 100%;
    height: 100%;
    border-radius: 50%;
    background: repeating-conic-gradient(var(--c), var(--c) var(--r), transparent var(--r), transparent calc(var(--r) * 2));
    -webkit-mask-image: var(--mask);
    mask-image: var(--mask);
    // animation: raysRotate 120000ms linear infinite;
  }
}

@keyframes raysRotate {
  50% {
    transform: rotate(180deg) scale(1.5);
  }
  100% {
    transform: rotate(360deg) scale(1);
  }
}

.fish-wrapper {
  --mask: linear-gradient(180deg, rgba(0, 0, 0, 1.0), transparent);
  width: 100%;
  height: 100vh;
  position: fixed;
  top: 0;
  left: 0;
  perspective: 100rem;
  perspective-origin: center center;
  transform-style: preserve-3d;
  pointer-events: none;
  -webkit-mask-image: var(--mask);
  mask-image: var(--mask);
  z-index: 2;
}

.fish {
  --bodyW: 2rem;
  --o: 0.95;
  --l: 100%;
  --c: hsla(250deg, 50%, var(--l), var(--o, 0.6));
  --size: 10rem;
  position: relative;
  width: var(--size);
  height: var(--size);
  transform-style: preserve-3d;
  transform-origin: center;
  
  @media (min-width: 1000px) {
    --size: 20rem;
    --bodyW: 4rem;
  }
}

.fish__skeleton {
  --clip: polygon(0 var(--bodyW), 45% 0, 55% 0, 100% var(--bodyW), 50% 100%);
  position: absolute;
  width: 100%;
  height: 100%;
  background: 
    repeating-linear-gradient(0deg, var(--c), var(--c) 0.1rem, transparent 0, transparent 0.5rem),
    linear-gradient(var(--c) var(--bodyW), transparent var(--bodyW)), 
    linear-gradient(90deg, transparent calc((var(--bodyW) / 2) - 0.1rem), var(--c) 0, var(--c) calc((var(--bodyW) / 2) + 0.1rem), transparent 0);
  top: calc(var(--bodyW) / 4);
  left: calc(var(--bodyW) * 0.75);
  width: var(--bodyW);
  height: calc(var(--bodyW) * 4);
  -webkit-clip-path: var(--clip);
  clip-path: var(--clip);
  opacity: 0;
  transform: translate3d(0, 0, calc(var(--bodyW) / -2)) rotate(90deg);
  transform-origin: center center;
}

.fish__inner {
  --a: 9.5deg;
  width: calc(var(--bodyW) * 1.5);
  height: var(--size);
  transform-style: preserve-3d;
  transform: rotate(90deg);
}

.fish__body {
  --l: 75%;
  --c: hsla(250deg, 50%, var(--l), var(--o, 0.6));
  position: absolute;
  top: var(--bodyW);
  left: 0;
  width: var(--bodyW);
  height: calc(3 * var(--bodyW));
  background: var(--c);
  clip-path: polygon(0 0, 100% 0, 50% 100%);
  transform: translateZ(calc(var(--bodyW) / -2)) rotateX(var(--a));
  transform-origin: center top;
  
  &:nth-child(2) {
    --i: 2;
    --l: 75%;
    transform: translateZ(calc(var(--bodyW) / 2)) rotateX(calc(var(--a) * -1));
  }
  
  &:nth-child(3) {
    --i: 3;
    --l: 95%;
    transform: rotateY(90deg) translate3d(calc(var(--bodyW) / -2), 0, 0) rotateX(var(--a));
    transform-origin: left top;
  }
  
  &:nth-child(4) {
    --i: 4;
    --l: 50%;
    transform: rotateY(90deg) translate3d(calc(var(--bodyW) / 2), 0, 0) rotateX(calc(var(--a) * -1));
    transform-origin: right top;
  }
}

.fish__head {
  --a: 23.5deg;
  --l: 85%;
  --c: hsla(250deg, 50%, var(--l), var(--o, 0.6));
  position: absolute;
  top: 0;
  left: 0;
  width: var(--bodyW);
  height: var(--bodyW);
  background: var(--c);
  clip-path: polygon(40% 0, 60% 0, 100% 100%, 0 100%);
  transform: translateZ(calc(var(--bodyW) / 2)) rotateX(var(--a));
  transform-origin: center bottom;
  
  &--2 {
    --i: 2;
    --l: 80%;
    transform: translateZ(calc(var(--bodyW) / -2)) rotateX(calc(var(--a) * -1));
  }
  
  &--3 {
    --i: 3;
    --l: 90%;
    transform: rotateY(90deg) translate3d(calc(var(--bodyW) / -2), 0, 0) rotateX(calc(var(--a) * -1));
    transform-origin: left bottom;
  }
  
  &--4 {
    --l: 55%;
    transform: rotateY(90deg) translate3d(calc(var(--bodyW) / 2), 0, 0) rotateX(var(--a));
    transform-origin: right bottom;
  }
}

.fish__tail-main {
  --o: 0.9;
  --l: 90%;
  --c: hsla(250deg, 50%, var(--l), var(--o, 0.6));
  width: var(--bodyW);
  height: var(--bodyW);
  background-color: var(--c);
  position: absolute;
  left: 0;
  bottom: var(--bodyW);
  clip-path: polygon(50% 0, 100% 100%, 0 100%);
}

.fish__tail-fork {
  --o: 0.9;
  --l: 95%;
  --c: hsla(250deg, 50%, var(--l), var(--o, 0.6));
  width: var(--bodyW);
  height: var(--bodyW);
  background-color: var(--c);
  position: absolute;
  left: 0;
  bottom: 0;
  clip-path: polygon(0 0, 100% 0, 100% 70%, 90% 100%, 70% 70%, 50% 30%, 30% 70%, 10% 100%, 0 70%);
  transform-origin: top center;
  transform: rotateX(-45deg);
  animation: tail 1000ms infinite alternate;
}

.fish__fin {
  width: calc(var(--bodyW) / 8 * 3);
  height: var(--bodyW);
  background-color: var(--c);
  position: absolute;
  top: calc(var(--bodyW) * 1.5);
  left: calc(var(--bodyW) / 8 * 3);
  clip-path: polygon(50% 0, 100% 30%, 100% 60%, 50% 100%, 0 60%, 0 30%);
  transform-origin: top center;
  transform: translateZ(calc(var(--bodyW) / 2)) rotateY(0deg) rotateX(5deg) rotate(10deg);
  animation: fin 1500ms infinite alternate linear;
  
  &--2 {
    transform: translateZ(calc(var(--bodyW) / -2)) rotateY(0deg) rotateX(-5deg) rotate(10deg);
    animation: fin2 1500ms infinite alternate linear;
  }
}

@keyframes tail {
  to {
    transform: rotateX(45deg);
  }
}

@keyframes fin {
  100% {
    transform: translateZ(calc(var(--bodyW) / 2)) rotateY(10deg) rotateX(20deg) rotate(-10deg);
  }
}

@keyframes fin2 {
  100% {
    transform: translateZ(calc(var(--bodyW) / -2)) rotateY(-10deg) rotateX(-20deg) rotate(-10deg);
  }
}

/* Lights */
.lights {
  position: fixed;
  pointer-events: none;
  top: 0;
  left: 0;
  width: 100%;
  height: 100vh;
}

.lights__group {
  position: relative;
  height: 100%;
}

.lights__light {
  --size: 0.35rem;
  width: var(--size);
  height: var(--size);
  position: absolute;
  background: rgba(255, 255, 255, 1);
  border-radius: 100%;
  top: 10%;
  left: 25%;
  filter: blur(0.1rem);
  animation: blink 2500ms var(--d, 0ms) infinite alternate;
  
  &:nth-child(2) {
    --d: 200ms;
    top: 40%;
    left: 12%;
  }
  
  &:nth-child(3) {
    --d: 350ms;
    top: 60%;
    left: 18%;
  }
  
  &:nth-child(4) {
    --d: 600ms;
    top: 25%;
    left: 66%;
  }
  
  &:nth-child(5) {
    --d: 1210ms;
    top: 43%;
    left: 55%;
  }
  
  &:nth-child(6) {
    --d: 420ms;
    top: 90%;
    left: 37%;
  }
  
  &:nth-child(7) {
    --d: 1100ms;
    top: 82%;
    left: 91%;
  }
  
  &:nth-child(8) {
    --d: 1560ms;
    top: 67%;
    left: 81%;
  }
}

@keyframes blink {
  to {
    opacity: 0;
  }
}


.content {
  position: relative;
  z-index: 1;
  padding-bottom: 100vh;
}

section {
  height: 100vh;
  width: 100%;
  margin-top: 100vh;
  
  &:nth-child(4n),
  &:nth-child(4n - 1) {
    --col: 3;
  }
}

.section__content {
  width: 100%;
  position: fixed;
  top: 0;
  height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  padding: 1rem;
  
  @media (min-width: 50rem) {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 25rem));
    gap: 2rem;
    padding: 3rem;
    
    > * {
      grid-column: var(--col, 1);
      opacity: 0;
    }
  }
}

.bubbles {
  position: fixed;
  top: 0;
  left: 5rem;
  // transform-style: preserve-3d;
  transform-origin: center;
  transform: translate3d(10rem, 5rem, 0) rotateX(20deg) rotateY(0deg)
}

.bubbles__inner {
  width: 10rem;
  height: 10rem;
}

.bubbles__bubble {
  --c: rgba(255, 255, 255, 0.4);
  --size: 2.5rem;
  position: absolute;
  width: var(--size);
  height: var(--size);
  border-radius: 50%;
  background: radial-gradient(transparent 30%, var(--c, white)), radial-gradient(circle at 100% 0%, transparent 30%, var(--c, white));
  transform-origin: center;
  transform: scale(0);
  opacity: 0;
  
  &:nth-child(2) {
    --size: 1.8rem;
    top: 3rem;
    left: 2rem;
  }
  
  &:nth-child(3) {
    --size: 1.2rem;
    top: 6rem;
    left: 0;
  }
}

.indicator {
  text-align: center;
  position: fixed;
  bottom: 1rem;
  left: 50%;
  transform: translate3d(-50%, 0, 0);
  font-size: 1.2rem;
  
  span {
    display: block;
    
    &:nth-child(2) {
      animation: arrowMove 600ms infinite alternate;
    }
  }
}

@keyframes arrowMove {
  to {
    transform: translate3d(0, 0.5rem, 0);
  }
}
View Compiled
gsap.registerPlugin(ScrollTrigger)
gsap.registerPlugin(MotionPathPlugin)

const rx = window.innerWidth < 1000 ? window.innerWidth / 1200 : 1
const ry = window.innerHeight < 700 ? window.innerHeight / 1200 : 1

const path = [
      // 1
      { x: 800, y: 200 },
      { x: 900, y: 20 },
      { x: 1100, y: 100 },
      // 2
      { x: 1000, y: 200 },
      { x: 900, y: 20 },
      { x: 10, y: 500 },
      // 3
      { x: 100, y: 300 },
      { x: 500, y: 400 },
      { x: 1000, y: 200 },
      // 4
      { x: 1100, y: 300 },
      { x: 400, y: 400 },
      { x: 200, y: 250 },
      // 5
      { x: 100, y: 300 },
      { x: 500, y: 450 },
      { x: 1100, y: 500 }
    ]

const scaledPath = path.map(({ x, y }) => {
  return {
    x: x * rx,
    y: y * ry
  }
})

const sections = [...document.querySelectorAll('section')]
const fish = document.querySelector('.fish')
const fishHeadAndBody = 
      [
        ...document.querySelectorAll('.fish__head'),
        ...document.querySelectorAll('.fish__body')
      ]
const lights = [...document.querySelectorAll('[data-lights]')]
const rays = document.querySelector('[data-rays]')

const bubbles = gsap.timeline()
bubbles.set('.bubbles__bubble', {
  y: 100,
})
bubbles.to('.bubbles__bubble', {
  scale: 1.2,
  y: -300,
  opacity: 1,
  duration: 2,
  stagger: 0.2,
})
bubbles.to('.bubbles__bubble', {
  scale: 1,
  opacity: 0,
  duration: 1,
}, '-=1')

bubbles.pause()

const tl = gsap.timeline({
  scrollTrigger: {
    scrub: 1.5,
  },
})
tl.to(fish, {
  motionPath: {
    path: scaledPath,
    align: 'self',
    alignOrigin: [0.5, 0.5],
    autoRotate: true
  },
  duration: 10,
  immediateRender: true,
  // ease: 'power4'
})
tl.to('.indicator', {
  opacity: 0
}, 0)
tl.to(fish, {
  rotateX: 180
}, 1)
tl.to(fish, {
  rotateX: 0
}, 2.5)
tl.to(fish, {
  z: -500,
  duration: 2,
}, 2.5)
tl.to(fish, {
  rotateX: 180
}, 4)
tl.to(fish, {
  rotateX: 0
}, 5.5)
tl.to(fish, {
  z: -50,
  duration: 2,
}, 5)
tl.to(fish, {
  rotate: 0,
  duration: 1,
}, '-=1')
tl.to('.fish__skeleton', {
  opacity: 0.6,
  duration: 0.1,
  repeat: 4
}, '-=3')
tl.to(fishHeadAndBody, {
  opacity: 0,
  duration: 0.1,
  repeat: 4
}, '-=3')
tl.to('.fish__inner', {
  opacity: 0.1,
  duration: 1
}, '-=1')
tl.to('.fish__skeleton', {
  opacity: 0.1,
  duration: 1
}, '-=1')

bubbles.play()
tl.pause()

const lightsTl = gsap.timeline({
  scrollTrigger: {
    scrub: 6
  }
})
lightsTl.from(lights[0], {
  x: window.innerWidth * -1,
  y: window.innerHeight,
  ease: 'power4.out',
  duration: 80
}, 0)
lightsTl.to(lights[0], {
  x: window.innerWidth,
  y: window.innerHeight * -1,
  ease: 'power4.out',
  duration: 80
}, '-=5')

const makeBubbles = (p, i) => {
  const { top, left } = fish.getBoundingClientRect()
  gsap.to(p, { opacity: 1, duration: 1 })
  gsap.set('.bubbles', {
    x: left,
    y: top
  })
  if (bubbles.paused) {
    bubbles.restart()
  }
  if (i > 6) {
    gsap.to('.bubbles', {
      opacity: 0
    })
  }
}

const rotateFish = (self) => {
  if (self.direction === -1) {
    gsap.to(fish, { rotationY: 180, duration: 0.4 })
  } else {
    gsap.to(fish, { rotationY: 0, duration: 0.4 })
  }
}

const hideText = (p) => {
  gsap.to(p, { opacity: 0, duration: 1 })
}

sections.forEach((section, i) => {
  const p = section.querySelector('p')
  gsap.to(p, { opacity: 0 })
  
  ScrollTrigger.create({
    trigger: section,
    start: "top top",
    onEnter: () => makeBubbles(p, i),
    onEnterBack: () => {
      if (i <= 6) {
        gsap.to('.bubbles', {
          opacity: 1
        })
      }
    },
    onLeave: () => {
      hideText(p)
      if (i == 0) {
      gsap.to('.rays', {
        opacity: 0,
        y: -500,
        duration: 8,
        ease: 'power4.in'
      })
      }
    },
    onLeaveBack: () => hideText(p),
    onUpdate: (self) => rotateFish(self)
  })
})

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/gsap/3.5.1/gsap.min.js
  2. https://cdnjs.cloudflare.com/ajax/libs/gsap/3.3.4/ScrollTrigger.min.js
  3. https://s3-us-west-2.amazonaws.com/s.cdpn.io/16327/MotionPathPlugin.min.js
  4. https://s3-us-west-2.amazonaws.com/s.cdpn.io/16327/MotionPathHelper.min.js?v=46
  5. https://cdn.jsdelivr.net/npm/lodash.throttle@4.1.1/index.js