Album Grid That Folds into a 3D Stack with View Toggle

A music shelf with two layouts and one toggle. In grid view four album covers sit two by two with their titles underneath. Switch to stack view and each cover springs to the middle, turns on its vertical axis and settles into a leaning pile, largest at the front, like records in a crate, with the front album's title shown below. Tapping any cover in the stack pulls it forward. The covers are gradients with simple drawn artwork, so there are no image files. The widget flips between views on its own until it is touched, and sits beside a heading on desktop and below it on phones.

Code

// Adapted from Animata (https://animata.design/docs/widget/music-stack-interaction)
// Copyright (c) Animata. MIT License: https://we.inc/components/licenses
import { useEffect, useState } from "react";
import { motion, useReducedMotion } from "framer-motion";

const albums = [
  { id: "a", title: "Low Tide", artist: "Marlowe Finch", tone: "from-teal-300 to-cyan-600", art: "rings" },
  { id: "b", title: "Paper Suns", artist: "The Quiet Hours", tone: "from-amber-300 to-rose-500", art: "sun" },
  { id: "c", title: "Night Bus Home", artist: "Odile Park", tone: "from-indigo-400 to-fuchsia-600", art: "bars" },
  { id: "d", title: "Green Rooms", artist: "Saffron Lane", tone: "from-lime-300 to-emerald-600", art: "grid" },
];
const COVER = 132; // cover size in px
const GAP = 16;
const ROW = COVER + 52; // a cover plus its caption
const STAGE_W = COVER * 2 + GAP;
const STAGE_H = ROW * 2;

function Art({ kind }: { kind: string }) {
  return (
    <svg viewBox="0 0 100 100" className="absolute inset-0 h-full w-full" aria-hidden="true">
      {kind === "rings" && [14, 26, 38].map((r) => <circle key={r} cx="50" cy="56" r={r} fill="none" stroke="white" strokeWidth="2" opacity={0.8 - r / 80} />)}
      {kind === "sun" && (
        <>
          <circle cx="50" cy="60" r="24" fill="white" opacity="0.85" />
          <rect x="0" y="62" width="100" height="38" fill="black" opacity="0.22" />
        </>
      )}
      {kind === "bars" && [0, 1, 2, 3, 4, 5].map((i) => <rect key={i} x={14 + i * 13} y={70 - ((i * 37) % 44)} width="7" height={16 + ((i * 37) % 44)} rx="3" fill="white" opacity="0.8" />)}
      {kind === "grid" && [0, 1, 2].flatMap((r) => [0, 1, 2].map((c) => <rect key={`${r}${c}`} x={22 + c * 20} y={22 + r * 20} width="16" height="16" rx="4" fill="white" opacity={(r + c) % 2 ? 0.35 : 0.85} />))}
    </svg>
  );
}

export default function AlbumGridThatFoldsIntoA3dStackWithViewToggle() {
  const reduce = useReducedMotion();
  const [stacked, setStacked] = useState(false);
  const [order, setOrder] = useState(albums.map((album) => album.id));
  const [auto, setAuto] = useState(true);

  // Flips between the two views on its own until someone touches the widget.
  useEffect(() => {
    if (!auto || reduce) return;
    const timer = window.setInterval(() => setStacked((current) => !current), 2400);
    return () => window.clearInterval(timer);
  }, [auto, reduce]);

  const setView = (next: boolean) => {
    setAuto(false);
    setStacked(next);
  };
  const front = albums.find((album) => album.id === order[0]) ?? albums[0];
  const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 190, damping: 24, mass: 0.9 };

  return (
    <section className="bg-zinc-100 px-4 py-14 sm:px-6 sm:py-20">
      <div className="mx-auto grid max-w-5xl items-center gap-10 md:grid-cols-2 md:gap-16">
        <div>
          <p className="text-sm font-semibold uppercase tracking-widest text-fuchsia-700">Your shelf</p>
          <h2 className="mt-3 text-3xl font-semibold tracking-tight text-zinc-900 sm:text-4xl">Flick through records the way you would in a shop.</h2>
          <p className="mt-4 max-w-md text-base leading-relaxed text-zinc-700">
            Switch from a tidy grid to a leaning stack. Every cover travels to its new place on a spring, and tapping a cover in
            the stack pulls it to the front.
          </p>
        </div>

        <div className="mx-auto w-full max-w-[340px] rounded-[2rem] bg-zinc-900 p-5 text-white shadow-2xl shadow-zinc-400/50">
          <div className="flex items-center justify-between">
            <p className="text-sm font-semibold">Recently played</p>
            <p className="text-xs text-zinc-400">{albums.length} albums</p>
          </div>

          <ul className="relative mx-auto mt-5" style={{ width: STAGE_W, height: STAGE_H, perspective: 900 }}>
            {albums.map((album, index) => {
              const depth = order.indexOf(album.id);
              const grid = { x: (index % 2) * (COVER + GAP), y: Math.floor(index / 2) * ROW, scale: 1, rotateY: 0 };
              const stack = { x: 18 + depth * 40, y: 74 - depth * 4, scale: 1.3 - depth * 0.1, rotateY: 26 };
              return (
                <motion.li
                  key={album.id}
                  className="absolute left-0 top-0 origin-left"
                  style={{ width: COVER, zIndex: stacked ? albums.length - depth : 1 }}
                  initial={false}
                  animate={stacked ? stack : grid}
                  transition={spring}
                >
                  <button
                    type="button"
                    disabled={!stacked}
                    aria-label={`Bring ${album.title} by ${album.artist} to the front`}
                    onClick={() => {
                      setAuto(false);
                      setOrder((current) => [album.id, ...current.filter((id) => id !== album.id)]);
                    }}
                    className={`relative block overflow-hidden rounded-2xl bg-gradient-to-br ${album.tone} shadow-xl shadow-black/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-900 disabled:cursor-default`}
                    style={{ width: COVER, height: COVER }}
                  >
                    <Art kind={album.art} />
                  </button>
                  <motion.div initial={false} animate={{ opacity: stacked ? 0 : 1 }} transition={{ duration: 0.2 }} className="mt-2">
                    <p className="truncate text-sm font-semibold leading-tight">{album.title}</p>
                    <p className="truncate text-xs text-zinc-400">{album.artist}</p>
                  </motion.div>
                </motion.li>
              );
            })}
            <motion.li
              aria-live="polite"
              initial={false}
              animate={{ opacity: stacked ? 1 : 0, y: stacked ? 0 : 10 }}
              transition={{ duration: 0.25, delay: stacked ? 0.2 : 0 }}
              className="absolute inset-x-0 bottom-3 text-center"
            >
              <p className="text-base font-semibold">{front.title}</p>
              <p className="text-sm text-zinc-400">{front.artist}</p>
            </motion.li>
          </ul>

          <div className="mt-4 flex justify-center">
            <div className="flex gap-1 rounded-full bg-zinc-800 p-1" role="group" aria-label="Layout">
              {[
                { label: "Grid view", value: false, path: "M3 3h6v6H3zM11 3h6v6h-6zM3 11h6v6H3zM11 11h6v6h-6z" },
                { label: "Stack view", value: true, path: "M10 2.5 2.5 6.5 10 10.5l7.5-4L10 2.5ZM2.5 10l7.5 4 7.5-4M2.5 13.5l7.5 4 7.5-4" },
              ].map((option) => (
                <button
                  key={option.label}
                  type="button"
                  aria-label={option.label}
                  aria-pressed={stacked === option.value}
                  onClick={() => setView(option.value)}
                  className={`flex h-10 w-16 items-center justify-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white ${
                    stacked === option.value ? "bg-white text-zinc-900" : "text-zinc-300 hover:text-white"
                  }`}
                >
                  <svg viewBox="0 0 20 20" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" aria-hidden="true">
                    <path d={option.path} />
                  </svg>
                </button>
              ))}
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/album-grid-that-folds-into-a-3d-stack-with-view-toggle.json

When to use it

How to customize it

Accessibility

Questions

How do the covers move between the two layouts?

Every cover is absolutely positioned at the top left of the stage and placed with transforms. Two objects hold its x, y, scale and rotation for the grid and for the stack, and framer-motion springs between them when the view changes.

Why not use framer-motion's layout animation?

Layout animation is good at moving boxes between positions, but it does not animate a 3D turn as part of the same move. Driving the transforms directly keeps position, size and rotation on one spring.

What makes the stack look three dimensional?

The stage has `perspective: 900px` and each cover gets `rotateY(26deg)` around its left edge. The perspective makes the far edge of each cover look smaller, so the flat squares read as leaning sleeves.

How does a cover come to the front?

An `order` array lists album ids from front to back. Clicking a cover moves its id to the start. Each cover's depth is its index in that array, which sets its offset, size and `z-index`.

Can it hold more than four albums?

Yes for the stack, which just gets deeper. For the grid, increase the stage height by one `ROW` for every extra pair, or add a third column by changing the two `% 2` and `/ 2` calculations to 3.

Adapted from Music Stack Interaction in Animata, used under its open-source licence. Licences and credits

Related: Fanned Card Stack with Cycle Controls, Draggable Card Stack Carousel with Spring Physics, Coverflow Gallery with 3D Perspective and Autoplay.

More galleries and carousels or all free UI components. Start for free to have a full site built around it.

More in Galleries

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.

Product

Who It's For

Features

Resources

Company

View Sitemap