05 / blog / migrationNovember 20269 min read

Locomotive Scroll alternatives in 2026: Lenis, native CSS and more

Locomotive Scroll does two jobs: it smooths the scroll, and it tells elements when they are on screen through data-scroll attributes. In 2026 each job has a replacement of its own, from Lenis to plain CSS. Here is what each attribute becomes, how Lenis and an animation library share one scroll, and what happens to keys, anchor links and reduced motion along the way.

  • Migration
  • Locomotive Scroll
  • Lenis
  • Migration
Locomotive v5
9.3 KB, Lenis inside
Lenis alone
5.3 KB
CSS timelines
0 KB
  • WheelLenis eases it, lerp 0.1
  • Keysthe browser, as without Lenis
  • Touchthe browser (syncTouch: false)
  • Scrollbarthe browser

window.scrollYone real scroll position

  • CSS view() and scroll()
  • IntersectionObserver
  • svg-scroll-draw
Fig. 1 — where Lenis sits. It eases wheel input and writes each step to the page’s real scroll position, so CSS timelines, observers and svg-scroll-draw all read one number. Keys, touch and the scrollbar stay the browser’s.
01The starting point

What Locomotive Scroll does, in v4 and v5

Locomotive Scroll comes from the Montreal studio Locomotive. Its npm description sums it up: detection of elements in the viewport, and smooth scrolling with parallax. You mark elements with data-scroll, and it adds the class is-inview when they arrive, writes a --progress value, or moves them at their own speed.

index.html3 lines
<h2 data-scroll data-scroll-repeat>Arrives each time it scrolls in</h2>
<div class="chapter" data-scroll data-scroll-css-progress></div>
<div class="layer" data-scroll data-scroll-speed="0.2"></div>
main.js4 lines
import LocomotiveScroll from 'locomotive-scroll';

// Lenis smooth scrolling, with the data-scroll detection on top.
const scroll = new LocomotiveScroll();

There are two quite different versions. Version 4, last released as 4.1.4 on 3 February 2022, scrolled the page itself: in smooth mode it set overflow: hidden on the page, moved the content with transforms, drew its own scrollbar and handled the keyboard. Pages were built from data-scroll-container and data-scroll-section wrappers.

Version 5 is a rewrite on top of Lenis. 5.0.0 came out on 15 January 2026 after a long beta, and 5.1.0 on 13 November 2026; it depends on Lenis 1.3.27. The page scrolls natively, with the browser’s own scrollbar, and the detection runs on IntersectionObserver. The container and section wrappers are gone, and so is data-scroll-sticky.

02Migration

Each data-scroll attribute, and what replaces it

Every Locomotive feature is one of four jobs: smooth scrolling, in-view classes and progress, parallax, and sticky or horizontal sections. The browser now does most of them. The right-hand column is this site’s library, for when you want the JavaScript version or more than a class.

Locomotive ScrollIn the platformIn svg-scroll-draw
new LocomotiveScroll()Native scrolling, or Lenis on its own—, it does not smooth scrolling
data-scroll + is-inviewIntersectionObserver adding a classscrollReveal()
data-scroll-repeatRemove the class when it leaves{ once: false }
data-scroll-offset, -positionrootMargin; animation-rangetrigger: { start, end }
data-scroll-css-progress@property on a view() timelinescrollProgress()
data-scroll-call, -event-progressThe observer’s callbackonEnter, onProgress
data-scroll-speedtranslate on a view() timelinescrollParallax()
data-scroll-sticky (v4)position: stickyscrollPin()
horizontal (v4 direction, v5 Lenis orientation)A sticky stage and a transformscrollHorizontal()
data-scroll-to<a href="#id"> and scroll-behavior—, or Lenis’s scrollTo()
Locomotive attributes as of 5.1.0, plus the two v4 ones that have no v5 attribute. Sizes on this page are minified and gzipped, measured 2026-08-14 on Locomotive Scroll 5.0.1 and Lenis 1.3.26.
03Job 1

Smooth scrolling: Lenis, or the browser’s own

If the eased, weighty feel is what you liked, use Lenis directly. It is the engine inside Locomotive v5, it is 5.3 KB against Locomotive’s 9.3 KB, and its current release is 1.3.27 (15 October 2026), with a 2.0 in development builds. Link its small stylesheet, lenis/dist/lenis.css, as well.

smooth.js6 lines
import Lenis from 'lenis';

const lenis = new Lenis({
  autoRaf: true, // run its own requestAnimationFrame loop
  anchors: true, // ease same-page #links too, honouring scroll-margin-top
});

Lenis takes over the mouse wheel only. It cancels each wheel event and moves the page about a tenth of the remaining distance per frame (lerp: 0.1), with scrollTo, so the page’s real position follows. Touch scrolling stays native unless you set syncTouch, and so do keys and the scrollbar, as Fig. 1 shows.

Many teams now leave the wheel alone instead. Trackpads and phones already scroll with momentum, and on a phone Lenis is not easing anything by default. A wheel listener that cancels the event means each step waits for the main thread, so a busy page shows it as lag, where native scrolling can run off the main thread. Scrolling areas inside the page, such as a code block or a modal, need data-lenis-prevent or allowNestedScroll: true to get their wheel events back. And some readers simply notice that the page trails the wheel. Whether that is weight or lag is a matter of taste, and worth deciding on purpose.

Without a library, the one smoothing most sites still want is on anchor links:

smooth.css8 lines
/* Anchor links glide instead of jumping, unless the reader asked
   for less motion. Wheel and touch scrolling are left as they are. */
@media (prefers-reduced-motion: no-preference) {
  html { scroll-behavior: smooth; }
}

/* Land headings below a fixed header 5rem tall. */
h2[id] { scroll-margin-top: 5rem; }
04Job 2

In-view classes and a progress value

is-inview is an IntersectionObserver and a class. The script below plays once, as Locomotive does by default, or keeps watching for elements marked data-repeat. It adds will-reveal before it observes, so if the script does not load, nothing stays hidden.

inview.js16 lines
const io = new IntersectionObserver((entries) => {
  for (const { target, isIntersecting } of entries) {
    if (isIntersecting) {
      target.classList.add('is-inview');
      // Locomotive plays once by default; data-scroll-repeat kept watching.
      if (!target.hasAttribute('data-repeat')) io.unobserve(target);
    } else {
      target.classList.remove('is-inview');
    }
  }
});

for (const el of document.querySelectorAll('[data-inview]')) {
  el.classList.add('will-reveal'); // hidden only once the script runs
  io.observe(el);
}
inview.css9 lines
@media (prefers-reduced-motion: no-preference) {
  .will-reveal {
    transition: opacity 0.6s ease-out, translate 0.6s ease-out;
  }
  .will-reveal:not(.is-inview) {
    opacity: 0;
    translate: 0 2rem;
  }
}

data-scroll-css-progress is a scroll timeline. Register --progress as a number so the browser can animate it, and run it on view(). Its default range, cover, is the same span as Locomotive’s default: from the element’s top entering at the bottom of the screen to its bottom leaving at the top. In our test the two values matched at every point we sampled, in Chromium, Firefox and WebKit.

progress.css19 lines
/* data-scroll-css-progress: --progress runs 0 to 1, from the moment the
   element's top enters at the bottom to the moment its bottom leaves
   at the top. That span is view()'s default range, cover. */
@property --progress {
  syntax: '<number>';
  inherits: true;
  initial-value: 0;
}

@supports (animation-timeline: view()) {
  [data-progress] {
    animation: progress linear both;
    animation-timeline: view();
  }
}

@keyframes progress {
  to { --progress: 1; }
}

Use it in any property, for example opacity: var(--progress). Browsers without scroll timelines skip the block and leave --progress at 0, so write the rule so that 0 is a fine resting state. How view() and its ranges work is in CSS scroll-driven animations explained, and the scroll trigger visualizer shows where a start and an end line fall as you scroll.

With svg-scroll-draw, the same two jobs are a call each. scrollReveal is 6.1 KB and scrollProgress 2.9 KB:

reveal.js10 lines
import { scrollReveal } from 'svg-scroll-draw/reveal';
import { scrollProgress } from 'svg-scroll-draw/progress';

// data-scroll plus your is-inview CSS: fades each card up 32px as it
// scrolls in, then stays. Pass { once: false } for data-scroll-repeat.
scrollReveal('.card');

// data-scroll-css-progress: writes --scroll-progress, 0 to 1, over the
// same span as Locomotive's --progress.
scrollProgress('.chapter');
05Job 3

Parallax

In v5, data-scroll-speed moves an element from speed × the screen’s height below its place to the same distance above it, while it crosses the screen. That is a translate keyframe pair on a view() timeline, and the same numbers give the same positions: in our test the CSS layer and Locomotive’s sat at the same pixel all the way through.

parallax.css15 lines
/* data-scroll-speed="0.2": 20% of the screen's height below its place as
   it enters, 20% above as it leaves, so it runs ahead of the page. */
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .layer {
      animation: speed-02 linear both;
      animation-timeline: view();
    }
  }
}

@keyframes speed-02 {
  from { translate: 0 20vh; }
  to   { translate: 0 -20vh; }
}

One difference: Locomotive handles an element that is already on screen when the page loads by starting it at its place, and this CSS starts it part of the way down. Keep layers like this below the first screen, or give the top one its own keyframes. scrollParallax does the same in JavaScript, on its own scale:

parallax.js5 lines
import { scrollParallax } from 'svg-scroll-draw';

// A scale of its own: speed is how far the element travels while it
// crosses the screen, as a share of its own height. 0.4 = 40% of it.
scrollParallax('.layer', { speed: 0.4 });

Depth, layers and speeds are covered in parallax scrolling with CSS and JavaScript.

06Job 4

Sticky, pinned and horizontal sections

v4’s data-scroll-sticky existed because in smooth mode the page never really scrolled: the content was moved with transforms, so position: sticky had no scrolling to stick against. With native scrolling, under Lenis or not, sticky works again:

sticky.css12 lines
/* data-scroll-sticky: the media holds at the top while the text beside
   it scrolls past, and leaves with the end of its section. */
.feature {
  display: grid;
  grid-template-columns: 1fr 1fr;
}

.feature .media {
  position: sticky;
  top: 0;
  align-self: start;
}

Sticky holds an element for as long as its parent scrolls past. To hold it for a set distance and push the rest of the page down meanwhile, as GSAP’s pin does, there is scrollPin (1.6 KB); pin sections on scroll without GSAP covers it in full.

pin.js5 lines
import { scrollPin } from 'svg-scroll-draw/pin';

// Holds .stage at the top for one more screen of scrolling, then lets
// it go. The next section waits below it, as GSAP's pin spacing does.
scrollPin('.stage');

v4 could scroll the whole page sideways, and v5 can through Lenis’s orientation option. A section that moves sideways while the page scrolls down is more common now: a tall outer box, a sticky stage inside it, and a track that slides left.

index.html9 lines
<section class="outer">
  <div class="stage">
    <div class="track">
      <article class="panel">One</article>
      <article class="panel">Two</article>
      <article class="panel">Three</article>
    </div>
  </div>
</section>
horizontal.css4 lines
.outer { height: 300vh; }
.stage { position: sticky; top: 0; height: 100vh; overflow: clip; }
.track { display: flex; width: max-content; }
.panel { width: 100vw; }
horizontal.js4 lines
import { scrollHorizontal } from 'svg-scroll-draw/horizontal';

// Slides the track left as .outer scrolls past, measured from the layout.
scrollHorizontal('.track');

The stage uses overflow: clip, not hidden, so tabbing to a panel off to the side cannot scroll the stage behind the script’s back. The details, and a CSS-only version, are in horizontal scroll sections without GSAP.

07Together

Lenis with svg-scroll-draw

Keeping the smooth scroll and moving the animation to another library is the common middle path, and it needs nothing in between. Lenis moves the page’s real scroll position on every frame, so window.scrollY, getBoundingClientRect() and CSS scroll timelines all see the eased value. In our test, window.scrollY equalled lenis.scroll on every frame we sampled, in all three engines.

main.js10 lines
import Lenis from 'lenis';
import { scrollDraw } from 'svg-scroll-draw';
import { scrollReveal } from 'svg-scroll-draw/reveal';

new Lenis({ autoRaf: true });

// Lenis writes each eased step to the real scroll position, so these
// read it as they read native scrolling: no adapter, no shared loop.
scrollDraw('#route');
scrollReveal('.card');

svg-scroll-draw also ships createLenisAdapter(lenis) from svg-scroll-draw/lenis, which makes window.scrollY report the value from Lenis’s scroll event. With the lenis package scrolling the window, as here, the two numbers are already the same, so leave it out. The Lenis scroll animation page has more on the pair, and the docs list the adapter.

08Accessibility

Reduced motion, keys and anchor links

Lenis 1.3 respects prefers-reduced-motion: reduce by default, through its respectReducedMotion option. With it set, the wheel’s lerp goes from 0.1 to 1, so the page reaches where the wheel sends it in about a tenth of a second instead of about a second, and lenis.scrollTo() and anchor links jump. Locomotive v5 passes that on, and also turns its parallax off under reduced motion and on touch screens.

Lenis does not listen to the keyboard. Arrow keys, Space, Page Up and Down, Home and End, and tabbing to a link further down all scroll the page natively, and Lenis picks up the new position. That is a real change from v4, which moved the page itself and had to handle keys on its own.

Same-page links are the browser’s unless you set anchors: true. Then Lenis eases to the target, honours scroll-margin-top, and the address bar still gets the #hash. Without it, CSS scroll-behavior decides, which is why the native version above sits inside a reduced-motion query.

The CSS replacements carry their own guard: the reveal and the parallax only animate inside prefers-reduced-motion: no-preference. The --progress value is not guarded, because it is only a number; guard whatever moves with it. svg-scroll-draw shows reveals and drawings finished under reduced motion, except scrollHorizontal, which keeps scrubbing by default because the panels are only reachable through it. Reduced motion for scroll animations goes through every effect.

— / next

See where a trigger starts

Moving data-scroll-offset and data-scroll-position over? Pick a start and an end line, scroll the preview, and watch progress run from 0 to 1.

Star on GitHubFound this useful? A star helps others find it.

→Parts list

Keep reading

More from the blog