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 "Horizontal scroll colour morph" for my site using the Enrichr Library element at https://enrichr.dev/elements/horizontal-scroll-colour-morph.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/horizontal-scroll-colour-morph.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:
- theme: enrichr
- text: Design Bold. Ship Fast.
- length: 2.7
- auto: 1
- bgFrom: #fbf8f1
- bgTo: #e6ff55
- textFrom: #0c1e18
- textTo: #005236
- accent: #e6ff55
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/horizontal-scroll-colour-morph.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-hcm">
<div class="ef-hcm__scroll" tabindex="0" role="region" aria-label="Scroll inside this panel to slide the words sideways and morph the colour">
<div class="ef-hcm__track">
<div class="ef-hcm__stage">
<canvas class="ef-hcm__stars" aria-hidden="true"></canvas>
<div class="ef-hcm__wrap">
<h3 class="ef-hcm__text">Design Bold. Ship Fast.</h3>
</div>
<p class="ef-hcm__hint">Scroll to morph the colour</p>
</div>
</div>
</div>
<div class="ef-hcm__bar" aria-hidden="true"></div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-hcm { --ef-hcm-w: 1280px; --ef-hcm-h: 26rem; --ef-hcm-run: 0px; --ef-hcm-acc: #e6ff55; --ef-hcm-bg: #0c1e18; position: relative; width: 100%; min-height: 26rem; overflow: hidden; border-radius: 1rem; background: var(--ef-hcm-bg); color: #fbf8f1; }
.ef-hcm .ef-hcm__scroll { position: absolute; inset: 0; overflow-x: hidden; overflow-y: auto; outline: none; scrollbar-width: none; }
.ef-hcm .ef-hcm__scroll::-webkit-scrollbar { display: none; }
.ef-hcm .ef-hcm__scroll:focus-visible .ef-hcm__stage { box-shadow: inset 0 0 0 3px var(--ef-hcm-acc); }
.ef-hcm .ef-hcm__track { position: relative; height: calc(var(--ef-hcm-h) + var(--ef-hcm-run)); }
.ef-hcm .ef-hcm__stage { position: sticky; top: 0; height: var(--ef-hcm-h); overflow: hidden; display: flex; align-items: center; background-color: var(--ef-hcm-bg); transition: background-color 0.1s linear; will-change: background-color; }
.ef-hcm .ef-hcm__stars { position: absolute; inset: 0; display: block; width: 100%; height: 100%; pointer-events: none; opacity: 0.5; }
.ef-hcm .ef-hcm__wrap { position: relative; width: 100%; }
.ef-hcm .ef-hcm__text { position: relative; display: flex; width: max-content; box-sizing: content-box; margin: 0; padding: 0 calc(var(--ef-hcm-w) * 0.2) 0 var(--ef-hcm-w); gap: calc(var(--ef-hcm-w) * 0.04); white-space: nowrap; font-family: 'Parkinsans', 'Space Grotesk', sans-serif; font-size: clamp(3rem, calc(var(--ef-hcm-w) * 0.12), 13rem); font-weight: 800; line-height: 1; letter-spacing: -0.04em; color: #fbf8f1; opacity: 1; text-transform: none; text-shadow: 0 10px 30px rgba(0, 0, 0, 0.4); transition: color 0.1s linear; will-change: transform; }
.ef-hcm .ef-hcm__word, .ef-hcm .ef-hcm__space { display: inline-block; white-space: nowrap; }
.ef-hcm .ef-hcm__char { display: inline-block; will-change: transform; }
.ef-hcm .ef-hcm__hint { position: absolute; left: 50%; bottom: 1.1rem; z-index: 2; margin: 0; padding: 0.35rem 0.95rem; border-radius: 2rem; transform: translateX(-50%); white-space: nowrap; font-family: 'Instrument Sans', system-ui, sans-serif; font-size: 0.8rem; font-weight: 500; line-height: 1.3; color: var(--ef-hcm-acc); opacity: 1; background: rgba(5, 13, 10, 0.55); border: 1px solid color-mix(in srgb, var(--ef-hcm-acc) 35%, transparent); transition: opacity 0.3s ease; pointer-events: none; }
.ef-hcm .ef-hcm__bar { position: absolute; top: 0; left: 0; z-index: 3; width: 0; height: 4px; background: linear-gradient(90deg, #1a7a4c, var(--ef-hcm-acc)); transition: width 0.05s linear; pointer-events: none; }
.ef-hcm.ef-hcm--calm .ef-hcm__text { flex-wrap: wrap; justify-content: center; width: auto; padding: 0 6%; gap: 0 0.08em; white-space: normal; text-align: center; font-size: clamp(2.2rem, calc(var(--ef-hcm-w) * 0.09), 9rem); transform: none; }
.ef-hcm.ef-hcm--calm .ef-hcm__hint, .ef-hcm.ef-hcm--calm .ef-hcm__bar { display: none; }JavaScript
// Enrichr Library · https://enrichr.dev
// Horizontal scroll colour morph: a pinned band where scrolling down slides one huge line of type sideways while the
// background and the type colour morph from one theme colour to the other. The band sticks inside its own scroller for a
// run of 2.7 box widths (about the original's 3,500 px on a laptop). Scroll progress drives three things, as in the original
// GSAP ScrollTrigger version: the line moves left by its full width with 0.5 s of scrub smoothing; the colours follow
// progress exactly; and each letter tumbles in from a random height (-200% to 20%) and tilt (-25 to 25 deg) with a
// back.out(1.2) ease while its left edge travels from the right edge of the box to 30% across, with 1 s of smoothing.
// Scrub smoothing restarts an expo.out tween toward the new progress on every scroll update, like ScrollTrigger's scrub.
// Seventy faint stars twinkle behind. Autoplay scrolls the band on its own (forward, pause, back) until someone scrolls,
// touches or hovers it. Reduced motion shows one still frame: the line wrapped and centred over a gradient of the two
// background colours, no tumbling and no autoplay. The animation pauses off screen and in hidden tabs.
// mount(root, { theme ("enrichr", "day-to-night", "cyberpunk", "emerald", "custom"), text, length (scroll run, x box width),
// auto ("1" on, "0" off), bgFrom, bgTo, textFrom, textTo (used by the custom theme), accent (progress bar, hint) })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var box = root.querySelector(".ef-hcm");
var scroller = root.querySelector(".ef-hcm__scroll");
var stage = root.querySelector(".ef-hcm__stage");
var canvas = root.querySelector(".ef-hcm__stars");
var textEl = root.querySelector(".ef-hcm__text");
var hint = root.querySelector(".ef-hcm__hint");
var bar = root.querySelector(".ef-hcm__bar");
var ctx = canvas.getContext("2d");
var THEMES = {
"enrichr": ["#0c1e18", "#005236", "#fbf8f1", "#e6ff55"],
"day-to-night": ["#d97706", "#020617", "#ffffff", "#38bdf8"],
"cyberpunk": ["#be185d", "#0891b2", "#fef08a", "#ffffff"],
"emerald": ["#0f172a", "#047857", "#38bdf8", "#a7f3d0"]
};
var opts = {}, cols = null, W = 1, H = 1, run = 0, dpr = 1, chars = [], stars = [];
var main = { from: 0, to: 0, cur: 0, t: 0 }, P = 0, lastP = -1, lastColP = -1, textW = 1;
var raf = 0, last = 0, visible = true, destroyed = false, io = null, ro = null, offs = [], hold = 0, auto = null, lastText = null;
function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }
function now() { return performance.now(); }
function hex(h, d) { var m = /^#?([0-9a-f]{6})$/i.exec(h || ""); var v = parseInt(m ? m[1] : d, 16); return [v >> 16, (v >> 8) & 255, v & 255]; }
function mix(a, b, t) { return "rgb(" + Math.round(a[0] + (b[0] - a[0]) * t) + "," + Math.round(a[1] + (b[1] - a[1]) * t) + "," + Math.round(a[2] + (b[2] - a[2]) * t) + ")"; }
// stable random numbers per letter, so a resize or a colour change keeps the same tumble
function rand(i, salt) { var h = Math.imul(i + 1 + salt * 7919, 374761393) ^ Math.imul(salt + 13, 668265263); h = Math.imul(h ^ (h >>> 13), 1274126177); return ((h ^ (h >>> 16)) >>> 0) / 4294967296; }
function backOut(p) { var c = 1.2; p -= 1; return p * p * ((c + 1) * p + c) + 1; } // GSAP back.out(1.2)
function expoOut(p) { return p >= 1 ? 1 : 1 - Math.pow(2, -10 * p); } // ScrollTrigger's scrub ease
function sineInOut(p) { return (1 - Math.cos(Math.PI * p)) / 2; }
// SplitText by hand: words of letters, the full line kept as the heading's accessible name
function split() {
var text = String(opts.text || "").trim() || "Design Bold. Ship Fast.";
if (text === lastText) return;
lastText = text;
textEl.textContent = "";
textEl.setAttribute("aria-label", text);
chars = [];
text.split(/\s+/).forEach(function (word, wi, words) {
var w = document.createElement("span"); w.className = "ef-hcm__word"; w.setAttribute("aria-hidden", "true");
Array.from(word).forEach(function (ch) {
var c = document.createElement("span"); c.className = "ef-hcm__char"; c.textContent = ch;
var i = chars.length;
chars.push({ el: c, y: -200 + rand(i, 1) * 220, r: -25 + rand(i, 2) * 50, x0: 0, ps: 0, pe: 1, from: 0, to: 0, cur: 0, t: 0, drawn: -1 });
w.appendChild(c);
});
textEl.appendChild(w);
if (wi < words.length - 1) { var s = document.createElement("span"); s.className = "ef-hcm__space"; s.setAttribute("aria-hidden", "true"); s.innerHTML = " "; textEl.appendChild(s); }
});
}
// where each letter sits in the untransformed line, and the slice of progress over which it tumbles in:
// from its left edge at the right edge of the box ("left 100%") to its left edge 30% across ("left 30%")
function measure() {
textW = Math.max(1, textEl.offsetWidth);
chars.forEach(function (c) {
var x = c.el.offsetLeft; // the heading is the offset parent (position: relative)
c.ps = (x - W) / textW; c.pe = (x - 0.3 * W) / textW;
});
}
function size() {
W = Math.max(1, scroller.clientWidth); H = Math.max(1, box.clientHeight);
run = reduce ? 0 : Math.round(opts.length * W);
box.style.setProperty("--ef-hcm-w", W + "px");
box.style.setProperty("--ef-hcm-h", H + "px");
box.style.setProperty("--ef-hcm-run", run + "px");
dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
stars = [];
for (var i = 0; i < 70; i++) stars.push({ x: Math.random() * W, y: Math.random() * H, r: Math.random() * 1.5 + 0.5, a: Math.random(), s: Math.random() * 0.02 + 0.005 });
measure();
lastP = -1; lastColP = -1;
drawStars(0);
}
function drawStars(dt) {
ctx.clearRect(0, 0, W, H);
var k = dt * 60; // the original stepped once per frame at 60 fps
for (var i = 0; i < stars.length; i++) {
var s = stars[i];
s.a += s.s * k;
if (s.a > 1 || s.a < 0) s.s = -s.s;
ctx.fillStyle = "rgba(255,255,255," + Math.min(1, Math.abs(s.a)) + ")";
ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); ctx.fill();
}
}
function progress() { return run > 0 ? Math.min(1, Math.max(0, scroller.scrollTop / run)) : 0; }
function paintColours(p) {
if (p === lastColP) return;
lastColP = p;
stage.style.backgroundColor = mix(cols[0], cols[1], p);
textEl.style.color = mix(cols[2], cols[3], p);
bar.style.width = (p * 100) + "%";
hint.style.opacity = p > 0.02 ? "0" : "1";
}
function paintLetters(t) {
var p = Math.min(1, (t - main.t) / 500);
main.cur = main.from + (main.to - main.from) * expoOut(p);
textEl.style.transform = "translate3d(" + (-100 * main.cur).toFixed(4) + "%,0,0)";
for (var i = 0; i < chars.length; i++) {
var c = chars[i], q = Math.min(1, (t - c.t) / 1000);
c.cur = c.from + (c.to - c.from) * expoOut(q);
if (Math.abs(c.cur - c.drawn) < 0.0005) continue;
c.drawn = c.cur;
var e = backOut(c.cur);
c.el.style.transform = "translate(0," + (c.y * (1 - e)).toFixed(2) + "%) rotate(" + (c.r * (1 - e)).toFixed(2) + "deg)";
c.el.style.opacity = String(Math.max(0, Math.min(1, e)));
}
}
// a new scroll position restarts each scrub tween from where it is now toward the new target
function onProgress(t) {
P = progress();
if (P === lastP) return;
lastP = P;
paintColours(P);
main.from = main.cur; main.to = P; main.t = t;
for (var i = 0; i < chars.length; i++) {
var c = chars[i], target = Math.min(1, Math.max(0, (P - c.ps) / (c.pe - c.ps)));
if (target !== c.to) { c.from = c.cur; c.to = target; c.t = t; }
}
}
function snapAll() {
P = progress(); lastP = P; lastColP = -1; paintColours(P);
main.from = main.to = main.cur = P; main.t = 0;
chars.forEach(function (c) { var v = Math.min(1, Math.max(0, (P - c.ps) / (c.pe - c.ps))); c.from = c.to = c.cur = v; c.t = 0; c.drawn = -1; });
paintLetters(now());
}
// autoplay: forward over the whole run, pause, back to the start, pause, again
function autoOn() { return !reduce && String(opts.auto) !== "0"; }
function autoStep(t) {
var max = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
if (!auto) { var at = scroller.scrollTop; auto = { from: at, to: at < max * 0.5 ? max : 0, t: t + 1000 }; auto.d = auto.to > at ? 9000 : 2600; }
var p = (t - auto.t) / auto.d;
if (p < 0) return;
scroller.scrollTop = auto.from + (auto.to - auto.from) * sineInOut(Math.min(1, p));
if (p >= 1) { var back = auto.to > 0; auto = { from: auto.to, to: back ? 0 : max, t: t + (back ? 1500 : 1000), d: back ? 2600 : 9000 }; }
}
function pause(ms) { hold = Math.max(hold, now() + ms); auto = null; }
function loop(t) {
raf = requestAnimationFrame(loop);
var dt = Math.min(0.05, (t - last) / 1000 || 0); last = t;
if (!visible || document.hidden) return;
if (autoOn() && t > hold) autoStep(t);
// render the scrub tweens first, then restart them toward the new scroll position (GSAP's ticker order), so a
// continuous scroll advances them by one frame of expo.out each frame instead of freezing them
paintLetters(t);
onProgress(t);
drawStars(dt);
}
function on(el, type, fn, opt) { el.addEventListener(type, fn, opt); offs.push(function () { el.removeEventListener(type, fn, opt); }); }
function onUser() { pause(4000); }
function update(next) {
opts = Object.assign({ theme: "enrichr", text: "Design Bold. Ship Fast.", length: 2.7, auto: "1", bgFrom: "#fbf8f1", bgTo: "#e6ff55", textFrom: "#0c1e18", textTo: "#005236", accent: "#e6ff55" }, opts, next || {});
opts.length = Math.max(0.5, num(opts.length, 2.7));
var th = THEMES[opts.theme] || [opts.bgFrom, opts.bgTo, opts.textFrom, opts.textTo];
cols = [hex(th[0], "0c1e18"), hex(th[1], "005236"), hex(th[2], "fbf8f1"), hex(th[3], "e6ff55")];
box.style.setProperty("--ef-hcm-acc", opts.accent);
box.style.setProperty("--ef-hcm-bg", th[0]);
if (reduce) stage.style.backgroundImage = "linear-gradient(90deg," + th[0] + "," + th[1] + ")";
if (destroyed) return;
split();
size();
if (reduce) { stage.style.backgroundColor = th[0]; textEl.style.color = th[2]; chars.forEach(function (c) { c.el.style.transform = ""; c.el.style.opacity = ""; }); }
else snapAll();
}
box.classList.toggle("ef-hcm--calm", reduce);
update(o);
if (document.fonts && document.fonts.ready) document.fonts.ready.then(function () { if (!destroyed) { measure(); if (!reduce) snapAll(); } });
if ("ResizeObserver" in window) { ro = new ResizeObserver(function () { if (destroyed) return; var w = scroller.clientWidth, h = box.clientHeight; if (w !== W || h !== H) { size(); if (!reduce) snapAll(); } }); ro.observe(box); }
if (!reduce) {
if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(box); }
["wheel", "touchstart", "pointerdown", "keydown"].forEach(function (t) { on(scroller, t, onUser, { passive: true }); });
on(scroller, "pointerenter", function (e) { if (e.pointerType === "mouse") pause(1e9); });
on(scroller, "pointerleave", function (e) { if (e.pointerType === "mouse") hold = now() + 2000; });
last = now(); raf = requestAnimationFrame(loop);
}
return {
update: update,
destroy: function () {
destroyed = true; cancelAnimationFrame(raf);
offs.forEach(function (f) { f(); }); offs = [];
if (io) io.disconnect();
if (ro) ro.disconnect();
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"theme":"enrichr","text":"Design Bold. Ship Fast.","length":2.7,"auto":1,"bgFrom":"#fbf8f1","bgTo":"#e6ff55","textFrom":"#0c1e18","textTo":"#005236","accent":"#e6ff55"});How it works
The band has its own scroller. Inside it a sticky stage stays put while a track 2.7 box widths taller scrolls past, which is the same as pinning a section for about 3,500 px on a laptop screen. Scroll progress from 0 to 1 drives three things, as in the GSAP ScrollTrigger original. The line of type moves left by its own full width, and starts one box width to the right, so it enters from the right edge and leaves on the left. The background and type colours are mixed between the theme's start and end colours exactly at the current progress, and a thin bar fills along the top. Each letter is wrapped in its own span and tumbles in from a random height between -200% and 20% and a random tilt of up to 25 degrees, with a back.out(1.2) ease, over the stretch of scroll where its left edge travels from the right edge of the box to 30% across. The line follows the scroll with 0.5 seconds of smoothing and the letters with 1 second, done the way ScrollTrigger's scrub does it: on every scroll update an expo.out tween restarts from where the value is now toward the new progress. Seventy faint stars twinkle on a canvas behind. Autoplay scrolls the band forward, pauses, and comes back, so a preview moves without anyone scrolling.
Accessibility and performance
The heading keeps the whole line as its accessible name and the letter spans are hidden from screen readers. The scroller is a focusable region, so arrow keys, Page Down and the space bar drive it, and it shows a lime focus ring. With prefers-reduced-motion there is no scroll run, no tumbling and no autoplay: the line is wrapped and centred over a still gradient of the two background colours. Autoplay stops as soon as you scroll, touch, press a key or hover the band. Check text contrast at both ends when you pick custom colours.
Only transforms, opacity and two colours change per frame, so there is no layout work while scrolling. Letter positions are measured once, and again only after a resize or when fonts finish loading. A letter's style is written only when its value actually changes. The star canvas uses the box size with the pixel ratio capped at 2, and the whole loop pauses off screen and in hidden tabs.
| Section | Full page |
|---|---|
| Techniques | Scroll-driven, Text animation |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| On CodePen | Open the live pen on our CodePen account; fork it to experiment. |
| Inspired by | Horizontal scroll background transition by Enrichr on CodePen. Original code, written from scratch. |
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
Can it pin to the page scroll instead of its own scroller?
Yes. Make the track part of the page, keep the stage position: sticky, and compute progress from the track's getBoundingClientRect instead of the scroller's scrollTop. The animation code stays the same. The inner scroller is used here so the band works inside any box, including a preview panel that clips its content.
Does it need GSAP, ScrollTrigger or SplitText?
No. The letters are split into spans by a few lines of JavaScript, and the scrub, the back.out ease and the colour mix are each a line of maths. The timings match the original: 0.5 s smoothing for the line, 1 s for the letters, and colours that follow the scroll exactly.
How do I use my own colours?
Pick Custom in the theme menu, then set the start and end colours for the background and the text. The four other themes are the presets from the original pen.
Why does it move on its own?
That is autoplay, there so a preview shows the effect without scrolling. It pauses while you interact with the band. Set Autoplay to Off for a band that only moves when scrolled.