SaaS & Ops

Gallery View

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.

Install

npx shadcn@latest add @paragon/gallery-view

gallery-view.tsx

"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>
  );
}