South Africans know the feeling: 18:00, Stage 4, and the whole street goes dark. I wanted a solar-installer website that shows what a battery backup does instead of just saying it. So I built Sunvault Solar, a demo site where scrolling plays one day of an install, from loadshedding at dusk to a house that stays lit at night.
Live demo: https://sunvault-solar-bice.vercel.app
Code: https://github.com/Andisa05/sunvault-solar
The idea: one function, one number
The whole scene is a single function: render(p), where p is scroll progress from 0 to 1. Given p, it draws the sky, stars, sun, houses, panels and battery for that exact moment. It has no internal state and no timeline of its own, so scrolling backwards just works.
const clamp = (v, a = 0, b = 1) => Math.min(b, Math.max(a, v));
// progress of one stage inside the whole scroll, 0 → 1
const seg = (p, a, b) => clamp((p - a) / (b - a));
seg() does most of the work. It turns "the part of the scroll between 42% and 60%" into its own 0 → 1 value, which I use for each stage:
const day = Math.min(seg(p, 0.42, 0.6), 1 - seg(p, 0.8, 0.9)); // sunrise, then sunset
const ours = seg(p, 0.85, 0.92); // our house lights come on
The sky colour is a blend between night and day colours based on day, the sun moves along an arc, and stars fade out as it gets lighter.
Wiring it to the scroll
GSAP ScrollTrigger pins the section and scrubs a plain object from 0 to 1. On every update I call render:
const state = { p: 0 };
gsap.to(state, {
p: 1,
ease: "none",
scrollTrigger: { trigger: section, start: "top top", end: "+=400%", pin: true, scrub: 0.8 },
onUpdate: () => scene.render(state.p),
});
end: "+=400%" means the scene lasts four screen-heights of scrolling, and scrub: 0.8 adds a little smoothing so it feels weighty rather than jumpy. The five captions ("Site survey", "Install day" and so on) switch based on Math.floor(p * 5).
Smooth scrolling with Lenis
Lenis gives the page its smooth, slightly eased scroll. The key is to drive it from GSAP's ticker so both libraries agree on every frame:
const lenis = new Lenis({ lerp: 0.1 });
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
I skip Lenis entirely when the visitor has prefers-reduced-motion turned on.
Why canvas instead of video
- Nothing to download. The scene is about 200 lines of drawing code, not megabytes of video frames.
-
Sharp on every screen. I size the canvas by
devicePixelRatio(capped at 2) so it stays crisp on phones. - Easy to change. Want nine panels instead of eight, or a different roof colour? Change a number.
One small trick: the star field uses a seeded random generator, so stars stay in the same place on every frame instead of jumping around.
Stack
Next.js 16 (App Router), React 19, GSAP + ScrollTrigger, Lenis and Canvas 2D, deployed on Vercel.
If you're building something similar, or need a site like this for your business, I'm a Full Stack Developer in Gauteng, South Africa: https://andisa-portfolio.vercel.app
Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.