Spectrum List
A scroll-driven project list: the row crossing the centre cross-fades the whole background to its colour and swaps a pinned preview image to match.
A free, copy-paste scroll animations component for React, Next.js — built with Tailwind CSS, installable with the shadcn CLI.
Configuration
Tweak these live from theConfigurecontrol in the preview.
Interaction Type
- Scroll — the centred row drives the colour
- The pinned preview cross-fades to match
- Click a row to scroll it to the centre
Dependency
This component uses motion for its animation. Install it, then drop the file in.
npm install motionPoint the CLI here once
A namespace is a local alias, so the CLI has to be told what @harshdev-ui resolves to. One command, once per project:
npx shadcn registry add "@harshdev-ui=https://ui.harshpandav.dev/r/{name}.json"After that every component installs by name. To skip it entirely, pass the URL straight to the CLI — shadcn add https://ui.harshpandav.dev/r/spectrum-list.json — or copy the file from Source code below.
[ shadcn CLI 3.0 registry ]…or add the component in one command
Install via CLI
pnpm dlx shadcn add @harshdev-ui/spectrum-listHow to use
Give each item a title, category, date, colour and image. As you scroll the list, whichever row crosses the vertical centre becomes active — the background cross-fades to its colour and a pinned preview image swaps to match. Click any row to smooth-scroll it to the centre.
The component fills its parent (give the parent a height), scrolls internally, and pins the preview to its own bottom-right, so it works framed in a card or full-screen. Detection is a centre-band IntersectionObserver, so it's driven by layout rather than scroll math.
Basic usage
import { SpectrumList } from "@/components/harsh-ui/spectrum-list";
const items = [
{ title: "Beyond the Surface", category: "Augmented Reality",
date: "01 sep", color: "#22c55e", image: "/work/beyond.jpg" },
{ title: "Aarzoo", category: "Print Design",
date: "04 sep", color: "#ef4444", image: "/work/aarzoo.jpg" },
];
const Demo = () => (
<div className="h-[80vh] overflow-hidden rounded-3xl">
<SpectrumList items={items} />
</div>
);Scroll effects & preview modes
effect changes how rows react to scroll (cylinder wheel, flat, coverflow swing, spotlight focus); preview places the active image as a floating card, a full-height panel, or a full-screen background.
<SpectrumList
items={items}
effect="coverflow"
preview="panel"
size="lg"
/>Auto-advancing preview
autoPlay steps through the rows on its own and pauses whenever the user scrolls — handy for hero sections and card previews.
<SpectrumList items={items} autoPlay />Demo
import { SpectrumListDemo } from "@/components/demos/spectrum-list-demo";
const Demo = () => (
<div className="h-screen w-full">
<SpectrumListDemo />
</div>
);Props
Notes
- The active row is found with an IntersectionObserver whose root margin collapses to a zero-height band at the centre, so it's layout-driven and cheap.
- A separate rAF-throttled scroll pass tilts, scales and fades each row by its distance from the centre, using per-row perspective so rows never pull their neighbours.
- The background colour and every preview image cross-fade with Motion / AnimatePresence keyed on the active image.
- Container-query padding (50cqh top and bottom) lets the first and last rows reach the centre band.
- autoPlay advances the rows on a timer and steps aside the moment the user scrolls, resuming shortly after.
Source code
The complete spectrum-list.tsx — toggle TSX / JSX to copy it in either language.
"use client";
import { useEffect, useRef, useState } from "react";
import { motion, AnimatePresence } from "motion/react";
/**
* Harsh Dev UI · Developed by Harsh Pandav
* https://harshpandav.dev · hello@harshpandav.dev
*
* SpectrumList
* A tall list of titles. As you scroll, the item crossing the vertical centre
* becomes "active" — the whole background cross-fades to that item's colour and
* a pinned preview image cross-fades to match. Click a row to scroll it to centre.
*
* Detection uses an IntersectionObserver with a zero-height band at the centre
* of the scroll container (`rootMargin: '-50% 0px -50% 0px'`), so it's driven by
* layout, not scroll math — cheap and smooth.
*
* The component fills its parent (give the parent a height), scrolls internally,
* and pins the preview to its own bottom-right — so it works in a framed box or
* full-screen alike.
*/
export type SpectrumItem = {
title: string;
category: string;
date: string;
image: string;
color: string;
textColor?: string;
};
export type ScrollEffect = "cylinder" | "flat" | "coverflow" | "spotlight";
export type ScrollPreview = "card" | "panel" | "background";
export type ScrollSize = "sm" | "md" | "lg";
export type SpectrumListProps = {
items: SpectrumItem[];
effect?: ScrollEffect;
preview?: ScrollPreview;
size?: ScrollSize;
colorDuration?: number;
autoPlay?: boolean;
className?: string;
};
type EffectStyle = {
transform: string;
opacity: number;
filter: string;
origin: string;
};
const SCROLL_EFFECTS: Record<ScrollEffect, (c: number, a: number) => EffectStyle> = {
flat: () => ({ transform: "none", opacity: 1, filter: "none", origin: "center" }),
cylinder: (c, a) => ({
transform: `perspective(900px) rotateX(${c * 40}deg) scale(${Math.max(0.74, 1 - a * 0.16)})`,
opacity: Math.max(0.16, 1 - a * 0.85),
filter: "none",
origin: "center",
}),
coverflow: (c, a) => ({
transform: `perspective(1100px) rotateY(${a * 30}deg) scale(${Math.max(0.86, 1 - a * 0.1)})`,
opacity: Math.max(0.2, 1 - a * 0.8),
filter: "none",
origin: "left center",
}),
spotlight: (c, a) => ({
transform: `scale(${Math.max(0.68, 1 - a * 0.22)})`,
opacity: Math.max(0.12, 1 - a * 0.9),
filter: `blur(${Math.min(7, a * 4.5)}px)`,
origin: "center",
}),
};
const SIZES: Record<ScrollSize, string> = {
sm: "clamp(1.6rem, 5.5vw, 4rem)",
md: "clamp(2.1rem, 8vw, 6.5rem)",
lg: "clamp(2.7rem, 10vw, 8.5rem)",
};
export function SpectrumList({
items,
effect = "cylinder",
preview = "card",
size = "md",
colorDuration = 0.6,
autoPlay = false,
className = "",
}: SpectrumListProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<(HTMLLIElement | null)[]>([]);
const [active, setActive] = useState(0);
useEffect(() => {
const root = scrollRef.current;
if (!root) return;
const io = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (e.isIntersecting) {
setActive(Number((e.target as HTMLElement).dataset.index));
}
});
},
{ root, rootMargin: "-50% 0px -50% 0px", threshold: 0 },
);
itemRefs.current.forEach((el) => el && io.observe(el));
return () => io.disconnect();
}, [items]);
useEffect(() => {
const root = scrollRef.current;
if (!root) return;
const fx = SCROLL_EFFECTS[effect] || SCROLL_EFFECTS.cylinder;
let raf = 0;
const paint = () => {
raf = 0;
const rect = root.getBoundingClientRect();
const centreY = rect.top + rect.height / 2;
const half = rect.height / 2 || 1;
itemRefs.current.forEach((el) => {
if (!el) return;
const r = el.getBoundingClientRect();
const d = (r.top + r.height / 2 - centreY) / half;
const c = Math.max(-1.6, Math.min(1.6, d));
const s = fx(c, Math.abs(c));
el.style.transformOrigin = s.origin;
el.style.transform = s.transform === "none" ? "" : s.transform;
el.style.opacity = String(s.opacity);
el.style.filter = s.filter;
});
};
const onScroll = () => {
if (!raf) raf = requestAnimationFrame(paint);
};
paint();
root.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
return () => {
root.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
if (raf) cancelAnimationFrame(raf);
};
}, [items, effect]);
const focus = (i: number) =>
itemRefs.current[i]?.scrollIntoView({ block: "center", behavior: "smooth" });
useEffect(() => {
if (!autoPlay) return;
const root = scrollRef.current;
if (!root) return;
let paused = false;
let resume: ReturnType<typeof setTimeout>;
const onInteract = () => {
paused = true;
clearTimeout(resume);
resume = setTimeout(() => (paused = false), 2500);
};
root.addEventListener("wheel", onInteract, { passive: true });
root.addEventListener("touchstart", onInteract, { passive: true });
root.addEventListener("pointerdown", onInteract);
const id = setInterval(() => {
if (paused) return;
setActive((prev) => {
const next = (prev + 1) % items.length;
itemRefs.current[next]?.scrollIntoView({ block: "center", behavior: "smooth" });
return next;
});
}, 1600);
return () => {
clearInterval(id);
clearTimeout(resume);
root.removeEventListener("wheel", onInteract);
root.removeEventListener("touchstart", onInteract);
root.removeEventListener("pointerdown", onInteract);
};
}, [autoPlay, items.length]);
const current = items[active] || items[0];
const onDark = preview === "background";
const ink = onDark
? {
active: "#ffffff",
idle: "rgba(255,255,255,0.22)",
pillIdleText: "rgba(255,255,255,0.55)",
pillIdleBorder: "rgba(255,255,255,0.28)",
dateActive: "rgba(255,255,255,0.75)",
dateIdle: "rgba(255,255,255,0.3)",
}
: {
active: current.textColor || "#0a0a0a",
idle: "rgba(0,0,0,0.14)",
pillIdleText: "rgba(0,0,0,0.3)",
pillIdleBorder: "rgba(0,0,0,0.14)",
dateActive: "rgba(0,0,0,0.6)",
dateIdle: "rgba(0,0,0,0.22)",
};
const fontSize = SIZES[size] || SIZES.md;
return (
<motion.div
className={`relative h-full overflow-hidden ${className}`}
initial={false}
animate={{ backgroundColor: onDark ? "var(--background)" : current.color }}
transition={{ duration: colorDuration, ease: [0.22, 1, 0.36, 1] }}
>
{onDark && (
<div className="pointer-events-none absolute inset-0 z-0 overflow-hidden">
<AnimatePresence>
<motion.img
key={current.image}
src={current.image}
alt=""
draggable={false}
className="absolute inset-0 h-full w-full object-cover"
initial={{ opacity: 0, scale: 1.08 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, transition: { duration: 0.2, ease: "easeOut" } }}
transition={{ duration: 0.7, ease: [0.22, 1, 0.36, 1] }}
/>
</AnimatePresence>
<div className="absolute inset-0 bg-linear-to-r from-black/85 via-black/45 to-black/10" />
</div>
)}
<div
ref={scrollRef}
className="no-scrollbar relative z-10 h-full overflow-y-auto overscroll-contain [container-type:size]"
>
<ul
className={`px-6 py-[50cqh] sm:px-12 lg:px-20 ${
preview === "panel" ? "sm:pr-[40%]" : ""
}`}
>
{items.map((item, i) => {
const on = i === active;
return (
<li
key={item.title}
ref={(el) => {
itemRefs.current[i] = el;
}}
data-index={i}
onClick={() => focus(i)}
className="group flex cursor-pointer flex-wrap items-center gap-x-5 gap-y-1 py-2.5 will-change-[transform,opacity] sm:py-4"
>
<h3
className="font-medium leading-[0.92] tracking-tight transition-colors duration-500"
style={{ color: on ? ink.active : ink.idle, fontSize }}
>
{item.title}
</h3>
<span
className="whitespace-nowrap rounded-full px-4 py-1.5 text-xs font-medium transition-all duration-500 sm:text-sm"
style={
on
? { background: "rgba(255,255,255,0.9)", color: "#0a0a0a", boxShadow: "none" }
: { color: ink.pillIdleText, boxShadow: `inset 0 0 0 1px ${ink.pillIdleBorder}` }
}
>
{item.category}
</span>
<span
className="text-xs tabular-nums transition-colors duration-500 sm:text-sm"
style={{ color: on ? ink.dateActive : ink.dateIdle }}
>
[{item.date}]
</span>
</li>
);
})}
</ul>
</div>
{preview === "panel" && (
<>
<div className="pointer-events-none absolute right-0 top-0 z-0 hidden h-full w-[46%] overflow-hidden sm:block">
<AnimatePresence>
<motion.img
key={current.image}
src={current.image}
alt={current.title}
draggable={false}
className="absolute inset-0 h-full w-full object-cover"
style={{
maskImage: "linear-gradient(to right, transparent 0%, #000 55%)",
WebkitMaskImage: "linear-gradient(to right, transparent 0%, #000 55%)",
}}
initial={{ opacity: 0 }}
animate={{ opacity: 0.8 }}
exit={{ opacity: 0, transition: { duration: 0.2, ease: "easeOut" } }}
transition={{ duration: 0.7, ease: [0.22, 1, 0.36, 1] }}
/>
</AnimatePresence>
<div
className="absolute inset-y-0 left-0 w-3/4 backdrop-blur-md"
style={{
maskImage:
"linear-gradient(to right, transparent 0%, #000 35%, #000 65%, transparent 100%)",
WebkitMaskImage:
"linear-gradient(to right, transparent 0%, #000 35%, #000 65%, transparent 100%)",
}}
/>
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-0 h-[46%] overflow-hidden sm:hidden">
<AnimatePresence>
<motion.img
key={current.image}
src={current.image}
alt={current.title}
draggable={false}
className="absolute inset-0 h-full w-full object-cover"
style={{
maskImage: "linear-gradient(to top, #000 0%, #000 24%, transparent 100%)",
WebkitMaskImage: "linear-gradient(to top, #000 0%, #000 24%, transparent 100%)",
}}
initial={{ opacity: 0 }}
animate={{ opacity: 0.8 }}
exit={{ opacity: 0, transition: { duration: 0.2, ease: "easeOut" } }}
transition={{ duration: 0.7, ease: [0.22, 1, 0.36, 1] }}
/>
</AnimatePresence>
<div
className="absolute inset-x-0 top-0 h-2/3 backdrop-blur-md"
style={{
maskImage:
"linear-gradient(to bottom, transparent 0%, #000 35%, #000 65%, transparent 100%)",
WebkitMaskImage:
"linear-gradient(to bottom, transparent 0%, #000 35%, #000 65%, transparent 100%)",
}}
/>
</div>
</>
)}
{preview === "card" && (
<div className="pointer-events-none absolute bottom-5 right-5 z-20 w-[42vw] max-w-[23rem] sm:bottom-8 sm:right-8">
<div className="relative aspect-[4/5] overflow-hidden rounded-2xl bg-muted shadow-2xl ring-1 ring-border">
<AnimatePresence>
<motion.img
key={current.image}
src={current.image}
alt={current.title}
draggable={false}
className="absolute inset-0 h-full w-full object-cover"
initial={{ opacity: 0, scale: 1.06 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, transition: { duration: 0.2, ease: "easeOut" } }}
transition={{ duration: 0.55, ease: [0.22, 1, 0.36, 1] }}
/>
</AnimatePresence>
</div>
</div>
)}
</motion.div>
);
}
Keep in mind
Some components here are recreations of the best interactions out there. I don't claim to be the original creator — this is my attempt to study, replicate, and often add a few extra features. I've tried to credit everyone; if I missed something, let me know.
Contact
Found a bug or an issue? Drop a mail — hello@harshpandav.dev
License & Usage
- Free to use and modify in personal and commercial projects.
- Attribution to Harsh Dev UI is appreciated but not required.
- MIT licensed.
