Copied!
Text

Scroll velocity skew

Text that leans and splits into two colour fringes in proportion to how fast the page scrolls.

← Library

Element · Vanilla JS + CSSUpdated November 5, 2026

TextElementVanilla JS + CSS

Live preview needs JavaScript. The code below works without any dependencies.

Prompt for your AI assistant

Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.

Build "Scroll velocity skew" for my site using the Enrichr Library element at https://enrichr.dev/elements/scroll-velocity-skew.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/scroll-velocity-skew.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.

Settings:
- text: Scroll fast
- skew: 12
- split: 8
- preview: 0
- color: #ffffff
- colorA: #ff3d6e
- colorB: #35e0ff

Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.

The same spec as JSON, for tools that read it directly: /elements/scroll-velocity-skew.json.

Code

Mount it on an element that contains the markup. It returns an object with update(options) and destroy().

HTML

<div class="ef-vel"><span class="ef-vel__t"></span></div>

CSS

.ef-vel { overflow: visible; }
.ef-vel__t { display: inline-block; font-size: clamp(2.4rem, 9vw, 6.5rem); font-weight: 800; line-height: 1.05; letter-spacing: -0.03em; will-change: transform, text-shadow; transform-origin: 50% 100%; }
@media (prefers-reduced-motion: reduce) { .ef-vel__t { transform: none !important; text-shadow: none !important; } }

JavaScript

// Scroll velocity skew and RGB split: text leans and splits into two colour fringes in proportion to how fast the page scrolls.
// It reads window.scrollY itself, so it works with native scroll, Lenis or any other smooth scroll.
// mount(root, { text, skew (max degrees), split (max px), range (scroll px/s for full effect), smoothing (0.05-0.5),
//               preview (0 = real scroll, 1-100 = fixed % of full effect), color, colorA, colorB })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var el = root.querySelector(".ef-vel__t");
  var opts = {}, raf = 0, last = 0, lastY = window.scrollY, v = 0, visible = true, io = null;

  function apply() {
    var n = Math.max(-1, Math.min(1, v / opts.range));
    var s = (n * opts.split).toFixed(2);
    el.style.transform = "skewX(" + (-n * opts.skew).toFixed(2) + "deg)";
    el.style.textShadow = s + "px 0 " + opts.colorA + ", " + -s + "px 0 " + opts.colorB;
  }
  function tick(t) {
    raf = requestAnimationFrame(tick);
    var dt = Math.max(1, t - last) / 1000;
    last = t;
    var y = window.scrollY;
    var speed = (y - lastY) / dt;
    lastY = y;
    if (!visible) return;
    var target = opts.preview > 0 ? (opts.preview / 100) * opts.range : speed;
    v += (target - v) * opts.smoothing;
    if (Math.abs(v) < 0.5) v = 0;
    apply();
  }
  function update(next) {
    opts = Object.assign({ text: "Scroll fast", skew: 12, split: 8, range: 2400, smoothing: 0.18, preview: 0, color: "#ffffff", colorA: "#ff3d6e", colorB: "#35e0ff" }, opts, next || {});
    el.textContent = opts.text;
    el.style.color = opts.color;
    root.setAttribute("aria-label", opts.text);
    v = 0;
    apply();
  }
  update(o);
  if (!reduce) {
    if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(root); }
    raf = requestAnimationFrame(tick);
  }
  return { update: update, destroy: function () { cancelAnimationFrame(raf); if (io) io.disconnect(); } };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"text":"Scroll fast","skew":12,"split":8,"preview":0,"color":"#ffffff","colorA":"#ff3d6e","colorB":"#35e0ff"});

How it works

A requestAnimationFrame loop reads window.scrollY every frame and turns the change into a speed in pixels per second. That speed is smoothed toward its target, scaled against a range you choose, and applied as a skew and as two opposite text shadows in different colours. When scrolling stops the value eases back to zero, so the text settles. Because it reads the real scroll position, it works with native scroll, Lenis or any other smooth-scroll library.

Accessibility and performance

The effect is decorative and the text stays real text. With prefers-reduced-motion nothing skews or splits. Keep the fringe colours distinct enough from the background that the text stays readable while it moves.

One scroll read and two style writes per frame while the element is on screen, paused when it is not. Transform and text-shadow only; no layout work.

SectionText
TechniquesScroll-driven, Text animation
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested

Find this technique on a real site

Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.

Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns

Licence and provenance

Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.

This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.

Word read highlightWords in a paragraph light up one by one as it scrolls through the viewport. Scroll spring headingAn oversized heading slides sideways with scroll progress through a spring, lagging and settling. Marquee tickerEndless horizontal text that pauses on hover, with speed, gap and direction controls.

FAQ

Does it need Lenis or GSAP?

No. It reads window.scrollY itself, so it works with native scrolling and also with any smooth-scroll library.

Why use text-shadow for the colour split?

Two shadows offset in opposite directions give a cheap chromatic fringe with no extra elements. A WebGL version can split real channels, but this one runs anywhere.

How do I preview it without scrolling?

Use the preview strength control. It feeds a fixed speed into the same code path the real scroll uses.