<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mohammed ALAklouk</title>
    <description>The latest articles on DEV Community by Mohammed ALAklouk (@mohammed_alaklouk_fb32d2d).</description>
    <link>https://dev.to/mohammed_alaklouk_fb32d2d</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4067739%2Fce877999-4fdc-4432-bd27-a5bb170e5209.png</url>
      <title>DEV Community: Mohammed ALAklouk</title>
      <link>https://dev.to/mohammed_alaklouk_fb32d2d</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mohammed_alaklouk_fb32d2d"/>
    <language>en</language>
    <item>
      <title>Building an ocean you can scroll to the bottom of</title>
      <dc:creator>Mohammed ALAklouk</dc:creator>
      <pubDate>Sat, 08 Aug 2026 14:37:15 +0000</pubDate>
      <link>https://dev.to/mohammed_alaklouk_fb32d2d/building-an-ocean-you-can-scroll-to-the-bottom-of-4gck</link>
      <guid>https://dev.to/mohammed_alaklouk_fb32d2d/building-an-ocean-you-can-scroll-to-the-bottom-of-4gck</guid>
      <description>&lt;p&gt;&lt;strong&gt;&lt;a href="https://mohammed-alaklouk.github.io/The-deep-ocean/" rel="noopener noreferrer"&gt;Live demo →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Have you seen those sites with the fancy scroll animations like the ones on &lt;a href="https://www.awwwards.com/websites/gsap/" rel="noopener noreferrer"&gt;Awwwards&lt;/a&gt;? I had never written a scroll-based animation before and didn't know what was actually behind that effect. I thought it would be fun to try to make one myself and learn some new techniques along the way.&lt;/p&gt;

&lt;p&gt;I remembered seeing this site called &lt;a href="https://neal.fun/deep-sea/" rel="noopener noreferrer"&gt;The Deep Sea&lt;/a&gt; by &lt;a href="https://neal.fun/" rel="noopener noreferrer"&gt;neal.fun&lt;/a&gt;. You scroll down through the depths and sea creatures appear at their real depths, and it just keeps going on and on. It makes you feel how deep the ocean really is. It's a great piece of work, go play with it before reading any further.&lt;/p&gt;

&lt;p&gt;It was the perfect inspiration for my project. I rebuilt it. Same concept, a 10,924 meter descent, 128 creatures at their real depths, my own hero design and particle system. It's plain HTML, CSS, JavaScript, plus GSAP and Lenis.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwiz6ryewhk87raze7xjr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwiz6ryewhk87raze7xjr.png" alt="The hero: a sunset sky with drifting clouds, waves, a bobbing buoy, and the sun sitting inside the O of " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  GSAP and Lenis
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://greensock.com/" rel="noopener noreferrer"&gt;GSAP&lt;/a&gt; or GreenSock Animation Platform is a powerful library for creating smooth and performant animations. You give it a target element, the final and initial properties you want to animate, and the duration of the animation. It handles all the math and timing for you with an interpolation function of your choosing. You can chain animations together through timelines. You can use plugins to change the behavior of the animations. I used the &lt;a href="https://greensock.com/scrolltrigger/" rel="noopener noreferrer"&gt;ScrollTrigger&lt;/a&gt; plugin to trigger animations and interpolate based on the scroll position instead of time. It has a built-in scrub feature that allows you to tie the progress of an animation to the scroll position. This is perfect for creating scroll-based animations.&lt;/p&gt;

&lt;p&gt;However, the default scroll behavior of the browser is not very smooth. It can feel very janky and inconsistent, especially on mobile devices. That's where &lt;a href="https://www.lenis.dev/" rel="noopener noreferrer"&gt;Lenis&lt;/a&gt; saves the day. It is a library that smooths out the scroll behavior of the browser by using requestAnimationFrame and easing functions. It provides a simple API to control scroll behavior and lets you define custom callbacks for scroll events.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting them to work together
&lt;/h2&gt;

&lt;p&gt;You would think these two would fight over who controls the scrolling, but they don't. ScrollTrigger never scrolls anything. It only reads how far down the page you are. And Lenis doesn't replace the scroll position either; it works out a smoothed value each frame and hands it to &lt;code&gt;window.scrollTo&lt;/code&gt;. The page's real scroll position is the true one the whole time, which is why I never needed ScrollTrigger's &lt;code&gt;scrollerProxy&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The actual problem is frames. Lenis needs something to advance it once per frame, and GSAP is already running its own loop to render tweens. Two loops means two callbacks every frame, in an order you didn't choose, and if GSAP renders before Lenis has moved, ScrollTrigger reads a scroll position that is one frame old. Every scrubbed animation ends up trailing the page by about 16ms. It doesn't look like a bug. It just makes the whole page feel slightly disconnected from your input.&lt;/p&gt;

&lt;p&gt;Three lines fix it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;lenis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;scroll&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ScrollTrigger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;gsap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ticker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;lenis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;time&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;gsap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ticker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lagSmoothing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first line is the important one. Lenis fires its &lt;code&gt;scroll&lt;/code&gt; event synchronously when it changes the scroll position, so I gave it a callback to tell ScrollTrigger to update its internal state immediately. That way, when GSAP renders, it has the updated scroll position.&lt;/p&gt;

&lt;p&gt;The second line gets rid of Lenis's own loop and drives it from GSAP's ticker instead, so there is one loop and one clock. Now GSAP runs its loop -&amp;gt; calls Lenis to update -&amp;gt; Lenis fires the scroll event -&amp;gt; ScrollTrigger updates its state -&amp;gt; GSAP renders. All in one frame. Note that Lenis's &lt;code&gt;.raf()&lt;/code&gt; expects a time in milliseconds, but GSAP's ticker gives it in seconds, so I multiply by 1000.&lt;/p&gt;

&lt;p&gt;GSAP has a feature where it lies to its ticker about how long the last frame took if it was over 500ms. This is so animations don't jump around if the page lagged for a while. But Lenis relies on the delta time being precise to scroll smoothly. If that feature was on, then if the user scrolls quickly or experiences lag, the animation will not respond to their input. The third line disables this feature.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdcedczoo43hiytbbt7ta.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdcedczoo43hiytbbt7ta.png" alt="Two independent loops let GSAP render before Lenis has moved, so ScrollTrigger reads a scroll position one frame old. Driving Lenis from GSAP's ticker resolves the whole chain inside a single frame." width="799" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Zones
&lt;/h2&gt;

&lt;p&gt;The site covers a depth of 10,924 meters. This is very long for a single page, especially when it's mostly empty. I use a scale of 50 pixels per 3 meters inherited from the grid the original site placed its creatures on, so its row data would line up with mine. This means the page is around 182,000 pixels long.&lt;/p&gt;

&lt;p&gt;The ocean is divided into these five zones: &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The sunlight zone (0–200 m)&lt;/li&gt;
&lt;li&gt;The twilight zone (200–1,000 m)&lt;/li&gt;
&lt;li&gt;The midnight zone (1,000–4,000 m)&lt;/li&gt;
&lt;li&gt;The abyssal zone (4,000–6,000 m)&lt;/li&gt;
&lt;li&gt;The hadal zone (6,000–10,924 m)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The zones are represented by a full-width div with a height of the zone's depth in pixels. I store each zone's span and resize the divs on startup. Each zone has a different background gradient and has a different number of particles to give a different feel to each. The background gradients go from light to dark as you go deeper, and the particles are more sparse in the deeper zones. I store the colors as CSS variables so I can reuse them in different parts of the design.&lt;/p&gt;

&lt;h3&gt;
  
  
  The depth meter
&lt;/h3&gt;

&lt;p&gt;I needed to tell the user how deep they were, so I added a depth meter that shows the current depth in meters. It is centered horizontally and fixed at 70% of the viewport height. It updates its value based on the scroll position and the scale factor.&lt;/p&gt;

&lt;p&gt;Determining the depth is a bit tricky because the page includes elements other than the zones. I get the top position of the zones relative to the viewport and the height of the zones. The counter is offset at 70% of the viewport height, which works out to (0.7 * window-height - relativeTop) / zonesHeight. I then clamp the value between 0 and 1 and multiply it by the total depth to get the current depth in meters.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;updateDepthCounter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;zonesContainer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBoundingClientRect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;counterRefY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHeight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// counter line, in viewport coords&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;progress&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;counterRefY&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;top&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;totalDepth&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;scrollDiv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; m`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I hide it until the user is already in the sunlight zone, then I start a GSAP tween to slide it in with a subtle fade. I also unstick it when the user scrolls past the last zone, so it doesn't overlap with the footer. &lt;/p&gt;

&lt;h2&gt;
  
  
  The glass cards
&lt;/h2&gt;

&lt;p&gt;I really like the glassy text effects Apple uses. I wanted to see how they're made and if I could make them myself. Turns out, it's just a blur, a gradient, and a smart box-shadow and border. &lt;/p&gt;

&lt;p&gt;I have a blur filter on the background. Anything behind the text will be blurred and show up through it. I saturate the colors so they pop more. To make it look like an actual piece of glass interacting with light, I add a diagonal gradient that goes from a light gray to a darker gray. The border makes it look like a glass pane with actual edges. The box-shadow adds a subtle drop shadow to make it look like it's floating above the background.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.glass-card&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;linear-gradient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="m"&gt;135deg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0%&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;0.34&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="m"&gt;0%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0%&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;0.16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="m"&gt;55%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0%&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;0.24&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0%&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;0.28&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;16px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;-webkit-backdrop-filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;blur&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;16px&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;saturate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;130%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;backdrop-filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;blur&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;16px&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;saturate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;130%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;box-shadow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;4px&lt;/span&gt; &lt;span class="m"&gt;18px&lt;/span&gt; &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;220&lt;/span&gt; &lt;span class="m"&gt;45%&lt;/span&gt; &lt;span class="m"&gt;11%&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;0.12&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;      &lt;span class="c"&gt;/* soft drop shadow */&lt;/span&gt;
    &lt;span class="nb"&gt;inset&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0%&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c"&gt;/* faint top sheen  */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1gbghgnclkla49uuoux0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1gbghgnclkla49uuoux0.png" alt="The twilight zone card in place, with creatures around it, the depth meter, and the zone dots." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Text animations
&lt;/h2&gt;

&lt;p&gt;I wanted the text to slide in with a subtle fade, stay on screen for a while, and then leave. I built that in three parts for each text element in the zones, using GSAP's &lt;code&gt;fromTo&lt;/code&gt; method to animate the opacity and y position, and ScrollTrigger to drive it from the scroll position. Only the entrance needed a movement of its own. On the way out the text just fades, and the page scrolling normally carries it up and away.&lt;/p&gt;

&lt;h3&gt;
  
  
  The pin that stretched the page
&lt;/h3&gt;

&lt;p&gt;GSAP has a built-in pin feature to freeze an element in place while you scroll. I reached for it without thinking much of it, but to my surprise I found the height of the zones had gotten all messed up and they were a lot longer than they should be. &lt;/p&gt;

&lt;p&gt;This is a quirk of how GSAP does its pinning. It wraps the element in a div with a pin-spacer class and adds the pin span in px to the pin-spacer's padding. This of course makes the zones longer and messes up the math. Each element is pinned for 800px, which is 48 meters, and with 5 text elements that adds up to 4000px or around 240 meters. I used the pinSpacing:false option to prevent GSAP from adding extra space for the pin and it worked, but the text would sometimes jitter between transitions, so I used pinType:"transform", which updates the transform property instead of the position property.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbpyez48ay5wnkvkj45is.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbpyez48ay5wnkvkj45is.png" alt="The page as laid out beside the page as GSAP rendered it: a pin-spacer after every zone adds 800px each, 4000px in total, pushing the depth scale 240 metres out." width="800" height="391"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;All three parts together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 1. Entrance: fades in as zone scrolls up into view&lt;/span&gt;
&lt;span class="nx"&gt;gsap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;textEl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;scrollTrigger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;parentZone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;top bottom&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;top top+=100px&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;scrub&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 2. Pin: freezes in place for the rest of the zone, while the zone scrolls past&lt;/span&gt;
&lt;span class="nx"&gt;gsap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;textEl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;scrollTrigger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;parentZone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;top top&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="c1"&gt;// Starts pinning when the zone reaches the top of the viewport&lt;/span&gt;
      &lt;span class="na"&gt;end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;top top-=800px&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;// Ends exactly 800px later&lt;/span&gt;
      &lt;span class="na"&gt;scrub&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;pin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;textEl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;pinSpacing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;pinType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;transform&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 3. Exit: fades out WHILE drifting up naturally&lt;/span&gt;
&lt;span class="nx"&gt;gsap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;textEl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;immediateRender&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;scrollTrigger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;parentZone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;top top-=800px&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// Starts fading EXACTLY when the pin unlocks&lt;/span&gt;
      &lt;span class="na"&gt;end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;top top-=1300px&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;scrub&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The particles
&lt;/h2&gt;

&lt;p&gt;Particles are a big part of the design. They give the ocean a sense of depth and movement and emphasize how barren the deeper zones are.&lt;/p&gt;

&lt;p&gt;Each particle stores its own position, velocity, and lifespan. They are generated randomly across the canvas, plus a buffer past each edge so they can drift in from offscreen, and each one gets a random velocity and lifespan. They are updated every frame and removed when they go out of bounds or their lifespan is over. Particles move around and pulse slowly to give the ocean a sense of movement. &lt;/p&gt;

&lt;p&gt;I also made them shift their position as the user scrolls. To make it more interesting, I gave each particle its own random distance from the camera, and they react to the scroll differently based on that distance. The closer they are, the more they move with the scroll. The farther they are, the less they move with the scroll. This gives a parallax effect and makes the ocean feel more 3D.&lt;/p&gt;

&lt;p&gt;I first made it so each zone has its own likelihood of spawning a particle, but that made the transitions between zones look weird and uneven. So I interpolated the spawn rate based on depth instead, and that got me better results. &lt;/p&gt;

&lt;p&gt;A new problem arose with the spawns. Basically, they would take a while to populate the areas you scrolled to because only one particle could spawn per frame. To fix this, I ditched the spawn rate and instead opted for a target count, where based on how deep you are, a certain number of particles will be spawned. To make it look like the particles have been there all along, instead of creating the particles at age 0, I randomly pre-age them.&lt;/p&gt;

&lt;p&gt;The count tops out at 300. It ramps up over the first few hundred meters, starts tapering at 5,000 m, and reaches zero at 8,000 m, so the bottom of the hadal zone is completely empty. That's the point.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbw5pywym2l4uz2u8j1jg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbw5pywym2l4uz2u8j1jg.png" alt="Target particle count against depth: a ramp to 300 over the first 305 metres, flat until 5,000 m, then a curved taper to zero at 8,000 m." width="800" height="382"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxyptfaf5ds6u4udts2p2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxyptfaf5ds6u4udts2p2.png" alt="The hadal zone at 9,534 metres — black, with no particles at all." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Creatures
&lt;/h2&gt;

&lt;p&gt;The original site had 128 creatures.&lt;/p&gt;

&lt;p&gt;It's always a good idea to isolate your data from the code and I didn't want my creatures.js to be 1000+ lines. I decided to store their data in a JSON file instead.&lt;/p&gt;

&lt;p&gt;I experimented with AI in the project. I made a Claude agent handle the positions and sizes.&lt;/p&gt;

&lt;p&gt;My first schema was just a depth and a scale multiplier per creature. Claude got the depths right. It used the average depth each creature stays at, but it completely hallucinated the scales. It was so bad that it made the polar bear the same size as a clownfish (2-5 inches). I then made it use a tiered classification system where small fish are almost the same size, then medium animals, then big sharks/whales etc. That did the trick.&lt;/p&gt;

&lt;p&gt;After the scale was done, I tackled the positions. At this point each creature's position was its average depth with a random x offset, which made the creatures draw over each other and was a big mess in the higher zones because they had most of the creatures. When I brought that to Claude's attention, it wrote a python script that calculated the final image sizes and positions in pixels on the page, then it used a complex algorithm to just nudge the creatures around so they don't overlap. It took a really, really long time to do that and it looked so bad at the end.&lt;/p&gt;

&lt;p&gt;I was done with its ideas and made it scan the original site and get the positions from there. The site was using a grid and represented the position with columns and rows, and the size with column width and height. I used the same approach and made it copy the values into the json file. One entry looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Manatee"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"slug"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"manatee"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"depth"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"col"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"colWidth"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rowHeight"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;col&lt;/code&gt;, &lt;code&gt;colWidth&lt;/code&gt; and &lt;code&gt;rowHeight&lt;/code&gt; go straight onto the CSS grid. The row comes from the depth: at 3 meters per row, it's just &lt;code&gt;Math.round(depth / 3)&lt;/code&gt;. That's the whole placement system, and it's why the page had to be scaled at 50 pixels per 3 meters in the first place.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7v9qr1l70nf9nrtd49ey.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7v9qr1l70nf9nrtd49ey.png" alt="One JSON entry placed on the grid: col 5 spanning 3 columns, row 1 spanning 2 rows, with the row derived from the depth." width="800" height="409"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzn8nim58vs3ff114mg4u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzn8nim58vs3ff114mg4u.png" alt="A gummy shark and a diver at 337 metres, with the depth meter and zone dots." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Loading this many images all at once and making the site wait for them will make the site stutter on load, so I lazy loaded them with native &lt;code&gt;loading="lazy"&lt;/code&gt; and &lt;code&gt;decoding="async"&lt;/code&gt;, no library. Lazy loading usually costs you layout shift as images pop in, but not here. The grid cell already reserves its space from the row and column data, so there's nothing for the image to push around when it arrives.&lt;/p&gt;

&lt;p&gt;I gave each creature a simple entrance animation where they fade in with a blur and a small displacement. On the way out they fade and blur back out, without the displacement. Drifting up and away at the same time looked like too much.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting around 182,000 pixels
&lt;/h2&gt;

&lt;p&gt;The page is very long and that causes a big problem: nobody wants to scroll all that by hand, and once you're a good amount through the site there is no convenient way to go back. &lt;/p&gt;

&lt;p&gt;I added five dots on the right edge, each for one of the zones. The dot for whichever zone you are scrolling through is filled. Clicking a dot scrolls you to its corresponding zone. They fade in the same way as the depth meter and unpin near the footer.  &lt;/p&gt;

&lt;p&gt;The one I like more is the auto-scroll button. When you click it, it does the scrolling for you. It scrolls slowly and it feels kind of like you're sinking. I made it so it's slow enough you can register the creatures as they pass, but fast enough that the lower zones are not a grueling black screen.&lt;/p&gt;

&lt;p&gt;To make it feel right I measure the speed in screenfuls per second instead of pixels per second, as follows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;screenfuls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;toY&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;fromY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHeight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;lenis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scrollTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;toY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;screenfuls&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;screensPerSec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;easing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A fixed pixels-per-second scroll would give users a different experience if they had a shorter screen. The creatures would just zip through the smaller screen length quickly. Dividing by the viewport height gives everyone the same pace. The easing is important too. GSAP and Lenis both default to an ease-in-out, which would make the descent inconsistent, so I dropped it.&lt;/p&gt;

&lt;p&gt;One more thing was needed. Going full speed from standing in the hero felt too abrupt, so I ramp up the speed in the first 500 meters. It starts at half speed then goes linearly to full speed. This also has a cool side effect where you spend more time in the creature-dense parts, which I think is better than going at full speed through them. Any wheel, touch, or key input cancels the scroll immediately. &lt;/p&gt;

&lt;p&gt;I put a back-to-top button that works the same way, screenfuls-per-second and linear easing, just about fourteen times faster. It's a long way up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hero
&lt;/h2&gt;

&lt;p&gt;For the hero I followed the original design but wanted to give it some zest. I wanted it to look like a sunset. A warm background, some clouds, waves and text in the middle.&lt;/p&gt;

&lt;p&gt;I started with the text. The hero reads "Let's explore the" on one line and "Ocean" on the next. I used a different font for each, and sized "Ocean" so it spans nearly the full width of the line above it, so it pops out. I gave it a simple fade / slide in animation, then it idles bobbing up and down. When you scroll it slides out.&lt;/p&gt;

&lt;h3&gt;
  
  
  The gradient
&lt;/h3&gt;

&lt;p&gt;The gradient on the "Ocean" text was pretty tricky. CSS only lets you set text color as a solid color, so I had to improvise. I gave the background a gradient and made it only show behind the rasterized letters with &lt;code&gt;background-clip: text&lt;/code&gt;. Then I made the text color transparent and voilà.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.hero-title&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;linear-gradient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;160deg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;220&lt;/span&gt; &lt;span class="m"&gt;45%&lt;/span&gt; &lt;span class="m"&gt;11%&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="m"&gt;30%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;190&lt;/span&gt; &lt;span class="m"&gt;70%&lt;/span&gt; &lt;span class="m"&gt;35%&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;-webkit-background-clip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;background-clip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;-webkit-text-fill-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;transparent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* Overrides `color` from any other rule that applies */&lt;/span&gt;
  &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;transparent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The sun
&lt;/h3&gt;

&lt;p&gt;The big 'O' looked like the sun, so I decided to put the sun inside of it. &lt;/p&gt;

&lt;p&gt;The sun is pretty simple. The body is just a radial gradient, the light is a box shadow. I blur the whole thing and change the blend mode to screen so it affects the elements around it.&lt;/p&gt;

&lt;p&gt;It's my favorite part of the design.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.sun&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;absolute&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;50%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;50%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;translate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;-50%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;-50%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.5em&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.5em&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;50%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;radial-gradient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nb"&gt;circle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;45&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt; &lt;span class="m"&gt;92%&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="m"&gt;0%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;38&lt;/span&gt; &lt;span class="m"&gt;90%&lt;/span&gt; &lt;span class="m"&gt;72%&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="m"&gt;40%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;28&lt;/span&gt; &lt;span class="m"&gt;85%&lt;/span&gt; &lt;span class="m"&gt;58%&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;box-shadow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0.2em&lt;/span&gt; &lt;span class="m"&gt;0.1em&lt;/span&gt; &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;40&lt;/span&gt; &lt;span class="m"&gt;90%&lt;/span&gt; &lt;span class="m"&gt;70%&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0.6em&lt;/span&gt; &lt;span class="m"&gt;0.25em&lt;/span&gt; &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;33&lt;/span&gt; &lt;span class="m"&gt;80%&lt;/span&gt; &lt;span class="m"&gt;60%&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;1.2em&lt;/span&gt; &lt;span class="m"&gt;0.5em&lt;/span&gt; &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;28&lt;/span&gt; &lt;span class="m"&gt;70%&lt;/span&gt; &lt;span class="m"&gt;55%&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;2em&lt;/span&gt; &lt;span class="m"&gt;1em&lt;/span&gt; &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;28&lt;/span&gt; &lt;span class="m"&gt;70%&lt;/span&gt; &lt;span class="m"&gt;55%&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;blur&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;4px&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;mix-blend-mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;screen&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;pointer-events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;z-index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;-1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The waves
&lt;/h3&gt;

&lt;p&gt;I stacked three SVGs of quadratic Bézier waves on top of each other, each with its own color. I wrote a keyframe with a different duration for each one so their cycles never sync up, and made each wave's movement direction opposite to the other two so they look organic. That was enough to sell it.&lt;/p&gt;

&lt;p&gt;I added a buoy to make the scene feel alive; it's also a bunch of SVGs stitched together, and it just bobs up and down.&lt;/p&gt;

&lt;p&gt;I've encountered a few issues with the waves. &lt;/p&gt;

&lt;p&gt;First off, sometimes a few rows of pixels would be a different color between the waves and the zone. When the waves moved, the top wave could slide up so much the lower layers would show through, and if you're lucky even the hero background itself. I fixed this by making the waves taller and shifting them down. I also changed the sunlight zone's color to be the same as the wave, and now they all blend together and you don't notice. &lt;/p&gt;

&lt;p&gt;Mobile messed up those waves so much. Because their view box's dimensions were fixed and got stretched with &lt;code&gt;preserveAspectRatio="none"&lt;/code&gt;, they got squeezed into less space on smaller screens so they looked compressed and choppy. I fixed that by constructing the waves based on the screen width on load and resize. I also gave them fewer humps on smaller screens. &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjn53siaho83msd94cbmo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjn53siaho83msd94cbmo.png" alt="The same wave on desktop, squeezed on a phone, and rebuilt for the phone with one correctly proportioned hump." width="800" height="309"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Even the buoy wasn't safe from mobile. I gave it a fixed position, but because the waves change based on the screen width, this position could land on a peak or trough, so it was frequently floating in mid-air. I fixed this by doing a binary search along the front wave for its actual height under the buoy and anchoring it to that. This also runs on load and resize. &lt;/p&gt;

&lt;h3&gt;
  
  
  The clouds
&lt;/h3&gt;

&lt;p&gt;I used a few clever tricks for the clouds. The cloud is a div with &lt;code&gt;border-radius: 50%;&lt;/code&gt; so it's an ellipse. I specify the width and height per cloud separately. Then I add a few box shadows with no blur and a spread radius to each cloud. Because I'm not blurring, the shadows are opaque; they act like extra puffs at different sizes and offsets. Then I blur the whole thing, and now they look like one soft and fluffy mass.&lt;/p&gt;

&lt;p&gt;That got me a pretty convincing cloud. Then I make them drift 100vw from their starting position to the right. I gave them durations from 45s to 65s. Bigger clouds are slower and more transparent so they look far away.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr6k80i8cbftle9vs5r9z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr6k80i8cbftle9vs5r9z.png" alt="A cloud in three steps: one ellipse, then opaque box-shadows acting as extra puffs, then a blur over the whole thing." width="800" height="291"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Reduced motion
&lt;/h2&gt;

&lt;p&gt;The site is full of moving and scaling elements. For people with vestibular disorders or motion sensitivity, that kind of design makes them dizzy and nauseous. I listen for prefers-reduced-motion; when it's set, I disable smooth scrolling and the browser's native scroll takes over, the particle system gets a 0 target all the time so it's disabled, and the ambient loops like the waves, the clouds, and the bobbing buoy are cancelled.&lt;/p&gt;

&lt;p&gt;I had to think hard about the scroll-driven entrances. I first disabled them too, but that made them either too boring or just pop in with no transition. I decided to drop the movement in the animations and keep the fade-in. Reduced motion doesn't mean no animations, just no motion. &lt;/p&gt;

&lt;h2&gt;
  
  
  What I ended up learning
&lt;/h2&gt;

&lt;p&gt;I started this project wanting to learn scroll animations, and that turned out to be the simplest part of it. Setting up an animation is as easy as defining the start and end properties and passing an element identifier alongside them. The scroll animations were also very simple, just three lines to sync GSAP and Lenis, and a &lt;code&gt;scrub: true&lt;/code&gt; in the animation. This part was mostly reading docs and looking up examples.  &lt;/p&gt;

&lt;p&gt;Everything else was a different kind of problem. The weird responsiveness issues with the waves and the buoy have taught me a lot about how to debug and profile a page. The pin issue was a side effect that would have no real impact on a normal site, and it made a lot of sense once I understood it. It taught me to dig into how things work under the hood to understand the "random" behaviors. &lt;/p&gt;

&lt;h2&gt;
  
  
  On using AI
&lt;/h2&gt;

&lt;p&gt;The creatures in particular taught me a lot about the limitations of AI and how to best use it in projects moving forward. I originally wrote a basic skeleton of the site myself then decided to try out vibe coding, but that wasn't really a good idea. AI isn't great at integrating systems together so it performs poorly on projects that get revised constantly and things get added to them frequently. I had to rewrite the entire particle logic, large parts of the creature logic, and large parts of script.js, and parts of them are still sketchy. It writes long and descriptive comments but a lot of times it just makes up why a change is happening or what the change is.&lt;/p&gt;

&lt;p&gt;This actually made it pretty hard to trace what changed and why, alongside lots of implementation details, because I didn't go over everything that it wrote. Also there were plenty of times where it was just faster for me to write the code myself because of how long it needs to think, and how long it would take me to verify the code it wrote, and that's even if what it wrote actually worked. To save time I didn't verify a lot of the code when it was written and that led to a whole lot of bugs. AI is a powerful tool but that is all it is, a tool. It performed poorly when I treated it more as a developer than a tool. &lt;/p&gt;

&lt;p&gt;Overall this project has taught me a lot about web development, project management, debugging and profiling, and AI usage. I had a blast building this site.&lt;/p&gt;

&lt;p&gt;Check out the site here &lt;strong&gt;&lt;a href="https://mohammed-alaklouk.github.io/The-deep-ocean/" rel="noopener noreferrer"&gt;Live demo&lt;/a&gt;&lt;/strong&gt; · &lt;strong&gt;&lt;a href="https://github.com/Mohammed-ALAklouk/The-deep-ocean" rel="noopener noreferrer"&gt;Source on GitHub&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>ai</category>
      <category>gsap</category>
    </item>
  </channel>
</rss>
