Integration directory
A full directory of connected tools with neutral drawn monograms instead of logos: a searchable catalog, one integration page, an A to Z index and the in-app view with switches.
Every connection, easy to find
An integration directory answers whether your product works with the tools people already use. This one draws a monogram for every tool instead of using foreign logos, so the directory looks consistent and needs no permission to show a brand.
Catalog with search puts the search on top and categories with live counts in a side rail that becomes a chip row on phones, with a card grid and an empty state that asks what is missing. Integration page tells one integration fully, both marks joined by a line that lights up on connect, what it does, the setup steps, the data it can see and related tools in a sticky side column. A to Z index shows featured tools on top and every entry grouped by letter, the alphabet bar sticks while scrolling and marks the letter in view. Manage connections is the in-app view with tabs for all and connected, a search and real switches that connect with a short busy state.
Let the empty search state ask what is missing, the answers tell you which integration to build next. Say on every integration page which data it can see, that answers the security question before it is asked. Use the same names in the directory and in the app.
Variants
Details
What Integration directory brings along, read from the registry and the source.
- Category
- Integrations
- Area
- Content
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/integration-directory.tsx
- npm packages
- None
- Languages
- English and German, 30 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The IntegrationDirectoryProps interface from components/ui/integration-directory.tsx, with the comments from the code.
variantoptionalIntegrationDirectoryVariantcatalogdetaila-zmanage
eyebrowoptionalReact.ReactNodetitlerequiredReact.ReactNodedescriptionoptionalReact.ReactNodeitemsrequiredIntegrationItem[]categoriesoptionalstring[]Order of the categories, derived from the items otherwise
featuredoptionalstring[]a-z: names shown large on top
detailoptionalIntegrationDetaildetail: the integration is items[0], this is its page
requestoptional{ label: string; href: string }Where to ask for a missing integration
Built-in texts
Integration directory writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 30 texts
| Key | English | German |
|---|---|---|
| search | English Search integrations | German Integrationen durchsuchen |
| searchPh | English Search by name or what it does | German Nach Name oder Aufgabe suchen |
| all | English All | German Alle |
| categories | English Categories | German Kategorien |
| results | English {n} integrations | German {n} Integrationen |
| result1 | English 1 integration | German 1 Integration |
| empty | English Nothing matches “{q}” yet. | German Zu „{q}“ passt noch nichts. |
| emptyHint | English Tell us what you use, we build the most requested ones first. | German Sag uns, was du nutzt. Die meistgewünschten bauen wir zuerst. |
| clear | English Clear search | German Suche leeren |
| popular | English Popular | German Beliebt |
| new | English New | German Neu |
| beta | English Beta | German Beta |
| connect | English Connect | German Verbinden |
| connecting | English Connecting | German Wird verbunden |
| connected | English Connected | German Verbunden |
| disconnect | English Disconnect | German Trennen |
| configure | English Settings | German Einstellungen |
| synced | English Synced just now | German Gerade abgeglichen |
| what | English What it does | German Was es kann |
| setup | English Set up in {n} steps | German In {n} Schritten eingerichtet |
| permissions | English What it can see | German Worauf es zugreift |
| about | English About | German Steckbrief |
| related | English Works well with | German Passt gut zu |
| jump | English Jump to letter | German Zum Buchstaben springen |
| featured | English Featured | German Empfohlen |
| tabAll | English All | German Alle |
| tabOn | English Connected | German Verbunden |
| available | English Available | German Verfügbar |
| noneOn | English Nothing connected yet. Switch on what you use below. | German Noch nichts verbunden. Schalte unten ein, was du nutzt. |
| open | English View {name} | German {name} ansehen |
Accessibility
Read from the source of the component.
- Integration directory handles the keys Arrow left, Arrow right and Space itself.
- Sets aria-busy, aria-checked, aria-controls, aria-current, aria-label, aria-labelledby, aria-live, aria-pressed and aria-selected, so screen readers announce purpose and state.
- Uses the roles switch, tab, tablist and tabpanel.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Moves with the scroll, its progress follows the position on the page.
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 4 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 769 lines
'use client';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useInView, useLocale, useTexts } from '@/lib/kit';
import { useScrollFade } from '@/components/ui/motion';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Eyebrow, Section } from '@/components/ui/section';
/*
* A full directory of the tools a product connects to. Services never show a
* third-party logo: every entry gets a neutral monogram, a tile with initials
* and a small drawn shape picked from its name, so a list stays calm and
* nobody's trademark sits in the kit. Pass `mark` to use your own artwork.
*
* catalog search, categories in a side rail (chips on phones), card grid, empty state
* detail one integration: the connection drawn, what it does, setup, permissions
* a-z an alphabet bar that jumps, featured on top, grouped columns
* manage the in-app view: connected and available, switches with real states
*/
export type IntegrationDirectoryVariant = 'catalog' | 'detail' | 'a-z' | 'manage';
export interface IntegrationItem {
name: string;
category: string;
description: string;
badge?: 'popular' | 'new' | 'beta';
/** manage: starts connected */
connected?: boolean;
href?: string;
/** Your own mark instead of the drawn monogram */
mark?: React.ReactNode;
}
export interface IntegrationDetail {
/** Your product, shown on the left of the connection */
product: string;
features: { icon?: IconName; title: string; text?: string }[];
steps: string[];
permissions: string[];
facts: { label: string; value: string }[];
docs?: { label: string; href: string };
}
export interface IntegrationDirectoryProps {
variant?: IntegrationDirectoryVariant;
eyebrow?: React.ReactNode;
title: React.ReactNode;
description?: React.ReactNode;
items: IntegrationItem[];
/** Order of the categories, derived from the items otherwise */
categories?: string[];
/** a-z: names shown large on top */
featured?: string[];
/** detail: the integration is items[0], this is its page */
detail?: IntegrationDetail;
/** Where to ask for a missing integration */
request?: { label: string; href: string };
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
search: 'Search integrations',
searchPh: 'Search by name or what it does',
all: 'All',
categories: 'Categories',
results: '{n} integrations',
result1: '1 integration',
empty: 'Nothing matches “{q}” yet.',
emptyHint: 'Tell us what you use, we build the most requested ones first.',
clear: 'Clear search',
popular: 'Popular',
new: 'New',
beta: 'Beta',
connect: 'Connect',
connecting: 'Connecting',
connected: 'Connected',
disconnect: 'Disconnect',
configure: 'Settings',
synced: 'Synced just now',
what: 'What it does',
setup: 'Set up in {n} steps',
permissions: 'What it can see',
about: 'About',
related: 'Works well with',
jump: 'Jump to letter',
featured: 'Featured',
tabAll: 'All',
tabOn: 'Connected',
available: 'Available',
noneOn: 'Nothing connected yet. Switch on what you use below.',
open: 'View {name}',
},
de: {
search: 'Integrationen durchsuchen',
searchPh: 'Nach Name oder Aufgabe suchen',
all: 'Alle',
categories: 'Kategorien',
results: '{n} Integrationen',
result1: '1 Integration',
empty: 'Zu „{q}“ passt noch nichts.',
emptyHint: 'Sag uns, was du nutzt. Die meistgewünschten bauen wir zuerst.',
clear: 'Suche leeren',
popular: 'Beliebt',
new: 'Neu',
beta: 'Beta',
connect: 'Verbinden',
connecting: 'Wird verbunden',
connected: 'Verbunden',
disconnect: 'Trennen',
configure: 'Einstellungen',
synced: 'Gerade abgeglichen',
what: 'Was es kann',
setup: 'In {n} Schritten eingerichtet',
permissions: 'Worauf es zugreift',
about: 'Steckbrief',
related: 'Passt gut zu',
jump: 'Zum Buchstaben springen',
featured: 'Empfohlen',
tabAll: 'Alle',
tabOn: 'Verbunden',
available: 'Verfügbar',
noneOn: 'Noch nichts verbunden. Schalte unten ein, was du nutzt.',
open: '{name} ansehen',
},
};
type Texts = (typeof TEXTS)['en'];
type ViewProps = IntegrationDirectoryProps & { t: Texts };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
const delay = (ms: number) => ({ '--kit-delay': `${ms}ms` }) as React.CSSProperties;
const hash = (s: string) => [...s].reduce((a, c) => (a * 31 + c.charCodeAt(0)) >>> 0, 7);
const initials = (name: string) => {
const w = name.split(/[\s-]+/).filter(Boolean);
return (w.length > 1 ? w[0][0] + w[1][0] : name.slice(0, 2)).toUpperCase();
};
/* Six small shapes, drawn on a 20 px grid, one per service picked from its name */
const SHAPES = [
<circle key="c" cx="10" cy="10" r="4.5" />,
<rect key="s" x="5.5" y="5.5" width="9" height="9" rx="2" />,
<path key="t" d="M10 4.5 15.5 14.5h-11Z" />,
<path key="d" d="m10 4 6 6-6 6-6-6Z" />,
<path key="h" d="M6 5h8M6 10h8M6 15h8" />,
<path key="a" d="M4.5 14a5.5 5.5 0 0 1 11 0" />,
];
/** A neutral mark for a service: a tinted tile, its initials and one drawn shape */
export function IntegrationMark({ name, mark, size = 'md', className }: { name: string; mark?: React.ReactNode; size?: 'sm' | 'md' | 'lg'; className?: string }) {
const h = hash(name);
const box = size === 'lg' ? 'size-16 rounded-2xl text-[19px]' : size === 'sm' ? 'size-9 rounded-lg text-[12px]' : 'size-11 rounded-xl text-[14px]';
if (mark) return <span className={cn('grid shrink-0 place-items-center overflow-hidden border border-border bg-card', box, className)}>{mark}</span>;
const mixes = [14, 22, 30, 18, 26];
return (
<span
aria-hidden
className={cn('relative grid shrink-0 place-items-center overflow-hidden border border-[color-mix(in_oklab,var(--foreground)_10%,transparent)] font-semibold tracking-[-0.02em] text-foreground shadow-[var(--highlight)]', box, className)}
style={{ background: `color-mix(in oklab, var(--primary) ${mixes[h % mixes.length]}%, var(--card))` }}
>
<svg viewBox="0 0 20 20" className="absolute bottom-0.5 right-0.5 size-[34%] text-[color-mix(in_oklab,var(--primary)_55%,var(--foreground))] opacity-50" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
{SHAPES[(h >> 3) % SHAPES.length]}
</svg>
<span className="relative">{initials(name)}</span>
</span>
);
}
function Badge({ kind, t }: { kind?: IntegrationItem['badge']; t: Texts }) {
if (!kind) return null;
return (
<span
className={cn(
'inline-flex h-5 shrink-0 items-center rounded-full px-2 text-[11px] font-medium',
kind === 'new' && 'bg-primary text-primary-foreground',
kind === 'popular' && 'bg-[color-mix(in_oklab,var(--primary)_12%,transparent)] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]',
kind === 'beta' && 'border border-border-strong text-muted-foreground',
)}
>
{t[kind]}
</span>
);
}
function Head({ eyebrow, title, description, center, className }: Pick<IntegrationDirectoryProps, 'eyebrow' | 'title' | 'description'> & { center?: boolean; className?: string }) {
return (
<div className={cn('max-w-[46rem]', center && 'mx-auto max-w-[42rem] text-center', className)}>
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
<h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{title}</h2>
{description && (
<p className={cn('kit-rise mt-5 max-w-[36rem] text-pretty text-lead text-muted-foreground', center && 'mx-auto')} style={delay(80)}>
{description}
</p>
)}
</div>
);
}
function SearchBox({ value, onChange, t, className }: { value: string; onChange: (v: string) => void; t: Texts; className?: string }) {
const id = useId();
return (
<div className={cn('relative', className)}>
<label htmlFor={id} className="sr-only">
{t.search}
</label>
<Icon name="search" size={18} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
id={id}
type="search"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={t.searchPh}
autoComplete="off"
className="h-12 w-full rounded-xl border border-border bg-card pl-11 pr-11 text-base text-foreground shadow-[var(--highlight),var(--shadow-sm)] placeholder:text-subtle-foreground transition-[border-color,box-shadow] hover:border-border-strong focus-visible:border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] focus-visible:shadow-[0_0_0_4px_var(--glow)] focus-visible:outline-none sm:text-[15px] [&::-webkit-search-cancel-button]:hidden"
/>
{value && (
<button type="button" onClick={() => onChange('')} aria-label={t.clear} className="absolute right-1 top-1/2 grid size-10 -translate-y-1/2 cursor-pointer place-items-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary">
<Icon name="x" size={16} />
</button>
)}
</div>
);
}
const match = (it: IntegrationItem, q: string) => {
const s = q.trim().toLowerCase();
return !s || `${it.name} ${it.description} ${it.category}`.toLowerCase().includes(s);
};
function useCategories(p: IntegrationDirectoryProps) {
return useMemo(() => p.categories ?? [...new Set(p.items.map((i) => i.category))], [p.categories, p.items]);
}
function Empty({ q, t, request, onClear }: { q: string; t: Texts; request?: IntegrationDirectoryProps['request']; onClear: () => void }) {
return (
<div className="kit-rise flex flex-col items-center rounded-2xl border border-dashed border-border-strong px-6 py-14 text-center">
<span className="grid size-12 place-items-center rounded-full bg-surface text-muted-foreground">
<Icon name="plug" size={22} />
</span>
<p className="mt-4 text-[16px] font-semibold text-foreground">{fill(t.empty, { q })}</p>
<p className="mt-1.5 max-w-[24rem] text-pretty text-[14px] text-muted-foreground">{t.emptyHint}</p>
<div className="mt-6 flex flex-col gap-2 sm:flex-row">
{request && (
<Button href={request.href} icon="arrow-right">
{request.label}
</Button>
)}
<Button variant="outline" onClick={onClear}>
{t.clear}
</Button>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* catalog */
/* ------------------------------------------------------------------ */
function Card({ it, t, index }: { it: IntegrationItem; t: Texts; index: number }) {
return (
<a
href={it.href ?? '#'}
aria-label={fill(t.open, { name: it.name })}
className="group/card kit-rise relative grid h-full cursor-pointer grid-cols-[auto_minmax(0,1fr)] content-start gap-x-3.5 rounded-2xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:flex sm:flex-col sm:p-5 transition-[transform,box-shadow,border-color] duration-300 ease-[var(--ease-kit)] hover:-translate-y-0.5 hover:border-border-strong hover:shadow-[var(--highlight),var(--shadow-md)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-primary motion-reduce:hover:translate-y-0"
style={delay(Math.min(index, 8) * 40)}
>
<span className="row-span-3 flex items-start justify-between gap-3 sm:row-span-1">
<IntegrationMark name={it.name} mark={it.mark} className="transition-transform duration-300 ease-[var(--ease-kit)] group-hover/card:scale-[1.06] motion-reduce:transition-none" />
<span className="max-sm:hidden"><Badge kind={it.badge} t={t} /></span>
</span>
<span className="flex items-center gap-2 text-[15.5px] font-semibold text-foreground sm:mt-4">
{it.name}
<span className="sm:hidden"><Badge kind={it.badge} t={t} /></span>
</span>
<span className="mt-1 line-clamp-2 text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{it.description}</span>
<span className="mt-auto flex items-center justify-between gap-2 pt-4 text-[12.5px] text-subtle-foreground max-sm:hidden">
{it.category}
<Icon name="arrow-up-right" size={15} className="text-muted-foreground opacity-0 transition-[opacity,transform] duration-300 group-hover/card:translate-x-0.5 group-hover/card:opacity-100 group-focus-visible/card:opacity-100" />
</span>
</a>
);
}
function Catalog(p: ViewProps) {
const { t } = p;
const cats = useCategories(p);
const [cat, setCat] = useState<string | null>(null);
const [q, setQ] = useState('');
const [rowRef, fade] = useScrollFade<HTMLDivElement>();
const list = p.items.filter((it) => (!cat || it.category === cat) && match(it, q));
const count = (c: string | null) => p.items.filter((it) => (!c || it.category === c) && match(it, q)).length;
const choices: (string | null)[] = [null, ...cats];
const name = (c: string | null) => c ?? t.all;
return (
<Section className={p.className}>
<div className="grid grid-cols-[minmax(0,1fr)] gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,24rem)] lg:items-end">
<Head {...p} />
<SearchBox value={q} onChange={setQ} t={t} className="kit-rise" />
</div>
<div className="mt-10 grid grid-cols-[minmax(0,1fr)] gap-8 lg:mt-14 lg:grid-cols-[13rem_minmax(0,1fr)] lg:gap-10">
{/* categories: a rail on desktop, a scroll row of chips on phones and tablets */}
<nav aria-label={t.categories} className="min-w-0">
<p className="mb-3 text-[12px] font-medium uppercase tracking-[0.1em] text-subtle-foreground max-lg:sr-only">{t.categories}</p>
<div ref={rowRef} className={cn('kit-no-scrollbar relative -mx-6 flex gap-2 overflow-x-auto px-6 lg:mx-0 lg:flex-col lg:gap-0.5 lg:overflow-visible lg:px-0', fade, 'lg:[mask-image:none]')}>
{choices.map((c) => {
const on = cat === c;
return (
<button
key={c ?? 'all'}
type="button"
aria-pressed={on}
onClick={() => setCat(c)}
className={cn(
'flex min-h-11 shrink-0 cursor-pointer items-center justify-between gap-3 rounded-full border px-4 text-[14px] font-medium transition-[background-color,border-color,color] duration-200 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary lg:min-h-10 lg:rounded-lg lg:border-transparent lg:px-3',
on ? 'border-foreground bg-foreground text-background lg:bg-accent lg:text-foreground' : 'border-border text-muted-foreground hover:border-border-strong hover:text-foreground lg:hover:bg-surface',
)}
>
<span className="whitespace-nowrap">{name(c)}</span>
<span className={cn('text-[12px] tabular-nums', on ? 'opacity-80' : 'text-subtle-foreground')}>{count(c)}</span>
</button>
);
})}
</div>
</nav>
<div className="min-w-0">
<p aria-live="polite" className="mb-4 text-[13px] text-muted-foreground">
{list.length === 1 ? t.result1 : fill(t.results, { n: list.length })}
</p>
{list.length ? (
<ul className="grid grid-cols-[minmax(0,1fr)] gap-3 sm:grid-cols-2 xl:grid-cols-3">
{list.map((it, i) => (
<li key={it.name} className="min-w-0">
<Card it={it} t={t} index={i} />
</li>
))}
</ul>
) : (
<Empty q={q} t={t} request={p.request} onClear={() => { setQ(''); setCat(null); }} />
)}
</div>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* detail */
/* ------------------------------------------------------------------ */
function Detail(p: ViewProps) {
const { t } = p;
const it = p.items[0];
const d = p.detail;
const [state, setState] = useState<'off' | 'busy' | 'on'>(it?.connected ? 'on' : 'off');
const [ref, inView] = useInView<HTMLDivElement>();
const toggle = () => {
if (state === 'busy') return;
if (state === 'on') return setState('off');
setState('busy');
window.setTimeout(() => setState('on'), prefersReducedMotion() ? 0 : 1200);
};
if (!it || !d) return null;
const related = p.items.slice(1, 4);
return (
<Section className={p.className}>
<div className="grid grid-cols-[minmax(0,1fr)] gap-12 lg:grid-cols-[minmax(0,1fr)_minmax(0,21rem)] lg:gap-16">
<div className="min-w-0">
{/* the connection: our mark, a line with travelling dots, their mark */}
<div ref={ref} className="kit-rise flex items-center gap-3">
<IntegrationMark name={d.product} size="lg" />
<span aria-hidden className="relative h-px w-16 bg-border [--id-w:4rem] sm:w-24 sm:[--id-w:6rem]">
<span className={cn('absolute inset-0 origin-left bg-primary transition-transform duration-700 ease-[var(--ease-kit)]', state === 'on' ? 'scale-x-100' : 'scale-x-0')} />
{state !== 'off' && inView && (
<>
<span className="absolute -top-[3px] left-0 size-[7px] animate-[id-travel_1.6s_linear_infinite] rounded-full bg-primary shadow-[0_0_8px_var(--glow)] motion-reduce:hidden" />
<span className="absolute -top-[3px] left-0 size-[7px] animate-[id-travel_1.6s_linear_.8s_infinite] rounded-full bg-primary opacity-60 motion-reduce:hidden" />
</>
)}
<span className={cn('absolute left-1/2 top-1/2 grid size-6 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border bg-background transition-colors duration-300', state === 'on' ? 'border-primary text-primary' : 'border-border text-muted-foreground')}>
<Icon name={state === 'on' ? 'check' : 'plug'} size={12} strokeWidth={2} />
</span>
</span>
<IntegrationMark name={it.name} mark={it.mark} size="lg" />
<style>{'@keyframes id-travel{from{transform:translateX(0);opacity:0}15%{opacity:1}85%{opacity:1}to{transform:translateX(var(--id-w,5.5rem));opacity:0}}'}</style>
</div>
<div className="mt-8 flex flex-wrap items-center gap-3">
<h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{p.title}</h2>
<Badge kind={it.badge} t={t} />
</div>
{p.description && <p className="kit-rise mt-4 max-w-[38rem] text-pretty text-lead text-muted-foreground" style={delay(80)}>{p.description}</p>}
<div className="kit-rise mt-8 flex flex-col gap-3 sm:flex-row sm:items-center" style={delay(140)}>
<Button size="lg" onClick={toggle} variant={state === 'on' ? 'outline' : 'primary'} iconLeft={state === 'on' ? 'check' : state === 'off' ? 'plug' : undefined} aria-live="polite" className="max-sm:w-full">
{state === 'busy' && <Icon name="loader" size={17} className="animate-kit-spin [--kit-duration:0.8s]" />}
{state === 'busy' ? t.connecting : state === 'on' ? t.connected : t.connect}
</Button>
{state === 'on' && (
<button type="button" onClick={toggle} className="kit-rise inline-flex min-h-11 cursor-pointer items-center justify-center rounded-lg px-3 text-[14px] font-medium text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary">
{t.disconnect}
</button>
)}
{d.docs && (
<a href={d.docs.href} className="inline-flex min-h-11 cursor-pointer items-center justify-center gap-1.5 text-[14px] font-medium text-foreground underline-offset-4 hover:underline sm:ml-2">
{d.docs.label}
<Icon name="external" size={15} />
</a>
)}
</div>
<h3 className="mt-14 text-[13px] font-medium uppercase tracking-[0.1em] text-subtle-foreground">{t.what}</h3>
<ul className="mt-5 grid gap-px overflow-hidden rounded-2xl border border-border bg-border sm:grid-cols-2">
{d.features.map((f, i) => (
<li key={i} className="bg-card p-5">
<div className="kit-rise" style={delay(i * 70)}>
<Icon name={f.icon ?? 'check'} size={19} className="text-primary" />
<p className="mt-3 text-[15px] font-semibold text-foreground">{f.title}</p>
{f.text && <p className="mt-1 text-pretty text-[14px] leading-relaxed text-muted-foreground">{f.text}</p>}
</div>
</li>
))}
</ul>
<h3 className="mt-14 text-[13px] font-medium uppercase tracking-[0.1em] text-subtle-foreground">{fill(t.setup, { n: d.steps.length })}</h3>
<ol className="relative mt-5 grid gap-5">
<span aria-hidden className="absolute bottom-3 left-[15px] top-3 w-px bg-border" />
{d.steps.map((s, i) => (
<li key={i} className="relative flex items-start gap-4">
<span className="relative grid size-[31px] shrink-0 place-items-center rounded-full border border-border bg-card text-[13px] font-semibold text-foreground">{i + 1}</span>
<p className="pt-1 text-pretty text-[15px] leading-relaxed text-foreground">{s}</p>
</li>
))}
</ol>
</div>
<aside className="grid content-start gap-4 lg:sticky lg:top-24">
<div className="kit-rise rounded-2xl border border-border bg-card p-6 shadow-[var(--highlight),var(--shadow-sm)]" style={delay(160)}>
<h3 className="text-[14px] font-semibold text-foreground">{t.about}</h3>
<dl className="mt-4 grid gap-3 text-[14px]">
{d.facts.map((f) => (
<div key={f.label} className="flex justify-between gap-4 border-b border-border pb-3 last:border-b-0 last:pb-0">
<dt className="text-muted-foreground">{f.label}</dt>
<dd className="text-right font-medium text-foreground">{f.value}</dd>
</div>
))}
</dl>
</div>
<div className="kit-rise rounded-2xl border border-border bg-card p-6 shadow-[var(--highlight),var(--shadow-sm)]" style={delay(220)}>
<h3 className="flex items-center gap-2 text-[14px] font-semibold text-foreground">
<Icon name="shield" size={16} className="text-primary" />
{t.permissions}
</h3>
<ul className="mt-4 grid gap-2.5 text-[14px] text-muted-foreground">
{d.permissions.map((x, i) => (
<li key={i} className="flex gap-2.5">
<Icon name="eye" size={15} className="mt-0.5 shrink-0 text-subtle-foreground" />
<span className="text-pretty">{x}</span>
</li>
))}
</ul>
</div>
{related.length > 0 && (
<div className="kit-rise rounded-2xl border border-border bg-card p-2 shadow-[var(--highlight),var(--shadow-sm)]" style={delay(280)}>
<h3 className="px-4 pb-1 pt-4 text-[14px] font-semibold text-foreground">{t.related}</h3>
<ul>
{related.map((r) => (
<li key={r.name}>
<a href={r.href ?? '#'} className="group/rel flex min-h-14 cursor-pointer items-center gap-3 rounded-xl px-4 py-2 transition-colors hover:bg-surface focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary">
<IntegrationMark name={r.name} mark={r.mark} size="sm" />
<span className="min-w-0 flex-1">
<span className="block truncate text-[14px] font-medium text-foreground">{r.name}</span>
<span className="block truncate text-[12.5px] text-muted-foreground">{r.category}</span>
</span>
<Icon name="chevron-right" size={16} className="text-subtle-foreground transition-transform group-hover/rel:translate-x-0.5" />
</a>
</li>
))}
</ul>
</div>
)}
</aside>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* a-z */
/* ------------------------------------------------------------------ */
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
function AZ(p: ViewProps) {
const { t } = p;
const base = useId().replace(/:/g, '');
const locale = useLocale();
const [q, setQ] = useState('');
const sorted = useMemo(() => [...p.items].sort((a, b) => a.name.localeCompare(b.name, locale)), [p.items, locale]);
const list = sorted.filter((it) => match(it, q));
const groups = ALPHABET.map((L) => ({ L, items: list.filter((it) => it.name[0]?.toUpperCase() === L) }));
const rest = list.filter((it) => !ALPHABET.includes(it.name[0]?.toUpperCase() ?? ''));
if (rest.length) groups.push({ L: '#', items: rest });
const featured = (p.featured ?? []).map((n) => p.items.find((i) => i.name === n)).filter(Boolean) as IntegrationItem[];
const [active, setActive] = useState<string | null>(null);
// a click on a letter holds that letter while the page scrolls there, the observer takes over afterwards
const hold = useRef(0);
const [barRef, fade] = useScrollFade<HTMLDivElement>();
useEffect(() => {
const els = groups.filter((g) => g.items.length).map((g) => document.getElementById(`${base}-${g.L}`)).filter(Boolean) as HTMLElement[];
if (!els.length) return;
// the letter in view is the last section whose top has passed a quarter of the window, none above the list
let frame = 0;
const read = () => {
frame = 0;
if (Date.now() < hold.current) return;
const line = window.innerHeight * 0.25;
const passed = els.filter((el) => el.getBoundingClientRect().top <= line);
const last = passed[passed.length - 1];
const end = last && last.getBoundingClientRect().bottom < 0;
setActive(last && !end ? (last.id.split('-').pop() ?? null) : null);
};
const onScroll = () => { if (!frame) frame = window.requestAnimationFrame(read); };
read();
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll);
return () => {
window.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onScroll);
if (frame) window.cancelAnimationFrame(frame);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q, base]);
return (
<Section className={p.className}>
<Head {...p} />
{featured.length > 0 && !q && (
<div className="mt-12">
<p className="text-[12px] font-medium uppercase tracking-[0.1em] text-subtle-foreground">{t.featured}</p>
<ul className="mt-4 grid grid-cols-[minmax(0,1fr)] gap-3 sm:grid-cols-2 lg:grid-cols-4">
{featured.map((it, i) => (
<li key={it.name} className="min-w-0">
<a
href={it.href ?? '#'}
className="group/f kit-rise relative flex h-full cursor-pointer items-center gap-4 overflow-hidden rounded-2xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,box-shadow] duration-300 hover:border-[color-mix(in_oklab,var(--primary)_40%,var(--border))] hover:shadow-[var(--highlight),0_0_0_4px_var(--glow),var(--shadow-md)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-primary"
style={delay(i * 70)}
>
<IntegrationMark name={it.name} mark={it.mark} />
<span className="min-w-0">
<span className="flex items-center gap-2 text-[15px] font-semibold text-foreground">
<span className="truncate">{it.name}</span>
<Badge kind={it.badge} t={t} />
</span>
<span className="block truncate text-[13px] text-muted-foreground">{it.category}</span>
</span>
</a>
</li>
))}
</ul>
</div>
)}
{/* the bar sticks while the list scrolls under it */}
<SearchBox value={q} onChange={setQ} t={t} className="mt-12 lg:hidden" />
<div className="sticky top-0 z-10 -mx-6 mt-4 border-b border-border bg-[color-mix(in_oklab,var(--background)_88%,transparent)] px-6 py-2 backdrop-blur lg:-mx-8 lg:mt-12 lg:px-8 lg:py-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:gap-6">
<SearchBox value={q} onChange={setQ} t={t} className="max-lg:hidden lg:w-80 lg:shrink-0" />
<nav aria-label={t.jump} className="min-w-0 flex-1">
<div ref={barRef} className={cn('kit-no-scrollbar flex gap-0.5 overflow-x-auto', fade)}>
{groups.map(({ L, items }) =>
items.length ? (
<a
key={L}
href={`#${base}-${L}`}
aria-current={active === L ? 'location' : undefined}
onClick={() => {
hold.current = Date.now() + 1200;
setActive(L);
}}
className={cn(
'grid size-11 shrink-0 cursor-pointer place-items-center rounded-lg text-[14px] font-semibold transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary lg:size-8 lg:text-[13px]',
active === L ? 'bg-primary text-primary-foreground' : 'text-foreground hover:bg-accent',
)}
>
{L}
</a>
) : (
<span key={L} aria-hidden className="grid size-11 shrink-0 place-items-center text-[14px] text-[color-mix(in_oklab,var(--foreground)_25%,transparent)] lg:size-8 lg:text-[13px]">
{L}
</span>
),
)}
</div>
</nav>
</div>
</div>
{list.length === 0 ? (
<div className="mt-8">
<Empty q={q} t={t} request={p.request} onClear={() => setQ('')} />
</div>
) : (
<div className="mt-2">
{groups
.filter((g) => g.items.length)
.map(({ L, items }) => (
<section key={L} id={`${base}-${L}`} aria-labelledby={`${base}-${L}-h`} className="grid scroll-mt-40 grid-cols-[minmax(0,1fr)] gap-4 border-b border-border py-8 sm:grid-cols-[4rem_minmax(0,1fr)] lg:scroll-mt-24">
<h3 id={`${base}-${L}-h`} className="text-[28px] font-semibold leading-none tracking-[-0.03em] text-foreground sm:pt-2">
{L}
</h3>
<ul className="grid grid-cols-[minmax(0,1fr)] gap-1 sm:grid-cols-2 lg:grid-cols-3">
{items.map((it) => (
<li key={it.name} className="min-w-0">
<a href={it.href ?? '#'} className="group/i flex min-h-14 cursor-pointer items-center gap-3 rounded-xl px-2 py-2 transition-colors hover:bg-surface focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary">
<IntegrationMark name={it.name} mark={it.mark} size="sm" />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2">
<span className="truncate text-[14.5px] font-medium text-foreground">{it.name}</span>
<Badge kind={it.badge} t={t} />
</span>
<span className="mt-0.5 block text-pretty text-[13px] leading-snug text-muted-foreground">{it.description}</span>
</span>
</a>
</li>
))}
</ul>
</section>
))}
</div>
)}
</Section>
);
}
/* ------------------------------------------------------------------ */
/* manage */
/* ------------------------------------------------------------------ */
function Toggle({ on, busy, onChange, label }: { on: boolean; busy: boolean; onChange: () => void; label: string }) {
return (
<button
type="button"
role="switch"
aria-checked={on}
aria-busy={busy || undefined}
aria-label={label}
onClick={onChange}
className="relative grid h-11 w-14 shrink-0 cursor-pointer place-items-center rounded-full focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary"
>
<span className={cn('relative h-6 w-11 rounded-full transition-colors duration-300', on ? 'bg-primary' : 'bg-track ring-1 ring-inset ring-border-strong')}>
<span className={cn('absolute left-0.5 top-0.5 grid size-5 place-items-center rounded-full shadow-[var(--shadow-sm)] transition-[transform,background-color] duration-300 ease-[var(--ease-kit)]', on ? 'translate-x-5 bg-primary-foreground' : 'bg-[color-mix(in_oklab,var(--foreground)_50%,var(--card))]')}>
{busy && <Icon name="loader" size={12} className="animate-kit-spin text-primary [--kit-duration:0.8s]" />}
</span>
</span>
</button>
);
}
function Manage(p: ViewProps) {
const { t } = p;
const cats = useCategories(p);
const [on, setOn] = useState<Record<string, boolean>>(() => Object.fromEntries(p.items.map((i) => [i.name, Boolean(i.connected)])));
const [busy, setBusy] = useState<Record<string, boolean>>({});
const [tab, setTab] = useState<0 | 1>(0);
const [q, setQ] = useState('');
const timers = useRef<number[]>([]);
useEffect(() => () => timers.current.forEach((x) => window.clearTimeout(x)), []);
const flip = (name: string) => {
if (busy[name]) return;
if (on[name]) return setOn((x) => ({ ...x, [name]: false }));
setBusy((x) => ({ ...x, [name]: true }));
setOn((x) => ({ ...x, [name]: true }));
timers.current.push(window.setTimeout(() => setBusy((x) => ({ ...x, [name]: false })), prefersReducedMotion() ? 0 : 1100));
};
const n = Object.values(on).filter(Boolean).length;
const list = p.items.filter((it) => match(it, q) && (tab === 0 || on[it.name]));
const tabs = [`${t.tabAll}`, `${t.tabOn}`];
const uid = useId();
return (
<Section background="surface" className={p.className}>
<Head {...p} />
<div className="kit-rise mt-10 overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]" style={delay(140)}>
<div className="flex flex-col gap-3 border-b border-border p-3 sm:flex-row sm:items-center sm:justify-between sm:p-4">
<div role="tablist" className="inline-flex gap-1 rounded-xl bg-surface p-1" onKeyDown={(e) => {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
e.preventDefault();
const next = tab === 0 ? 1 : 0;
setTab(next);
(e.currentTarget.querySelectorAll('[role="tab"]')[next] as HTMLElement | undefined)?.focus();
}}>
{tabs.map((label, i) => (
<button
key={i}
type="button"
role="tab"
id={`${uid}-t${i}`}
aria-selected={tab === i}
aria-controls={`${uid}-p`}
tabIndex={tab === i ? 0 : -1}
onClick={() => setTab(i as 0 | 1)}
className={cn('inline-flex h-11 flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg px-4 text-[14px] font-medium transition-[background-color,color,box-shadow] duration-300 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary sm:h-9', tab === i ? 'bg-card text-foreground shadow-[var(--highlight),var(--shadow-sm)]' : 'text-muted-foreground hover:text-foreground')}
>
{label}
<span className={cn('rounded-full px-1.5 text-[11.5px] tabular-nums', tab === i ? 'bg-muted' : 'bg-transparent')}>{i === 0 ? p.items.length : n}</span>
</button>
))}
</div>
<SearchBox value={q} onChange={setQ} t={t} className="sm:w-72 [&_input]:h-11 [&_input]:shadow-none" />
</div>
<div role="tabpanel" id={`${uid}-p`} aria-labelledby={`${uid}-t${tab}`}>
{list.length === 0 ? (
<p className="px-6 py-14 text-center text-[14px] text-muted-foreground">{tab === 1 && !q ? t.noneOn : fill(t.empty, { q })}</p>
) : (
cats
.map((c) => ({ c, items: list.filter((it) => it.category === c) }))
.filter((g) => g.items.length)
.map(({ c, items }) => (
<div key={c}>
<p className="border-b border-border bg-surface px-4 py-2 text-[12px] font-medium uppercase tracking-[0.1em] text-subtle-foreground sm:px-6">{c}</p>
<ul>
{items.map((it) => {
const isOn = Boolean(on[it.name]);
const isBusy = Boolean(busy[it.name]);
return (
<li key={it.name} className="flex items-center gap-3 border-b border-border px-4 py-3.5 last:border-b-0 sm:gap-4 sm:px-6">
<IntegrationMark name={it.name} mark={it.mark} />
<div className="min-w-0 flex-1">
<p className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-[15px] font-semibold text-foreground">{it.name}</span>
<Badge kind={it.badge} t={t} />
</p>
<p className="mt-0.5 text-pretty text-[13.5px] text-muted-foreground">{it.description}</p>
<p aria-live="polite" className={cn('mt-1 flex items-center gap-1.5 text-[12.5px]', isOn ? 'text-success-foreground' : 'text-subtle-foreground')}>
<span className={cn('size-1.5 rounded-full', isOn ? (isBusy ? 'animate-kit-pulse bg-primary' : 'bg-success') : 'bg-[color-mix(in_oklab,var(--foreground)_25%,transparent)]')} aria-hidden />
{isBusy ? t.connecting : isOn ? `${t.connected} · ${t.synced}` : t.available}
</p>
</div>
{isOn && !isBusy && (
<a href={it.href ?? '#'} aria-label={`${t.configure}, ${it.name}`} className="kit-rise grid size-11 shrink-0 cursor-pointer place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary max-sm:hidden">
<Icon name="settings" size={18} />
</a>
)}
<Toggle on={isOn} busy={isBusy} onChange={() => flip(it.name)} label={`${it.name}, ${isOn ? t.connected : t.connect}`} />
</li>
);
})}
</ul>
</div>
))
)}
</div>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
const VIEWS: Record<IntegrationDirectoryVariant, (p: ViewProps) => React.ReactNode> = {
catalog: Catalog,
detail: Detail,
'a-z': AZ,
manage: Manage,
};
export function IntegrationDirectory({ variant = 'catalog', labels, ...rest }: IntegrationDirectoryProps) {
const t = useTexts(TEXTS, labels);
const View = VIEWS[variant] ?? Catalog;
return <View {...rest} variant={variant} t={t} />;
}Frequent questions
How do I install Integration directory?
With aiw add @ai-website/integration-directory. The command puts components/ui/integration-directory.tsx into your project, the code is yours from then on. It brings along motion, button, icon and section. It needs no further npm packages.
Which variants does Integration directory have?
4 variants, Catalog with search, Integration page, A to Z index and Manage connections. You pick one with the variant prop, for example variant="detail".
Can I translate the texts of Integration directory?
Yes. Integration directory carries 30 texts of its own in English and German, such as “Search integrations”, “Search by name or what it does” and “All”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Integration directory?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Integration directory with aiw_components and install it with aiw_add @ai-website/integration-directory. Name the variant in your prompt, for example variant="catalog", and the assistant writes your content straight into the props.