A scroll-driven story in a fixed-height frame. Four text steps scroll up the left side while a visual stays pinned on the right and changes with each step: a gauge refills, a sun travels round a 24 hour dial, a band of bars lights up and the time flips. On phones the visual pins to the top of the frame and the steps scroll underneath it. It plays through once on load so the effect is visible without scrolling.
import { useEffect, useRef, useState } from "react";
import type { RefObject } from "react";
import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform } from "framer-motion";
const steps = [
{
time: "06:40",
hour: 6.7,
title: "Sunrise top-up",
body: "The first light hits the east roof and the battery starts drinking. Nothing is bought from the grid while the kettle boils.",
level: 34,
flow: "+2.1 kW",
flowLabel: "Solar to battery",
window: [6, 10],
glow: "bg-amber-400",
},
{
time: "13:15",
hour: 13.25,
title: "Midday surplus",
body: "By lunch the panels make more than the house can use. The battery fills to the brim and the rest is sold back at the daytime rate.",
level: 96,
flow: "+4.8 kW",
flowLabel: "Charging at full rate",
window: [10, 16],
glow: "bg-emerald-400",
},
{
time: "18:30",
hour: 18.5,
title: "Peak rate dodge",
body: "Prices triple at six. The house quietly switches to stored power, so dinner, laundry and the heat pump all run on this afternoon's sun.",
level: 61,
flow: "-3.2 kW",
flowLabel: "Battery to home",
window: [16, 21],
glow: "bg-rose-400",
},
{
time: "02:10",
hour: 2.2,
title: "Overnight reserve",
body: "A fifth of the charge is always held back. If the street goes dark, the fridge, the router and two rooms of lights stay on until morning.",
level: 22,
flow: "-0.4 kW",
flowLabel: "Standby load only",
window: [21, 24],
glow: "bg-indigo-400",
},
];
// Household use for each hour of the day, 0 to 1.
const usage = [0.18, 0.15, 0.14, 0.14, 0.16, 0.24, 0.46, 0.62, 0.5, 0.36, 0.3, 0.34, 0.44, 0.4, 0.32, 0.36, 0.52, 0.78, 0.96, 0.88, 0.7, 0.5, 0.32, 0.22];
const DEMO_SECONDS = 9;
const RING = 2 * Math.PI * 70;
function easeDemo(t: number) {
if (t < 0.1) return (5 * t * t) / 0.9;
if (t > 0.9) return 1 - (5 * (1 - t) * (1 - t)) / 0.9;
return (t - 0.05) / 0.9;
}
/** Scrolls the frame slowly once so the preview shows the effect. Stops for good on any input. */
function useDemoScroll(ref: RefObject<HTMLDivElement>, enabled: boolean, seconds: number) {
useEffect(() => {
const el = ref.current;
if (!el || !enabled) return;
let raf = 0;
let last = 0;
let t = 0;
let done = false;
const inputs = ["wheel", "touchstart", "pointerdown", "keydown"];
const tick = (now: number) => {
if (done) return;
const dt = last ? Math.min(now - last, 50) : 0;
last = now;
t = Math.min(1, t + dt / (seconds * 1000));
el.scrollTop = easeDemo(t) * (el.scrollHeight - el.clientHeight);
if (t < 1) raf = requestAnimationFrame(tick);
};
const io = new IntersectionObserver(
([entry]) => {
cancelAnimationFrame(raf);
last = 0;
if (entry.isIntersecting && !done) raf = requestAnimationFrame(tick);
},
{ threshold: 0.25 }
);
const stop = () => {
done = true;
cancelAnimationFrame(raf);
};
io.observe(el);
inputs.forEach((name) => el.addEventListener(name, stop, { passive: true }));
return () => {
stop();
io.disconnect();
inputs.forEach((name) => el.removeEventListener(name, stop));
};
}, [ref, enabled, seconds]);
}
function Visual({ active, reduced }: { active: number; reduced: boolean }) {
const step = steps[active];
const level = useMotionValue(step.level);
const levelText = useTransform(level, (v) => Math.round(v));
const dash = useTransform(level, (v) => RING * (1 - v / 100));
// Position on the 24 hour dial: midnight at the bottom, noon at the top.
const angle = useMotionValue((step.hour / 24) * 360);
const sunX = useTransform(angle, (a) => 110 - 98 * Math.sin((a * Math.PI) / 180));
const sunY = useTransform(angle, (a) => 110 + 98 * Math.cos((a * Math.PI) / 180));
useEffect(() => {
if (reduced) {
level.set(step.level);
angle.set((step.hour / 24) * 360);
return;
}
// Always travel forward around the dial, the way a clock does.
let target = (step.hour / 24) * 360;
const now = angle.get();
while (target < now - 180) target += 360;
while (target > now + 180) target -= 360;
const a = animate(level, step.level, { type: "spring", stiffness: 60, damping: 16 });
const b = animate(angle, target, { type: "spring", stiffness: 50, damping: 15 });
return () => {
a.stop();
b.stop();
};
}, [active, reduced, level, angle, step.level, step.hour]);
const night = step.hour < 6 || step.hour > 20;
const [from, to] = step.window;
return (
<div className="relative flex h-full flex-col justify-center overflow-hidden bg-slate-900 px-5 py-5 lg:px-10">
{steps.map((s, i) => (
<div
key={s.time}
aria-hidden="true"
className={`pointer-events-none absolute -right-20 -top-24 h-72 w-72 rounded-full blur-3xl transition-opacity duration-1000 motion-reduce:transition-none ${s.glow} ${i === active ? "opacity-25" : "opacity-0"}`}
/>
))}
<div className="relative flex items-center gap-5 lg:flex-col lg:gap-8">
<svg viewBox="0 0 220 220" className="h-32 w-32 shrink-0 sm:h-36 sm:w-36 lg:h-60 lg:w-60" aria-hidden="true">
<circle cx="110" cy="110" r="98" fill="none" className="stroke-slate-700" strokeWidth="1" strokeDasharray="2 7" />
<circle cx="110" cy="110" r="70" fill="none" className="stroke-slate-800" strokeWidth="12" />
<g transform="rotate(-90 110 110)">
<motion.circle cx="110" cy="110" r="70" fill="none" className="stroke-emerald-400" strokeWidth="12" strokeLinecap="round" strokeDasharray={RING} style={{ strokeDashoffset: dash }} />
</g>
<motion.circle cx={sunX} cy={sunY} r="13" className={`transition-colors duration-700 ${night ? "fill-indigo-300/20" : "fill-amber-300/25"}`} />
<motion.circle cx={sunX} cy={sunY} r="7" className={`transition-colors duration-700 ${night ? "fill-indigo-200" : "fill-amber-300"}`} />
<text x="110" y="106" textAnchor="middle" className="fill-white text-[40px] font-semibold tracking-tight">
<motion.tspan>{levelText}</motion.tspan>
<tspan className="fill-slate-400 text-[18px]" dx="2">
%
</tspan>
</text>
<text x="110" y="130" textAnchor="middle" className="fill-slate-400 text-[11px] uppercase tracking-widest">
stored
</text>
</svg>
<div className="min-w-0 flex-1 lg:w-full lg:flex-none lg:text-center">
<div className="relative h-10 overflow-hidden lg:h-14">
<AnimatePresence initial={false}>
<motion.p
key={step.time}
className="absolute inset-0 text-4xl font-semibold tabular-nums tracking-tight text-white lg:text-5xl"
initial={reduced ? false : { y: 36, opacity: 0, filter: "blur(6px)" }}
animate={{ y: 0, opacity: 1, filter: "blur(0px)" }}
exit={reduced ? { opacity: 0 } : { y: -36, opacity: 0, filter: "blur(6px)" }}
transition={{ type: "spring", stiffness: 170, damping: 22 }}
>
{step.time}
</motion.p>
</AnimatePresence>
</div>
<div className="relative mt-2 h-11 overflow-hidden sm:h-6">
<AnimatePresence initial={false}>
<motion.p
key={step.flow}
className="absolute inset-0 text-sm leading-5 text-slate-300"
initial={reduced ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.35, delay: 0.08 }}
>
<span className="block font-semibold tabular-nums text-emerald-300 sm:mr-2 sm:inline">{step.flow}</span>
{step.flowLabel}
</motion.p>
</AnimatePresence>
</div>
</div>
</div>
<div className="relative mt-5 lg:mt-10">
<div className="relative flex h-14 items-end gap-[3px] lg:h-24 lg:gap-1.5">
{usage.map((u, h) => {
const on = h >= from && h < to;
return (
<div
key={h}
className={`flex-1 origin-bottom rounded-sm transition-all duration-500 motion-reduce:transition-none ${on ? "bg-emerald-400" : "bg-slate-700"}`}
style={{ height: `${u * 100}%`, transitionDelay: `${(on ? h - from : 0) * 35}ms` }}
/>
);
})}
</div>
<div className="relative mt-2 h-1 rounded-full bg-slate-800">
<motion.div
className="absolute inset-y-0 rounded-full bg-emerald-400"
initial={false}
animate={{ left: `${(from / 24) * 100}%`, width: `${((to - from) / 24) * 100}%` }}
transition={reduced ? { duration: 0 } : { type: "spring", stiffness: 120, damping: 20 }}
/>
</div>
<div className="mt-2 flex justify-between text-[11px] tabular-nums text-slate-400">
<span>00:00</span>
<span>06:00</span>
<span>12:00</span>
<span>18:00</span>
<span>24:00</span>
</div>
</div>
</div>
);
}
export default function StickyScrollStorytellingWithPinnedVisual() {
const reduced = !!useReducedMotion();
const frame = useRef<HTMLDivElement>(null);
const pinned = useRef<HTMLDivElement>(null);
const stepEls = useRef<(HTMLLIElement | null)[]>([]);
const [active, setActive] = useState(0);
useDemoScroll(frame, !reduced, DEMO_SECONDS);
useEffect(() => {
const el = frame.current;
if (!el) return;
let raf = 0;
const measure = () => {
raf = 0;
const top = el.getBoundingClientRect().top;
const pinHeight = pinned.current ? pinned.current.offsetHeight : 0;
// Side by side: the trigger line is the middle of the frame. Stacked: the middle of the space under the pinned visual.
const stacked = pinHeight < el.clientHeight * 0.9;
const line = stacked ? pinHeight + (el.clientHeight - pinHeight) * 0.3 : el.clientHeight * 0.55;
let current = 0;
stepEls.current.forEach((node, i) => {
if (node && node.getBoundingClientRect().top - top <= line) current = i;
});
setActive(current);
};
const onScroll = () => {
if (!raf) raf = requestAnimationFrame(measure);
};
measure();
el.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
return () => {
cancelAnimationFrame(raf);
el.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
};
}, []);
return (
<section className="bg-slate-950 px-4 py-12 sm:py-16">
<div className="mx-auto max-w-5xl">
<div className="max-w-xl">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-emerald-400">Voltline Home</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-white sm:text-4xl">One battery, one ordinary Tuesday.</h2>
<p className="mt-3 text-base text-slate-300">Scroll through 24 hours and watch where the power goes.</p>
</div>
<div className="mt-8 overflow-hidden rounded-3xl border border-white/10 bg-slate-900">
<div
ref={frame}
tabIndex={0}
role="region"
aria-label="A day with a home battery, told in four steps"
className="relative h-[600px] overflow-y-auto overscroll-contain focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-emerald-400"
>
<div className="lg:grid lg:grid-cols-2">
<div ref={pinned} className="sticky top-0 z-10 h-[290px] border-b border-white/10 lg:order-2 lg:h-[600px] lg:border-b-0 lg:border-l">
<Visual active={active} reduced={reduced} />
</div>
<ol className="px-6 pb-16 pt-6 lg:px-12 lg:pb-20 lg:pt-[150px]">
{steps.map((s, i) => (
<li
key={s.time}
ref={(node) => {
stepEls.current[i] = node;
}}
aria-current={i === active ? "step" : undefined}
className={`flex min-h-[230px] flex-col justify-start py-6 transition-opacity duration-500 motion-reduce:transition-none lg:min-h-[360px] lg:py-10 ${i === active ? "opacity-100" : "opacity-40"}`}
>
<div className="flex items-center gap-3">
<span className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-semibold transition-colors duration-500 ${i === active ? "bg-emerald-400 text-slate-950" : "bg-slate-800 text-slate-300"}`}>{i + 1}</span>
<span className="text-sm tabular-nums text-slate-300">{s.time}</span>
</div>
<h3 className="mt-4 text-2xl font-semibold tracking-tight text-white lg:text-3xl">{s.title}</h3>
<p className="mt-3 max-w-md text-base leading-7 text-slate-300">{s.body}</p>
</li>
))}
</ol>
</div>
</div>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/sticky-scroll-storytelling-with-pinned-visual.json
The visual has `position: sticky` and `top: 0` inside the scrolling frame, so it stays put while its sibling column of text moves. A scroll listener checks which step has crossed a trigger line and stores that index in state. The visual reads the index and animates to the matching values.
There is one visual with values that animate: the ring's `strokeDashoffset`, the sun's position, the highlighted bars and the text. Changing values on the same elements reads as one object transforming, which feels smoother than fading between four separate pictures.
Replace the body of the `Visual` component. Stack your images in one absolutely positioned box and set `opacity` from `active === index`, with a `transition-opacity` class. Keep the wrapper so the pinning still works.
Yes. Remove the fixed height and `overflow-y-auto` from the frame, listen to `window` scroll, measure against `window.innerHeight`, and change the pinned height to `h-screen`. Sticky then pins against the browser window.
Sticky fails when an ancestor between the element and the scroller has `overflow: hidden`, or when the parent is no taller than the sticky element. Here the parent grid spans all the steps, which gives the visual room to stay pinned.
Related: Reading Progress Bar with Section Reveals, Parallax Mountain Scene in a Scroll Frame, Self-Drawing Vertical Timeline with Pop-In Items.
More scroll effects or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.