SaaS & Ops

Template Gallery

A template picker grid with a category filter rail, colored preview tiles, live counts and a selected-state check badge.

Install

npx shadcn@latest add @paragon/template-gallery

template-gallery.tsx

"use client";

import * as React from "react";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";

export interface Template {
  id: string;
  name: string;
  description: string;
  category: string;
  /** Emoji glyph shown on the preview tile. */
  glyph?: string;
  /** Preview accent color, any CSS color. */
  accent?: string;
}

export interface TemplateGalleryProps
  extends Omit<React.ComponentProps<"div">, "onSelect"> {
  templates?: Template[];
  /** Ordered category names for the filter rail; "All" is prepended. */
  categories?: string[];
  value?: string;
  defaultValue?: string;
  onSelect?: (id: string) => void;
}

const DEFAULT_TEMPLATES: Template[] = [
  { id: "blank", name: "Blank doc", description: "Start from an empty page", category: "Docs", glyph: "πŸ“„", accent: "oklch(0.7 0 0)" },
  { id: "prd", name: "Product spec", description: "Problem, scope, milestones", category: "Docs", glyph: "🧭", accent: "oklch(0.7 0.15 250)" },
  { id: "meeting", name: "Meeting notes", description: "Agenda, decisions, actions", category: "Docs", glyph: "πŸ—’οΈ", accent: "oklch(0.72 0.14 90)" },
  { id: "sprint", name: "Sprint board", description: "Kanban with WIP limits", category: "Projects", glyph: "πŸ—οΈ", accent: "oklch(0.72 0.16 30)" },
  { id: "roadmap", name: "Roadmap", description: "Quarters and initiatives", category: "Projects", glyph: "πŸ—ΊοΈ", accent: "oklch(0.7 0.14 160)" },
  { id: "tracker", name: "Bug tracker", description: "Triage and status columns", category: "Projects", glyph: "🐞", accent: "oklch(0.65 0.2 25)" },
  { id: "crm", name: "Lightweight CRM", description: "Deals, contacts, pipeline", category: "Databases", glyph: "πŸ“‡", accent: "oklch(0.72 0.15 300)" },
  { id: "content", name: "Content calendar", description: "Plan posts by channel", category: "Databases", glyph: "πŸ—“οΈ", accent: "oklch(0.7 0.15 210)" },
];

/**
 * A template picker grid with a category filter rail. Picking a category
 * filters the tiles; each tile is a real radio-style button showing a colored
 * preview, name and description, with a check badge on the selected template.
 * A live count sits by the heading. Selection is roving-tabindex friendly and
 * fully keyboard reachable; the grid reflows responsively.
 */
export function TemplateGallery({
  templates = DEFAULT_TEMPLATES,
  categories,
  value,
  defaultValue,
  onSelect,
  className,
  ...props
}: TemplateGalleryProps) {
  const cats = React.useMemo(() => {
    const derived =
      categories ?? [...new Set(templates.map((t) => t.category))];
    return ["All", ...derived];
  }, [categories, templates]);

  const [category, setCategory] = React.useState("All");
  const [internal, setInternal] = React.useState(defaultValue ?? "");
  const selected = value ?? internal;

  const visible = React.useMemo(
    () =>
      category === "All"
        ? templates
        : templates.filter((t) => t.category === category),
    [templates, category],
  );

  const select = (id: string) => {
    if (value === undefined) setInternal(id);
    onSelect?.(id);
  };

  return (
    <div
      data-slot="template-gallery"
      className={cn("w-full", className)}
      {...props}
    >
      <div className="mb-4 flex items-center gap-2">
        <h3 className="text-sm font-medium">Choose a template</h3>
        <span className="rounded-full bg-secondary px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground tabular-nums">
          {visible.length}
        </span>
      </div>

      <div
        role="tablist"
        aria-label="Template categories"
        className="mb-4 flex flex-wrap items-center gap-1.5"
      >
        {cats.map((cat) => {
          const active = cat === category;
          return (
            <button
              key={cat}
              role="tab"
              aria-selected={active}
              onClick={() => setCategory(cat)}
              className={cn(
                "rounded-full px-2.5 py-1 text-xs font-medium outline-none",
                "transition-colors duration-(--duration-fast) ease-out",
                "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
                active
                  ? "bg-primary text-primary-foreground"
                  : "bg-secondary text-secondary-foreground hover:bg-secondary/80",
              )}
            >
              {cat}
            </button>
          );
        })}
      </div>

      <div
        role="radiogroup"
        aria-label="Templates"
        className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-3"
      >
        {visible.map((template) => {
          const isSelected = template.id === selected;
          return (
            <button
              key={template.id}
              type="button"
              role="radio"
              aria-checked={isSelected}
              onClick={() => select(template.id)}
              className={cn(
                "group/tile relative flex flex-col overflow-hidden rounded-xl bg-card text-left outline-none",
                "transition-[box-shadow] duration-(--duration-fast) ease-out",
                isSelected
                  ? "shadow-[0_0_0_2px_var(--color-primary)]"
                  : "shadow-border hover:shadow-border-hover",
                "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
              )}
            >
              <div
                aria-hidden
                className="relative flex h-24 items-center justify-center overflow-hidden"
                style={{
                  background: `linear-gradient(135deg, ${template.accent ?? "var(--color-secondary)"} 0%, transparent 140%)`,
                }}
              >
                <span className="text-3xl drop-shadow-sm">{template.glyph}</span>
                {isSelected && (
                  <span className="absolute top-2 right-2 flex size-5 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm">
                    <Check className="size-3.5" aria-hidden />
                  </span>
                )}
              </div>
              <div className="flex flex-1 flex-col gap-0.5 p-3">
                <p className="text-sm font-medium">{template.name}</p>
                <p className="text-xs text-muted-foreground">
                  {template.description}
                </p>
              </div>
            </button>
          );
        })}
      </div>
    </div>
  );
}