Sections

Integration Grid

A searchable integrations wall with category chips, layout-animated re-sorting, and connect buttons that blur-swap to a connected state.

Install

npx shadcn@latest add @paragon/integration-grid

Also installs: button

integration-grid.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, Search } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/paragon/ui/button";

/* ------------------------------------------------------------------ */

const CATEGORIES = [
  "All",
  "Source control",
  "Alerting",
  "Data",
  "Communication",
] as const;

type Category = (typeof CATEGORIES)[number];

interface Integration {
  name: string;
  category: Exclude<Category, "All">;
  blurb: string;
  hue: string;
  connected?: boolean;
}

const INTEGRATIONS: Integration[] = [
  {
    name: "GitHub",
    category: "Source control",
    blurb: "Build and deploy on every push to your repositories.",
    hue: "bg-neutral-800 text-white dark:bg-neutral-200 dark:text-neutral-900",
    connected: true,
  },
  {
    name: "GitLab",
    category: "Source control",
    blurb: "Trigger pipelines from merge requests and protected branches.",
    hue: "bg-orange-600/15 text-orange-700 dark:text-orange-400",
  },
  {
    name: "Slack",
    category: "Communication",
    blurb: "Deploy notifications and approval prompts in your channels.",
    hue: "bg-purple-600/15 text-purple-700 dark:text-purple-400",
    connected: true,
  },
  {
    name: "Linear",
    category: "Communication",
    blurb: "Link deploys to issues and auto-close on release.",
    hue: "bg-indigo-600/15 text-indigo-700 dark:text-indigo-400",
  },
  {
    name: "PagerDuty",
    category: "Alerting",
    blurb: "Page on-call when a deploy degrades error budgets.",
    hue: "bg-emerald-600/15 text-emerald-700 dark:text-emerald-400",
  },
  {
    name: "Datadog",
    category: "Alerting",
    blurb: "Correlate deploy markers with traces and monitors.",
    hue: "bg-violet-600/15 text-violet-700 dark:text-violet-400",
    connected: true,
  },
  {
    name: "Snowflake",
    category: "Data",
    blurb: "Ship deploy and usage events to your warehouse.",
    hue: "bg-sky-600/15 text-sky-700 dark:text-sky-400",
  },
  {
    name: "Postgres",
    category: "Data",
    blurb: "Managed connection strings with per-environment credentials.",
    hue: "bg-blue-600/15 text-blue-700 dark:text-blue-400",
  },
  {
    name: "Grafana",
    category: "Alerting",
    blurb: "Annotate dashboards with releases and rollbacks.",
    hue: "bg-amber-600/15 text-amber-700 dark:text-amber-400",
  },
];

/* ------------------------------------------------------------------ */

function ConnectButton({
  name,
  connected,
  onToggle,
}: {
  name: string;
  connected: boolean;
  onToggle: () => void;
}) {
  const reducedMotion = useReducedMotion();
  return (
    <Button
      variant="outline"
      size="sm"
      aria-pressed={connected}
      aria-label={connected ? `${name} connected` : `Connect ${name}`}
      onClick={onToggle}
      className={cn(
        "w-[6.25rem]",
        connected && "text-emerald-700 dark:text-emerald-400",
      )}
    >
      <AnimatePresence mode="popLayout" initial={false}>
        <motion.span
          key={connected ? "connected" : "connect"}
          initial={
            reducedMotion
              ? { opacity: 0 }
              : { opacity: 0, scale: 0.5, filter: "blur(4px)" }
          }
          animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
          exit={
            reducedMotion
              ? { opacity: 0 }
              : { opacity: 0, scale: 0.5, filter: "blur(4px)" }
          }
          transition={{ type: "spring", duration: 0.3, bounce: 0 }}
          className="inline-flex items-center gap-1.5"
        >
          {connected ? (
            <>
              <Check aria-hidden className="size-3.5" />
              Connected
            </>
          ) : (
            "Connect"
          )}
        </motion.span>
      </AnimatePresence>
    </Button>
  );
}

export interface IntegrationGridProps extends React.ComponentProps<"section"> {
  /** Heading above the grid. */
  title?: string;
}

/**
 * An integrations wall: search plus category chips filter a card grid that
 * re-sorts with layout animations, and each card's connect button blur-swaps
 * into a connected state. Layout motion collapses to instant under reduced
 * motion.
 */
export function IntegrationGrid({
  title = "Integrations",
  className,
  ...props
}: IntegrationGridProps) {
  const reducedMotion = useReducedMotion();
  const searchId = React.useId();
  const [query, setQuery] = React.useState("");
  const [category, setCategory] = React.useState<Category>("All");
  const [connected, setConnected] = React.useState<Set<string>>(
    () => new Set(INTEGRATIONS.filter((i) => i.connected).map((i) => i.name)),
  );

  const visible = INTEGRATIONS.filter(
    (i) =>
      (category === "All" || i.category === category) &&
      i.name.toLowerCase().includes(query.trim().toLowerCase()),
  );

  const layoutTransition = reducedMotion
    ? { duration: 0 }
    : { type: "spring" as const, duration: 0.4, bounce: 0 };

  return (
    <section aria-label={title} className={cn("w-full", className)} {...props}>
      <header className="flex flex-wrap items-center justify-between gap-x-8 gap-y-3">
        <div>
          <h2 className="text-xl font-semibold tracking-tight">{title}</h2>
          <p className="mt-1 text-sm text-muted-foreground">
            Connect Relay to the tools your team already runs on.
          </p>
        </div>
        <div className="relative w-full sm:w-64">
          <label htmlFor={searchId} className="sr-only">
            Search integrations
          </label>
          <Search
            aria-hidden
            className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground"
          />
          <input
            id={searchId}
            type="search"
            placeholder="Search integrations…"
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            className={cn(
              "h-8 w-full rounded-lg border border-input bg-transparent pr-3 pl-8 text-sm outline-none",
              "transition-[border-color,box-shadow] duration-150 ease-out",
              "placeholder:text-muted-foreground",
              "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
            )}
          />
        </div>
      </header>

      {/* Category chips */}
      <div
        role="group"
        aria-label="Filter by category"
        className="mt-4 flex flex-wrap gap-1.5"
      >
        {CATEGORIES.map((cat) => {
          const active = category === cat;
          return (
            <button
              key={cat}
              type="button"
              aria-pressed={active}
              onClick={() => setCategory(cat)}
              className={cn(
                "pressable relative h-7 rounded-full px-3 text-[13px] font-medium whitespace-nowrap",
                "transition-[background-color,color,box-shadow] duration-150 ease-out",
                "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
                "after:absolute after:inset-x-0 after:top-1/2 after:h-10 after:-translate-y-1/2",
                active
                  ? "bg-primary text-primary-foreground"
                  : "text-muted-foreground shadow-border hover:text-foreground hover:shadow-border-hover",
              )}
            >
              {cat}
            </button>
          );
        })}
      </div>

      {/* Grid */}
      <div className="mt-5 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
        <AnimatePresence initial={false} mode="popLayout">
          {visible.map((integration) => {
            const isConnected = connected.has(integration.name);
            return (
              <motion.article
                key={integration.name}
                layout
                initial={
                  reducedMotion
                    ? { opacity: 0 }
                    : { opacity: 0, scale: 0.96, filter: "blur(4px)" }
                }
                animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
                exit={
                  reducedMotion
                    ? { opacity: 0, transition: { duration: 0.1 } }
                    : {
                        opacity: 0,
                        scale: 0.96,
                        filter: "blur(4px)",
                        transition: { duration: 0.15 },
                      }
                }
                transition={layoutTransition}
                className="flex flex-col rounded-xl bg-card p-4 text-card-foreground shadow-border"
              >
                <div className="flex items-start justify-between gap-3">
                  <span
                    aria-hidden
                    className={cn(
                      "flex size-9 items-center justify-center rounded-lg text-sm font-bold",
                      integration.hue,
                    )}
                  >
                    {integration.name.charAt(0)}
                  </span>
                  <span className="rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
                    {integration.category}
                  </span>
                </div>
                <h3 className="mt-3 text-sm font-semibold">
                  {integration.name}
                </h3>
                <p className="mt-1 flex-1 text-[13px] text-pretty text-muted-foreground">
                  {integration.blurb}
                </p>
                <div className="mt-4">
                  <ConnectButton
                    name={integration.name}
                    connected={isConnected}
                    onToggle={() =>
                      setConnected((prev) => {
                        const next = new Set(prev);
                        if (next.has(integration.name))
                          next.delete(integration.name);
                        else next.add(integration.name);
                        return next;
                      })
                    }
                  />
                </div>
              </motion.article>
            );
          })}
        </AnimatePresence>
      </div>

      {/* Empty state */}
      {visible.length === 0 && (
        <div className="mt-5 rounded-xl border border-dashed py-10 text-center">
          <p className="text-sm text-muted-foreground">
            No integrations match{" "}
            <span className="font-medium text-foreground">
{query.trim()}
            </span>
            {category !== "All" && <> in {category}</>}.
          </p>
          <Button
            variant="ghost"
            size="sm"
            className="mt-2 text-muted-foreground"
            onClick={() => {
              setQuery("");
              setCategory("All");
            }}
          >
            Clear filters
          </Button>
        </div>
      )}
    </section>
  );
}