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.
| Section | Text |
|---|---|
| Techniques | Scroll-driven, Text animation |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
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.
More elements
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.