Skip to content
UI Kit
Accent color

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.

4 variantsOne of 2 components in Integrations

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

Catalog with searchNew in 1.2

Search on top, categories with live counts in a side rail that becomes a chip row on phones, a card grid that rises in and an empty state that asks what is missing.

Integration pageNew in 1.2

One integration told 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 indexNew in 1.2

Featured tools on top, then every entry grouped by letter. The alphabet bar sticks while scrolling, marks the letter in view and jumps on click, letters without entries stay quiet.

Manage connectionsNew in 1.2

The in-app view: tabs for all and connected with counts, a search, entries grouped by category and real switches that connect with a short busy state.

Details

What Integration directory brings along, read from the registry and the source.

Category
Integrations
Variants
4
Version
1.0.0 · since 1.2.0
File
components/ui/integration-directory.tsx
Brings along
utilskitmotionbuttoniconsection
npm packages
None
Languages
English and German, 30 texts of its own
Studio
Adjust in code

Props

The IntegrationDirectoryProps interface from components/ui/integration-directory.tsx, with the comments from the code.

  • variantoptional
    IntegrationDirectoryVariant

    catalogdetaila-zmanage

  • eyebrowoptional
    React.ReactNode
  • titlerequired
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • itemsrequired
    IntegrationItem[]
  • categoriesoptional
    string[]

    Order of the categories, derived from the items otherwise

  • featuredoptional
    string[]

    a-z: names shown large on top

  • detailoptional
    IntegrationDetail

    detail: 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
searchEnglish Search integrationsGerman Integrationen durchsuchen
searchPhEnglish Search by name or what it doesGerman Nach Name oder Aufgabe suchen
allEnglish AllGerman Alle
categoriesEnglish CategoriesGerman Kategorien
resultsEnglish {n} integrationsGerman {n} Integrationen
result1English 1 integrationGerman 1 Integration
emptyEnglish Nothing matches “{q}” yet.German Zu „{q}“ passt noch nichts.
emptyHintEnglish Tell us what you use, we build the most requested ones first.German Sag uns, was du nutzt. Die meistgewünschten bauen wir zuerst.
clearEnglish Clear searchGerman Suche leeren
popularEnglish PopularGerman Beliebt
newEnglish NewGerman Neu
betaEnglish BetaGerman Beta
connectEnglish ConnectGerman Verbinden
connectingEnglish ConnectingGerman Wird verbunden
connectedEnglish ConnectedGerman Verbunden
disconnectEnglish DisconnectGerman Trennen
configureEnglish SettingsGerman Einstellungen
syncedEnglish Synced just nowGerman Gerade abgeglichen
whatEnglish What it doesGerman Was es kann
setupEnglish Set up in {n} stepsGerman In {n} Schritten eingerichtet
permissionsEnglish What it can seeGerman Worauf es zugreift
aboutEnglish AboutGerman Steckbrief
relatedEnglish Works well withGerman Passt gut zu
jumpEnglish Jump to letterGerman Zum Buchstaben springen
featuredEnglish FeaturedGerman Empfohlen
tabAllEnglish AllGerman Alle
tabOnEnglish ConnectedGerman Verbunden
availableEnglish AvailableGerman Verfügbar
noneOnEnglish Nothing connected yet. Switch on what you use below.German Noch nichts verbunden. Schalte unten ein, was du nutzt.
openEnglish 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
components/ui/integration-directory.tsx
'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.

Often searched

Jump to

Copied to the clipboard