Flow Card
A feature card that shows events flowing from sources, through a hub, to an endpoint.
Preview
Installation
pnpm dlx shadcn@latest add https://makeui.dev/r/flow-card.jsonDependencies
- motion
- lucide-react
Interaction
The card cycles through its sources on its own, only while it is on screen. The active source lights up and a pulse runs along its line into the hub, then out to the endpoint. Click any source to trigger it yourself.
Props
| Prop | Type | Description |
|---|---|---|
| title | string | Heading under the diagram. |
| description | string | Supporting text under the heading. |
| sources | { id; label; icon? }[] | Nodes on the left. Two to four work best. |
| hub | { label; hint? } | The centre node. Defaults to "POST /webhook". |
| target | string | Label of the endpoint on the right. |
| interval | number | Milliseconds between automatic pulses. Defaults to 2800. |
| className | string | Extra classes merged onto the root element. |
How to use
import FlowCard from "@/components/ui/flow-card"
export function Example() {
return (
<FlowCard
title="Webhooks"
description="Send every event to your own endpoint."
target="My API"
/>
)
}Source code
components/ui/flow-card.tsx
"use client"
import { type ReactNode, useEffect, useRef, useState } from "react"
import { motion, useInView, useReducedMotion } from "motion/react"
import { CreditCard, GitBranch, Mail, Server, ShoppingCart } from "lucide-react"
import { cn } from "@/lib/utils"
type Source = { id: string; label: string; icon?: ReactNode }
const DEFAULT_SOURCES: Source[] = [
{ id: "payments", label: "Payments", icon: <CreditCard className="size-3.5" /> },
{ id: "orders", label: "Orders", icon: <ShoppingCart className="size-3.5" /> },
{ id: "deploys", label: "Deploys", icon: <GitBranch className="size-3.5" /> },
]
// Everything is drawn on a 400 x 220 grid; the HTML nodes use the same grid in percentages.
const W = 400
const H = 220
const HUB_X = 200
const HUB_Y = H / 2
const SRC_X = 100
const TGT_X = 304
const sourceY = (i: number, n: number) => ((i + 1) / (n + 1)) * H
const pathIn = (y: number) =>
`M${SRC_X} ${y} C${SRC_X + 34} ${y} ${HUB_X - 62} ${HUB_Y} ${HUB_X - 50} ${HUB_Y}`
const pathOut = `M${HUB_X + 50} ${HUB_Y} L${TGT_X} ${HUB_Y}`
export default function FlowCard({
title = "Webhooks",
description = "Send every event to your own endpoint.",
sources = DEFAULT_SOURCES,
hub = { label: "POST /webhook" },
target = "Your API",
interval = 2800,
className,
}: {
title?: string
description?: string
sources?: Source[]
hub?: { label: string; hint?: string }
target?: string
interval?: number
className?: string
}) {
const ref = useRef<HTMLDivElement>(null)
const inView = useInView(ref, { margin: "0px 0px -10% 0px" })
const reduce = useReducedMotion()
const n = sources.length
const [active, setActive] = useState(0)
const [tick, setTick] = useState(0)
const [stage, setStage] = useState<0 | 1 | 2>(0) // 0 idle, 1 source line, 2 hub and endpoint
// Auto-cycle only while on screen. Clicking bumps `tick`, which restarts the timer.
useEffect(() => {
if (!inView || reduce || n < 2) return
const id = setInterval(() => {
setActive((a) => (a + 1) % n)
setTick((t) => t + 1)
}, interval)
return () => clearInterval(id)
}, [inView, reduce, interval, n, tick])
useEffect(() => {
if (!inView) return
setStage(1)
const a = setTimeout(() => setStage(2), 650)
const b = setTimeout(() => setStage(0), 1700)
return () => {
clearTimeout(a)
clearTimeout(b)
}
}, [tick, inView])
const trigger = (i: number) => {
setActive(i)
setTick((t) => t + 1)
}
const node =
"absolute flex -translate-y-1/2 items-center gap-1.5 rounded-xl border bg-background px-2 py-1.5 text-xs font-medium transition-colors duration-300"
return (
<div
ref={ref}
className={cn(
"w-full rounded-3xl border border-border bg-card p-5 sm:p-6",
className,
)}
>
<div className="relative w-full" style={{ aspectRatio: `${W} / ${H}` }}>
<svg
viewBox={`0 0 ${W} ${H}`}
className="absolute inset-0 size-full"
fill="none"
aria-hidden
>
{sources.map((s, i) => (
<path
key={s.id}
d={pathIn(sourceY(i, n))}
stroke="var(--border)"
strokeWidth={1.5}
strokeLinecap="round"
/>
))}
<path
d={pathOut}
stroke="var(--border)"
strokeWidth={1.5}
strokeLinecap="round"
/>
{!reduce && stage > 0 && (
<>
<motion.path
key={`in-${tick}`}
d={pathIn(sourceY(active, n))}
stroke="var(--accent)"
strokeWidth={2}
strokeLinecap="round"
initial={{ pathLength: 0, opacity: 1 }}
animate={{ pathLength: 1, opacity: [1, 1, 0] }}
transition={{ duration: 0.65, ease: "easeInOut" }}
/>
<motion.path
key={`out-${tick}`}
d={pathOut}
stroke="var(--accent)"
strokeWidth={2}
strokeLinecap="round"
initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: [1, 1, 0] }}
transition={{ duration: 0.5, delay: 0.65, ease: "easeInOut" }}
/>
</>
)}
</svg>
{sources.map((s, i) => (
<button
key={s.id}
type="button"
onClick={() => trigger(i)}
aria-pressed={i === active}
className={cn(
node,
"left-0 max-w-[24%] cursor-pointer hover:border-foreground/30",
i === active
? "border-accent text-foreground"
: "border-border text-muted-foreground",
)}
style={{ top: `${(sourceY(i, n) / H) * 100}%` }}
>
{s.icon}
<span className="truncate">{s.label}</span>
</button>
))}
<div
className={cn(
node,
"left-1/2 -translate-x-1/2 flex-col items-start gap-0 px-3 font-mono text-[11px]",
stage === 2
? "border-accent text-foreground"
: "border-border text-muted-foreground",
)}
style={{ top: `${(HUB_Y / H) * 100}%` }}
>
<span className="whitespace-nowrap">{hub.label}</span>
{hub.hint && (
<span className="font-sans text-[10px] text-muted-foreground">
{hub.hint}
</span>
)}
</div>
<div
className={cn(
node,
"right-0 max-w-[24%]",
stage === 2
? "border-accent text-foreground"
: "border-border text-muted-foreground",
)}
style={{ top: `${(HUB_Y / H) * 100}%` }}
>
<Server className="size-3.5 shrink-0" />
<span className="truncate">{target}</span>
</div>
</div>
<h3 className="mt-5 font-medium tracking-tight">{title}</h3>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
</div>
)
}