SaaS — Integrations Directory
A polished integrations directory for a fictional SaaS product, featuring a live search field, category filter chips with running counts, and a responsive grid of integration cards with SVG logo marks and one-click connect toggles. A slide-in detail drawer explains what each integration does, lists the permissions it requests, and surfaces usage and rating stats. Empty states, toast feedback, and a working light and dark theme toggle round out a clean catalog experience.
MCP
Code
:root {
--bg: #f7f8fb;
--surface: #ffffff;
--surface-2: #f1f3f9;
--ink: #0f1222;
--muted: #646b85;
--brand: #6366f1;
--brand-d: #4f46e5;
--ok: #16a34a;
--warn: #d97706;
--danger: #dc2626;
--line: rgba(15, 18, 34, .1);
--line-strong: rgba(15, 18, 34, .16);
--shadow: 0 1px 2px rgba(15, 18, 34, .06), 0 8px 24px rgba(15, 18, 34, .06);
--shadow-lg: 0 12px 40px rgba(15, 18, 34, .16);
--radius: 14px;
--ease: cubic-bezier(.2, .7, .3, 1);
}
[data-theme="dark"] {
--bg: #0b0d18;
--surface: #14172a;
--surface-2: #1c2038;
--ink: #eef0f8;
--muted: #9aa1bd;
--brand: #818cf8;
--brand-d: #6366f1;
--line: rgba(255, 255, 255, .1);
--line-strong: rgba(255, 255, 255, .18);
--shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 8px 24px rgba(0, 0, 0, .35);
--shadow-lg: 0 12px 40px rgba(0, 0, 0, .55);
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: var(--bg);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transition: background .25s var(--ease), color .25s var(--ease);
}
h1, h2, h3 { line-height: 1.2; margin: 0; }
button { font: inherit; cursor: pointer; }
:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
border-radius: 8px;
}
.skip {
position: absolute;
left: -9999px;
top: 8px;
background: var(--brand);
color: #fff;
padding: 8px 14px;
border-radius: 8px;
z-index: 100;
}
.skip:focus { left: 16px; }
/* ---------- Topbar ---------- */
.topbar {
position: sticky;
top: 0;
z-index: 30;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px clamp(16px, 4vw, 40px);
background: color-mix(in srgb, var(--surface) 86%, transparent);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--line);
}
.brand { display: flex; align-items: center; gap: 10px; min-width: 0; }
.brand-mark {
display: grid;
place-items: center;
width: 32px; height: 32px;
border-radius: 9px;
color: #fff;
background: linear-gradient(135deg, var(--brand), var(--brand-d));
box-shadow: 0 4px 12px rgba(99, 102, 241, .4);
}
.brand-name { font-weight: 800; font-size: 17px; letter-spacing: -.01em; }
.brand-sub {
font-size: 13px; color: var(--muted); font-weight: 600;
padding-left: 10px; margin-left: 2px;
border-left: 1px solid var(--line);
}
.topbar-actions { display: flex; align-items: center; gap: 10px; }
.conn-pill {
font-size: 12.5px; font-weight: 700;
padding: 6px 12px;
border-radius: 999px;
background: color-mix(in srgb, var(--ok) 14%, var(--surface));
color: var(--ok);
border: 1px solid color-mix(in srgb, var(--ok) 30%, transparent);
white-space: nowrap;
}
.theme-toggle {
width: 38px; height: 38px;
display: grid; place-items: center;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--surface);
color: var(--ink);
font-size: 16px;
transition: background .15s, border-color .15s, transform .15s;
}
.theme-toggle:hover { border-color: var(--line-strong); transform: translateY(-1px); }
/* ---------- Hero ---------- */
main { max-width: 1120px; margin: 0 auto; padding: 0 clamp(16px, 4vw, 40px) 80px; }
.hero { padding: clamp(28px, 6vw, 56px) 0 8px; text-align: center; }
.hero h1 {
font-size: clamp(28px, 5vw, 40px);
font-weight: 800;
letter-spacing: -.025em;
background: linear-gradient(120deg, var(--ink), color-mix(in srgb, var(--brand) 70%, var(--ink)));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.hero-sub {
color: var(--muted);
font-size: clamp(14px, 2vw, 16px);
max-width: 540px;
margin: 12px auto 0;
}
.search-wrap {
position: relative;
max-width: 460px;
margin: 24px auto 0;
}
.search-ico {
position: absolute;
left: 16px; top: 50%;
transform: translateY(-50%);
color: var(--muted);
pointer-events: none;
display: flex;
}
#search {
width: 100%;
padding: 14px 44px 14px 46px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--surface);
color: var(--ink);
font-size: 15px;
box-shadow: var(--shadow);
transition: border-color .15s, box-shadow .15s;
}
#search::placeholder { color: var(--muted); }
#search:focus { outline: none; border-color: var(--brand); box-shadow: 0 0 0 4px rgba(99, 102, 241, .15); }
.search-clear {
position: absolute;
right: 12px; top: 50%;
transform: translateY(-50%);
width: 26px; height: 26px;
border: none;
border-radius: 7px;
background: var(--surface-2);
color: var(--muted);
font-size: 18px;
line-height: 1;
display: grid; place-items: center;
}
.search-clear:hover { color: var(--ink); }
/* ---------- Chips ---------- */
.chips {
display: flex;
flex-wrap: wrap;
gap: 9px;
justify-content: center;
margin: 26px 0 8px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 8px 14px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface);
color: var(--ink);
font-size: 13.5px;
font-weight: 600;
transition: background .15s, border-color .15s, color .15s, transform .1s;
}
.chip:hover { border-color: var(--line-strong); transform: translateY(-1px); }
.chip[aria-pressed="true"] {
background: var(--brand);
border-color: var(--brand);
color: #fff;
}
.chip-count {
font-size: 11.5px;
font-weight: 700;
padding: 1px 7px;
border-radius: 999px;
background: var(--surface-2);
color: var(--muted);
}
.chip[aria-pressed="true"] .chip-count {
background: rgba(255, 255, 255, .25);
color: #fff;
}
/* ---------- Grid ---------- */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
margin-top: 24px;
}
.card {
display: flex;
flex-direction: column;
text-align: left;
padding: 20px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
box-shadow: var(--shadow);
transition: border-color .15s, box-shadow .2s, transform .15s;
width: 100%;
position: relative;
animation: cardIn .35s var(--ease) both;
}
.card:hover { transform: translateY(-3px); border-color: var(--line-strong); box-shadow: var(--shadow-lg); }
@keyframes cardIn { from { opacity: 0; transform: translateY(8px); } }
.card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.logo {
display: grid;
place-items: center;
width: 46px; height: 46px;
border-radius: 12px;
color: #fff;
flex: none;
box-shadow: 0 4px 10px rgba(15, 18, 34, .14);
}
.logo svg { width: 26px; height: 26px; }
.logo-lg { width: 52px; height: 52px; }
.logo-lg svg { width: 30px; height: 30px; }
.connected-tag {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11.5px;
font-weight: 700;
padding: 4px 9px;
border-radius: 999px;
background: color-mix(in srgb, var(--ok) 14%, var(--surface));
color: var(--ok);
border: 1px solid color-mix(in srgb, var(--ok) 28%, transparent);
}
.connected-tag::before {
content: "";
width: 6px; height: 6px;
border-radius: 50%;
background: var(--ok);
}
.card-name { font-size: 16px; font-weight: 700; letter-spacing: -.01em; }
.card-cat {
display: inline-block;
font-size: 11.5px;
font-weight: 600;
color: var(--muted);
margin-top: 2px;
}
.card-blurb {
font-size: 13.5px;
color: var(--muted);
margin: 10px 0 16px;
flex: 1;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-actions { display: flex; gap: 8px; }
.btn {
flex: 1;
padding: 9px 14px;
border-radius: 10px;
font-size: 13.5px;
font-weight: 700;
border: 1px solid transparent;
transition: background .15s, border-color .15s, color .15s, transform .1s;
}
.btn:active { transform: scale(.97); }
.btn-connect {
background: var(--brand);
color: #fff;
}
.btn-connect:hover { background: var(--brand-d); }
.btn-connect[data-on="true"] {
background: var(--surface);
color: var(--ink);
border-color: var(--line-strong);
}
.btn-connect[data-on="true"]:hover { border-color: var(--danger); color: var(--danger); background: color-mix(in srgb, var(--danger) 8%, var(--surface)); }
.btn-ghost {
background: var(--surface-2);
color: var(--ink);
border-color: var(--line);
}
.btn-ghost:hover { border-color: var(--line-strong); }
/* ---------- Empty ---------- */
.empty {
text-align: center;
padding: 64px 20px;
color: var(--muted);
}
.empty-art {
display: inline-grid;
place-items: center;
width: 96px; height: 96px;
border-radius: 22px;
background: var(--surface);
border: 1px solid var(--line);
color: var(--brand);
margin-bottom: 18px;
}
.empty h2 { color: var(--ink); font-size: 20px; }
.empty p { margin: 8px 0 18px; }
/* ---------- Drawer ---------- */
.drawer-scrim {
position: fixed;
inset: 0;
background: rgba(15, 18, 34, .42);
backdrop-filter: blur(2px);
z-index: 40;
animation: fade .2s var(--ease);
}
@keyframes fade { from { opacity: 0; } }
.drawer {
position: fixed;
top: 0; right: 0;
height: 100dvh;
width: min(440px, 100vw);
background: var(--surface);
border-left: 1px solid var(--line);
box-shadow: var(--shadow-lg);
z-index: 50;
display: flex;
flex-direction: column;
animation: slideIn .3s var(--ease);
}
@keyframes slideIn { from { transform: translateX(100%); } }
.drawer-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 22px 22px 18px;
border-bottom: 1px solid var(--line);
}
.drawer-id { display: flex; gap: 14px; align-items: center; }
.drawer-id h2 { font-size: 19px; letter-spacing: -.01em; }
.drawer-cat { font-size: 13px; color: var(--muted); font-weight: 600; }
.drawer-close {
width: 34px; height: 34px;
border: 1px solid var(--line);
border-radius: 9px;
background: var(--surface);
color: var(--muted);
font-size: 20px;
line-height: 1;
display: grid; place-items: center;
flex: none;
}
.drawer-close:hover { color: var(--ink); border-color: var(--line-strong); }
.drawer-body {
flex: 1;
overflow-y: auto;
padding: 20px 22px;
}
.drawer-blurb { color: var(--muted); font-size: 14.5px; margin: 0 0 18px; }
.drawer-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 22px;
}
.meta-stat {
padding: 14px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--surface-2);
text-align: center;
}
.meta-num { display: block; font-size: 20px; font-weight: 800; letter-spacing: -.02em; }
.meta-lbl { font-size: 12px; color: var(--muted); font-weight: 600; }
.drawer-h {
font-size: 13px;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--muted);
margin: 0 0 10px;
}
.feat-list, .perm-list { list-style: none; padding: 0; margin: 0 0 24px; display: grid; gap: 9px; }
.feat-list li, .perm-list li {
display: flex;
gap: 10px;
font-size: 14px;
align-items: flex-start;
}
.feat-list li::before {
content: "";
flex: none;
margin-top: 2px;
width: 18px; height: 18px;
border-radius: 50%;
background:
linear-gradient(var(--ok), var(--ok)) center/10px 2px no-repeat,
color-mix(in srgb, var(--ok) 16%, transparent);
-webkit-mask: none;
position: relative;
}
.feat-list li .tick {
flex: none; margin-top: 1px;
width: 18px; height: 18px;
display: grid; place-items: center;
border-radius: 50%;
background: color-mix(in srgb, var(--ok) 16%, transparent);
color: var(--ok);
}
.feat-list li::before { display: none; }
.perm-list li .lock {
flex: none; margin-top: 1px;
width: 18px; height: 18px;
display: grid; place-items: center;
color: var(--brand);
}
.perm-list li span.txt { color: var(--ink); }
.perm-list li small { display: block; color: var(--muted); font-size: 12.5px; }
.drawer-foot {
padding: 16px 22px calc(16px + env(safe-area-inset-bottom));
border-top: 1px solid var(--line);
}
.drawer-foot .btn-connect {
width: 100%;
padding: 13px;
font-size: 15px;
border-radius: 11px;
}
/* ---------- Toast ---------- */
.toast-host {
position: fixed;
left: 50%;
bottom: 24px;
transform: translateX(-50%);
z-index: 80;
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
pointer-events: none;
}
.toast {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 16px;
border-radius: 11px;
background: var(--ink);
color: var(--bg);
font-size: 13.5px;
font-weight: 600;
box-shadow: var(--shadow-lg);
animation: toastIn .28s var(--ease);
}
.toast.out { animation: toastOut .25s var(--ease) forwards; }
.toast .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--ok); }
.toast.warn .dot { background: var(--warn); }
@keyframes toastIn { from { opacity: 0; transform: translateY(10px); } }
@keyframes toastOut { to { opacity: 0; transform: translateY(10px); } }
@media (max-width: 480px) {
.grid { grid-template-columns: 1fr; }
.brand-sub { display: none; }
.conn-pill { display: none; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .001ms !important; transition-duration: .001ms !important; }
}(function () {
"use strict";
// ---------- SVG logo marks ----------
function svg(paths) {
return '<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">' + paths + "</svg>";
}
var MARKS = {
bolt: svg('<path d="M13 2 4 14h6l-1 8 9-12h-6l1-8z" fill="currentColor"/>'),
chat: svg('<path d="M4 5h16v11H9l-5 4V5z" fill="currentColor"/>'),
grid: svg('<path d="M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z" fill="currentColor"/>'),
chart: svg('<path d="M4 20V4M4 20h16M8 16v-5M13 16V8M18 16v-9" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/>'),
card: svg('<rect x="3" y="6" width="18" height="12" rx="2" fill="currentColor"/><path d="M3 10h18" stroke="#fff" stroke-width="2"/>'),
cloud: svg('<path d="M7 18a4 4 0 0 1 .5-7.97A5 5 0 0 1 17 10a3.5 3.5 0 0 1 0 8H7z" fill="currentColor"/>'),
code: svg('<path d="M9 8 5 12l4 4M15 8l4 4-4 4" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>'),
ticket: svg('<path d="M4 7a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2 2 2 0 0 0 0 4 2 2 0 0 1-2 2H6a2 2 0 0 1-2-2 2 2 0 0 0 0-4z" fill="currentColor"/>'),
mail: svg('<rect x="3" y="5" width="18" height="14" rx="2" fill="currentColor"/><path d="M4 7l8 6 8-6" stroke="#fff" stroke-width="1.8"/>'),
spark: svg('<path d="M12 3l2 6 6 2-6 2-2 6-2-6-6-2 6-2 2-6z" fill="currentColor"/>'),
map: svg('<path d="M9 4 3 6v14l6-2 6 2 6-2V4l-6 2-6-2z" fill="currentColor"/>'),
db: svg('<ellipse cx="12" cy="6" rx="7" ry="3" fill="currentColor"/><path d="M5 6v12c0 1.6 3.1 3 7 3s7-1.4 7-3V6" stroke="currentColor" stroke-width="2" fill="none"/>')
};
// ---------- Data ----------
var INTEGRATIONS = [
{ id: "salescloud", name: "SaleCloud CRM", cat: "CRM", color: "#2563eb", mark: "cloud", installs: "8,420", rating: "4.8",
blurb: "Sync contacts, deals and pipeline stages two ways in real time.",
features: ["Two-way contact & deal sync", "Map custom fields to records", "Trigger workflows on stage change"],
perms: [["Read contacts & accounts", "View names, emails and company records"], ["Write deal updates", "Create and update opportunities"]] },
{ id: "pipelead", name: "PipeLead", cat: "CRM", color: "#0ea5e9", mark: "grid", installs: "3,190", rating: "4.6",
blurb: "Push qualified leads straight into your sales pipeline automatically.",
features: ["Auto-route new leads by owner", "Dedupe against existing records", "Activity timeline sync"],
perms: [["Read pipeline data", "View stages and deal values"], ["Create leads", "Add new lead records on your behalf"]] },
{ id: "slacknest", name: "SlackNest", cat: "Comms", color: "#7c3aed", mark: "chat", installs: "12,840", rating: "4.9",
blurb: "Send alerts, daily digests and approval requests to any channel.",
features: ["Route alerts to channels", "Interactive approve / reject buttons", "Daily summary digests"],
perms: [["Post messages", "Send messages to selected channels"], ["Read channel list", "List channels for routing rules"]] },
{ id: "ringline", name: "RingLine", cat: "Comms", color: "#db2777", mark: "ticket", installs: "2,070", rating: "4.4",
blurb: "Log calls and SMS conversations against contacts automatically.",
features: ["Auto-log inbound & outbound calls", "Attach recordings to records", "SMS reply notifications"],
perms: [["Read call logs", "Access call metadata and recordings"], ["Match contacts", "Link calls to existing contacts"]] },
{ id: "postmark", name: "MailBeam", cat: "Comms", color: "#ea580c", mark: "mail", installs: "6,510", rating: "4.7",
blurb: "Reliable transactional email delivery with open and click tracking.",
features: ["Transactional email sending", "Open & click event webhooks", "Bounce and complaint handling"],
perms: [["Send email", "Deliver transactional emails on your behalf"], ["Read delivery events", "Access bounce and open data"]] },
{ id: "gitforge", name: "GitForge", cat: "Dev", color: "#0f172a", mark: "code", installs: "9,930", rating: "4.9",
blurb: "Link commits and pull requests to issues and deployment status.",
features: ["Link PRs to work items", "Deploy status in your dashboard", "Comment sync on commits"],
perms: [["Read repositories", "View repos, commits and PR metadata"], ["Write status checks", "Post commit and deploy statuses"]] },
{ id: "shiprunner", name: "ShipRunner CI", cat: "Dev", color: "#059669", mark: "bolt", installs: "4,260", rating: "4.5",
blurb: "Stream build and deploy events into your activity feed instantly.",
features: ["Live build & deploy events", "Failure alerts to comms apps", "Rollback notifications"],
perms: [["Read pipelines", "View build and deploy history"], ["Receive webhooks", "Get notified on pipeline events"]] },
{ id: "vaultkeys", name: "VaultKeys", cat: "Dev", color: "#4f46e5", mark: "db", installs: "1,540", rating: "4.6",
blurb: "Securely store and rotate the secrets your integrations rely on.",
features: ["Encrypted secret storage", "Automatic key rotation", "Per-environment scoping"],
perms: [["Manage secrets", "Read and rotate stored credentials"]] },
{ id: "metricflow", name: "MetricFlow", cat: "Analytics", color: "#d97706", mark: "chart", installs: "7,380", rating: "4.8",
blurb: "Pipe product events into dashboards and funnels with zero code.",
features: ["Event & funnel tracking", "Cohort and retention charts", "Scheduled metric exports"],
perms: [["Read event stream", "Access product usage events"], ["Read user properties", "View aggregated profile traits"]] },
{ id: "insightlens", name: "InsightLens", cat: "Analytics", color: "#0891b2", mark: "spark", installs: "3,640", rating: "4.5",
blurb: "Turn raw usage data into shareable insight reports automatically.",
features: ["Auto-generated insight reports", "Anomaly detection alerts", "Embed charts anywhere"],
perms: [["Read analytics data", "Query aggregated metrics"]] },
{ id: "geomap", name: "GeoMap", cat: "Analytics", color: "#16a34a", mark: "map", installs: "1,210", rating: "4.3",
blurb: "Visualize accounts and revenue on an interactive territory map.",
features: ["Account location mapping", "Territory revenue heatmaps", "Region filtering"],
perms: [["Read account locations", "Access billing addresses and regions"]] },
{ id: "stripepay", name: "PayBridge", cat: "Billing", color: "#635bff", mark: "card", installs: "10,720", rating: "4.9",
blurb: "Sync subscriptions, invoices and payment status with your billing.",
features: ["Subscription & invoice sync", "Failed payment recovery", "Revenue reporting"],
perms: [["Read charges & subscriptions", "View payment and plan data"], ["Write invoice events", "Update billing status on records"]] },
{ id: "ledgerly", name: "Ledgerly", cat: "Billing", color: "#be123c", mark: "ticket", installs: "2,880", rating: "4.4",
blurb: "Reconcile invoices with your accounting ledger every night.",
features: ["Nightly invoice reconciliation", "Tax category mapping", "Export to accounting"],
perms: [["Read invoices", "Access invoice line items and totals"]] }
];
var CATS = ["CRM", "Comms", "Dev", "Analytics", "Billing"];
// ---------- State ----------
var state = { q: "", cat: "All", connected: {} };
// seed a couple as connected
state.connected["gitforge"] = true;
state.connected["slacknest"] = true;
// ---------- DOM refs ----------
var $ = function (id) { return document.getElementById(id); };
var grid = $("grid"), chipsEl = $("chips"), empty = $("empty");
var searchInput = $("search"), searchClear = $("searchClear");
var connPill = $("connPill"), toastHost = $("toastHost");
// ---------- Toast ----------
function toast(msg, kind) {
var t = document.createElement("div");
t.className = "toast" + (kind ? " " + kind : "");
t.innerHTML = '<span class="dot"></span><span>' + msg + "</span>";
toastHost.appendChild(t);
setTimeout(function () {
t.classList.add("out");
t.addEventListener("animationend", function () { t.remove(); });
}, 2400);
}
// ---------- Helpers ----------
function countConnected() {
var n = 0;
for (var k in state.connected) if (state.connected[k]) n++;
return n;
}
function updateConnPill() {
var n = countConnected();
connPill.textContent = n + " connected";
}
function catCount(cat) {
if (cat === "All") return INTEGRATIONS.length;
return INTEGRATIONS.filter(function (i) { return i.cat === cat; }).length;
}
function matches(item) {
if (state.cat !== "All" && item.cat !== state.cat) return false;
if (state.q) {
var hay = (item.name + " " + item.cat + " " + item.blurb).toLowerCase();
if (hay.indexOf(state.q) === -1) return false;
}
return true;
}
// ---------- Chips ----------
function renderChips() {
chipsEl.innerHTML = "";
var all = ["All"].concat(CATS);
all.forEach(function (cat) {
var b = document.createElement("button");
b.className = "chip";
b.type = "button";
b.setAttribute("aria-pressed", state.cat === cat ? "true" : "false");
b.innerHTML = cat + ' <span class="chip-count">' + catCount(cat) + "</span>";
b.addEventListener("click", function () {
state.cat = cat;
renderChips();
renderGrid();
});
chipsEl.appendChild(b);
});
}
// ---------- Grid ----------
function renderGrid() {
var list = INTEGRATIONS.filter(matches);
grid.innerHTML = "";
if (!list.length) {
grid.hidden = true;
empty.hidden = false;
return;
}
grid.hidden = false;
empty.hidden = true;
list.forEach(function (item, i) {
var on = !!state.connected[item.id];
var card = document.createElement("article");
card.className = "card";
card.style.animationDelay = Math.min(i * 28, 280) + "ms";
card.innerHTML =
'<div class="card-top">' +
'<span class="logo" style="background:linear-gradient(135deg,' + item.color + ',' + shade(item.color) + ')">' + MARKS[item.mark] + "</span>" +
(on ? '<span class="connected-tag">Connected</span>' : "") +
"</div>" +
'<div class="card-name">' + item.name + "</div>" +
'<span class="card-cat">' + item.cat + "</span>" +
'<p class="card-blurb">' + item.blurb + "</p>" +
'<div class="card-actions">' +
'<button class="btn btn-ghost" data-act="details">Details</button>' +
'<button class="btn btn-connect" data-act="connect" data-on="' + on + '">' + (on ? "Disconnect" : "Connect") + "</button>" +
"</div>";
card.querySelector('[data-act="details"]').addEventListener("click", function () { openDrawer(item.id); });
card.querySelector('[data-act="connect"]').addEventListener("click", function () { toggleConnect(item.id); });
grid.appendChild(card);
});
}
function shade(hex) {
// darken a hex color ~18%
var c = hex.replace("#", "");
if (c.length === 3) c = c[0] + c[0] + c[1] + c[1] + c[2] + c[2];
var r = parseInt(c.slice(0, 2), 16), g = parseInt(c.slice(2, 4), 16), b = parseInt(c.slice(4, 6), 16);
var f = 0.78;
function h(v) { return ("0" + Math.round(v * f).toString(16)).slice(-2); }
return "#" + h(r) + h(g) + h(b);
}
// ---------- Connect toggle ----------
function toggleConnect(id) {
var item = INTEGRATIONS.filter(function (x) { return x.id === id; })[0];
var nowOn = !state.connected[id];
state.connected[id] = nowOn;
if (nowOn) toast(item.name + " connected");
else toast(item.name + " disconnected", "warn");
updateConnPill();
renderGrid();
if (!$("drawer").hidden && drawerId === id) syncDrawerButton(id);
}
// ---------- Drawer ----------
var drawerId = null, lastFocus = null;
function openDrawer(id) {
var item = INTEGRATIONS.filter(function (x) { return x.id === id; })[0];
if (!item) return;
drawerId = id;
lastFocus = document.activeElement;
$("drawerLogo").style.background = "linear-gradient(135deg," + item.color + "," + shade(item.color) + ")";
$("drawerLogo").innerHTML = MARKS[item.mark];
$("drawerName").textContent = item.name;
$("drawerCat").textContent = item.cat + " integration";
$("drawerBlurb").textContent = item.blurb;
$("drawerInstalls").textContent = item.installs;
$("drawerRating").textContent = item.rating + " ★";
var feat = $("drawerFeatures");
feat.innerHTML = "";
item.features.forEach(function (f) {
var li = document.createElement("li");
li.innerHTML = '<span class="tick"><svg viewBox="0 0 24 24" width="12" height="12"><path d="M5 12l4 4 10-10" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg></span><span>' + f + "</span>";
feat.appendChild(li);
});
var perms = $("drawerPerms");
perms.innerHTML = "";
item.perms.forEach(function (p) {
var li = document.createElement("li");
li.innerHTML = '<span class="lock"><svg viewBox="0 0 24 24" width="14" height="14"><rect x="5" y="11" width="14" height="9" rx="2" fill="currentColor"/><path d="M8 11V8a4 4 0 0 1 8 0v3" fill="none" stroke="currentColor" stroke-width="2"/></svg></span><span><span class="txt">' + p[0] + "</span><small>" + p[1] + "</small></span>";
perms.appendChild(li);
});
syncDrawerButton(id);
$("scrim").hidden = false;
var d = $("drawer");
d.hidden = false;
d.focus();
document.addEventListener("keydown", onKey);
}
function syncDrawerButton(id) {
var on = !!state.connected[id];
var btn = $("drawerConnect");
btn.textContent = on ? "Disconnect integration" : "Connect integration";
btn.setAttribute("data-on", on);
}
function closeDrawer() {
$("drawer").hidden = true;
$("scrim").hidden = true;
drawerId = null;
document.removeEventListener("keydown", onKey);
if (lastFocus && lastFocus.focus) lastFocus.focus();
}
function onKey(e) {
if (e.key === "Escape") closeDrawer();
}
$("drawerClose").addEventListener("click", closeDrawer);
$("scrim").addEventListener("click", closeDrawer);
$("drawerConnect").addEventListener("click", function () {
if (drawerId) toggleConnect(drawerId);
});
// ---------- Search ----------
searchInput.addEventListener("input", function () {
state.q = searchInput.value.trim().toLowerCase();
searchClear.hidden = !searchInput.value;
renderGrid();
});
searchClear.addEventListener("click", function () {
searchInput.value = "";
state.q = "";
searchClear.hidden = true;
searchInput.focus();
renderGrid();
});
$("emptyReset").addEventListener("click", function () {
searchInput.value = "";
state.q = "";
state.cat = "All";
searchClear.hidden = true;
renderChips();
renderGrid();
});
// ---------- Theme ----------
var themeBtn = $("themeToggle");
themeBtn.addEventListener("click", function () {
var dark = document.documentElement.getAttribute("data-theme") === "dark";
document.documentElement.setAttribute("data-theme", dark ? "light" : "dark");
themeBtn.setAttribute("aria-pressed", String(!dark));
themeBtn.querySelector(".theme-ico").textContent = dark ? "☾" : "☀";
});
// ---------- Init ----------
renderChips();
renderGrid();
updateConnPill();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Northwind — Integrations Directory</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<a class="skip" href="#catalog">Skip to integrations</a>
<header class="topbar" role="banner">
<div class="brand">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22"><path d="M4 13l5 5 11-12" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
<span class="brand-name">Northwind</span>
<span class="brand-sub">Integrations</span>
</div>
<div class="topbar-actions">
<span class="conn-pill" id="connPill" aria-live="polite">0 connected</span>
<button class="theme-toggle" id="themeToggle" type="button" aria-pressed="false" aria-label="Toggle dark mode">
<span class="theme-ico" aria-hidden="true">☾</span>
</button>
</div>
</header>
<main id="catalog">
<section class="hero" aria-labelledby="heroTitle">
<h1 id="heroTitle">Connect your stack</h1>
<p class="hero-sub" id="heroSub">Browse integrations across CRM, comms, dev tooling, analytics and billing. Connect in one click — no code required.</p>
<div class="search-wrap" role="search">
<span class="search-ico" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18"><circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="2"/><path d="M21 21l-4.3-4.3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</span>
<input id="search" type="search" placeholder="Search integrations…" aria-label="Search integrations" autocomplete="off" />
<button class="search-clear" id="searchClear" type="button" aria-label="Clear search" hidden>×</button>
</div>
</section>
<nav class="chips" id="chips" aria-label="Filter by category"></nav>
<section class="grid" id="grid" aria-label="Integrations" aria-live="polite"></section>
<div class="empty" id="empty" hidden>
<div class="empty-art" aria-hidden="true">
<svg viewBox="0 0 64 64" width="56" height="56"><rect x="10" y="14" width="44" height="36" rx="6" fill="none" stroke="currentColor" stroke-width="2.5"/><path d="M10 24h44M22 36h20" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></svg>
</div>
<h2>No integrations found</h2>
<p>Try a different search term or clear your filters.</p>
<button class="btn-ghost" id="emptyReset" type="button">Reset filters</button>
</div>
</main>
<!-- Detail drawer -->
<div class="drawer-scrim" id="scrim" hidden></div>
<aside class="drawer" id="drawer" role="dialog" aria-modal="true" aria-labelledby="drawerName" hidden tabindex="-1">
<header class="drawer-head">
<div class="drawer-id">
<span class="logo logo-lg" id="drawerLogo" aria-hidden="true"></span>
<div>
<h2 id="drawerName">Integration</h2>
<span class="drawer-cat" id="drawerCat"></span>
</div>
</div>
<button class="drawer-close" id="drawerClose" type="button" aria-label="Close panel">×</button>
</header>
<div class="drawer-body">
<p class="drawer-blurb" id="drawerBlurb"></p>
<div class="drawer-meta">
<div class="meta-stat"><span class="meta-num" id="drawerInstalls"></span><span class="meta-lbl">teams using</span></div>
<div class="meta-stat"><span class="meta-num" id="drawerRating"></span><span class="meta-lbl">avg rating</span></div>
</div>
<h3 class="drawer-h">What it does</h3>
<ul class="feat-list" id="drawerFeatures"></ul>
<h3 class="drawer-h">Permissions requested</h3>
<ul class="perm-list" id="drawerPerms"></ul>
</div>
<footer class="drawer-foot">
<button class="btn-connect" id="drawerConnect" type="button"></button>
</footer>
</aside>
<div class="toast-host" id="toastHost" aria-live="assertive" aria-atomic="true"></div>
<script src="script.js"></script>
</body>
</html>Integrations Directory
A self-contained integrations catalog for a fictional SaaS workspace called Northwind. A sticky top bar tracks how many integrations are connected and toggles a light or dark theme. The hero hosts a live search box, and a row of category chips (CRM, Comms, Dev, Analytics, Billing) each show a running count of matching integrations.
The grid renders integration cards built from inline SVG logo marks, a name, category and blurb, plus a Connect button and a connected badge. Typing in the search box or picking a chip filters the grid instantly; when nothing matches, an intentional empty state offers to reset the filters. Connecting or disconnecting flips the card state, updates the header counter, and fires a toast.
Selecting Details opens a slide-in drawer describing what the integration does, the permissions it requests, and headline usage stats. The drawer’s connect button stays in sync with the card, closes on Escape or scrim click, and restores focus to where you left off.
Illustrative SaaS UI only — fictional product, metrics, and billing. No real backend.