makeui
DocsComponents

© 2026 makeui. Open source, MIT licensed.

DocsComponentsGitHub

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.json

Dependencies

  • 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

PropTypeDescription
titlestringHeading under the diagram.
descriptionstringSupporting 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".
targetstringLabel of the endpoint on the right.
intervalnumberMilliseconds between automatic pulses. Defaults to 2800.
classNamestringExtra 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>
  )
}

On this page

POST /webhook
Your API

Webhooks

Send every event to your own endpoint.