Mikkel Manniche
Dansk English Deutsch

Widgets

Deployment card

A build status card with an animated bar per step and details that fold out.

Adapted from Watermelon UI (MIT) and rewritten with keyboard support, labels and reduced motion.

Preview

Usage

deployment-card-demo.tsx
import { useEffect, useState } from 'react'
import { DeploymentCard, type DeploymentStep } from '@/components/deployment-card'

const plan = [
  { id: 'install', label: 'Install packages', seconds: 3 },
  { id: 'build', label: 'Build the shop', seconds: 5 },
  { id: 'checks', label: 'Run checks', seconds: 3 },
  { id: 'domains', label: 'Assign domains', seconds: 2 },
]

export default function DeploymentCardDemo() {
  const [tick, setTick] = useState(0)
  const total = plan.reduce((n, s) => n + s.seconds, 0) * 4

  useEffect(() => {
    if (tick >= total) return
    const t = setTimeout(() => setTick((n) => n + 1), 250)
    return () => clearTimeout(t)
  }, [tick, total])

  const steps: DeploymentStep[] = plan.map((p, i) => {
    const start = plan.slice(0, i).reduce((n, s) => n + s.seconds * 4, 0)
    const len = p.seconds * 4
    const done = Math.max(0, Math.min(len, tick - start))
    const status = done === len ? (p.id === 'checks' ? 'warning' : 'success') : done > 0 ? 'running' : 'pending'
    return {
      id: p.id,
      label: p.label,
      status,
      progress: done / len,
      duration: done > 0 ? `${(done / 4).toFixed(done === len ? 0 : 1)}s` : undefined,
      details:
        p.id === 'checks' && done === len ? (
          <ul className="space-y-1">
            <li>2 images are larger than 500 kB.</li>
            <li>1 page has no meta description.</li>
          </ul>
        ) : p.id === 'build' && done === len ? (
          '48 pages, 212 assets, 1.8 MB in all.'
        ) : undefined,
    }
  })

  return (
    <div className="flex justify-center">
      <DeploymentCard
        title="shop-storefront"
        environment="Production"
        branch="main"
        commit="8f3c2a1"
        message="Add the spring collection"
        state={tick >= total ? 'ready' : 'building'}
        steps={steps}
        actions={
          <button type="button" onClick={() => setTick(0)} className="min-h-11 rounded-xl border bg-card px-4 text-sm font-medium">
            Redeploy
          </button>
        }
      />
    </div>
  )
}

Source

deployment-card.tsx
// Based on Watermelon UI's “Deployment card” (MIT, © 2026 Watermelon Platform Contributors,
// github.com/WatermelonCorp/watermelon-platform). Rewritten as a controlled card: you pass the steps, it animates what changes.
import { Check, ChevronDown, CircleAlert, GitBranch, LoaderCircle, TriangleAlert } from 'lucide-react'
import { AnimatePresence, MotionConfig, motion } from 'motion/react'
import { useState, type ReactNode } from 'react'
import { cn } from '@/lib/utils'

export type DeploymentStepStatus = 'pending' | 'running' | 'success' | 'warning' | 'error'

export type DeploymentStep = {
  id: string
  label: string
  status: DeploymentStepStatus
  /** 0 to 1. Drawn as the bar under the step. */
  progress: number
  duration?: string
  /** Shown when the step is opened, for logs or numbers. */
  details?: ReactNode
}

export type DeploymentCardProps = {
  title: string
  environment: string
  branch: string
  commit: string
  message: string
  steps: DeploymentStep[]
  /** Overall state in the header. */
  state: 'building' | 'ready' | 'failed'
  stateLabels?: Record<DeploymentCardProps['state'], string>
  actions?: ReactNode
  className?: string
}

const tone: Record<DeploymentStepStatus, string> = {
  pending: 'text-muted-foreground',
  running: 'text-foreground',
  success: 'text-success',
  warning: 'text-amber-500',
  error: 'text-destructive',
}
const bar: Record<DeploymentStepStatus, string> = {
  pending: 'bg-border',
  running: 'bg-foreground',
  success: 'bg-success',
  warning: 'bg-amber-500',
  error: 'bg-destructive',
}

function StepIcon({ status }: { status: DeploymentStepStatus }) {
  const cls = 'size-4'
  if (status === 'running') return <LoaderCircle className={cn(cls, 'animate-spin motion-reduce:animate-none')} aria-hidden />
  if (status === 'success') return <Check className={cls} strokeWidth={3} aria-hidden />
  if (status === 'warning') return <TriangleAlert className={cls} aria-hidden />
  if (status === 'error') return <CircleAlert className={cls} aria-hidden />
  return <span className="block size-2 rounded-full bg-current opacity-50" aria-hidden />
}

/** A build and deploy card: status in the header, one animated bar per step, and details that fold out. */
export function DeploymentCard({
  title,
  environment,
  branch,
  commit,
  message,
  steps,
  state,
  stateLabels = { building: 'Building', ready: 'Ready', failed: 'Failed' },
  actions,
  className,
}: DeploymentCardProps) {
  const [openId, setOpenId] = useState<string | null>(null)
  const dot = state === 'ready' ? 'bg-success' : state === 'failed' ? 'bg-destructive' : 'bg-amber-500'

  return (
    <MotionConfig reducedMotion="user" transition={{ type: 'spring', stiffness: 260, damping: 30 }}>
      <article className={cn('w-full max-w-md overflow-hidden rounded-3xl border bg-card text-card-foreground shadow-sm', className)}>
        <header className="flex items-start gap-3 border-b p-5">
          <div className="min-w-0 flex-1">
            <p className="text-xs text-muted-foreground">{environment}</p>
            <h3 className="truncate font-semibold">{title}</h3>
            <p className="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground">
              <GitBranch className="size-3.5" aria-hidden />
              <span className="font-mono">{branch}</span>
              <span aria-hidden>·</span>
              <span className="font-mono">{commit}</span>
            </p>
            <p className="mt-1 truncate text-sm">{message}</p>
          </div>
          <span className="inline-flex shrink-0 items-center gap-2 rounded-full border px-2.5 py-1 text-xs font-medium" role="status">
            <span className="relative flex size-2">
              {state === 'building' && <span className={cn('absolute inset-0 animate-ping rounded-full opacity-60 motion-reduce:animate-none', dot)} />}
              <span className={cn('relative size-2 rounded-full transition-colors duration-300', dot)} />
            </span>
            {stateLabels[state]}
          </span>
        </header>
        <ol className="divide-y">
          {steps.map((s) => {
            const open = openId === s.id
            return (
              <li key={s.id}>
                <button
                  type="button"
                  aria-expanded={s.details ? open : undefined}
                  disabled={!s.details}
                  onClick={() => setOpenId(open ? null : s.id)}
                  className="flex w-full items-center gap-3 px-5 py-3.5 text-left transition-colors duration-150 enabled:hover:bg-muted/50"
                >
                  <span className={cn('grid size-6 shrink-0 place-items-center rounded-full bg-muted transition-colors duration-300', tone[s.status])}>
                    <StepIcon status={s.status} />
                  </span>
                  <span className="min-w-0 flex-1">
                    <span className="flex items-center justify-between gap-2 text-sm">
                      <span className={cn('truncate font-medium', s.status === 'pending' && 'text-muted-foreground')}>{s.label}</span>
                      <span className="shrink-0 text-xs text-muted-foreground tabular-nums">{s.duration}</span>
                    </span>
                    <span className="mt-2 block h-1 overflow-hidden rounded-full bg-muted">
                      <motion.span
                        className={cn('block h-full origin-left rounded-full transition-colors duration-300', bar[s.status])}
                        initial={false}
                        animate={{ scaleX: Math.max(0, Math.min(1, s.progress)) }}
                      />
                    </span>
                  </span>
                  {s.details && (
                    <ChevronDown className={cn('size-4 shrink-0 text-muted-foreground transition-transform duration-200', open && 'rotate-180')} aria-hidden />
                  )}
                </button>
                <AnimatePresence initial={false}>
                  {open && (
                    <motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }} exit={{ height: 0, opacity: 0 }} className="overflow-hidden">
                      <div className="px-5 pb-4 pl-14 text-sm text-muted-foreground">{s.details}</div>
                    </motion.div>
                  )}
                </AnimatePresence>
              </li>
            )
          })}
        </ol>
        {actions && <footer className="flex justify-end gap-2 border-t bg-muted/30 p-3">{actions}</footer>}
      </article>
    </MotionConfig>
  )
}

Install

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

With the registry added (components.json)

npx shadcn@latest add @manniche/deployment-card

Files

  • deployment-card.tsx

Manniche Lab · open source

Free tools for AI agents and web development.