• Beyond the Surface

    Augmented Reality[01 sep]
  • Aarzoo

    Print Design[04 sep]
  • Lost Horizons

    Concept Art[09 sep]
  • Eternal Echoes

    Typography[12 sep]
  • Abstract Dimensions

    Experimental Media[18 sep]
  • Silent Stories

    Photography[23 sep]
  • Fading Memories

    Editorial Design[27 sep]
  • Weekend

    Sound Design[02 oct]
  • Shattered Glass

    Art Installations[08 oct]
  • Timeless Essence

    Brand Strategy[15 oct]
  • Parallel Worlds

    UX/UI Design[21 oct]
  • Invisible Threads

    Fashion Styling[29 oct]
Beyond the Surface

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.

effectcylinder · flat · coverflow · spotlight — default "cylinder".
previewcard · panel · background — default "panel".
sizesm · md · lg — default "md".
colorDuration0.2–1.5s — default 0.6s.

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 motion

Point 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-list

How 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

itemsArray<{ title, category, date, image, color, textColor? }> — color is the background for that row, image the active preview, textColor overrides the active title colour. Required.
effect"cylinder" | "flat" | "coverflow" | "spotlight" — how rows react to scroll: picker wheel (rotateX), none, rotateY swing, or scale + blur focus. Default "cylinder".
preview"card" | "panel" | "background" — where the active image shows: floating bottom-right, full-height right side, or full-screen (no colour, light text). Default "card".
size"sm" | "md" | "lg" — the title font size. Default "md".
colorDurationSeconds for the background colour cross-fade. Default 0.6.
autoPlayAuto-advance through the rows, pausing on user interaction. Default false.

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.

Source code
"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.

Command Palette

Search for a command to run...