CI Checks
A pull request checks panel: running, passed and failed jobs with durations, expandable failure logs, and re-run.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/ci-checks.json'use client'
import { useEffect, useState } from 'react'
import './ci-checks.css'
export interface CiJob { id: string; name: string; ms: number; fail?: string[] }
type S = 'queued' | 'running' | 'pass' | 'fail'
export interface CiChecksProps {
jobs: CiJob[]
title?: string
onRerun?: (ids: string[]) => void
fill?: 'dark' | 'light'
className?: string
}
const Icon = ({ s }: { s: S }) => (
<svg className="sd-cic__i" data-s={s} viewBox="0 0 20 20" aria-hidden>
<circle cx="10" cy="10" r="8" />
{s === 'pass' && <path d="M6.5 10.2l2.4 2.3 4.6-5" />}
{s === 'fail' && <path d="M7 7l6 6M13 7l-6 6" />}
</svg>
)
/** Pull-request checks with re-run. */
export function CiChecks({ jobs, title = 'feat: play mode for batch 10', onRerun, fill = 'dark', className }: CiChecksProps) {
const [st, setSt] = useState<Record<string, { s: S; t: number; run: number }>>(() => Object.fromEntries(jobs.map((j) => [j.id, { s: 'queued' as S, t: 0, run: 0 }])))
const [open, setOpen] = useState<string | null>(null)
const [round, setRound] = useState(0)
useEffect(() => {
const timers: ReturnType<typeof setTimeout>[] = []
jobs.forEach((j, i) => {
if (st[j.id].s !== 'queued') return
timers.push(setTimeout(() => setSt((x) => ({ ...x, [j.id]: { ...x[j.id], s: 'running' } })), 250 + i * 220))
timers.push(setTimeout(() => setSt((x) => ({ ...x, [j.id]: { s: j.fail && x[j.id].run === 0 ? 'fail' : 'pass', t: j.ms, run: x[j.id].run } })), 250 + i * 220 + Math.min(2600, j.ms / 40)))
})
return () => timers.forEach(clearTimeout)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [round, jobs])
const vals = jobs.map((j) => st[j.id].s)
const pass = vals.filter((v) => v === 'pass').length, fail = vals.filter((v) => v === 'fail').length, busy = vals.some((v) => v === 'queued' || v === 'running')
const failed = jobs.filter((j) => st[j.id].s === 'fail').map((j) => j.id)
const rerun = () => { onRerun?.(failed); setOpen(null); setSt((x) => { const n = { ...x }; failed.forEach((id) => (n[id] = { s: 'queued', t: 0, run: n[id].run + 1 })); return n }); setRound((r) => r + 1) }
const sec = (ms: number) => (ms >= 60000 ? `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s` : `${Math.round(ms / 1000)}s`)
return (
<div className={['sd-cic', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-cic__h"><div><b>{title}</b><small aria-live="polite">{busy ? `${pass + fail} of ${jobs.length} checks done` : fail ? `${fail} failing, ${pass} passed` : `All ${pass} checks passed`}</small></div></div>
<ul>
{jobs.map((j) => {
const s = st[j.id]
return (
<li key={j.id}>
<div><Icon s={s.s} /><span>{j.name}</span>
{s.s === 'fail' && <button type="button" className="sd-cic__x" aria-expanded={open === j.id} onClick={() => setOpen(open === j.id ? null : j.id)}>{open === j.id ? 'Hide' : 'Details'}</button>}
<small>{s.s === 'queued' ? 'Queued' : s.s === 'running' ? 'Running' : `${s.s === 'pass' ? 'Passed' : 'Failed'} · ${sec(s.t)}`}</small></div>
{open === j.id && j.fail && <pre>{j.fail.map((l, i) => i === j.fail!.length - 1 ? <b key={i}>{l}</b> : l + '\n')}</pre>}
</li>
)
})}
</ul>
<div className="sd-cic__f"><button type="button" disabled={busy || !fail} onClick={rerun}>{fail ? `Re-run ${fail} failed` : busy ? 'Running checks…' : 'Ready to merge'}</button></div>
</div>
)
}.sd-cic { --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-cic[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-cic :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-cic :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-cic .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-cic { width: min(100%, 350px); grid-template-columns: minmax(0, 1fr); border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); display: grid; overflow: hidden; }
.sd-cic__h { display: flex; align-items: center; gap: 10px; padding: 14px; border-bottom: 1px solid var(--line); }
.sd-cic__h b { display: block; font-size: 14px; }
.sd-cic__h small { color: var(--mute); font-size: 12px; }
.sd-cic__h > div { flex: 1; min-width: 0; }
.sd-cic__h button { height: 30px; padding: 0 12px; border-radius: 8px; font-size: 12px; font-weight: 600; box-shadow: inset 0 0 0 1px var(--line); }
.sd-cic__h button:disabled { opacity: 0.4; }
.sd-cic ul { list-style: none; margin: 0; padding: 4px 0; }
.sd-cic li > div { display: flex; align-items: center; gap: 10px; padding: 8px 14px; font-size: 13px; }
.sd-cic li > div > span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sd-cic li small { color: var(--mute); font-size: 11.5px; font-variant-numeric: tabular-nums; }
.sd-cic__i { flex: none; width: 18px; height: 18px; }
.sd-cic__i circle, .sd-cic__i path { fill: none; stroke: var(--fg); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.sd-cic__i[data-s='queued'] circle { stroke: var(--mute); stroke-dasharray: 2 3; }
.sd-cic__i[data-s='running'] { animation: sd-cic-spin 900ms linear infinite; }
.sd-cic__i[data-s='running'] circle { stroke-dasharray: 30 60; }
.sd-cic__i[data-s='pass'] circle { fill: var(--fg); }
.sd-cic__i[data-s='pass'] path { stroke: var(--bg); }
.sd-cic__i[data-s='fail'] circle { stroke-width: 2.5; }
@keyframes sd-cic-spin { to { transform: rotate(1turn); } }
.sd-cic__x { font-size: 11.5px; color: var(--mute) !important; text-decoration: underline; text-underline-offset: 2px; }
.sd-cic pre { margin: 0 14px 8px; padding: 8px 10px; border-radius: 8px; background: var(--soft); font: 11px/1.55 ui-monospace, monospace; white-space: pre-wrap; color: var(--mute); }
.sd-cic pre b { color: var(--fg); font-weight: 600; }
.sd-cic__f { padding: 10px 14px; border-top: 1px solid var(--line); font-size: 12.5px; }
.sd-cic__f button { width: 100%; height: 38px; border-radius: 10px; font-weight: 600; background: var(--strong) !important; color: var(--on) !important; }
.sd-cic__f button:disabled { opacity: 0.4; }
@media (prefers-reduced-motion: reduce) { .sd-cic *, .sd-cic *::before, .sd-cic *::after { animation: none !important; transition: none !important; } }Overview
Jobs tick through queued, running and done states. The summary line counts what passed and what failed; a failed job expands to show the last log lines.
Re-run failed jobs restarts only those. States are shown by icon shape (ring, check, cross) as well as words.
When to use
- PR pages, deploy gates, internal dashboards.
When not to use
- Full build logs: link to the CI provider.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/ci-checks.json$
npx shadcn@latest add https://sundaeui.dev/r/ci-checks.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/ci-checks.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/ci-checks.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 ci-checks.tsx and ci-checks.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 { CiChecks } from '@/components/sundae/ci-checks'
<CiChecks />Props
| Prop | Type | Default |
|---|---|---|
jobs*Jobs with simulated durations. | { id: string; name: string; ms: number; fail?: string[] }[] | — |
onRerunCalled on re-run. | (ids: string[]) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Each job announces its state in text; the summary is a live region.
- Failure logs use a disclosure button with aria-expanded.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-cic; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is CI Checks free?
- Yes. CI Checks is free for personal and commercial projects, with full source on this page.
- How do I install CI Checks?
- Run npx shadcn@latest add https://sundaeui.dev/r/ci-checks.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does CI Checks support keyboard and screen readers?
- Each job announces its state in text; the summary is a live region. Failure logs use a disclosure button with aria-expanded.
- Does CI Checks respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Order Tracker
Components · RefinedFlight Status
Components · RefinedToast Stack
Components · Refined- Pro
Sheet Drawer
Components · Refined Avatar Stack
Components · Refined- Pro
Notification Center
Components · Refined