A React gallery with shared-layout views that morph between list, card and pack layouts. Built with Motion.
0.855 MB
0.209 MB
0.412 MB
1.02 MB
"use client";
import { Gallery, type GalleryItem, type GalleryView } from "@/components/gallery";
import { useState } from "react";
const ITEMS: GalleryItem[] = [
{
id: "midnight-ocean",
imageSrc: "https://picsum.photos/id/64/400/400",
name: "midnight_ocean.jpg",
sizeMB: 0.855,
timestamp: "2026-08-29T20:50:45.056Z",
},
{
id: "sunset-chase",
imageSrc: "https://picsum.photos/id/65/400/400",
name: "sunset_chase.jpg",
sizeMB: 0.209,
timestamp: "2026-08-29T20:50:45.056Z",
},
];
function Demo() {
const [view, setView] = useState<GalleryView>("pack");
return (
<Gallery
items={ITEMS}
defaultView="pack"
onViewChange={onViewChange}
/>
);
}Install the following dependencies:
npm install motion lucide-react react-use-measurebun add motion lucide-react react-use-measuredeno add motion lucide-react react-use-measurepnpm add motion lucide-react react-use-measureyarn add motion lucide-react react-use-measureCopy and paste the following code into your project.
"use client";
import { Layers, LayoutGrid, List } from "lucide-react";
import {
LayoutGroup,
MotionConfig,
motion,
type Transition,
useReducedMotion,
} from "motion/react";
import { type KeyboardEvent, useId, useMemo, useRef, useState } from "react";
import useMeasure from "react-use-measure";
import { cn } from "@/lib/utils";
export type GalleryView = "list" | "card" | "pack";
export interface GalleryItem {
id: string;
imageSrc: string;
name: string;
sizeMB: number; // in MB
timestamp: string;
}
const VIEWS = [
{ icon: List, id: "list", label: "List view" },
{ icon: LayoutGrid, id: "card", label: "Card view" },
{ icon: Layers, id: "pack", label: "Pack view" },
] as const;
const SNAPPY_EASE = [0.22, 1, 0.36, 1] as const;
const INSTANT = { duration: 0 } as const;
const PACK_ANGLE_MIN = 10;
const PACK_ANGLE_MAX = 22;
const TOP_CARD_ANGLE_SCALE = 0.35;
const LIST_RADIUS = 12;
const CARD_RADIUS = 20;
const PACK_RADIUS = 24;
const LAYOUT_SPRING: Transition = {
bounce: 0.15,
duration: 0.4,
type: "spring",
};
const OPACITY_TRANSITION: Transition = {
duration: 0.22,
ease: SNAPPY_EASE,
};
const EXIT_OPACITY: Transition = {
duration: 0.16,
ease: SNAPPY_EASE,
};
function hashToUnit(value: string): number {
let hash = 0;
for (const char of value) {
hash = (hash * 31 + char.charCodeAt(0)) % 2_147_483_647;
}
return (Math.abs(hash) % 1000) / 1000;
}
function packAngleForItem(id: string, index: number, total: number): number {
const magnitude =
PACK_ANGLE_MIN + hashToUnit(id) * (PACK_ANGLE_MAX - PACK_ANGLE_MIN);
const sign = hashToUnit(`${id}-sign`) >= 0.5 ? 1 : -1;
let angle = magnitude * sign;
if (index % 2 === 0) {
angle = -angle;
}
if (index === total - 1) {
angle *= TOP_CARD_ANGLE_SCALE;
}
return angle;
}
function formatTimestamp(isoTimestamp: string): string {
return new Date(isoTimestamp).toLocaleDateString("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
});
}
function imageRadius(view: GalleryView): number {
switch (view) {
case "list":
return LIST_RADIUS;
case "card":
return CARD_RADIUS;
case "pack":
return PACK_RADIUS;
default: {
const _exhaustive: never = view;
return _exhaustive;
}
}
}
function GalleryImage({
item,
layoutId,
radius,
rotate,
className,
shouldReduceMotion,
}: {
className?: string;
item: GalleryItem;
layoutId: string;
radius: number;
rotate: number;
shouldReduceMotion: boolean;
}) {
const motionTransition = shouldReduceMotion ? INSTANT : LAYOUT_SPRING;
return (
<motion.div
animate={{
borderRadius: radius,
rotate: shouldReduceMotion ? 0 : rotate,
}}
className={cn("origin-center overflow-hidden shadow-sm", className)}
initial={false}
layoutId={layoutId}
transition={motionTransition}
>
{/* biome-ignore lint/performance/noImgElement: usable also within react */}
{/* biome-ignore lint/correctness/useImageSize: no need */}
<img
alt={item.name}
className="size-full object-cover"
src={item.imageSrc}
/>
</motion.div>
);
}
function ItemMeta({
groupId,
item,
shouldReduceMotion,
view,
}: {
groupId: string;
item: GalleryItem;
shouldReduceMotion: boolean;
view: "list" | "card";
}) {
const title = (
<motion.h3
className="m-0 font-sans font-semibold text-[#101010] text-sm md:text-base"
layout="position"
layoutId={`${groupId}-title-${item.id}`}
transition={shouldReduceMotion ? INSTANT : LAYOUT_SPRING}
>
{item.name}
</motion.h3>
);
const sizeMB = (
<motion.p
className={cn(
"m-0 font-sans",
view === "list"
? "text-xs text-zinc-500 md:text-sm"
: "font-semibold text-[#101010] text-xs md:text-sm"
)}
layout="position"
layoutId={`${groupId}-sizeMB-${item.id}`}
transition={shouldReduceMotion ? INSTANT : LAYOUT_SPRING}
>
{item.sizeMB} MB
</motion.p>
);
const timestamp = (
<motion.span
className="inline-flex shrink-0 items-center gap-1 text-xs text-zinc-400 md:text-sm"
layout="position"
layoutId={`${groupId}-timestamp-${item.id}`}
transition={shouldReduceMotion ? INSTANT : LAYOUT_SPRING}
>
{formatTimestamp(item.timestamp)}
</motion.span>
);
if (view === "list") {
return (
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
<div className="min-w-0">
{title}
{sizeMB}
</div>
{timestamp}
</div>
);
}
return (
<div className="flex flex-col gap-1">
{title}
<div className="flex items-center justify-between gap-2">
{sizeMB}
{timestamp}
</div>
</div>
);
}
function ListCardPanel({
groupId,
items,
shouldReduceMotion,
view,
}: {
groupId: string;
items: GalleryItem[];
shouldReduceMotion: boolean;
view: "list" | "card";
}) {
const radius = imageRadius(view);
return (
<ul
className={cn(
view === "list"
? "flex list-none flex-col gap-3 p-0 md:gap-4"
: "grid list-none grid-cols-2 gap-3 p-0 md:gap-4"
)}
>
{items.map((item) => (
<li
className={cn(
view === "list" ? "flex items-center gap-3" : "flex flex-col gap-2"
)}
key={item.id}
>
<GalleryImage
className={
view === "list"
? "size-14 shrink-0 md:size-16"
: "aspect-square w-full"
}
item={item}
layoutId={`${groupId}-img-${item.id}`}
radius={radius}
rotate={0}
shouldReduceMotion={shouldReduceMotion}
/>
<ItemMeta
groupId={groupId}
item={item}
shouldReduceMotion={shouldReduceMotion}
view={view}
/>
</li>
))}
</ul>
);
}
function PackPanel({
groupId,
items,
shouldReduceMotion,
}: {
groupId: string;
items: GalleryItem[];
shouldReduceMotion: boolean;
}) {
const totalSizeMB = items.reduce((sum, item) => sum + item.sizeMB, 0);
const radius = imageRadius("pack");
return (
<div className="flex flex-col items-center">
<ul className="sr-only">
{items.map((item) => (
<li key={item.id}>
{item.name}, {item.sizeMB} MB, {formatTimestamp(item.timestamp)}
</li>
))}
</ul>
<div
aria-hidden="true"
className="relative mx-auto h-56 w-full max-w-sm overflow-visible md:h-72"
>
{items.map((item, index) => (
<div
className="absolute inset-0 flex items-center justify-center"
key={item.id}
style={{ zIndex: index }}
>
<GalleryImage
className="aspect-square w-[42%] max-w-48 md:max-w-56"
item={item}
layoutId={`${groupId}-img-${item.id}`}
radius={radius}
rotate={packAngleForItem(item.id, index, items.length)}
shouldReduceMotion={shouldReduceMotion}
/>
</div>
))}
</div>
<motion.div
animate={{ opacity: 1 }}
className="mb-4 text-center font-sans"
exit={{ opacity: 0, transition: EXIT_OPACITY }}
initial={shouldReduceMotion ? false : { opacity: 0 }}
transition={shouldReduceMotion ? INSTANT : OPACITY_TRANSITION}
>
<p className="m-0 font-medium text-sm text-zinc-500 md:text-base">
{items.length} Items
</p>
<p className="m-0 text-sm text-zinc-400 md:text-base">
{totalSizeMB} MB
</p>
</motion.div>
</div>
);
}
function Gallery({
className,
defaultView = "list",
items,
onViewChange,
title = "Gallery",
view: viewProp,
...props
}: React.ComponentProps<"section"> & {
defaultView?: GalleryView;
items: GalleryItem[];
onViewChange?: (view: GalleryView) => void;
title?: string;
view?: GalleryView;
}) {
const reactId = useId();
const groupId = `gallery${reactId.replaceAll(":", "")}`;
const [uncontrolledView, setUncontrolledView] =
useState<GalleryView>(defaultView);
const view = viewProp ?? uncontrolledView;
const shouldReduceMotion = useReducedMotion() ?? false;
const [slotRef, slotBounds] = useMeasure();
const tabRefs = useRef<
Partial<Record<GalleryView, HTMLButtonElement | null>>
>({});
const panelId = `${groupId}-panel`;
const setView = (next: GalleryView) => {
if (viewProp === undefined) {
setUncontrolledView(next);
}
onViewChange?.(next);
};
const activeTab = useMemo(
() => VIEWS.find((tab) => tab.id === view) ?? VIEWS[0],
[view]
);
const onTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
const index = VIEWS.findIndex((tab) => tab.id === view);
let nextIndex = index;
switch (event.key) {
case "ArrowRight":
case "ArrowDown":
nextIndex = (index + 1) % VIEWS.length;
break;
case "ArrowLeft":
case "ArrowUp":
nextIndex = (index - 1 + VIEWS.length) % VIEWS.length;
break;
case "Home":
nextIndex = 0;
break;
case "End":
nextIndex = VIEWS.length - 1;
break;
default:
return;
}
event.preventDefault();
const next = VIEWS[nextIndex];
if (!next) {
return;
}
setView(next.id);
tabRefs.current[next.id]?.focus();
};
return (
<MotionConfig
reducedMotion="user"
transition={shouldReduceMotion ? INSTANT : LAYOUT_SPRING}
>
<LayoutGroup id={groupId}>
<section
className={cn(
"not-prose w-full max-w-lg rounded-xl bg-white p-4 font-sans text-[#101010] shadow-xl shadow-zinc-200",
className
)}
{...props}
>
<h2 className="mb-4 font-bold font-serif text-2xl tracking-tight md:mb-5 md:text-3xl">
{title}
</h2>
<div
aria-label="Gallery views"
className="flex flex-wrap gap-2"
role="tablist"
>
{VIEWS.map((tab) => {
const isSelected = tab.id === view;
const Icon = tab.icon;
return (
<button
aria-controls={panelId}
aria-selected={isSelected}
className={cn(
"relative h-9 cursor-pointer rounded-full px-2 font-semibold text-xs outline-none",
"transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2",
"md:px-2.5 md:text-sm",
isSelected
? "bg-accent text-white"
: "bg-[#F2F2F2] text-zinc-500"
)}
id={`${groupId}-tab-${tab.id}`}
key={tab.id}
onClick={() => setView(tab.id)}
onKeyDown={onTabKeyDown}
ref={(node) => {
tabRefs.current[tab.id] = node;
}}
role="tab"
tabIndex={isSelected ? 0 : -1}
type="button"
>
<span className="relative z-10 inline-flex items-center gap-1.5">
<Icon aria-hidden="true" className="size-3.5" />
{tab.label}
</span>
</button>
);
})}
</div>
<div className="mt-4 h-px bg-border md:mt-5" />
<div
aria-labelledby={`${groupId}-tab-${activeTab.id}`}
className="pt-5 md:pt-6"
id={panelId}
role="tabpanel"
>
<motion.div
animate={{
height:
shouldReduceMotion || slotBounds.height === 0
? "auto"
: slotBounds.height,
}}
initial={false}
transition={shouldReduceMotion ? INSTANT : LAYOUT_SPRING}
>
<div ref={slotRef}>
{view === "pack" ? (
<PackPanel
groupId={groupId}
items={items}
shouldReduceMotion={shouldReduceMotion}
/>
) : (
<ListCardPanel
groupId={groupId}
items={items}
shouldReduceMotion={shouldReduceMotion}
view={view}
/>
)}
</div>
</motion.div>
</div>
</section>
</LayoutGroup>
</MotionConfig>
);
}
export { Gallery };tablist / tab / tabpanel, arrow keys, Home, End). Pack view exposes a visually hidden item list for screen readers and hides the decorative stack with aria-hidden.| Prop | Type | Default | Description |
|---|---|---|---|
items | GalleryItem[] | — | Images to render. |
title | string | "Gallery" | Visible section heading. |
defaultView | "list" | "card" | "pack" | "list" | Initial view when uncontrolled. |
view | "list" | "card" | "pack" | — | Controlled view. |
onViewChange | (view: GalleryView) => void | — | Fires when a tab is selected. |
className | string | — | Merged onto the root section. |
GalleryItem: id, name, imageSrc, sizeMB, timestamp.