Aadi

Deploy Status

Component

About

A deploy card from queued to ready: install, build, test and deploy steps with a live log tail for the running one, timings per step and in total, then the preview URL. Cancel and retry are built in.

Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Deploy Status to your project and fits it to your theme.

Code

How Deploy Status is used: the demo's code. The component's own source comes with a free account.

Usage.tsx

"use client"

import * as React from "react"
import { DeployStatus, DeployStatusState, DeployStep } from "@/components/ui/deploy-status"

const FIT_CSS = "html,body{overflow:hidden}[data-fit-small] [data-fit-hide]{display:none!important}"

/**
 * 21st shows a demo in a small frame: about 674 by 421 on a component page, 358 by 224 on a
 * phone and 245 by 139 on a profile card. A demo taller than its frame gets scrollbars, which
 * render white on dark pages. FitFrame fills the frame exactly instead.
 *
 * - "scale": the content shrinks until it fits, centered. For visuals such as orbs.
 * - "crop": the content stays at full size from the top and fades out at the bottom. For text
 *   that streams in, where a changing scale would make the page breathe.
 *
 * `minWidth` lays out a wide demo (a table, a two column panel) at that width and scales it down
 * to the frame, instead of letting it collapse to a narrow layout. The frame centers it, so it
 * shrinks evenly on both sides.
 *
 * Anything marked `data-fit-hide` (state buttons, the chatcn.dev line) drops out in frames
 * shorter than `hideBelow`, so a profile card shows the visual alone.
 */
function FitFrame({
  children,
  mode = "scale",
  hideBelow = 360,
  minWidth,
}: {
  children: React.ReactNode
  mode?: "scale" | "crop"
  hideBelow?: number
  minWidth?: string
}) {
  const content = React.useRef<HTMLDivElement>(null)
  const [cropped, setCropped] = React.useState(false)

  React.useLayoutEffect(() => {
    const el = content.current
    if (!el) return
    const measure = () => {
      const vw = window.innerWidth
      const vh = window.innerHeight
      el.toggleAttribute("data-fit-small", vh < hideBelow)
      // Layout sizes ignore the transform, so measuring never feeds back.
      const w = Math.max(el.offsetWidth, el.scrollWidth)
      const h = Math.max(el.offsetHeight, el.scrollHeight)
      const scale = mode === "scale" ? Math.min(1, vw / w, vh / h) : 1
      el.style.transform = scale < 1 ? `scale(${scale})` : ""
      setCropped(mode === "crop" && h > vh + 1)
    }
    measure()
    const observer = new ResizeObserver(measure)
    observer.observe(el)
    // Content that changes inside a fixed width box (a headline that cycles words) never resizes
    // the box itself, so changes below it measure again on the next frame.
    let frame = 0
    const mutations = new MutationObserver(() => {
      cancelAnimationFrame(frame)
      frame = requestAnimationFrame(measure)
    })
    mutations.observe(el, { subtree: true, childList: true, characterData: true })
    window.addEventListener("resize", measure)
    return () => {
      observer.disconnect()
      mutations.disconnect()
      cancelAnimationFrame(frame)
      window.removeEventListener("resize", measure)
    }
  }, [mode, hideBelow])

  const fade = "linear-gradient(to bottom, #000 calc(100% - 40px), transparent)"
  return (
    <div
      className="flex w-full justify-center overflow-hidden"
      style={{
        height: "100dvh",
        alignItems: mode === "crop" ? "flex-start" : "center",
        maskImage: cropped ? fade : undefined,
        WebkitMaskImage: cropped ? fade : undefined,
      }}
    >
      <style>{FIT_CSS}</style>
      <div
        ref={content}
        className="w-full shrink-0"
        style={{ transformOrigin: mode === "crop" ? "top center" : "center", minWidth }}
      >
        {children}
      </div>
    </div>
  )
}

const dim = (s: string) => `\u001b[2m${s}\u001b[22m`

const bold = (s: string) => `\u001b[1m${s}\u001b[22m`

const green = (s: string) => `\u001b[32m${s}\u001b[39m`

const installLog = [
  `${dim("$")} pnpm install --frozen-lockfile`,
  "Lockfile is up to date, resolution step is skipped",
  "Packages: +1184",
  "Progress: resolved 1184, reused 1172, downloaded 12, added 1184, done",
  "",
  `${bold("dependencies:")}`,
  `${green("+")} next 16.0.3`,
  `${green("+")} react 19.2.0`,
  `${green("+")} @tanstack/react-query 5.90.2`,
  "",
  `Done in 14.2s using pnpm v10.18.0`,
].join("\n")

const cyan = (s: string) => `\u001b[36m${s}\u001b[39m`

/** Build output, line by line, so the demo can tail it. */
const buildLines = [
  `${dim("$")} pnpm build`,
  "",
  `> console@0.42.0 build /workspace/console`,
  "> next build",
  "",
  `   ${cyan("▲")} Next.js 16.0.3`,
  "   - Environments: .env.production",
  "",
  "   Creating an optimized production build ...",
  ` ${green("✓")} Compiled successfully in 21.4s`,
  "   Linting and checking validity of types ...",
  ` ${green("✓")} Linting and checking validity of types`,
  "   Collecting page data ...",
  "   Generating static pages (0/38) ...",
  "   Generating static pages (19/38)",
  ` ${green("✓")} Generating static pages (38/38)`,
  "   Finalizing page optimization ...",
  "",
  "Route (app)                         Size  First Load JS",
  "┌ ○ /                          4.12 kB         128 kB",
  "├ ƒ /runs                      9.86 kB         141 kB",
  "├ ƒ /runs/[id]                 6.40 kB         137 kB",
  "└ ○ /settings                  3.02 kB         126 kB",
]

const testLog = [
  `${dim("$")} pnpm test --run`,
  "",
  ` ${green("✓")} src/lib/runs.test.ts (14 tests) 182ms`,
  ` ${green("✓")} src/app/runs/pagination.test.tsx (9 tests) 641ms`,
  ` ${green("✓")} src/lib/cursor.test.ts (6 tests) 38ms`,
  "",
  ` Test Files  ${green("3 passed")} (3)`,
  `      Tests  ${green("29 passed")} (29)`,
  "   Duration  2.91s",
].join("\n")

const deployLog = [
  "Uploading build outputs (412 files, 18.6 MB)",
  "Deploying serverless functions to fra1, iad1",
  "Assigning domain console-git-feat-runs-pagination.brightwell.dev",
  `${green("✓")} Deployment ready`,
].join("\n")

/** Per step: how long the demo spends on it, and how long the step "really" took. */
const PLAN = [
  { lines: installLog.split("\n"), ms: 1600, offset: 11_000 },
  { lines: buildLines, ms: 4600, offset: 18_000 },
  { lines: testLog.split("\n"), ms: 1800, offset: 6_000 },
  { lines: deployLog.split("\n"), ms: 1400, offset: 4_000 },
]

const buildLog = buildLines.join("\n")

const readySteps: DeployStep[] = [
  {
    id: "install",
    name: "Install dependencies",
    status: "done",
    durationMs: 14_200,
    log: installLog,
  },
  { id: "build", name: "Build", status: "done", durationMs: 46_800, log: buildLog },
  { id: "test", name: "Run tests", status: "done", durationMs: 8_400, log: testLog },
  { id: "deploy", name: "Deploy", status: "done", durationMs: 6_100, log: deployLog },
]

/** Shared content for the deploy-status examples: brightwell/console, PR 482. */

const COMMIT = "8f3c2a91d4be07c5a2e6f13b9d0c7e44a1b5f290"

const COMMIT_MESSAGE = "Paginate the runs list"

const BRANCH = "feat/runs-pagination"

const PREVIEW_URL = "https://console-git-feat-runs-pagination.brightwell.dev"

/** Queued, then each step runs with its log tailing live, then the preview lands. Replays. */
function DeployStatusDemo() {
  const [status, setStatus] = React.useState<DeployStatusState>("queued")
  const [active, setActive] = React.useState(-1)
  const [shown, setShown] = React.useState(0)
  const [stepStart, setStepStart] = React.useState(0)
  const [runStart, setRunStart] = React.useState(0)

  React.useEffect(() => {
    let id: number
    if (status === "queued") {
      id = window.setTimeout(() => {
        const now = Date.now()
        setRunStart(now - (PLAN[0]?.offset ?? 0))
        setStepStart(now - (PLAN[0]?.offset ?? 0))
        setActive(0)
        setShown(0)
        setStatus("building")
      }, 1400)
    } else if (status === "building") {
      const step = PLAN[active]
      if (!step) return
      if (shown < step.lines.length) {
        id = window.setTimeout(() => setShown((n) => n + 1), step.ms / step.lines.length)
      } else {
        id = window.setTimeout(() => {
          const next = active + 1
          if (next >= PLAN.length) {
            setStatus("ready")
            return
          }
          // The header total reads as the finished steps plus the one now running.
          const done = readySteps.slice(0, next).reduce((t, s) => t + (s.durationMs ?? 0), 0)
          const now = Date.now()
          setRunStart(now - done - (PLAN[next]?.offset ?? 0))
          setStepStart(now - (PLAN[next]?.offset ?? 0))
          setActive(next)
          setShown(0)
        }, 400)
      }
    } else {
      id = window.setTimeout(() => {
        setActive(-1)
        setShown(0)
        setStatus("queued")
      }, 5000)
    }
    return () => window.clearTimeout(id)
  }, [status, active, shown])

  const steps: DeployStep[] = readySteps.map((step, i) => {
    if (status === "ready") return step
    if (status === "queued" || i > active) return { id: step.id, name: step.name, status: "queued" }
    if (i < active) return step
    return {
      id: step.id,
      name: step.name,
      status: "running",
      startedAt: stepStart,
      log: PLAN[i]?.lines.slice(0, shown).join("\n") ?? "",
    }
  })

  return (
    <DeployStatus
      className="w-full max-w-lg"
      status={status}
      environment="preview"
      commit={COMMIT}
      commitMessage={COMMIT_MESSAGE}
      branch={BRANCH}
      steps={steps}
      startedAt={status === "building" ? runStart : undefined}
      url={PREVIEW_URL}
      onCancel={() => {
        setActive(-1)
        setStatus("queued")
      }}
    />
  )
}

/** The line under each 21st demo that points back to the kit. */
function MoreOn({ slug, children }: { slug: string; children: React.ReactNode }) {
  return (
    <p data-fit-hide className="text-[0.75rem]/[1rem] text-(color:--chatcn-fg-faint) text-center text-balance">
      {children}{" "}
      <a
        href={`https://chatcn.dev/docs/components/${slug}`}
        target="_blank"
        rel="noopener"
        className="text-(color:--chatcn-fg-subtle) hover:text-(color:--chatcn-fg) underline underline-offset-2"
      >
        chatcn.dev
      </a>
    </p>
  )
}

/** The kit's own example, framed for the 21st preview. */
export default function DeployStatusPreview() {
  return (
    <FitFrame minWidth="38rem">
      <div className="flex w-full flex-col items-center justify-center gap-4 p-6">
        <div className="flex w-full max-w-2xl justify-center">
          <DeployStatusDemo />
        </div>
        <MoreOn slug="deploy-status">Deploys from queued to ready, with live logs per step. On</MoreOn>
      </div>
    </FitFrame>
  )
}

Dependencies

More by Aadi

View all
  • Dither Orb

    Dither Orb

    12
  • Audio Visualizer

    Audio Visualizer

    9
  • Voice Orb

    Voice Orb

    8
  • Agent Lanes

    Agent Lanes

    6
  • Computer View

    Computer View

    3
  • Waveform

    Waveform

    2
  • Reasoning

    Reasoning

    2
  • Voice Session

    Voice Session

    1

Explore more

Part of chatcn — browse the full library on 21st.dev.