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 "Sideways scroll letters" for my site using the Enrichr Library element at https://enrichr.dev/elements/sideways-scroll-letters.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/sideways-scroll-letters.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: ⏾ Work Hard. Sleep Hard. ✦
- autoplay: 1
- speed: 1
- size: 10
- lift: 200
- tilt: 20
- color: #e6ff55
- background: #0c1e18
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/sideways-scroll-letters.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-ssl"><div class="ef-ssl__text">⏾ Work Hard. Sleep Hard. ✦</div></div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-ssl {
--ef-ssl-size: 10;
position: relative; box-sizing: border-box; width: 100%; min-height: 28rem; overflow: hidden; border-radius: 1rem;
display: flex; align-items: center; container-type: inline-size;
background: #0c1e18; color: #e6ff55;
}
.ef-ssl *, .ef-ssl *::before, .ef-ssl *::after { box-sizing: border-box; }
/* the line starts one full width to the right of the box, words 4% of the width apart */
.ef-ssl .ef-ssl__text {
position: relative; display: flex; flex: none; width: max-content; gap: 4cqw; padding: 0 0 0 100cqw; margin: 0;
white-space: nowrap; color: inherit; opacity: 1; will-change: transform;
font-family: 'Parkinsans', 'Space Grotesk', sans-serif; font-weight: 600; line-height: 1.1;
font-size: clamp(2rem, calc(var(--ef-ssl-size) * 1cqw), 12rem);
}
.ef-ssl .ef-ssl__word { display: inline-block; }
.ef-ssl .ef-ssl__ch { position: relative; display: inline-block; }
.ef-ssl .ef-ssl__sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
/* reduced motion: the whole line, wrapped and centred, letters at rest */
.ef-ssl.is-reduced { justify-content: center; }
.ef-ssl.is-reduced .ef-ssl__text {
flex: 1; width: auto; flex-wrap: wrap; justify-content: center; row-gap: .1em; padding: 2rem; transform: none !important;
white-space: normal; font-size: clamp(1.5rem, calc(var(--ef-ssl-size) * .6cqw), 7rem);
}
.ef-ssl.is-reduced .ef-ssl__ch { transform: none !important; }JavaScript
// Enrichr Library · https://enrichr.dev
// Sideways scroll letters: one huge line of text slides right to left across the box, and every letter arrives tossed (a
// random lift of up to 200% of its height, or a 20% drop, and a random tilt of up to 20 degrees) and settles into the line
// on a back-out curve with a slight overshoot as it crosses the box. The line starts one box-width to the right and ends
// fully off the left edge. Each letter's own progress runs from when its left edge meets the right side of the box to when
// it reaches 30% of the width, and it chases that progress with a 1 s power3-out catch-up that restarts on every change,
// so letters lag and float while the line moves, exactly like a GSAP ScrollTrigger containerAnimation with scrub: 1 on
// SplitText letters (re-implemented here: spans per letter, the line moved by a transform, no library).
// With autoplay on, the line glides across on a loop at a steady pace; with it off, the page scroll drives it, from the
// moment the box enters at the bottom of the screen to the moment it leaves at the top.
// Reduced motion: the whole line shows wrapped and centred, letters at rest, nothing moves.
// The loop pauses off screen and in hidden tabs.
// mount(root, { text, autoplay (1 = glide on a loop, 0 = follow the page scroll), speed (autoplay pace), size (% of the
// box width), lift (highest lift, % of letter height), tilt (max tilt, degrees), color, background })
function mount(root, o) {
var reduce = !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
var box = root.querySelector(".ef-ssl");
var line = box.querySelector(".ef-ssl__text");
var opts = {}, chars = [], sr = null, textW = 1, rootW = 1, P = 0, rt = 0, last = 0, raf = 0;
var visible = true, io = null, ro = null, destroyed = false, built = false;
function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }
function on(v) { return !(v === 0 || v === "0" || v === false || v === "off"); }
function make(tag, cls, txt) { var e = document.createElement(tag); if (cls) e.className = cls; if (txt != null) e.textContent = txt; return e; }
function backOut(p) { p -= 1; return p * p * (2.2 * p + 1.2) + 1; } // GSAP back.out(1.2)
function power3Out(p) { return 1 - (1 - p) * (1 - p) * (1 - p); }
function build() {
var text = String(opts.text == null ? "" : opts.text).replace(/\s+/g, " ").trim() || "Work Hard. Sleep Hard.";
line.textContent = ""; chars = [];
if (sr && sr.parentNode) sr.parentNode.removeChild(sr);
sr = make("span", "ef-ssl__sr", text); box.appendChild(sr);
line.setAttribute("aria-hidden", "true");
text.split(" ").forEach(function (w) {
var word = make("span", "ef-ssl__word");
Array.from(w).forEach(function (ch) {
var s = make("span", "ef-ssl__ch", ch);
word.appendChild(s);
// random(-200, 20) for the lift and random(-20, 20) for the tilt, kept as unit numbers so the sliders apply live
chars.push({ el: s, u: Math.random(), v: Math.random() * 2 - 1, x: 0, ps: 0, pe: 1, cur: 0, from: 0, to: 0, t0: -1e9 });
});
line.appendChild(word);
});
built = true;
measure();
snap();
}
// where each letter sits in the line, and the stretch of progress over which it settles
function measure() {
if (!built || reduce) return;
rootW = Math.max(1, box.clientWidth); textW = Math.max(1, line.offsetWidth);
chars.forEach(function (c) {
var x = c.el.offsetLeft;
c.ps = (x - rootW) / textW; // left edge at 100% of the box
c.pe = (x - 0.3 * rootW) / textW; // left edge at 30% of the box
});
}
function target(c) { var t = (P - c.ps) / (c.pe - c.ps || 1); return t < 0 ? 0 : t > 1 ? 1 : t; }
function snap() { chars.forEach(function (c) { c.cur = c.from = c.to = target(c); c.t0 = -1e9; }); render(); }
function scrollP() {
var r = box.getBoundingClientRect(), vh = window.innerHeight || document.documentElement.clientHeight;
var p = (vh - r.top) / (vh + r.height);
return p < 0 ? 0 : p > 1 ? 1 : p;
}
function render() {
if (reduce) return false;
line.style.transform = "translate3d(" + (-P * textW).toFixed(2) + "px,0,0)";
var moving = false, lift = opts.lift, tilt = opts.tilt;
for (var i = 0; i < chars.length; i++) {
var c = chars[i], t = target(c);
var k = Math.min(1, (rt - c.t0) / 1000);
c.cur = c.from + (c.to - c.from) * power3Out(k); // where the running catch-up has got to by this frame
if (Math.abs(t - c.to) > 1e-6) { c.from = c.cur; c.to = t; c.t0 = rt; k = 0; } // scrub: 1 restarts it from there
if (k < 1) moving = true;
var rest = 1 - backOut(c.cur); // share of the starting offset still left
var y = (-lift + c.u * (lift + 20)) * rest, r = c.v * tilt * rest;
c.el.style.transform = "translateY(" + y.toFixed(2) + "%) rotate(" + r.toFixed(2) + "deg)";
}
return moving;
}
function loop(now) {
raf = 0;
var dt = Math.min(50, Math.max(0, now - last)); last = now;
if (!visible || document.hidden || destroyed) return; // the observer and visibilitychange restart it
rt += dt;
var auto = opts.autoplay;
if (auto) {
P += dt * 0.0003 * opts.speed * rootW / textW; // about 0.3 box widths a second at speed 1
if (P >= 1) { P %= 1; snap(); } // the line is off screen at both ends, so the loop has no seam
} else P = scrollP();
var moving = render();
if (auto || moving) raf = requestAnimationFrame(loop);
}
function kick() { if (!raf && !destroyed && !reduce && built) { last = performance.now(); raf = requestAnimationFrame(loop); } }
function onScroll() { if (!opts.autoplay) kick(); }
function relayout() { if (destroyed) return; var keep = P; measure(); P = keep; render(); kick(); }
function update(partial) {
var prev = opts;
opts = Object.assign({ text: "⏾ Work Hard. Sleep Hard. ✦", autoplay: 1, speed: 1, size: 10, lift: 200, tilt: 20, color: "#e6ff55", background: "#0c1e18" }, opts, partial || {});
opts.autoplay = on(opts.autoplay);
opts.speed = Math.min(5, Math.max(0.05, num(opts.speed, 1)));
opts.size = Math.min(30, Math.max(2, num(opts.size, 10)));
opts.lift = Math.max(0, num(opts.lift, 200));
opts.tilt = Math.max(0, num(opts.tilt, 20));
box.style.background = opts.background;
box.style.color = opts.color;
box.style.setProperty("--ef-ssl-size", String(opts.size));
box.classList.toggle("is-reduced", reduce);
if (destroyed) return;
if (!built || prev.text !== opts.text) build();
else if (prev.size !== opts.size) relayout();
else render();
if (prev.autoplay !== opts.autoplay && !opts.autoplay) P = scrollP();
kick();
}
update(o);
window.addEventListener("scroll", onScroll, { passive: true });
if ("IntersectionObserver" in window) {
io = new IntersectionObserver(function (es) { visible = es[es.length - 1].isIntersecting; if (visible) kick(); });
io.observe(box);
}
if ("ResizeObserver" in window) { ro = new ResizeObserver(relayout); ro.observe(box); }
if (document.fonts && document.fonts.ready) document.fonts.ready.then(relayout);
function onVis() { if (!document.hidden) kick(); }
document.addEventListener("visibilitychange", onVis);
return {
update: update,
destroy: function () {
destroyed = true; cancelAnimationFrame(raf); raf = 0;
window.removeEventListener("scroll", onScroll);
document.removeEventListener("visibilitychange", onVis);
if (io) io.disconnect();
if (ro) ro.disconnect();
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"text":"⏾ Work Hard. Sleep Hard. ✦","autoplay":1,"speed":1,"size":10,"lift":200,"tilt":20,"color":"#e6ff55","background":"#0c1e18"});How it works
The line starts one box-width to the right of the box, words 4% of the width apart, and a transform slides it left until it has gone off the left edge. Every letter is its own span with a random lift (anywhere from 200% of its height up to 20% down) and a random tilt (up to 20 degrees either way). A letter's progress runs from the moment its left edge meets the right side of the box to the moment it reaches 30% of the width, and its offset eases to zero along a back-out curve (GSAP's back.out(1.2)), so it overshoots a little before it sits. Each letter chases its progress with a 1 s power3-out catch-up that restarts whenever the progress changes, which is how GSAP's scrub: 1 works, so letters lag and float while the line moves and settle once it stops. This is the original's ScrollTrigger containerAnimation and SplitText rebuilt in a few lines. With autoplay on, the line glides across at about a third of the box width per second and loops; it is off screen at both ends, so the loop has no seam. With autoplay off, the page scroll drives it, from the box entering at the bottom of the screen to leaving at the top.
Accessibility and performance
The text is visually hidden for screen readers and the moving letters are hidden from them, so it reads as one line. With prefers-reduced-motion the whole line shows wrapped and centred with every letter at rest, and nothing moves. Nothing needs keyboard input.
Each frame writes one transform for the line and one per letter, with no layout reads; letter positions are measured only on resize or when fonts load. The loop runs only while the box is on screen in a visible tab, and in scroll mode only while letters are still catching up.
| Section | Text |
|---|---|
| Techniques | Text animation, Scroll-driven, Stagger |
| 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 split text study 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
How do I pin it while the page scrolls, like the original?
Turn autoplay off, put the element inside a tall wrapper (for example 300vh) and give the element position: sticky; top: 0; height: 100vh. The progress then runs while the wrapper scrolls past and the text stays on screen.
Does it need GSAP, ScrollTrigger or SplitText?
No. The split is a loop that wraps each letter in a span, the slide is one transform, and the per-letter scrub is a small catch-up tween with the same back-out and power3-out curves GSAP uses.
Can I make the letters calmer?
Lower the highest lift and the tilt. At zero for both the line simply slides across like a big marquee.
Why are the moon and star a different style?
The brand font has no moon or star glyph, so the browser draws them from a system font, as the original did. Swap them for any characters you like in the text field.