Pipeline
import { useEffect, useState } from 'react';
const css = `
@keyframes card-pipeline-spin { to { transform: rotate(360deg); } }
.card-pipeline {
width: 240px;
padding: 14px;
border-radius: 16px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
}
.card-pipeline__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.card-pipeline__head b { color: light-dark(#373a40, #f1f2f3); font-size: 13px; font-weight: 600; }
.card-pipeline__status { padding: 2px 8px; border-radius: 999px; background: light-dark(#ebedf0, #232529); color: light-dark(#42454c, #e5e6e9); font-size: 10px; font-weight: 600; transition: background-color 0.3s ease, color 0.3s ease; }
.card-pipeline[data-done] .card-pipeline__status { background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); }
.card-pipeline__track { display: flex; align-items: center; }
.card-pipeline__stage { display: grid; justify-items: center; gap: 6px; width: 44px; }
.card-pipeline__node {
position: relative;
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 50%;
border: 1.5px solid light-dark(#cbced3, #393c41);
background: light-dark(#fbfcfd, #121417);
color: light-dark(#fbfcfd, #121417);
transition: background-color 0.3s ease, border-color 0.3s ease;
}
.card-pipeline__node[data-s='run'] { border-color: transparent; }
.card-pipeline__node[data-s='run']::before {
content: '';
position: absolute;
inset: -1.5px;
border-radius: 50%;
border: 1.5px solid light-dark(#dbdde1, #2e3035);
border-top-color: light-dark(#373a40, #f1f2f3);
animation: card-pipeline-spin 0.7s linear infinite;
}
.card-pipeline__node[data-s='ok'] { background: light-dark(#373a40, #f1f2f3); border-color: light-dark(#373a40, #f1f2f3); }
.card-pipeline__node svg { width: 14px; height: 14px; stroke-dasharray: 1; stroke-dashoffset: 1; transition: stroke-dashoffset 0.3s ease 0.1s; }
.card-pipeline__node[data-s='ok'] svg { stroke-dashoffset: 0; }
.card-pipeline__name { color: light-dark(#5f626a, #a7abb2); font-size: 10px; }
.card-pipeline__stage[data-s='ok'] .card-pipeline__name, .card-pipeline__stage[data-s='run'] .card-pipeline__name { color: light-dark(#373a40, #f1f2f3); }
.card-pipeline__link { flex: 1; height: 2px; margin-bottom: 18px; border-radius: 1px; background: light-dark(#dbdde1, #2e3035); overflow: hidden; }
.card-pipeline__link span { display: block; height: 100%; background: light-dark(#373a40, #f1f2f3); transform: scaleX(0); transform-origin: left; transition: transform 0.4s ease; }
.card-pipeline__link[data-on] span { transform: scaleX(1); }
.card-pipeline__log { margin-top: 12px; color: light-dark(#5f626a, #a7abb2); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; }
`;
const STAGES = ['Build', 'Test', 'Deploy'];
// One line per step: queued, build running/done, test running/done, deploy running/done.
const LOG = ['queued', 'compiling 214 modules', 'build ok', 'running 86 tests', '86 passed', 'uploading to edge', 'live in 41s'];
export function PipelineCard() {
// Step 0 = queued; 2n+1 = stage n running; 2n+2 = stage n done.
const [step, setStep] = useState(0);
useEffect(() => {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return setStep(6);
const id = window.setInterval(() => setStep((s) => (s >= 8 ? 0 : s + 1)), 750);
return () => clearInterval(id);
}, []);
const state = (n: number) => (step >= 2 * n + 2 ? 'ok' : step === 2 * n + 1 ? 'run' : 'idle');
const done = step >= 6;
return (
<>
<style>
{css}
</style>
<div className="card-pipeline" data-done={done || undefined} role="group" aria-label="Deploy pipeline">
<div className="card-pipeline__head">
<b>main · a4f2c1e</b>
<span className="card-pipeline__status" aria-live="polite">
{done ? 'Deployed' : step === 0 ? 'Queued' : 'Running'}
</span>
</div>
<div className="card-pipeline__track">
{STAGES.map((name, n) => (
<div key={name} style={{ display: 'contents' }}>
{n > 0 && (
<span className="card-pipeline__link" data-on={step >= 2 * n || undefined} aria-hidden="true">
<span />
</span>
)}
<div className="card-pipeline__stage" data-s={state(n)}>
<span className="card-pipeline__node" data-s={state(n)} aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none">
<path d="M5 12.5l4.5 4.5L19 7.5" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" pathLength={1} />
</svg>
</span>
<span className="card-pipeline__name">{name}</span>
</div>
</div>
))}
</div>
<div className="card-pipeline__log" aria-hidden="true">
> {LOG[Math.min(LOG.length - 1, step)]}
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCardPipeline(el) on the mounted element instead.
<div class="card-pipeline" role="group" aria-label="Deploy pipeline">
<div class="card-pipeline__head">
<b>main · a4f2c1e</b>
<span class="card-pipeline__status" aria-live="polite">Queued</span>
</div>
<div class="card-pipeline__track">
<div style="display:contents">
<div class="card-pipeline__stage" data-s="idle">
<span class="card-pipeline__node" data-s="idle" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none">
<path d="M5 12.5l4.5 4.5L19 7.5" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" pathLength="1"></path>
</svg>
</span>
<span class="card-pipeline__name">Build</span>
</div>
</div>
<div style="display:contents">
<span class="card-pipeline__link" aria-hidden="true">
<span></span>
</span>
<div class="card-pipeline__stage" data-s="idle">
<span class="card-pipeline__node" data-s="idle" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none">
<path d="M5 12.5l4.5 4.5L19 7.5" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" pathLength="1"></path>
</svg>
</span>
<span class="card-pipeline__name">Test</span>
</div>
</div>
<div style="display:contents">
<span class="card-pipeline__link" aria-hidden="true">
<span></span>
</span>
<div class="card-pipeline__stage" data-s="idle">
<span class="card-pipeline__node" data-s="idle" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none">
<path d="M5 12.5l4.5 4.5L19 7.5" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" pathLength="1"></path>
</svg>
</span>
<span class="card-pipeline__name">Deploy</span>
</div>
</div>
</div>
<div class="card-pipeline__log" aria-hidden="true">> queued</div>
</div>@keyframes card-pipeline-spin { to { transform: rotate(360deg); } }
.card-pipeline {
width: 240px;
padding: 14px;
border-radius: 16px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
}
.card-pipeline__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.card-pipeline__head b { color: light-dark(#373a40, #f1f2f3); font-size: 13px; font-weight: 600; }
.card-pipeline__status { padding: 2px 8px; border-radius: 999px; background: light-dark(#ebedf0, #232529); color: light-dark(#42454c, #e5e6e9); font-size: 10px; font-weight: 600; transition: background-color 0.3s ease, color 0.3s ease; }
.card-pipeline[data-done] .card-pipeline__status { background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); }
.card-pipeline__track { display: flex; align-items: center; }
.card-pipeline__stage { display: grid; justify-items: center; gap: 6px; width: 44px; }
.card-pipeline__node {
position: relative;
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 50%;
border: 1.5px solid light-dark(#cbced3, #393c41);
background: light-dark(#fbfcfd, #121417);
color: light-dark(#fbfcfd, #121417);
transition: background-color 0.3s ease, border-color 0.3s ease;
}
.card-pipeline__node[data-s='run'] { border-color: transparent; }
.card-pipeline__node[data-s='run']::before {
content: '';
position: absolute;
inset: -1.5px;
border-radius: 50%;
border: 1.5px solid light-dark(#dbdde1, #2e3035);
border-top-color: light-dark(#373a40, #f1f2f3);
animation: card-pipeline-spin 0.7s linear infinite;
}
.card-pipeline__node[data-s='ok'] { background: light-dark(#373a40, #f1f2f3); border-color: light-dark(#373a40, #f1f2f3); }
.card-pipeline__node svg { width: 14px; height: 14px; stroke-dasharray: 1; stroke-dashoffset: 1; transition: stroke-dashoffset 0.3s ease 0.1s; }
.card-pipeline__node[data-s='ok'] svg { stroke-dashoffset: 0; }
.card-pipeline__name { color: light-dark(#5f626a, #a7abb2); font-size: 10px; }
.card-pipeline__stage[data-s='ok'] .card-pipeline__name, .card-pipeline__stage[data-s='run'] .card-pipeline__name { color: light-dark(#373a40, #f1f2f3); }
.card-pipeline__link { flex: 1; height: 2px; margin-bottom: 18px; border-radius: 1px; background: light-dark(#dbdde1, #2e3035); overflow: hidden; }
.card-pipeline__link span { display: block; height: 100%; background: light-dark(#373a40, #f1f2f3); transform: scaleX(0); transform-origin: left; transition: transform 0.4s ease; }
.card-pipeline__link[data-on] span { transform: scaleX(1); }
.card-pipeline__log { margin-top: 12px; color: light-dark(#5f626a, #a7abb2); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; }function initCardPipeline(root) {
var status = root.querySelector('.card-pipeline__status');
var stages = root.querySelectorAll('.card-pipeline__stage');
var links = root.querySelectorAll('.card-pipeline__link');
var log = root.querySelector('.card-pipeline__log');
if (!status || stages.length !== 3 || !log) return;
// One line per step: queued, build running/done, test running/done, deploy running/done.
var LOG = ['queued', 'compiling 214 modules', 'build ok', 'running 86 tests', '86 passed', 'uploading to edge', 'live in 41s'];
function render(step) {
// Step 0 = queued; 2n+1 = stage n running; 2n+2 = stage n done.
var done = step >= 6;
stages.forEach(function (s, n) {
var state = step >= 2 * n + 2 ? 'ok' : step === 2 * n + 1 ? 'run' : 'idle';
s.setAttribute('data-s', state);
s.querySelector('.card-pipeline__node').setAttribute('data-s', state);
});
links.forEach(function (l, i) { l.toggleAttribute('data-on', step >= 2 * (i + 1)); });
root.toggleAttribute('data-done', done);
status.textContent = done ? 'Deployed' : step === 0 ? 'Queued' : 'Running';
log.textContent = '> ' + LOG[Math.min(LOG.length - 1, step)];
}
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
render(6);
return;
}
var step = 0;
var timer = setInterval(function () {
step = step >= 8 ? 0 : step + 1;
render(step);
}, 750);
return function () { clearInterval(timer); };
}
document.querySelectorAll('.card-pipeline').forEach(initCardPipeline);Goal: Create a "Pipeline" card.
Visual behavior: A deploy pipeline that runs itself: build, test and deploy each spin while they run, tick and hand off along a filling connector to the next, with a log line under the stages narrating every step until it reads Deployed.
Technique:
- One step counter drives everything: 0 is queued, 2n + 1 means stage n is running, 2n + 2 means it is done. An interval advances it every 750ms and loops.
- A running node shows a spinner (a ring with one bright border segment on a rotate keyframe); a done node fills and its tick draws in with a dashoffset transition.
- Connectors between nodes scale from 0 to 1 on X as each hand-off happens, and the badge changes from Queued to Running to Deployed.
- The log line has one entry per step, so it always matches what the stages show.
Accessibility: A group labelled "Deploy pipeline"; the status badge is an aria-live region. Under prefers-reduced-motion it shows the finished state.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Pipeline" card.
Visual behavior: A deploy pipeline that runs itself: build, test and deploy each spin while they run, tick and hand off along a filling connector to the next, with a log line under the stages narrating every step until it reads Deployed.
Technique:
- One step counter drives everything: 0 is queued, 2n + 1 means stage n is running, 2n + 2 means it is done. An interval advances it every 750ms and loops.
- A running node shows a spinner (a ring with one bright border segment on a rotate keyframe); a done node fills and its tick draws in with a dashoffset transition.
- Connectors between nodes scale from 0 to 1 on X as each hand-off happens, and the badge changes from Queued to Running to Deployed.
- The log line has one entry per step, so it always matches what the stages show.
Accessibility: A group labelled "Deploy pipeline"; the status badge is an aria-live region. Under prefers-reduced-motion it shows the finished state.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A deploy pipeline that runs itself: build, test and deploy each spin while they run, tick and hand off along a filling connector to the next, with a log line under the stages narrating every step until it reads Deployed.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- continuous, state change, draw, card
- Techniques
- interval, state machine, stroke dashoffset, keyframes
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A group labelled "Deploy pipeline"; the status badge is an aria-live region. Under prefers-reduced-motion it shows the finished state.