Mikkel Manniche
Dansk English Deutsch

Widgets

Integration card

En liste over integrationer, hvor hver række åbner et kort med, hvad den gør, og en forbind-knap.

Tilpasset fra Watermelon UI (MIT) og skrevet om med tastatur, etiketter og reduceret bevægelse.

Forhåndsvisning

Brug

integration-card-demo.tsx
import { CreditCard, Mail, Package, Receipt } from 'lucide-react'
import { IntegrationCard } from '@/components/integration-card'

const wait = () => new Promise<void>((r) => setTimeout(r, 700))

export default function IntegrationCardDemo() {
  return (
    <div className="flex justify-center">
      <IntegrationCard
        title="Shop integrations"
        onConnectChange={wait}
        items={[
          {
            id: 'payments',
            name: 'Payments',
            description: 'Take cards and wallets at checkout.',
            icon: <CreditCard />,
            tags: ['Checkout', 'Refunds'],
            triggers: ['A payment succeeds', 'A refund is issued'],
            actions: ['Mark the order as paid', 'Send a receipt'],
            connected: true,
          },
          {
            id: 'shipping',
            name: 'Shipping',
            description: 'Print labels and track parcels.',
            icon: <Package />,
            tags: ['Labels', 'Tracking'],
            triggers: ['An order is packed', 'A parcel is delivered'],
            actions: ['Create a label', 'Email the tracking link'],
          },
          {
            id: 'newsletter',
            name: 'Newsletter',
            description: 'Add buyers who opt in to your list.',
            icon: <Mail />,
            tags: ['Email'],
            triggers: ['A buyer ticks the box'],
            actions: ['Add to the list', 'Tag with the product'],
          },
          {
            id: 'accounting',
            name: 'Accounting',
            description: 'Send each sale to your books.',
            icon: <Receipt />,
            tags: ['VAT', 'Exports'],
            triggers: ['An order is paid'],
            actions: ['Create an invoice', 'Book the VAT'],
          },
        ]}
      />
    </div>
  )
}

Kilde

integration-card.tsx
// Based on Watermelon UI's “Integration card” (MIT, © 2026 Watermelon Platform Contributors,
// github.com/WatermelonCorp/watermelon-platform). Rewritten as a list of rows that open into a detail card, with no brand icons.
import { Check, Plus, X } from 'lucide-react'
import { AnimatePresence, MotionConfig, motion } from 'motion/react'
import { useEffect, useId, useRef, useState, type ReactNode } from 'react'
import { cn } from '@/lib/utils'

export type Integration = {
  id: string
  name: string
  description: string
  icon: ReactNode
  tags?: string[]
  /** Short lines shown in the open card, e.g. what it listens for and what it does. */
  triggers?: string[]
  actions?: string[]
  connected?: boolean
}

export type IntegrationCardProps = {
  title?: string
  items: Integration[]
  onConnectChange?: (id: string, connected: boolean) => Promise<void> | void
  connectLabel?: string
  connectedLabel?: string
  disconnectLabel?: string
  triggersLabel?: string
  actionsLabel?: string
  closeLabel?: string
  className?: string
}

/** A list of integrations. Each row opens into a card with what it does and a connect button. */
export function IntegrationCard({
  title = 'Integrations',
  items,
  onConnectChange,
  connectLabel = 'Connect',
  connectedLabel = 'Connected',
  disconnectLabel = 'Disconnect',
  triggersLabel = 'Triggers',
  actionsLabel = 'Actions',
  closeLabel = 'Close',
  className,
}: IntegrationCardProps) {
  const [openId, setOpenId] = useState<string | null>(null)
  const [state, setState] = useState(() => Object.fromEntries(items.map((i) => [i.id, !!i.connected])))
  const [busy, setBusy] = useState<string | null>(null)
  const base = useId()
  const rows = useRef<Record<string, HTMLButtonElement | null>>({})
  const closeRef = useRef<HTMLButtonElement>(null)
  const open = items.find((i) => i.id === openId)

  useEffect(() => {
    if (open) closeRef.current?.focus()
  }, [open])

  const close = () => {
    const id = openId
    setOpenId(null)
    if (id) requestAnimationFrame(() => rows.current[id]?.focus())
  }

  const toggle = async (id: string) => {
    const next = !state[id]
    setBusy(id)
    try {
      await onConnectChange?.(id, next)
      setState((s) => ({ ...s, [id]: next }))
    } finally {
      setBusy(null)
    }
  }

  return (
    <MotionConfig reducedMotion="user" transition={{ type: 'spring', stiffness: 380, damping: 34 }}>
      <motion.section
        layout
        className={cn('relative w-full max-w-md overflow-hidden rounded-3xl border bg-card text-card-foreground shadow-sm', className)}
        aria-label={title}
      >
        <AnimatePresence mode="popLayout" initial={false}>
          {!open ? (
            <motion.div key="list" layout="position" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
              <h3 className="px-5 pt-5 pb-2 text-sm font-semibold">{title}</h3>
              <ul className="p-2">
                {items.map((it) => (
                  <li key={it.id}>
                    <button
                      ref={(el) => {
                        rows.current[it.id] = el
                      }}
                      type="button"
                      onClick={() => setOpenId(it.id)}
                      aria-haspopup="dialog"
                      className="flex w-full items-center gap-3 rounded-2xl p-3 text-left transition-colors duration-150 hover:bg-muted/60"
                    >
                      <motion.span
                        layoutId={`${base}-icon-${it.id}`}
                        className="grid size-10 shrink-0 place-items-center rounded-xl border bg-card shadow-sm [&_svg]:size-5"
                      >
                        {it.icon}
                      </motion.span>
                      <span className="min-w-0 flex-1">
                        <motion.span layoutId={`${base}-name-${it.id}`} className="block w-fit text-sm font-medium">
                          {it.name}
                        </motion.span>
                        <span className="block truncate text-xs text-muted-foreground">{it.description}</span>
                      </span>
                      {state[it.id] && (
                        <span className="inline-flex items-center gap-1 rounded-full bg-success/10 px-2 py-0.5 text-xs font-medium text-success">
                          <Check className="size-3" strokeWidth={3} aria-hidden />
                          {connectedLabel}
                        </span>
                      )}
                    </button>
                  </li>
                ))}
              </ul>
            </motion.div>
          ) : (
            <motion.div
              key={open.id}
              layout="position"
              role="dialog"
              aria-modal="false"
              aria-labelledby={`${base}-title`}
              onKeyDown={(e) => e.key === 'Escape' && close()}
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              className="flex flex-col p-5"
            >
              <div className="flex items-start gap-3">
                <motion.span
                  layoutId={`${base}-icon-${open.id}`}
                  className="grid size-12 shrink-0 place-items-center rounded-2xl border bg-card shadow-md [&_svg]:size-6"
                >
                  {open.icon}
                </motion.span>
                <div className="min-w-0 flex-1">
                  <motion.h4 layoutId={`${base}-name-${open.id}`} id={`${base}-title`} className="w-fit font-semibold">
                    {open.name}
                  </motion.h4>
                  <p className="text-sm text-muted-foreground">{open.description}</p>
                </div>
                <button
                  ref={closeRef}
                  type="button"
                  onClick={close}
                  aria-label={closeLabel}
                  className="grid size-9 shrink-0 place-items-center rounded-full hover:bg-muted"
                >
                  <X className="size-4" aria-hidden />
                </button>
              </div>

              <motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.08 }} className="mt-4 space-y-4">
                {!!open.tags?.length && (
                  <ul className="flex flex-wrap gap-1.5">
                    {open.tags.map((t) => (
                      <li key={t} className="rounded-full border px-2.5 py-0.5 text-xs text-muted-foreground">
                        {t}
                      </li>
                    ))}
                  </ul>
                )}
                {[
                  [triggersLabel, open.triggers],
                  [actionsLabel, open.actions],
                ].map(([label, list]) =>
                  list?.length ? (
                    <div key={label as string}>
                      <p className="mb-1.5 text-xs font-medium tracking-wide text-muted-foreground uppercase">{label}</p>
                      <ul className="space-y-1 text-sm">
                        {(list as string[]).map((l) => (
                          <li key={l} className="flex gap-2">
                            <span className="mt-2 size-1 shrink-0 rounded-full bg-foreground/40" aria-hidden />
                            {l}
                          </li>
                        ))}
                      </ul>
                    </div>
                  ) : null,
                )}
              </motion.div>

              <button
                type="button"
                onClick={() => busy !== open.id && toggle(open.id)}
                aria-disabled={busy === open.id}
                aria-busy={busy === open.id}
                className={cn(
                  'mt-5 inline-flex min-h-11 items-center justify-center gap-2 rounded-xl px-4 text-sm font-medium transition-colors duration-150 aria-disabled:opacity-60',
                  state[open.id] ? 'border bg-card hover:bg-muted' : 'bg-foreground text-background hover:bg-foreground/90',
                )}
              >
                {state[open.id] ? <X className="size-4" aria-hidden /> : <Plus className="size-4" aria-hidden />}
                {state[open.id] ? disconnectLabel : connectLabel}
              </button>
            </motion.div>
          )}
        </AnimatePresence>
      </motion.section>
    </MotionConfig>
  )
}

Installér

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/integration-card.json

Med registret tilføjet (components.json)

npx shadcn@latest add @manniche/integration-card

Filer

  • integration-card.tsx

Manniche Lab · open source

Gratis værktøjer til AI-agenter og webudvikling.