Introduction
In this article we build DispatchBtn — an e-commerce order button with a fully animated delivery truck sequence built entirely from CSS keyframes and a single vanilla JS class toggle. When clicked, a package slides into the truck's cargo bay, the truck drives across the button, speed lines streak past, headlights ignite, and the label fades from "Dispatch Order" to "On Its Way ✓" — all inside the button's own 240×63px boundary. No frameworks, no SVG animation libraries, no canvas — just HTML, CSS custom properties, and 13 lines of JavaScript.
Every visual element — the truck cab, the cargo box, the speed lines, the windshield glare — is built from div elements and CSS pseudo-elements. The animation runs as a timed 10-second keyframe sequence choreographed across five separate @keyframes blocks. The entire component is re-themeable by editing a single :root block.
How to build a multi-element CSS animation inside a single button — how to create a pixel truck entirely from div elements and pseudo-elements — how to choreograph five independent keyframe animations on the same timeline — how to use overflow: hidden as a stage for off-screen actors — how to toggle CSS animations with a single JS class — how to use stroke-dashoffset for a check-mark draw-on effect — and how to structure all design tokens with CSS custom properties for a fully re-themeable component.
Step 1 — CSS Custom Properties
All colour and palette values live in :root as custom properties. The button uses nine colour tokens. Swapping the blue cab for any other colour requires touching only --btn-blue.
:root {
/* palette */
--btn-bg: #1C212E; /* button dark background */
--btn-blue: #275EFE; /* truck cab colour */
--btn-blue-light: #7699FF; /* windshield tint */
--btn-grey: #6C7486; /* cab connector */
--btn-grey-dark: #3F4656; /* connector gradient stop */
--btn-grey-light: #CDD9ED; /* cargo bay light stop */
--btn-green: #16BF78; /* success check stroke */
--btn-sand: #DCB773; /* package box dark stop */
--btn-sand-light: #EDD9A9; /* package box light stop */
}Step 2 — The Markup
The button contains five child elements. Two <span> labels handle the idle/success text states. Three <div> elements — .cargo, .vehicle, and .rail — are the animated actors. The truck is further divided into .cab-back, .cab-front, .windshield, and two .headlight divs.
<button class="ship-btn" id="shipBtn">
<!-- label: idle -->
<span class="lbl idle">Dispatch Order</span>
<!-- label: success (with SVG checkmark) -->
<span class="lbl done">
On Its Way
<svg viewBox="0 0 12 10">
<polyline points="1.5 6 4.5 9 10.5 1" />
</svg>
</span>
<!-- animated package box -->
<div class="cargo"></div>
<!-- delivery truck -->
<div class="vehicle">
<div class="cab-back"></div>
<div class="cab-front">
<div class="windshield"></div>
</div>
<div class="headlight top"></div>
<div class="headlight bottom"></div>
</div>
<!-- speed lines -->
<div class="rail"></div>
</button>| Element | Role | Animated |
|---|---|---|
| .ship-btn | Button container — clips all actors via overflow: hidden | No |
| .lbl.idle | Default label text | Yes — opacity fades out on .go |
| .lbl.done | Success label + SVG check | Yes — opacity fades in, check draws on |
| .cargo | Package box, starts off left edge | Yes — slides right into truck bay |
| .vehicle | Truck wrapper | Yes — drives across the button |
| .vehicle::before/after | Cargo bay door flaps | Yes — rotate open/closed |
| .headlight::before | Headlight beam glow | Yes — fades in as truck drives |
| .rail | Speed lines (box-shadow trick) | Yes — slides left as truck drives |
Step 3 — overflow: hidden as a Stage
The entire animation works because overflow: hidden on .ship-btn hides anything positioned outside its bounds. The truck starts at translateX(24px) — just past the right edge — and the cargo box starts at right: 100% — just past the left edge. Both are invisible until the animation moves them into frame, like actors waiting in the wings.
The button is not just a button — it is a stage. overflow: hidden is the curtain. Every actor enters from off-screen and exits to off-screen.
/* The button clips all out-of-bounds actors */
.ship-btn {
position: relative;
overflow: hidden;
/* -webkit-mask ensures overflow:hidden works with border-radius */
-webkit-mask-image: -webkit-radial-gradient(white, black);
}
/* Truck — starts off right edge, transform drives it left */
.vehicle {
left: 100%; /* anchor at right edge */
transform: translateX(24px); /* push 24px beyond right edge */
}
/* Cargo — starts off left edge */
.cargo {
right: 100%; /* anchor at left edge */
/* translateX animation moves it rightward into the button */
}Step 4 — Choreographing the Truck
The veh keyframe drives the entire truck sequence in four acts across 10 seconds. At 10% it enters from the right and parks centre-left to receive the package. At 40% it pulls forward slightly — the bay doors close on the box. At 60% it drives fully off the left edge. From 75% it resets to the right, ready for the next click.
/* ── truck: 4-act drive sequence ── */
@keyframes veh {
/* Act 1: enter from right, park at loading position */
10%, 30% { transform: translateX(-164px); }
/* Act 2: pull forward slightly — doors close on package */
40% { transform: translateX(-104px); }
/* Act 3: drive off the left edge */
60% { transform: translateX(-224px); }
/* Act 4: reset off right edge (instant jump, invisible) */
75%, 100%{ transform: translateX(24px); }
}
/* ── cargo door flaps: open to receive, close on package ── */
@keyframes hatch1 { 30%, 50% { transform: rotate(32deg); } }
@keyframes hatch2 { 30%, 50% { transform: rotate(-32deg); } }
/* Apply with staggered delays so top door opens first */
.ship-btn.go .vehicle::before {
animation: hatch1 2.4s ease forwards 0.3s;
}
.ship-btn.go .vehicle::after {
animation: hatch2 2.4s ease forwards 0.6s;
}From 60% to 75% the truck is fully off the left edge — invisible. The keyframe uses this hidden window to teleport back to translateX(24px) (the starting position off the right edge) with no visible jump. It is a classic animation loop trick: reset during the blackout, not during the visible sequence.
Step 5 — Package Box & Speed Lines
The .cargo box starts off the left edge and slides right as the truck parks. At 26% it abruptly becomes invisible — the package has been loaded. The speed lines use a pure CSS box-shadow trick: a single 6px-wide element with 22 box-shadow offsets creates 22 evenly-spaced lines at zero extra DOM cost.
/* ── package: slides in, disappears into truck bay ── */
@keyframes pkg {
8%, 10% { transform: translateX(40px); opacity: 1; }
25% { transform: translateX(112px); opacity: 1; }
26% { transform: translateX(112px); opacity: 0; } /* loaded */
100% { transform: translateX(0); opacity: 0; }
}
/* ── speed lines: one element, 22 box-shadow clones ── */
.rail {
opacity: 0;
height: 3px; width: 6px;
top: 30px; left: 100%;
background: #fff;
/* 22 copies spaced 15px apart, all white */
box-shadow:
15px 0 0 #fff, 30px 0 0 #fff, 45px 0 0 #fff, /* ... */
330px 0 0 #fff;
}
@keyframes rails {
0%, 30% { opacity: 0; transform: scaleY(.7) translateX(0); }
35%, 65% { opacity: 1; }
70% { opacity: 0; }
100% { transform: scaleY(.7) translateX(-400px); }
}Twenty-two speed lines from a single 6px div — the box-shadow trick lets you clone a shape across the button width at near-zero performance cost, since all shadows render in a single paint call.
Step 6 — Headlights & Perspective Windshield
The headlight beams are ::before pseudo-elements on .headlight. They fade in as the truck pulls away using the glow keyframe. The windshield uses perspective(4px) rotateY(3deg) to create a subtle 3D tilt, making the flat shape read as a real angled glass surface. A transform-origin: 0 50% ensures the tilt pivots from the left edge, where it meets the cab body.
/* ── windshield: perspective tilt for 3D feel ── */
.windshield {
background: var(--btn-blue-light);
transform: perspective(4px) rotateY(3deg);
transform-origin: 0 50%; /* pivot from left seam */
border-radius: 2px 8px 8px 2px;
}
/* ── headlight beam: fades in when truck drives ── */
.headlight::before {
content: "";
height: 4px; width: 7px;
opacity: 0; /* hidden until truck is moving */
background: linear-gradient(90deg,
rgba(240,220,95,1),
rgba(240,220,95,.7),
rgba(240,220,95,0) /* fades to transparent */
);
transform: perspective(2px) rotateY(-15deg);
}
@keyframes glow {
0%, 30% { opacity: 0; }
40%, 100%{ opacity: 1; }
}Step 7 — Label Transitions & SVG Checkmark Draw-On
The two labels share a --o CSS variable that drives opacity. On .go the idle label fades out immediately, and the done label fades in after a 7 second delay — just as the truck animation finishes. The SVG checkmark uses the classic stroke-dashoffset trick: the polyline has a stroke-dasharray equal to its own path length (16px), and animating stroke-dashoffset from 16 to 0 draws the stroke progressively.
/* ── idle label ── */
.lbl.idle { transition-delay: .3s; } /* slight delay on re-show */
.go .lbl.idle { opacity: 0; transition-delay: 0s; }
/* ── done label: appears at 7s when truck is gone ── */
.lbl.done { opacity: 0; }
.go .lbl.done { opacity: 1; transition-delay: 7s; }
/* ── SVG checkmark: stroke-dashoffset draw-on ── */
.lbl.done svg {
stroke: var(--btn-green);
stroke-dasharray: 16px; /* total path length */
stroke-dashoffset: 16px; /* fully hidden */
transition: stroke-dashoffset .3s ease;
}
.go .lbl.done svg {
stroke-dashoffset: 0; /* draw the check */
transition-delay: 7.3s; /* 0.3s after label fades in */
}Step 8 — 13 Lines of Vanilla JS
The entire animation is triggered by adding a single class — .go — to the button. A guard prevents re-triggering mid-animation. A setTimeout removes the class after 10 seconds, resetting the button cleanly. No jQuery, no animation library, no state management.
const btn = document.getElementById('shipBtn');
btn.addEventListener('click', () => {
// Guard: prevent re-triggering mid-animation
if (btn.classList.contains('go')) return;
// Start: add class triggers all CSS animations
btn.classList.add('go');
// Reset: remove class after animation completes (10s)
setTimeout(() => {
btn.classList.remove('go');
}, 10_000);
});The button has five simultaneous animations with different durations. animationend would fire five separate times on five different elements. Using a single setTimeout matching the longest animation (10 seconds) is simpler, more predictable, and avoids event delegation across the button's children.
Tuning Reference
| Token / Property | Default | Effect |
|---|---|---|
| --btn-blue | #275EFE | Truck cab colour. Change to retheme the whole truck |
| --btn-green | #16BF78 | Checkmark stroke colour on success label |
| 10s duration | 10s | Total animation length. Must match setTimeout value |
| translateX(-164px) | -164px | Truck loading position. Adjust if button width changes |
| translateX(-224px) | -224px | Truck exit position. Must exceed button width + truck width |
| transition-delay: 7s | 7s | When success label appears. Sync with truck exit keyframe |
| box-shadow on .rail | 15px steps | Speed line spacing. Tighter steps = denser lines |
| animation-delay hatch1/2 | 0.3s/0.6s | Stagger between top and bottom door opening |
| stroke-dashoffset delay | 7.3s | Check draw begins 0.3s after success label fades in |
Full Source Code
Save the following as dispatch-button.html and open in any modern browser. Zero dependencies, zero build step, zero JavaScript frameworks.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/dispatch-button.htmlEvery effect in DispatchBtn — the truck drive, the package load, the speed lines, the headlight glow, the checkmark draw-on — is pure CSS keyframes. Not a single line of JavaScript touches the animation itself.