Ajouter

Lorem ipsum

Lorem ipsum

Design

5 min

Scroll animations without GSAP: the minimalist CSS + JS technique

How to trigger section reveals on scroll without a JS library: a 100% CSS technique with 15 lines of JavaScript, designed for performance and conversion.

GSAP, ScrollTrigger, AOS, Locomotive Scroll... For most Webflow briefs, the immediate reaction when discussing scroll animations is to reach for a library. It’s understandable: these tools are powerful, well-documented, and capable of handling just about anything.

The problem is that "anything" isn't always the right goal. On an Awwwards site, where the animation IS the product, you can afford to add 60 to 100 KB of JS for complex sequences. On a conversion-focused site (SaaS landing page, B2B showcase site, product page), every kilobyte counts: weight slows down loading, and an overly prominent animation distracts the user from the action you want them to take.

In that case, the right question isn't "which library should I choose," but "do I really need a library at all?" For a simple use case like fading in a section as it enters the screen, the answer is often no. Here is the technique we use in-house: it’s virtually free in terms of performance, reliable on all devices, and takes about fifteen lines of JS.

The principle: JS opens the door, CSS handles the rest

The idea can be summed up in one sentence: JavaScript simply adds or removes a CSS class when an element enters the visible area of the screen (the viewport). The entire visual aspect—the appearance, scaling, and transition speed—is handled in pure CSS.

In practical terms:

  • On load and with every scroll, we calculate the position of each section relative to the screen using getBoundingClientRect().
  • If the section has entered the viewport (its top edge has passed a certain threshold), we add the class is--in-viewport.
  • The CSS defines the default "invisible" state (opacity: 0.5, scale: 0.5) and the "visible" state when the class is present (opacity: 1, scale: 1), with a transition using cubic-bezier to handle the animation between the two.

Zero dependencies, zero files to load, zero timelines to calculate. The JS simply toggles between two states, like a light switch. The CSS handles all the aesthetics.

The complete code

Two blocks to add to your page: the CSS and the JS. Line-by-line explanations follow each one.

The CSS

<style>
  /* ANIMATION */
  section {
    scale: 0.5;
    opacity: 0.5;
    transition: all 500ms cubic-bezier(0.215, 0.61, 0.355, 1);
  }
  section.is--in-viewport {
    opacity: 1;
    scale: 1;
  } 
  
  /* NO ANIMATION IN WEBFLOW */
  .wf-design-mode {
    section {
      scale: 1;
      opacity: 1;
    }
  }
</style>

Line by line:

  • section { scale: 0.5; opacity: 0.5; } defines the initial state: all <section> tags start at half size and half opacity.
  • transition: all 500ms cubic-bezier(0.215, 0.61, 0.355, 1) tells the browser to animate any property change over 500ms. This curve corresponds to a classic easeOutCubic (a quick start followed by a smooth slowdown at the end), which feels more natural than the default ease or linear.
  • section.is--in-viewport defines the end state: normal scale and opacity. As soon as the JS adds this class, the browser automatically animates the transition between the two states. No extra calculations needed in JS.
  • .wf-design-mode section { scale: 1; opacity: 1; } neutralizes the animation in the Webflow editor. Details just below.
  • The nested syntax .wf-design-mode { section { ... } } is native CSS nesting, supported by all modern browsers since 2023, not Sass or Less that needs to be compiled before publishing. It is equivalent to writing .wf-design-mode section { ... }.

good to know

Why opacity and scale, and not width or margin?

Opacity and scale are properties that the browser can animate directly on the GPU without recalculating the page layout for every frame. Conversely, a transition on width, height, or margin forces the browser to recalculate the position of all surrounding elements for every frame, which is resource-intensive and can cause stuttering. This detail is what makes the technique "virtually free" in terms of performance, not just the absence of a library.

The JavaScript

<script>
  document.addEventListener('DOMContentLoaded', function () {
    const sections = document.querySelectorAll('section');
    function checkSections() {
      sections.forEach(function (section) {
        const rect = section.getBoundingClientRect();
        const isInViewport = rect.top <= window.innerHeight - 100;
        section.classList.toggle('is--in-viewport', isInViewport);
      });
    }
    window.addEventListener('scroll', checkSections, { passive: true });
    window.addEventListener('resize', checkSections);
    checkSections();
  });
</script>

Line by line:

  • document.querySelectorAll('section') retrieves all sections on the page, once, upon loading.
  • checkSections() does all the heavy lifting: for each section, it reads its position using getBoundingClientRect(), which returns, among other things, rect.top, the distance in pixels between the top of the section and the top of the visible window.
  • rect.top <= window.innerHeight - 100 : the section is considered to have "entered" the viewport as soon as its top edge passes below the screen height minus 100px. This -100 is a trigger margin; it allows the animation to start slightly before the section is fully visible for a more natural effect. Adjust this value based on the height of your sections.
  • classList.toggle('is--in-viewport', isInViewport) adds or removes the class based on the calculation result. The second argument of the toggle avoids the need for an if/else statement: the class is applied if isInViewport is true, and removed otherwise.
  • Both addEventListener calls trigger checkSections() on every scroll and resize event. The { passive: true } option on the scroll listener tells the browser that we will never call preventDefault() inside it, allowing it to optimize scrolling without waiting for the JS to execute.
  • checkSections() is also called once upon loading, so that sections already visible on the screen (typically the hero section) appear immediately without waiting for the first scroll.

Note that this is not a one-time trigger: if the user scrolls back above the threshold, the class is removed and the animation plays in reverse. This ensures consistency in both scroll directions. If you prefer a one-time trigger, you must add a condition that prevents the class from being removed once it has been applied, for example by removing the element from the array. sections once it has reached is--in-viewport for the first time.

Extending the logic to other elements (text, buttons, images)

The JS only recognizes one selector: section. CSS determines everything that should react to this class. To animate a title, an image, or a button inside a section that is already being tracked, there is no need to touch the JS: simply add the desired selector to the is--in-viewportrule by targeting the child via a descendant selector.

section {
  scale: 0.5;
  opacity: 0.5;
  transition: all 500ms cubic-bezier(0.215, 0.61, 0.355, 1);
}
section.is--in-viewport {
  opacity: 1;
  scale: 1;
}

section h2,
section .card-image,
section .cta-button {
  opacity: 0;
  transform: translateY(20px);
  transition: all 600ms cubic-bezier(0.215, 0.61, 0.355, 1);
}
section.is--in-viewport h2,
section.is--in-viewport .card-image,
section.is--in-viewport .cta-button {
  opacity: 1;
  transform: translateY(0);
}

/* NO ANIMATION IN WEBFLOW */
.wf-design-mode {
  section {
    opacity: 1;
    scale: 1;
  }

  section h2,
  section .card-image,
  section .cta-button {
    opacity: 1;
    transform: translateY(0);
  }
}

Each child can even have its own animation (a translateY on the title, a scale on the image), as long as everything starts from the same trigger: the class applied to the parent section. The only time you need to touch the JS is if the element to be animated is not inside a tracked section, such as an isolated element higher up on the page. In that case, you would need to add it to the querySelectorAll so that it can be tracked independently.

Don't forget: disable the animation in the Webflow editor

Without the .wf-design-moderule, your sections would remain semi-transparent and permanently shrunk in the Designer, since the JS that triggers is--in-viewport does not execute the same way in that context. The result: you would be constantly working on a blurry, tiny design.

wf-design-mode is a class that Webflow automatically adds to the <html> tag when the page is viewed in the Designer, not in preview or on the published site. By targeting this class, we force scale: 1 and opacity: 1 only in this context, without affecting the actual behavior of the published site.

A habit to adopt systematically whenever an animation depends on a JS state: if the default state, before the JS kicks in, is not the "normal" design, you need a safeguard for the editor.

Where to place this code in Webflow

Two options, depending on whether the animation should apply to the entire site or just a single page:

  • Entire site : Site Settings → Custom Code → Header Code for the CSS (the <style>), Footer Code for JS (the <script>). JS needs to access the DOM once the page has loaded, so the Footer Code is the right place.
  • Single page : Page Settings for the relevant page → Custom Code section → "Inside Head Tag" for CSS, "Before Body Tag" for JS.

You can also paste everything into a single custom HTML embed (Add Element → Embed) directly in the Designer, but in that case, the code is only active where you place the embed: less practical if the logic needs to apply to all sections of the page.

The limitations of this technique (and when to switch to a real library)

This approach is intentionally simple, which means it has some accepted limitations:

  • No complex sequencing. You can't say "animate this element, then that one 200ms later, then reverse if the user scrolls back up." It's a back-and-forth between two states, not a timeline.
  • No scrubbing, that animation linked directly to the scroll position like a parallax effect. Here, the animation triggers once the threshold is crossed; it does not follow the scroll bar continuously.
  • A single trigger threshold (innerHeight - 100) for all sections. Managing a different threshold for each element requires adapting the JS, for example with a data- attribute that carries a custom margin.
  • A scroll listener that runs continuously. On a page with hundreds of elements to check, this can become a point of concern. At this stage, a IntersectionObserver would be more performant than manual scroll calculations, while still remaining dependency-free.

If the brief requires a precise timeline, section pinning, scroll-linked scrubbing, or orchestration between several independent elements, that's your cue to switch back to GSAP + ScrollTrigger rather than trying to force this technique beyond what it can cleanly handle.

Published on 10.09.2026

You might be interested in these tutorials

Similar tutorials

SEO / GEO

5 min read

5 views

How to Set Up a Redirect in Webflow? (2026)

Updated on 19.12.2025 by Sandro DA SILVA

SEO / GEO

5 min read

5 views

Add structured data to your Webflow site?

Updated on 21.08.2025 by Sandro DA SILVA

No-code

5 min read

5 views

How to Obfuscate a Link in Webflow

Updated on 23.04.2025 by Sandro DA SILVA

Let’s f*****G GO !!

Ready to launch
Your business?

Alexandre

Max

Enora

Bryan

Cannelle

Tiphaine

You'll :heart: our collaboration...