A card gallery grouped by a key, each group with a live count, flowing cards into a responsive auto-fill grid with cover strips and assignee avatars.
npx shadcn@latest add @paragon/gallery-view"use client";
import * as React from "react";
import {
AnimatePresence,
motion,
useInView,
useReducedMotion,
} from "motion/react";
import { ChevronRight, Inbox } from "lucide-react";
import { cn } from "@/lib/utils";
export interface GalleryCard {
id: string;
title: string;
/** Group key — cards are bucketed and shown under group headers. */
group: string;
subtitle?: string;
/** Small colored cover strip, any CSS color. */
cover?: string;
/** Trailing meta chips. */
tags?: string[];
assignee?: string;
}
export interface GalleryViewProps extends React.ComponentProps<"div"> {
cards?: GalleryCard[];
/** Ordered group keys; groups not listed append in first-seen order. */
groupOrder?: string[];
/** Called when a card is activated (click or Enter/Space). */
onCardActivate?: (id: string) => void;
/** Empty-state copy when there are no cards. */
emptyLabel?: string;
/** Drop the scroll reveal + layout motion. */
static?: boolean;
}
function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "•";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
function hueOf(value: string): number {
let hash = 0;
for (let i = 0; i < value.length; i++) {
hash = (hash << 5) - hash + value.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash) % 360;
}
const DEFAULT_CARDS: GalleryCard[] = [
{ id: "1", title: "Onboarding email revamp", group: "In progress", subtitle: "Lifecycle", cover: "oklch(0.7 0.15 250)", tags: ["Design"], assignee: "Sofia Marin" },
{ id: "2", title: "Pricing page A/B test", group: "In progress", subtitle: "Growth", cover: "oklch(0.72 0.16 30)", tags: ["Experiment"], assignee: "Jamie Lin" },
{ id: "3", title: "Docs search relevance", group: "Todo", subtitle: "Platform", cover: "oklch(0.7 0.14 160)", tags: ["Backend"], assignee: "Marcus Kane" },
{ id: "4", title: "Mobile push preferences", group: "Todo", subtitle: "Apps", cover: "oklch(0.72 0.15 300)", tags: ["Mobile"], assignee: "Dana Ortiz" },
{ id: "5", title: "Changelog RSS feed", group: "Shipped", subtitle: "Marketing", cover: "oklch(0.72 0.14 90)", tags: ["Content"], assignee: "Noah Bennett" },
{ id: "6", title: "SSO setup wizard", group: "Shipped", subtitle: "Identity", cover: "oklch(0.7 0.15 210)", tags: ["Security"], assignee: "Priya Raghavan" },
];
const DEFAULT_ORDER = ["In progress", "Todo", "Shipped"];
function GalleryCardTile({
card,
index,
animate,
onActivate,
}: {
card: GalleryCard;
index: number;
animate: boolean;
onActivate?: (id: string) => void;
}) {
return (
<motion.button
type="button"
layout={animate ? "position" : false}
initial={
animate
? { opacity: 0, y: 12, filter: "blur(4px)" }
: false
}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={
animate
? { opacity: 0, y: -8, filter: "blur(4px)" }
: { opacity: 0 }
}
transition={{
layout: { type: "spring", duration: 0.4, bounce: 0 },
opacity: { duration: 0.3, ease: [0.22, 1, 0.36, 1], delay: index * 0.04 },
y: { duration: 0.3, ease: [0.22, 1, 0.36, 1], delay: index * 0.04 },
filter: { duration: 0.3, ease: [0.22, 1, 0.36, 1], delay: index * 0.04 },
}}
onClick={() => onActivate?.(card.id)}
className={cn(
"group/card relative flex flex-col overflow-hidden rounded-xl bg-card text-left shadow-border outline-none",
"transition-[box-shadow] duration-(--duration-fast) ease-(--ease-out)",
"hover:shadow-border-hover",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
>
<span
aria-hidden
className="h-1.5 w-full shrink-0"
style={{ backgroundColor: card.cover ?? "var(--color-border)" }}
/>
<div className="flex flex-1 flex-col gap-2 p-3">
<div className="flex items-start justify-between gap-2">
<p className="min-w-0 text-sm leading-snug font-medium">
{card.title}
</p>
<ChevronRight
className={cn(
"size-4 shrink-0 text-muted-foreground opacity-0",
"transition-[opacity,translate] duration-(--duration-fast) ease-(--ease-out)",
"group-hover/card:translate-x-0.5 group-hover/card:opacity-100",
"group-focus-visible/card:translate-x-0.5 group-focus-visible/card:opacity-100",
"motion-reduce:transition-none",
)}
aria-hidden
/>
</div>
{card.subtitle && (
<p className="text-xs text-muted-foreground">{card.subtitle}</p>
)}
<div className="mt-auto flex items-center gap-2 pt-1">
{card.tags?.map((tag) => (
<span
key={tag}
className="rounded-full bg-secondary px-1.5 py-0.5 text-[10px] font-medium text-secondary-foreground"
>
{tag}
</span>
))}
{card.assignee && (
<span
title={card.assignee}
className="ml-auto flex size-5 items-center justify-center rounded-full text-[10px] font-medium"
style={{
backgroundColor: `oklch(0.9 0.05 ${hueOf(card.assignee)})`,
color: `oklch(0.4 0.09 ${hueOf(card.assignee)})`,
}}
>
{initials(card.assignee)}
</span>
)}
</div>
</div>
</motion.button>
);
}
/**
* A card gallery view grouped by a key (status, team, etc.). Each group gets a
* header with a live count; cards flow into a responsive auto-fill grid with a
* colored cover strip, title, meta chips and an assignee avatar. Cards reveal
* with the house enter language when the board scrolls into view, and animate
* with motion's layout engine as cards are added, removed or reordered. Cards
* are real buttons with a hover lift that nudges a chevron, so the grid stays
* keyboard-reachable. Empty groups fall away; an all-empty board shows a
* placeholder. Zero-props renders a full three-group board.
*/
export function GalleryView({
cards = DEFAULT_CARDS,
groupOrder = DEFAULT_ORDER,
onCardActivate,
emptyLabel = "No cards yet",
static: isStatic = false,
className,
...props
}: GalleryViewProps) {
const reducedMotion = useReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
const inView = useInView(rootRef, { once: true, amount: 0.15 });
const animate = !isStatic && !reducedMotion && inView;
const groups = React.useMemo(() => {
const map = new Map<string, GalleryCard[]>();
for (const card of cards) {
const list = map.get(card.group) ?? [];
list.push(card);
map.set(card.group, list);
}
const ordered = [
...groupOrder.filter((g) => map.has(g)),
...[...map.keys()].filter((g) => !groupOrder.includes(g)),
];
return ordered.map((key) => [key, map.get(key)!] as const);
}, [cards, groupOrder]);
return (
<div
ref={rootRef}
data-slot="gallery-view"
className={cn("w-full space-y-6", className)}
{...props}
>
{groups.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 rounded-xl border border-dashed border-border py-12 text-center">
<Inbox className="size-5 text-muted-foreground" aria-hidden />
<p className="text-sm text-muted-foreground">{emptyLabel}</p>
</div>
) : (
groups.map(([group, list]) => (
<section key={group}>
<div className="mb-2.5 flex items-center gap-2">
<h3 className="text-sm font-medium">{group}</h3>
<span className="rounded-full bg-secondary px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground tabular-nums">
{list.length}
</span>
</div>
<div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-3">
<AnimatePresence initial={false} mode="popLayout">
{list.map((card, index) => (
<GalleryCardTile
key={card.id}
card={card}
index={index}
animate={animate}
onActivate={onCardActivate}
/>
))}
</AnimatePresence>
</div>
</section>
))
)}
</div>
);
}