Deploy Status
A deployment card: build, upload and promote stages with live timing, the current production version, and rollback.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/deploy-status.json'use client'
import { useEffect, useRef, useState } from 'react'
import './deploy-status.css'
export interface DeployVersion { id: string; label: string; when: string }
export interface DeployStatusProps {
versions: DeployVersion[]
env?: string
onDeploy?: () => void
onRollback?: (id: string) => void
fill?: 'dark' | 'light'
className?: string
}
const STAGES: [string, number][] = [['Build', 2200], ['Upload', 900], ['Checks', 1300], ['Promote', 600]]
/** Deployment progress, production version and rollback. */
export function DeployStatus({ versions, env = 'Production', onDeploy, onRollback, fill = 'dark', className }: DeployStatusProps) {
const [hist, setHist] = useState(versions)
const [live, setLive] = useState(versions[0]?.id)
const [stage, setStage] = useState(-1)
const [el, setEl] = useState(0)
const [times, setTimes] = useState<number[]>([])
const [confirm, setConfirm] = useState<string | null>(null)
const [msg, setMsg] = useState('')
const n = useRef(hist.length + 41)
useEffect(() => {
if (stage < 0 || stage >= STAGES.length) return
const t0 = performance.now()
const tick = setInterval(() => setEl(performance.now() - t0), 100)
const done = setTimeout(() => {
setTimes((x) => [...x, STAGES[stage][1]])
if (stage === STAGES.length - 1) {
const id = `v1.${n.current++}`
setHist((h) => [{ id, label: 'Components batch 11', when: 'just now' }, ...h]); setLive(id); setStage(-1); setMsg(`${id} is live`)
} else setStage(stage + 1)
}, STAGES[stage][1])
return () => { clearInterval(tick); clearTimeout(done) }
}, [stage])
const busy = stage >= 0
const cur = hist.find((v) => v.id === live)
return (
<div className={['sd-dps', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-dps__top">
<span className="sd-dps__dot" data-busy={busy || undefined} aria-hidden />
<div><small>{env}</small><b>{cur?.id}</b></div>
<button type="button" className="sd-dps__go" disabled={busy} onClick={() => { setTimes([]); setStage(0); setEl(0); setMsg('Deploying'); onDeploy?.() }}>{busy ? 'Deploying…' : 'Deploy'}</button>
</div>
<ol aria-label="Stages">
{STAGES.map(([l], i) => {
const s = i < times.length ? 'done' : i === stage ? 'run' : 'todo'
return <li key={l} data-s={s} aria-current={s === 'run' ? 'step' : undefined}><i /><span>{l}{s === 'done' ? ` ${(times[i] / 1000).toFixed(1)}s` : s === 'run' ? ` ${(el / 1000).toFixed(1)}s` : ''}</span></li>
})}
</ol>
<ul className="sd-dps__hist" aria-label="History">
{hist.slice(0, 4).map((v) => (
<li key={v.id}>
<code>{v.id}</code><span>{v.label}</span>
{v.id === live ? <em>Live</em> : <>
<small>{v.when}</small>
<button type="button" disabled={busy} data-confirm={confirm === v.id || undefined} onClick={() => { if (confirm !== v.id) { setConfirm(v.id); return } setLive(v.id); setConfirm(null); setMsg(`Rolled back to ${v.id}`); onRollback?.(v.id) }} onBlur={() => setConfirm(null)}>{confirm === v.id ? 'Confirm' : 'Roll back'}</button>
</>}
</li>
))}
</ul>
<p className="sr" aria-live="polite">{msg}</p>
</div>
)
}.sd-dps { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-dps[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-dps :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-dps :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-dps .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-dps { width: min(100%, 340px); padding: 14px; grid-template-columns: minmax(0, 1fr); border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); display: grid; gap: 12px; }
.sd-dps__top { display: flex; align-items: center; gap: 10px; }
.sd-dps__top > div { flex: 1; min-width: 0; }
.sd-dps__top small { display: block; color: var(--mute); font-size: 11.5px; }
.sd-dps__top b { font-size: 15px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.sd-dps__dot { width: 8px; height: 8px; border-radius: 50%; background: var(--fg); }
.sd-dps__dot[data-busy] { background: transparent; box-shadow: inset 0 0 0 2px var(--fg); animation: sd-dps-p 1s ease-in-out infinite; }
@keyframes sd-dps-p { 50% { transform: scale(1.5); } }
.sd-dps__go { height: 34px; padding: 0 14px; border-radius: 10px; font-size: 13px; font-weight: 600; background: var(--strong) !important; color: var(--on) !important; }
.sd-dps__go:disabled { opacity: 0.4; }
.sd-dps ol { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; }
.sd-dps ol li { display: grid; gap: 4px; font-size: 11.5px; color: var(--mute); }
.sd-dps ol li i { height: 4px; border-radius: 2px; background: var(--line); overflow: hidden; position: relative; }
.sd-dps ol li[data-s='done'] i { background: var(--fg); }
.sd-dps ol li[data-s='done'], .sd-dps ol li[aria-current='step'] { color: var(--fg); }
.sd-dps ol li[aria-current='step'] i::after { content: ''; position: absolute; inset: 0; width: 40%; background: var(--fg); animation: sd-dps-r 900ms ease-in-out infinite alternate; }
@keyframes sd-dps-r { to { transform: translateX(150%); } }
.sd-dps ol li span { font-variant-numeric: tabular-nums; }
.sd-dps__hist { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
.sd-dps__hist li { display: flex; align-items: center; gap: 8px; padding: 9px 0; border-bottom: 1px solid var(--line); font-size: 12.5px; }
.sd-dps__hist li > span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sd-dps__hist code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; padding: 2px 6px; border-radius: 5px; background: var(--soft); }
.sd-dps__hist em { font-style: normal; font-size: 10.5px; font-weight: 700; padding: 2px 7px; border-radius: 99px; background: var(--strong); color: var(--on); }
.sd-dps__hist small { color: var(--mute); font-size: 11px; }
.sd-dps__hist button { padding: 4px 9px; border-radius: 7px; font-size: 11.5px; font-weight: 600; box-shadow: inset 0 0 0 1px var(--line); }
.sd-dps__hist button[data-confirm] { background: var(--strong) !important; color: var(--on) !important; box-shadow: none; }
@media (prefers-reduced-motion: reduce) { .sd-dps *, .sd-dps *::before, .sd-dps *::after { animation: none !important; transition: none !important; } }Overview
Deploy walks through stages with a timer on the active one. When it lands, the card shows the new version as production and keeps the previous one for rollback.
Rollback is a two-step confirm, and the history list below shows what has been live and when.
When to use
- Hosting dashboards, internal release tools.
When not to use
- Multi-region canaries: show per-region status.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/deploy-status.json$
npx shadcn@latest add https://sundaeui.dev/r/deploy-status.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/deploy-status.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/deploy-status.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy deploy-status.tsx and deploy-status.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { DeployStatus } from '@/components/sundae/deploy-status'
<DeployStatus />Props
| Prop | Type | Default |
|---|---|---|
versions*History, live first. | { id: string; label: string; when: string }[] | — |
onDeployCalled on deploy. | () => void | — |
onRollbackCalled on rollback. | (id: string) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Stages are an ordered list with aria-current on the active stage.
- Rollback needs an explicit confirm; outcomes are announced.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-dps; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Deploy Status free?
- Yes. Deploy Status is free for personal and commercial projects, with full source on this page.
- How do I install Deploy Status?
- Run npx shadcn@latest add https://sundaeui.dev/r/deploy-status.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Deploy Status support keyboard and screen readers?
- Stages are an ordered list with aria-current on the active stage. Rollback needs an explicit confirm; outcomes are announced.
- Does Deploy Status respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Incident Timeline
Components · RefinedToast Stack
Components · Refined- Pro
Sheet Drawer
Components · Refined Avatar Stack
Components · Refined- Pro
Notification Center
Components · Refined Hold to Confirm
Components · Refined