Widgets
Integration card
Eine Liste von Integrationen; jede Zeile öffnet eine Karte mit Funktionen und einem Verbinden-Button.
Angepasst von Watermelon UI (MIT) und neu geschrieben mit Tastatur, Beschriftungen und reduzierter Bewegung.
Vorschau
Verwendung
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>
)
}Quellcode
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>
)
}Installieren
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/integration-card.jsonMit eingetragener Registry (components.json)
npx shadcn@latest add @manniche/integration-card