32 CSS Timelines04 / 32
Order Tracking Timeline — Delivery Progress Stepper
The e-commerce essential: Placed → Packed → Shipped → Out for delivery → Delivered as a horizontal stepper whose connector fills to the current stage with one custom property, an animated pulse on the live step, ETA copy, and a container-query flip to the vertical mobile layout — the exact component order-status emails link to.
Published
The code
<section class="tls-04" aria-label="Order tracking timeline demo">
<div class="tls-04__card" style="--tl-progress:2.5;--tl-steps:5">
<header class="tls-04__head">
<div><p class="tls-04__no">Order #48-2214</p><p class="tls-04__eta">Arriving <time datetime="2026-07-21">Tue, Jul 21</time> by 8pm</p></div>
<span class="tls-04__badge">In transit</span>
</header>
<div class="tls-04__wrap">
<div class="tls-04__track" aria-hidden="true"><span class="tls-04__fill"></span></div>
<ol class="tls-04__steps" role="list">
<li class="tls-04__step" data-state="done"><span class="tls-04__dot" aria-hidden="true"></span><span class="tls-04__lab">Placed<span class="tls-04__sr">, completed</span></span><time class="tls-04__t" datetime="2026-07-17T10:12">Jul 17</time></li>
<li class="tls-04__step" data-state="done"><span class="tls-04__dot" aria-hidden="true"></span><span class="tls-04__lab">Packed<span class="tls-04__sr">, completed</span></span><time class="tls-04__t" datetime="2026-07-18T08:30">Jul 18</time></li>
<li class="tls-04__step" data-state="current" aria-current="step"><span class="tls-04__dot" aria-hidden="true"></span><span class="tls-04__lab">Shipped<span class="tls-04__sr">, current step</span></span><time class="tls-04__t" datetime="2026-07-19T06:15">Jul 19</time></li>
<li class="tls-04__step"><span class="tls-04__dot" aria-hidden="true"></span><span class="tls-04__lab">Out for delivery</span><span class="tls-04__t">—</span></li>
<li class="tls-04__step"><span class="tls-04__dot" aria-hidden="true"></span><span class="tls-04__lab">Delivered</span><span class="tls-04__t">—</span></li>
</ol>
</div>
</div>
</section><section class="tls-04" aria-label="Order tracking timeline demo">
<div class="tls-04__card" style="--tl-progress:2.5;--tl-steps:5">
<header class="tls-04__head">
<div><p class="tls-04__no">Order #48-2214</p><p class="tls-04__eta">Arriving <time datetime="2026-07-21">Tue, Jul 21</time> by 8pm</p></div>
<span class="tls-04__badge">In transit</span>
</header>
<div class="tls-04__wrap">
<div class="tls-04__track" aria-hidden="true"><span class="tls-04__fill"></span></div>
<ol class="tls-04__steps" role="list">
<li class="tls-04__step" data-state="done"><span class="tls-04__dot" aria-hidden="true"></span><span class="tls-04__lab">Placed<span class="tls-04__sr">, completed</span></span><time class="tls-04__t" datetime="2026-07-17T10:12">Jul 17</time></li>
<li class="tls-04__step" data-state="done"><span class="tls-04__dot" aria-hidden="true"></span><span class="tls-04__lab">Packed<span class="tls-04__sr">, completed</span></span><time class="tls-04__t" datetime="2026-07-18T08:30">Jul 18</time></li>
<li class="tls-04__step" data-state="current" aria-current="step"><span class="tls-04__dot" aria-hidden="true"></span><span class="tls-04__lab">Shipped<span class="tls-04__sr">, current step</span></span><time class="tls-04__t" datetime="2026-07-19T06:15">Jul 19</time></li>
<li class="tls-04__step"><span class="tls-04__dot" aria-hidden="true"></span><span class="tls-04__lab">Out for delivery</span><span class="tls-04__t">—</span></li>
<li class="tls-04__step"><span class="tls-04__dot" aria-hidden="true"></span><span class="tls-04__lab">Delivered</span><span class="tls-04__t">—</span></li>
</ol>
</div>
</div>
</section>.tls-04,
.tls-04 *,
.tls-04 *::before,
.tls-04 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.tls-04 {
min-height: 100vh;
width: 100%;
--tl-accent: #0972d3;
--tl-ok: #037f4c;
--tl-line: #e4e7eb;
--tl-ink: #16191f;
--tl-mut: #5f6b7a;
font-family: 'Segoe UI',system-ui,sans-serif;
background: #f4f6f8;
padding: 56px 24px;
display: grid;
place-items: center;
}
.tls-04__card {
width: min(720px,100%);
container-type: inline-size;
background: #fff;
border: 1px solid var(--tl-line);
border-radius: 16px;
padding: 26px 30px 30px;
box-shadow: 0 1px 4px rgba(15,25,40,.06);
}
.tls-04__head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 14px;
margin-bottom: 30px;
}
.tls-04__no {
font-size: 13px;
font-weight: 600;
color: var(--tl-mut);
}
.tls-04__eta {
font-size: 17px;
font-weight: 800;
color: var(--tl-ink);
letter-spacing: -.01em;
margin-top: 3px;
}
.tls-04__badge {
font-size: 11px;
font-weight: 700;
letter-spacing: .05em;
text-transform: uppercase;
color: var(--tl-accent);
background: #e8f2fc;
border-radius: 999px;
padding: 5px 12px;
}
.tls-04__wrap {
position: relative;
}
.tls-04__track {
position: absolute;
left: calc(10% );
right: 10%;
top: 13px;
height: 4px;
border-radius: 2px;
background: var(--tl-line);
overflow: hidden;
}
.tls-04__fill {
position: absolute;
inset: 0;
background: linear-gradient(90deg,var(--tl-ok),var(--tl-accent));
transform-origin: left;
transform: scaleX(calc(var(--tl-progress) / (var(--tl-steps) - 1)));
transition: transform .6s cubic-bezier(.3,.7,.3,1);
}
.tls-04__steps {
list-style: none;
display: flex;
position: relative;
}
.tls-04__step {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
text-align: center;
}
.tls-04__dot {
width: 30px;
height: 30px;
border-radius: 50%;
background: #fff;
border: 3px solid var(--tl-line);
position: relative;
z-index: 1;
}
.tls-04__step[data-state="done"] .tls-04__dot {
background: var(--tl-ok);
border-color: var(--tl-ok);
}
.tls-04__step[data-state="done"] .tls-04__dot::after {
content: '';
position: absolute;
left: 9px;
top: 6px;
width: 6px;
height: 11px;
border-right: 2.5px solid #fff;
border-bottom: 2.5px solid #fff;
rotate: 45deg;
}
.tls-04__step[data-state="current"] .tls-04__dot {
border-color: var(--tl-accent);
border-width: 4px;
}
.tls-04__step[data-state="current"] .tls-04__dot::before {
content: '';
position: absolute;
inset: -4px;
border-radius: 50%;
border: 2px solid var(--tl-accent);
animation: tls-04-pulse 1.8s ease-out infinite;
}
@keyframes tls-04-pulse {
0% {
transform: scale(1);
opacity: .7;
}
100% {
transform: scale(1.9);
opacity: 0;
}
}
.tls-04__lab {
font-size: 12.5px;
font-weight: 700;
color: var(--tl-mut);
max-width: 11ch;
text-wrap: balance;
}
.tls-04__step[data-state] .tls-04__lab {
color: var(--tl-ink);
}
.tls-04__t {
font-size: 11px;
color: var(--tl-mut);
}
.tls-04__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@container (max-width: 440px) {
.tls-04__track {
left: 14px;
right: auto;
top: 8px;
bottom: 8px;
width: 4px;
height: auto;
}
.tls-04__fill {
transform-origin: top;
transform: scaleY(calc(var(--tl-progress) / (var(--tl-steps) - 1)));
}
.tls-04__steps {
flex-direction: column;
gap: 22px;
}
.tls-04__step {
flex-direction: row;
text-align: left;
gap: 14px;
}
.tls-04__lab {
max-width: none;
}
.tls-04__t {
margin-left: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.tls-04__step[data-state="current"] .tls-04__dot::before {
animation: none;
opacity: .5;
}
.tls-04__fill {
transition: none;
}
}.tls-04,
.tls-04 *,
.tls-04 *::before,
.tls-04 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.tls-04 {
min-height: 100vh;
width: 100%;
--tl-accent: #0972d3;
--tl-ok: #037f4c;
--tl-line: #e4e7eb;
--tl-ink: #16191f;
--tl-mut: #5f6b7a;
font-family: 'Segoe UI',system-ui,sans-serif;
background: #f4f6f8;
padding: 56px 24px;
display: grid;
place-items: center;
}
.tls-04__card {
width: min(720px,100%);
container-type: inline-size;
background: #fff;
border: 1px solid var(--tl-line);
border-radius: 16px;
padding: 26px 30px 30px;
box-shadow: 0 1px 4px rgba(15,25,40,.06);
}
.tls-04__head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 14px;
margin-bottom: 30px;
}
.tls-04__no {
font-size: 13px;
font-weight: 600;
color: var(--tl-mut);
}
.tls-04__eta {
font-size: 17px;
font-weight: 800;
color: var(--tl-ink);
letter-spacing: -.01em;
margin-top: 3px;
}
.tls-04__badge {
font-size: 11px;
font-weight: 700;
letter-spacing: .05em;
text-transform: uppercase;
color: var(--tl-accent);
background: #e8f2fc;
border-radius: 999px;
padding: 5px 12px;
}
.tls-04__wrap {
position: relative;
}
.tls-04__track {
position: absolute;
left: calc(10% );
right: 10%;
top: 13px;
height: 4px;
border-radius: 2px;
background: var(--tl-line);
overflow: hidden;
}
.tls-04__fill {
position: absolute;
inset: 0;
background: linear-gradient(90deg,var(--tl-ok),var(--tl-accent));
transform-origin: left;
transform: scaleX(calc(var(--tl-progress) / (var(--tl-steps) - 1)));
transition: transform .6s cubic-bezier(.3,.7,.3,1);
}
.tls-04__steps {
list-style: none;
display: flex;
position: relative;
}
.tls-04__step {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
text-align: center;
}
.tls-04__dot {
width: 30px;
height: 30px;
border-radius: 50%;
background: #fff;
border: 3px solid var(--tl-line);
position: relative;
z-index: 1;
}
.tls-04__step[data-state="done"] .tls-04__dot {
background: var(--tl-ok);
border-color: var(--tl-ok);
}
.tls-04__step[data-state="done"] .tls-04__dot::after {
content: '';
position: absolute;
left: 9px;
top: 6px;
width: 6px;
height: 11px;
border-right: 2.5px solid #fff;
border-bottom: 2.5px solid #fff;
rotate: 45deg;
}
.tls-04__step[data-state="current"] .tls-04__dot {
border-color: var(--tl-accent);
border-width: 4px;
}
.tls-04__step[data-state="current"] .tls-04__dot::before {
content: '';
position: absolute;
inset: -4px;
border-radius: 50%;
border: 2px solid var(--tl-accent);
animation: tls-04-pulse 1.8s ease-out infinite;
}
@keyframes tls-04-pulse {
0% {
transform: scale(1);
opacity: .7;
}
100% {
transform: scale(1.9);
opacity: 0;
}
}
.tls-04__lab {
font-size: 12.5px;
font-weight: 700;
color: var(--tl-mut);
max-width: 11ch;
text-wrap: balance;
}
.tls-04__step[data-state] .tls-04__lab {
color: var(--tl-ink);
}
.tls-04__t {
font-size: 11px;
color: var(--tl-mut);
}
.tls-04__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@container (max-width: 440px) {
.tls-04__track {
left: 14px;
right: auto;
top: 8px;
bottom: 8px;
width: 4px;
height: auto;
}
.tls-04__fill {
transform-origin: top;
transform: scaleY(calc(var(--tl-progress) / (var(--tl-steps) - 1)));
}
.tls-04__steps {
flex-direction: column;
gap: 22px;
}
.tls-04__step {
flex-direction: row;
text-align: left;
gap: 14px;
}
.tls-04__lab {
max-width: none;
}
.tls-04__t {
margin-left: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.tls-04__step[data-state="current"] .tls-04__dot::before {
animation: none;
opacity: .5;
}
.tls-04__fill {
transition: none;
}
}/* No JavaScript — progress is one custom property (--tl-progress) on the card; the fill derives via scaleX(calc(progress / (steps − 1))), so a backend renders a single inline style. */
/* No JavaScript — progress is one custom property (--tl-progress) on the card; the fill derives via scaleX(calc(progress / (steps − 1))), so a backend renders a single inline style. */Here's a working CSS Timeline from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Order Tracking Timeline — Delivery Progress Stepper
Source: https://codefronts.com/layouts/css-timelines/order-tracking-timeline-delivery-progress-stepper/
The e-commerce essential: Placed → Packed → Shipped → Out for delivery → Delivered as a horizontal stepper whose connector fills to the current stage with one custom property, an animated pulse on the live step, ETA copy, and a container-query flip to the vertical mobile layout — the exact component order-status emails link to.
## HTML
```html
<section class="tls-04" aria-label="Order tracking timeline demo">
<div class="tls-04__card" style="--tl-progress:2.5;--tl-steps:5">
<header class="tls-04__head">
<div><p class="tls-04__no">Order #48-2214</p><p class="tls-04__eta">Arriving <time datetime="2026-07-21">Tue, Jul 21</time> by 8pm</p></div>
<span class="tls-04__badge">In transit</span>
</header>
<div class="tls-04__wrap">
<div class="tls-04__track" aria-hidden="true"><span class="tls-04__fill"></span></div>
<ol class="tls-04__steps" role="list">
<li class="tls-04__step" data-state="done"><span class="tls-04__dot" aria-hidden="true"></span><span class="tls-04__lab">Placed<span class="tls-04__sr">, completed</span></span><time class="tls-04__t" datetime="2026-07-17T10:12">Jul 17</time></li>
<li class="tls-04__step" data-state="done"><span class="tls-04__dot" aria-hidden="true"></span><span class="tls-04__lab">Packed<span class="tls-04__sr">, completed</span></span><time class="tls-04__t" datetime="2026-07-18T08:30">Jul 18</time></li>
<li class="tls-04__step" data-state="current" aria-current="step"><span class="tls-04__dot" aria-hidden="true"></span><span class="tls-04__lab">Shipped<span class="tls-04__sr">, current step</span></span><time class="tls-04__t" datetime="2026-07-19T06:15">Jul 19</time></li>
<li class="tls-04__step"><span class="tls-04__dot" aria-hidden="true"></span><span class="tls-04__lab">Out for delivery</span><span class="tls-04__t">—</span></li>
<li class="tls-04__step"><span class="tls-04__dot" aria-hidden="true"></span><span class="tls-04__lab">Delivered</span><span class="tls-04__t">—</span></li>
</ol>
</div>
</div>
</section>
```
## CSS
```css
.tls-04,
.tls-04 *,
.tls-04 *::before,
.tls-04 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.tls-04 {
min-height: 100vh;
width: 100%;
--tl-accent: #0972d3;
--tl-ok: #037f4c;
--tl-line: #e4e7eb;
--tl-ink: #16191f;
--tl-mut: #5f6b7a;
font-family: 'Segoe UI',system-ui,sans-serif;
background: #f4f6f8;
padding: 56px 24px;
display: grid;
place-items: center;
}
.tls-04__card {
width: min(720px,100%);
container-type: inline-size;
background: #fff;
border: 1px solid var(--tl-line);
border-radius: 16px;
padding: 26px 30px 30px;
box-shadow: 0 1px 4px rgba(15,25,40,.06);
}
.tls-04__head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 14px;
margin-bottom: 30px;
}
.tls-04__no {
font-size: 13px;
font-weight: 600;
color: var(--tl-mut);
}
.tls-04__eta {
font-size: 17px;
font-weight: 800;
color: var(--tl-ink);
letter-spacing: -.01em;
margin-top: 3px;
}
.tls-04__badge {
font-size: 11px;
font-weight: 700;
letter-spacing: .05em;
text-transform: uppercase;
color: var(--tl-accent);
background: #e8f2fc;
border-radius: 999px;
padding: 5px 12px;
}
.tls-04__wrap {
position: relative;
}
.tls-04__track {
position: absolute;
left: calc(10% );
right: 10%;
top: 13px;
height: 4px;
border-radius: 2px;
background: var(--tl-line);
overflow: hidden;
}
.tls-04__fill {
position: absolute;
inset: 0;
background: linear-gradient(90deg,var(--tl-ok),var(--tl-accent));
transform-origin: left;
transform: scaleX(calc(var(--tl-progress) / (var(--tl-steps) - 1)));
transition: transform .6s cubic-bezier(.3,.7,.3,1);
}
.tls-04__steps {
list-style: none;
display: flex;
position: relative;
}
.tls-04__step {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
text-align: center;
}
.tls-04__dot {
width: 30px;
height: 30px;
border-radius: 50%;
background: #fff;
border: 3px solid var(--tl-line);
position: relative;
z-index: 1;
}
.tls-04__step[data-state="done"] .tls-04__dot {
background: var(--tl-ok);
border-color: var(--tl-ok);
}
.tls-04__step[data-state="done"] .tls-04__dot::after {
content: '';
position: absolute;
left: 9px;
top: 6px;
width: 6px;
height: 11px;
border-right: 2.5px solid #fff;
border-bottom: 2.5px solid #fff;
rotate: 45deg;
}
.tls-04__step[data-state="current"] .tls-04__dot {
border-color: var(--tl-accent);
border-width: 4px;
}
.tls-04__step[data-state="current"] .tls-04__dot::before {
content: '';
position: absolute;
inset: -4px;
border-radius: 50%;
border: 2px solid var(--tl-accent);
animation: tls-04-pulse 1.8s ease-out infinite;
}
@keyframes tls-04-pulse {
0% {
transform: scale(1);
opacity: .7;
}
100% {
transform: scale(1.9);
opacity: 0;
}
}
.tls-04__lab {
font-size: 12.5px;
font-weight: 700;
color: var(--tl-mut);
max-width: 11ch;
text-wrap: balance;
}
.tls-04__step[data-state] .tls-04__lab {
color: var(--tl-ink);
}
.tls-04__t {
font-size: 11px;
color: var(--tl-mut);
}
.tls-04__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@container (max-width: 440px) {
.tls-04__track {
left: 14px;
right: auto;
top: 8px;
bottom: 8px;
width: 4px;
height: auto;
}
.tls-04__fill {
transform-origin: top;
transform: scaleY(calc(var(--tl-progress) / (var(--tl-steps) - 1)));
}
.tls-04__steps {
flex-direction: column;
gap: 22px;
}
.tls-04__step {
flex-direction: row;
text-align: left;
gap: 14px;
}
.tls-04__lab {
max-width: none;
}
.tls-04__t {
margin-left: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.tls-04__step[data-state="current"] .tls-04__dot::before {
animation: none;
opacity: .5;
}
.tls-04__fill {
transition: none;
}
}
```
## JavaScript
```js
/* No JavaScript — progress is one custom property (--tl-progress) on the card; the fill derives via scaleX(calc(progress / (steps − 1))), so a backend renders a single inline style. */
```Here's a working CSS Timeline from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Order Tracking Timeline — Delivery Progress Stepper
Source: https://codefronts.com/layouts/css-timelines/order-tracking-timeline-delivery-progress-stepper/
The e-commerce essential: Placed → Packed → Shipped → Out for delivery → Delivered as a horizontal stepper whose connector fills to the current stage with one custom property, an animated pulse on the live step, ETA copy, and a container-query flip to the vertical mobile layout — the exact component order-status emails link to.
## HTML
```html
<section class="tls-04" aria-label="Order tracking timeline demo">
<div class="tls-04__card" style="--tl-progress:2.5;--tl-steps:5">
<header class="tls-04__head">
<div><p class="tls-04__no">Order #48-2214</p><p class="tls-04__eta">Arriving <time datetime="2026-07-21">Tue, Jul 21</time> by 8pm</p></div>
<span class="tls-04__badge">In transit</span>
</header>
<div class="tls-04__wrap">
<div class="tls-04__track" aria-hidden="true"><span class="tls-04__fill"></span></div>
<ol class="tls-04__steps" role="list">
<li class="tls-04__step" data-state="done"><span class="tls-04__dot" aria-hidden="true"></span><span class="tls-04__lab">Placed<span class="tls-04__sr">, completed</span></span><time class="tls-04__t" datetime="2026-07-17T10:12">Jul 17</time></li>
<li class="tls-04__step" data-state="done"><span class="tls-04__dot" aria-hidden="true"></span><span class="tls-04__lab">Packed<span class="tls-04__sr">, completed</span></span><time class="tls-04__t" datetime="2026-07-18T08:30">Jul 18</time></li>
<li class="tls-04__step" data-state="current" aria-current="step"><span class="tls-04__dot" aria-hidden="true"></span><span class="tls-04__lab">Shipped<span class="tls-04__sr">, current step</span></span><time class="tls-04__t" datetime="2026-07-19T06:15">Jul 19</time></li>
<li class="tls-04__step"><span class="tls-04__dot" aria-hidden="true"></span><span class="tls-04__lab">Out for delivery</span><span class="tls-04__t">—</span></li>
<li class="tls-04__step"><span class="tls-04__dot" aria-hidden="true"></span><span class="tls-04__lab">Delivered</span><span class="tls-04__t">—</span></li>
</ol>
</div>
</div>
</section>
```
## CSS
```css
.tls-04,
.tls-04 *,
.tls-04 *::before,
.tls-04 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.tls-04 {
min-height: 100vh;
width: 100%;
--tl-accent: #0972d3;
--tl-ok: #037f4c;
--tl-line: #e4e7eb;
--tl-ink: #16191f;
--tl-mut: #5f6b7a;
font-family: 'Segoe UI',system-ui,sans-serif;
background: #f4f6f8;
padding: 56px 24px;
display: grid;
place-items: center;
}
.tls-04__card {
width: min(720px,100%);
container-type: inline-size;
background: #fff;
border: 1px solid var(--tl-line);
border-radius: 16px;
padding: 26px 30px 30px;
box-shadow: 0 1px 4px rgba(15,25,40,.06);
}
.tls-04__head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 14px;
margin-bottom: 30px;
}
.tls-04__no {
font-size: 13px;
font-weight: 600;
color: var(--tl-mut);
}
.tls-04__eta {
font-size: 17px;
font-weight: 800;
color: var(--tl-ink);
letter-spacing: -.01em;
margin-top: 3px;
}
.tls-04__badge {
font-size: 11px;
font-weight: 700;
letter-spacing: .05em;
text-transform: uppercase;
color: var(--tl-accent);
background: #e8f2fc;
border-radius: 999px;
padding: 5px 12px;
}
.tls-04__wrap {
position: relative;
}
.tls-04__track {
position: absolute;
left: calc(10% );
right: 10%;
top: 13px;
height: 4px;
border-radius: 2px;
background: var(--tl-line);
overflow: hidden;
}
.tls-04__fill {
position: absolute;
inset: 0;
background: linear-gradient(90deg,var(--tl-ok),var(--tl-accent));
transform-origin: left;
transform: scaleX(calc(var(--tl-progress) / (var(--tl-steps) - 1)));
transition: transform .6s cubic-bezier(.3,.7,.3,1);
}
.tls-04__steps {
list-style: none;
display: flex;
position: relative;
}
.tls-04__step {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
text-align: center;
}
.tls-04__dot {
width: 30px;
height: 30px;
border-radius: 50%;
background: #fff;
border: 3px solid var(--tl-line);
position: relative;
z-index: 1;
}
.tls-04__step[data-state="done"] .tls-04__dot {
background: var(--tl-ok);
border-color: var(--tl-ok);
}
.tls-04__step[data-state="done"] .tls-04__dot::after {
content: '';
position: absolute;
left: 9px;
top: 6px;
width: 6px;
height: 11px;
border-right: 2.5px solid #fff;
border-bottom: 2.5px solid #fff;
rotate: 45deg;
}
.tls-04__step[data-state="current"] .tls-04__dot {
border-color: var(--tl-accent);
border-width: 4px;
}
.tls-04__step[data-state="current"] .tls-04__dot::before {
content: '';
position: absolute;
inset: -4px;
border-radius: 50%;
border: 2px solid var(--tl-accent);
animation: tls-04-pulse 1.8s ease-out infinite;
}
@keyframes tls-04-pulse {
0% {
transform: scale(1);
opacity: .7;
}
100% {
transform: scale(1.9);
opacity: 0;
}
}
.tls-04__lab {
font-size: 12.5px;
font-weight: 700;
color: var(--tl-mut);
max-width: 11ch;
text-wrap: balance;
}
.tls-04__step[data-state] .tls-04__lab {
color: var(--tl-ink);
}
.tls-04__t {
font-size: 11px;
color: var(--tl-mut);
}
.tls-04__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@container (max-width: 440px) {
.tls-04__track {
left: 14px;
right: auto;
top: 8px;
bottom: 8px;
width: 4px;
height: auto;
}
.tls-04__fill {
transform-origin: top;
transform: scaleY(calc(var(--tl-progress) / (var(--tl-steps) - 1)));
}
.tls-04__steps {
flex-direction: column;
gap: 22px;
}
.tls-04__step {
flex-direction: row;
text-align: left;
gap: 14px;
}
.tls-04__lab {
max-width: none;
}
.tls-04__t {
margin-left: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.tls-04__step[data-state="current"] .tls-04__dot::before {
animation: none;
opacity: .5;
}
.tls-04__fill {
transition: none;
}
}
```
## JavaScript
```js
/* No JavaScript — progress is one custom property (--tl-progress) on the card; the fill derives via scaleX(calc(progress / (steps − 1))), so a backend renders a single inline style. */
```How this works
Progress is ONE number: the wrapper sets --tl-progress: 2.5 (stage index + half for 'in transit between steps') and a full-width track behind the steps scales its fill with transform: scaleX(calc(var(--tl-progress) / (var(--tl-steps) - 1))). Your backend renders one inline style; the connector, done-states and pulse all derive from it. Scaling transform (not width) keeps the fill animatable at 60fps.
Steps are an <ol> of fixed-flex items with icon dots. Completed steps show the border-drawn checkmark; the current step carries aria-current="step" plus a soft expanding-ring pulse (an ::after animating scale + opacity — transform-only). Every state is also written as visually-hidden text so the sequence reads 'Placed, completed. Shipped, current step…' in a screen reader.
A container query rotates the whole stepper vertical under 440px of container width — the track becomes a left rail (scaleY fill instead of scaleX), labels move beside their dots, and nothing about the DOM changes.
Techniques used: single --tl-progress custom property driving the fill via scaleX calc() · transform-scaled progress track (GPU, animatable) · aria-current="step" + hidden state text · expanding-ring pulse with transform/opacity only · container query horizontal→vertical flip with a scaleX→scaleY swap · time[datetime] ETA
Make it yours
- Wire the backend: render
style="--tl-progress:3"on the wrapper — 0-indexed, halves land mid-connector ('between facilities'). - Step count: add an
<li>and bump--tl-steps— the fill math self-adjusts. - Pulse subtlety: the ring's max scale (1.9) and duration (1.8s); remove it entirely under reduced motion (already handled).
- Brand it: --tl-accent for the fill/current, --tl-ok for done — two tokens, whole component.
Gotchas — read before shipping
- Animate the fill with transform:scaleX and transform-origin:left — animating width re-layouts the row every frame and stutters on mid-range phones.
- --tl-progress must never exceed --tl-steps − 1 — clamp() it in the calc if the value comes from user data.
- The pulse ring is decoration: keep it on ::after with aria-hidden semantics (pseudo-elements are never announced) and kill it in prefers-reduced-motion.
- In the vertical flip, remember transform-origin flips too (left → top) or the fill grows from the wrong end.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 114+ | 17.5+ | 121+ | 114+ |
Floor set by @container, text-wrap as this demo is written. The core technique itself goes back further — Chrome 105+.
Container query gates only the vertical flip; without it the stepper stays horizontal and scrolls. Everything else is baseline CSS.