A searchable integrations wall with category chips, layout-animated re-sorting, and connect buttons that blur-swap to a connected state.
npx shadcn@latest add @paragon/integration-gridAlso installs: button
"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>
);
}