Swimming on Scroll with GSAP

Featuring Michelle Barker

There’s something really beautiful about Weird Fishes by Michelle Barker. It’s a tiny CodePen experience that’s equal parts art piece and technical demo. As you scroll the fish swims deeper and deeper as lyrics from the Radiohead song of the same name fade in and out.

The fish swims around the screen in a set path thanks to GSAP’s MotionPath plugin. Another GSAP plugin, ScrollTrigger, is responsible for animating all the different elements as you scroll the length of the page.

I love the way Michelle used these plugins, and those are the main techniques I want to dig into for this article. Plus, they’re both free-to-use GSAP plugins*, with tons of potential, so I think you’ll get a lot out of this.

Michelle writes the fantastic blog, CSS In Real Life, and this is just one of her many excellent CodePens. She was very kind in helping us learn how she made Weird Fishes.

Creating the Fish

Before we get into the JavaScript, I want to touch on the star of the show. Michelle created the 3D fish using only HTML and CSS.

Could she have created a SeaHorse instead of a fish? Probably, but we won’t hold it against her.

The structure of the HTML makes controlling the different parts of the fish easier.

<div class="fish-wrapper">
  <div class="fish">
    <div class="fish__skeleton"></div>
    <div class="fish__inner">
      <div class="fish__body"></div>
      <div class="fish__body"></div>
      <div class="fish__body"></div>
      <div class="fish__body"></div>
      <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>

The outer .fish div will follow the motion path, allowing the parts inside to move independently.

The Body

The body is made similar to when we covered Ricardo Olivia Alonso’s work, by positioning multiple divs in 3d space to create a shape. As in that article, the key property is transform-style: preserve-3d; which allows for the shapes to exist in 3d space.

The tail and fins are on looping CSS animations. The tail rotates back and forth, while the fins do a bit of translation and rotation.

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

@keyframes fin {
  100% {
    transform: translateZ(2rem) rotateY(10deg) rotateX(20deg) rotate(-10deg);

@keyframes fin2 {
  100% {
    transform: translateZ(-2rem) rotateY(-10deg) rotateX(-20deg) rotate(-10deg);

The Skeleton

At the end of your scroll, the fish flickers to show its skeleton as the words “and weird fishes” appear on the screen.

Weird Fishes

It’s a great effect, and Michelle draws the skeleton with just one div and some CSS. The bones are all drawn with linear gradients applied to the background of the div, while the shape is a clip path.

Toggle the clip path to see the effect in this isolated demo.

Moving with Motion Path

At first I assumed the fish was moving along a curvy SVG path that would have been imported from an app like Illustrator or Figma. Michelle is using GSAP’s MotionPath Plugin, so that’s an option, but not the way it’s done here. She creates an array of coordinate points and then MotionPath animates the fish going from point to point.

In case you’re unfamiliar, here’s what an SVG path would look like.

  d="M142 1C37.9999 25 -25.5001 175.5 10.9999 199.5C47.4999 223.5 282 187 287 240.5C292 294 265.5 393 219.5 393C173.5 393 -25.5001 357.5 10.9999 330.5C47.5 303.5 185 335 163.5 364C142 393 79.0001 441.5 45 478C10.9999 514.5 64.4999 626 142 606.5C219.5 587 263.5 524 299.5 553C335.5 582 275.5 707.5 219.5 700.5C163.5 693.5 10.9999 632.5 10.9999 700.5C10.9999 768.5 132.5 853 176 841C210.8 831.4 264.5 824.333 287 822"

And here’s what Michelle’s array of points looks like.

const path = [
  { x: 800, y: 200 },
  { x: 900, y: 20 },
  { x: 1100, y: 100 },
  // ...and so on

Each have their benefits. If you want a very specific path, you’re probably better off using a vector program to draw that shape and import it. But if you want to adjust the path once you’re in your code, the coordinate array that Michelle has is much easier to read and tweak.

Here’s where Michelle uses MotionPath inside a GSAP timeline command.

tl.to(fish, {
  motionPath: {
    path: path,
    align: "self",
    alignOrigin: [0.5, 0.5],
    autoRotate: true,
  duration: 10,
  immediateRender: true,

autoRotate: true is what makes the fish face in the direction it’s going. The duration is set to ten seconds, so if we were to run this normally, the swimming animation would take 10 seconds.

Here’s just the fish animation with the scroll features removed, set to repeat forever.

Fade to Black

The way the fish fades to black seems so natural that I didn’t realize how it was happening for a while. As the fish starts to swim, there’s a point where it seems to move ‘behind’ the water, like it’s been on the surface and now it’s submerging. It’s actually moving behind it’s parent, .fish-wrapper, which has a mask applied to it.

The mask is just a gradient that extends for the whole page, so the more you scroll, the more the opacity fades on the fish.

Scroll Triggering Animations

ScrollTrigger is a fantastic library for handling scroll-based animations. Michelle is using it in multiple places to trigger animation: the fish, each <section> and the lights used throughout the piece.

It’s applied to the fish’s swimming animation, turning the scrollbar into a video playhead. It’s added on each <section> to make each line of text fade in and out. And it’s also used on the little lights in the background to move them from the bottom left off to the top right as you scroll.

The Fish

Now that the fish has its swimming animation, Michelle uses ScrollTrigger to scrub through that animation and animate a few other details.

When she declares the fish’s timeline, she passes ScrollTrigger in the config like this.

const tl = gsap.timeline({
  scrollTrigger: {
    scrub: 1,

Giving the property scrub a value of 1 means it’ll take one second for the animation to catch up to the position of the scroll bar. This is a nice way to make the animation feel smoother and less jumpy. Too high of a number and the animation can feel slow and out of sync.

Now the animation we saw before is handled entirely by ScrollTrigger.


There are eleven <section> elements in the CodePen, each with a different lyric from the song. Michelle runs the following function for each section in the HTML.

sections.forEach((section, i) => {
  const p = section.querySelector("p");
  gsap.to(p, { opacity: 0 });

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

She creates a ScrollTrigger instance for each section, and uses a few of the callbacks that ScrollTrigger provides. When the section enters the viewport, onEnter is triggered and calls makeBubbles(p, i). This doesn’t just make the fish emit it’s little bubbles, but also makes the text appear.

Take a look at this version where each <section> has a red  border and the ScrollTrigger ‘start’ and ‘end’ markers turned on, allowing you to see when they come in and out of view.

This is a really handy debugging feature built into ScrollTrigger. Just pass markers: true to the ScrollTrigger object to turn those on, and it makes creating with it so much easier.

Flipping the Fish

One great touch with Weird Fishes is how if you scroll back up, the fish turns around and swims back up along the path. It’s a small detail but key in making the fish feel more natural.

To do this, Michelle is hooking into ScrollTrigger’s onUpdate callback function. By calling the rotateFish function every time the user scrolls, she’s able to check which direction the user is scrolling by checking the direction attribute on the ScrollTrigger object. If the user is scrolling down, direction will be 1, and -1 if they’re scrolling up.

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

Scroll up and down on this mini pen to see the direction value change.

Wrap Up

I hope you were able to come away with some new techniques from this fantastic Pen! You can lead a horse to water, but you can’t make them use GSAP.

If anything I hope you’re inspired to take a song you love and turn it into a bit of beautiful, interactive code like Michelle did.

Thanks again to Michelle Barker for letting us explore her work. If you haven’t already, you should really hop over to CSS In Real Life, where Michelle does a wonderful job of writing about CSS. It’s one of my favorite frontend blogs.

Also take a look at her CodePens. She has helpful tools like this Intersection Observer Visualizer and brilliant demos, like this variable font animation.