20 CSS Loading Animations01 / 20

Pure CSSMIT licensed

E-Commerce Checkout Cart Loading Animation

The waiting state that costs the most money on the internet. Instead of a bare spinner, this one narrates the three things actually happening after a shopper hits Payprocessing payment → confirming order → generating receipt — while product tiles drop into an animated cart and a determinate bar fills. The percentage readout is pure CSS: an animated @property integer piped into counter(), so there is no setInterval repainting text every frame.

Published

Live Demo
Try it

The code

<div class="la-01" data-state="loading">
  <div class="la-01__chrome">
    <button class="la-01__btn la-01__theme" type="button" aria-pressed="false" aria-label="Switch to dark theme">
      <svg class="la-01__moon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M20 14.2A8.2 8.2 0 0 1 9.8 4 8.4 8.4 0 1 0 20 14.2"/></svg>
      <svg class="la-01__sun" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4"/><path d="M12 3.4v2M12 18.6v2M3.4 12h2M18.6 12h2M6 6l1.4 1.4M16.6 16.6 18 18M18 6l-1.4 1.4M7.4 16.6 6 18"/></svg>
    </button>
    <button class="la-01__btn la-01__done" type="button" aria-pressed="false" aria-label="Show completed state">
      <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.6 4.4 4.4L19 7.4"/></svg>
    </button>
  </div>

  <section class="la-01__card" role="status" aria-live="polite" aria-label="Placing your order">
    <div class="la-01__stage" aria-hidden="true">
      <span class="la-01__item la-01__item--a"></span>
      <span class="la-01__item la-01__item--b"></span>
      <span class="la-01__item la-01__item--c"></span>
      <svg class="la-01__cart" viewBox="0 0 64 64" focusable="false">
        <path d="M5 11h8l7.6 25.4A5 5 0 0 0 25.4 40h21.9a5 5 0 0 0 4.8-3.5L58 19H16.5"/>
        <circle cx="27" cy="52" r="4.2"/><circle cx="47" cy="52" r="4.2"/>
      </svg>
      <svg class="la-01__seal" viewBox="0 0 64 64" focusable="false">
        <circle cx="32" cy="32" r="21"/><path class="la-01__tick" d="M22 33 29.6 40.4 43 24.6"/>
      </svg>
    </div>

    <div class="la-01__copy">
      <p class="la-01__label">
        <span class="la-01__load">Processing payment</span>
        <span class="la-01__ready">Order confirmed</span>
      </p>
      <p class="la-01__meta"><span class="la-01__amt">$47.20</span> <span aria-hidden="true">·</span> Order #CF-48210</p>
    </div>

    <ol class="la-01__steps">
      <li class="la-01__step">Payment</li>
      <li class="la-01__step">Order</li>
      <li class="la-01__step">Receipt</li>
    </ol>

    <div class="la-01__track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="68" aria-valuetext="68 percent complete">
      <span class="la-01__fill"></span>
    </div>
    <p class="la-01__pct"></p>
  </section>
</div>
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap');

@property --la-01-p {
  syntax: '<integer>';
  inherits: false;
  initial-value: 0;
}

@property --la-01-spin {
  syntax: '<angle>';
  inherits: false;
  initial-value: 0deg;
}

.la-01 {
  --bg: #f2f3f7;
  --surface: #ffffff;
  --ink: #0b0e14;
  --muted: #646e7e;
  --line: #e4e7ee;
  --acc: #5b3df5;
  --acc-ink: #ffffff;
  --ok: #0f9d63;
  --ring: #5b3df5;
  --shadow: rgba(11,14,20,.16);
  --dur: 5.4s;
  --sans: "Geist",ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
  --mono: "Geist Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
  position: relative;
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  padding: clamp(1.25rem,5vw,4rem);
  background: radial-gradient(120% 80% at 50% -20%, color-mix(in oklab,var(--acc) 16%,transparent), transparent 60%), radial-gradient(80% 60% at 100% 100%, color-mix(in oklab,var(--ok) 10%,transparent), transparent 60%), var(--bg);
  color: var(--ink);
  font-family: var(--sans);
  -webkit-font-smoothing: antialiased;
}

.la-01,
.la-01 *,
.la-01 *::before,
.la-01 *::after {
  box-sizing: border-box;
}

@supports (color: oklch(0 0 0)) {
  .la-01 {
    --acc: oklch(54% .25 285);
    --ok: oklch(62% .16 158);
    --ink: oklch(15% .02 265);
    --muted: oklch(52% .015 265);
  }
}

.la-01__card {
  position: relative;
  inline-size: min(24rem,100%);
  padding: 2.1rem 1.6rem 1.5rem;
  display: grid;
  justify-items: center;
  gap: 1.15rem;
  border-radius: 1.75rem;
  background: linear-gradient(180deg, color-mix(in oklab,var(--surface) 94%,var(--acc)), var(--surface));
  border: 1px solid var(--line);
  container-type: inline-size;
  box-shadow: 0 1px 0 color-mix(in oklab,white 80%,transparent) inset, 0 40px 70px -40px var(--shadow), 0 2px 8px -4px var(--shadow);
}

@supports (corner-shape: squircle) {
  .la-01__card {
    corner-shape: squircle;
    border-radius: 2.2rem;
  }
}

.la-01__stage {
  position: relative;
  inline-size: 7.5rem;
  block-size: 7.5rem;
  display: grid;
  place-items: center;
}

.la-01__stage::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 999px;
  background: conic-gradient(from var(--la-01-spin), color-mix(in oklab,var(--acc) 70%,transparent), transparent 42%, color-mix(in oklab,var(--acc) 70%,transparent));
  mask: radial-gradient(closest-side, transparent 76%, black 77%);
  animation: la-01-spin 3.2s linear infinite;
}

@keyframes la-01-spin {
  to {
    --la-01-spin: 360deg;
  }
}

.la-01__cart,
.la-01__seal {
  position: absolute;
  inline-size: 3.5rem;
  block-size: 3.5rem;
  fill: none;
  stroke: var(--ink);
  stroke-width: 3.4;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.la-01__seal {
  stroke: var(--ok);
  opacity: 0;
  scale: .7;
  transition: opacity .35s ease, scale .45s cubic-bezier(.16,1,.3,1);
}

.la-01__tick {
  stroke-dasharray: 32;
  stroke-dashoffset: 32;
  transition: stroke-dashoffset .5s .12s ease;
}

.la-01__item {
  position: absolute;
  inline-size: .85rem;
  block-size: .85rem;
  border-radius: .28rem;
  background: var(--acc);
  box-shadow: 0 4px 10px -4px var(--acc);
  animation: la-01-drop 1.8s cubic-bezier(.5,0,.6,1) infinite;
}

.la-01__item--a {
  --x: -1.5rem;
  background: var(--acc);
  animation-delay: 0s;
}

.la-01__item--b {
  --x: 0rem;
  background: color-mix(in oklab,var(--acc) 55%,var(--ok));
  animation-delay: .6s;
}

.la-01__item--c {
  --x: 1.5rem;
  background: color-mix(in oklab,var(--acc) 40%,black);
  animation-delay: 1.2s;
}

@keyframes la-01-drop {
  0% {
    translate: var(--x) -3.4rem;
    opacity: 0;
    rotate: -25deg;
  }

  22% {
    opacity: 1;
  }

  60% {
    translate: var(--x) -.1rem;
    opacity: 1;
    rotate: 8deg;
  }

  74%,
    100% {
    translate: var(--x) .5rem;
    opacity: 0;
    rotate: 14deg;
  }
}

.la-01__copy {
  display: grid;
  gap: .3rem;
  justify-items: center;
  text-align: center;
}

.la-01__label {
  margin: 0;
  font-size: 1.12rem;
  font-weight: 600;
  letter-spacing: -.02em;
}

.la-01__ready {
  display: none;
}

.la-01__meta {
  margin: 0;
  font-family: var(--mono);
  font-size: .78rem;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

.la-01__amt {
  color: var(--ink);
  font-weight: 500;
}

.la-01__steps {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  gap: 1rem;
  flex-wrap: wrap;
  justify-content: center;
  font-size: .75rem;
  letter-spacing: .02em;
  color: var(--muted);
}

.la-01__step {
  display: flex;
  align-items: center;
  gap: .4rem;
  opacity: .38;
  animation: la-01-step var(--dur) linear infinite;
}

.la-01__step::before {
  content: "";
  inline-size: .42rem;
  block-size: .42rem;
  border-radius: 999px;
  background: currentColor;
}

.la-01__step:nth-child(1) {
  animation-delay: 0s;
}

.la-01__step:nth-child(2) {
  animation-delay: 1.8s;
}

.la-01__step:nth-child(3) {
  animation-delay: 3.6s;
}

@keyframes la-01-step {
  0%,
    3% {
    opacity: .34;
    color: var(--muted);
  }

  8%,
    30% {
    opacity: 1;
    color: var(--acc);
  }

  35%,
    100% {
    opacity: .34;
    color: var(--muted);
  }
}

.la-01__track {
  inline-size: 100%;
  block-size: .5rem;
  border-radius: 999px;
  overflow: hidden;
  background: color-mix(in oklab,var(--ink) 9%,transparent);
}

.la-01__fill {
  display: block;
  block-size: 100%;
  border-radius: inherit;
  inline-size: calc(var(--la-01-p, 68) * 1%);
  background: linear-gradient(90deg, color-mix(in oklab,var(--acc) 65%,white), var(--acc));
  animation: la-01-count var(--dur) linear infinite;
}

.la-01__pct {
  margin: 0;
  font-family: var(--mono);
  font-size: .72rem;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
  counter-reset: la-01-pct var(--la-01-p, 68);
  animation: la-01-count var(--dur) linear infinite;
}

.la-01__pct::after {
  content: counter(la-01-pct) "%";
}

@keyframes la-01-count {
  from {
    --la-01-p: 0;
  }

  to {
    --la-01-p: 100;
  }
}
/* ── completed state: what your success callback switches on ── */

.la-01.is-ready .la-01__cart {
  opacity: 0;
  scale: .8;
}

.la-01.is-ready .la-01__seal {
  opacity: 1;
  scale: 1;
}

.la-01.is-ready .la-01__tick {
  stroke-dashoffset: 0;
}

.la-01.is-ready .la-01__item,
.la-01.is-ready .la-01__stage::before {
  animation: none;
  opacity: 0;
}

.la-01.is-ready .la-01__load {
  display: none;
}

.la-01.is-ready .la-01__ready {
  display: inline;
}

.la-01.is-ready .la-01__step {
  animation: none;
  opacity: 1;
  color: var(--ok);
}

.la-01.is-ready .la-01__fill {
  animation: none;
  inline-size: 100%;
  background: var(--ok);
}

.la-01.is-ready .la-01__pct {
  animation: none;
  --la-01-p: 100;
}

.la-01:has(.la-01__done[aria-pressed="true"]) .la-01__card {
  border-color: color-mix(in oklab,var(--ok) 40%,var(--line));
}

@supports (transition-behavior: allow-discrete) {
  .la-01__seal {
    transition-behavior: allow-discrete;
  }

  @starting-style {
    .la-01.is-ready .la-01__seal {
      opacity: 0;
      scale: .7;
    }
  }
}
/* ── chrome ── */

.la-01__chrome {
  position: absolute;
  inset-block-start: clamp(.75rem,2.5vw,1.5rem);
  inset-inline-end: clamp(.75rem,2.5vw,1.5rem);
  z-index: 20;
  display: flex;
  gap: .5rem;
}

.la-01__btn {
  inline-size: 2.75rem;
  block-size: 2.75rem;
  display: grid;
  place-items: center;
  cursor: pointer;
  padding: 0;
  border-radius: 999px;
  border: 1px solid color-mix(in oklab,var(--ink) 16%,transparent);
  color: var(--ink);
  background: color-mix(in oklab,var(--surface) 72%,transparent);
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
  transition: background .25s ease, transform .25s cubic-bezier(.16,1,.3,1), color .25s ease;
}

.la-01__btn:hover {
  background: var(--surface);
  transform: translateY(-1px);
}

.la-01__btn:focus-visible {
  outline: 2px solid var(--ring);
  outline-offset: 3px;
}

.la-01__btn svg {
  grid-area: 1/1;
  inline-size: 1.15rem;
  block-size: 1.15rem;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.la-01__sun {
  display: none;
}

.la-01__theme[aria-pressed="true"] .la-01__sun {
  display: block;
}

.la-01__theme[aria-pressed="true"] .la-01__moon {
  display: none;
}

.la-01__done[aria-pressed="true"] {
  background: var(--ok);
  border-color: transparent;
  color: #fff;
}
/* ── dark theme ── */

@media (prefers-color-scheme: dark) {
  .la-01:not([data-theme="light"]) {
    --bg: #080a0f;
    --surface: #12151d;
    --ink: #eef1f6;
    --muted: #98a2b3;
    --line: #232936;
    --acc: #8b78ff;
    --ok: #37d39a;
    --ring: #8b78ff;
    --shadow: rgba(0,0,0,.6);
  }

  .la-01:not([data-theme="light"]) .la-01__card {
    box-shadow: 0 1px 0 color-mix(in oklab,white 8%,transparent) inset, 0 40px 80px -40px #000;
  }
}

.la-01[data-theme="dark"] {
  --bg: #080a0f;
  --surface: #12151d;
  --ink: #eef1f6;
  --muted: #98a2b3;
  --line: #232936;
  --acc: #8b78ff;
  --ok: #37d39a;
  --ring: #8b78ff;
  --shadow: rgba(0,0,0,.6);
}

.la-01[data-theme="dark"] .la-01__card {
  box-shadow: 0 1px 0 color-mix(in oklab,white 8%,transparent) inset, 0 40px 80px -40px #000;
}

@media (prefers-reduced-motion: reduce) {
  .la-01 * {
    animation: none !important;
    transition: none !important;
  }

  .la-01__item {
    opacity: 1;
    translate: var(--x) .2rem;
  }

  .la-01__stage::before {
    opacity: .5;
  }

  .la-01__fill {
    inline-size: 68%;
  }

  .la-01__step {
    opacity: 1;
  }
}

@media (forced-colors: active) {
  .la-01__card {
    border: 1px solid CanvasText;
  }

  .la-01__fill {
    background: Highlight;
  }

  .la-01__btn {
    border: 1px solid ButtonText;
    background: ButtonFace;
    color: ButtonText;
  }
}

@container (max-width: 19rem) {
  .la-01__steps {
    gap: .65rem;
    font-size: .7rem;
  }
}
(() => {
  const root = document.querySelector('.la-01');
  if (!root) return;
  const theme = root.querySelector('.la-01__theme');
  const done = root.querySelector('.la-01__done');
  const bar = root.querySelector('[role="progressbar"]');
  const dark = () => (root.dataset.theme || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')) === 'dark';
  const sync = () => {
    theme.setAttribute('aria-pressed', String(dark()));
    theme.setAttribute('aria-label', dark() ? 'Switch to light theme' : 'Switch to dark theme');
  };
  theme.addEventListener('click', () => { root.dataset.theme = dark() ? 'light' : 'dark'; sync(); });
  matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { if (!root.dataset.theme) sync(); });
  done.addEventListener('click', () => {
    const ready = root.classList.toggle('is-ready');        // the one line your success callback needs
    root.dataset.state = ready ? 'ready' : 'loading';
    done.setAttribute('aria-pressed', String(ready));
    done.setAttribute('aria-label', ready ? 'Return to loading state' : 'Show completed state');
    bar.setAttribute('aria-valuenow', ready ? 100 : 68);
    bar.setAttribute('aria-valuetext', ready ? 'Order confirmed' : '68 percent complete');
  });
  sync();
})();
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Loading Animation 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: E-Commerce Checkout Cart Loading Animation
Source: https://codefronts.com/motion/css-loading-animations/e-commerce-checkout-cart-loading-animation/

The waiting state that costs the most money on the internet. Instead of a bare spinner, this one narrates the three things actually happening after a shopper hits Pay — processing payment → confirming order → generating receipt — while product tiles drop into an animated cart and a determinate bar fills. The percentage readout is pure CSS: an animated @property integer piped into counter(), so there is no setInterval repainting text every frame.
## HTML
```html
<div class="la-01" data-state="loading">
  <div class="la-01__chrome">
    <button class="la-01__btn la-01__theme" type="button" aria-pressed="false" aria-label="Switch to dark theme">
      <svg class="la-01__moon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M20 14.2A8.2 8.2 0 0 1 9.8 4 8.4 8.4 0 1 0 20 14.2"/></svg>
      <svg class="la-01__sun" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4"/><path d="M12 3.4v2M12 18.6v2M3.4 12h2M18.6 12h2M6 6l1.4 1.4M16.6 16.6 18 18M18 6l-1.4 1.4M7.4 16.6 6 18"/></svg>
    </button>
    <button class="la-01__btn la-01__done" type="button" aria-pressed="false" aria-label="Show completed state">
      <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.6 4.4 4.4L19 7.4"/></svg>
    </button>
  </div>

  <section class="la-01__card" role="status" aria-live="polite" aria-label="Placing your order">
    <div class="la-01__stage" aria-hidden="true">
      <span class="la-01__item la-01__item--a"></span>
      <span class="la-01__item la-01__item--b"></span>
      <span class="la-01__item la-01__item--c"></span>
      <svg class="la-01__cart" viewBox="0 0 64 64" focusable="false">
        <path d="M5 11h8l7.6 25.4A5 5 0 0 0 25.4 40h21.9a5 5 0 0 0 4.8-3.5L58 19H16.5"/>
        <circle cx="27" cy="52" r="4.2"/><circle cx="47" cy="52" r="4.2"/>
      </svg>
      <svg class="la-01__seal" viewBox="0 0 64 64" focusable="false">
        <circle cx="32" cy="32" r="21"/><path class="la-01__tick" d="M22 33 29.6 40.4 43 24.6"/>
      </svg>
    </div>

    <div class="la-01__copy">
      <p class="la-01__label">
        <span class="la-01__load">Processing payment</span>
        <span class="la-01__ready">Order confirmed</span>
      </p>
      <p class="la-01__meta"><span class="la-01__amt">$47.20</span> <span aria-hidden="true">·</span> Order #CF-48210</p>
    </div>

    <ol class="la-01__steps">
      <li class="la-01__step">Payment</li>
      <li class="la-01__step">Order</li>
      <li class="la-01__step">Receipt</li>
    </ol>

    <div class="la-01__track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="68" aria-valuetext="68 percent complete">
      <span class="la-01__fill"></span>
    </div>
    <p class="la-01__pct"></p>
  </section>
</div>
```
## CSS
```css
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap');

@property --la-01-p {
  syntax: '<integer>';
  inherits: false;
  initial-value: 0;
}

@property --la-01-spin {
  syntax: '<angle>';
  inherits: false;
  initial-value: 0deg;
}

.la-01 {
  --bg: #f2f3f7;
  --surface: #ffffff;
  --ink: #0b0e14;
  --muted: #646e7e;
  --line: #e4e7ee;
  --acc: #5b3df5;
  --acc-ink: #ffffff;
  --ok: #0f9d63;
  --ring: #5b3df5;
  --shadow: rgba(11,14,20,.16);
  --dur: 5.4s;
  --sans: "Geist",ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
  --mono: "Geist Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
  position: relative;
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  padding: clamp(1.25rem,5vw,4rem);
  background: radial-gradient(120% 80% at 50% -20%, color-mix(in oklab,var(--acc) 16%,transparent), transparent 60%), radial-gradient(80% 60% at 100% 100%, color-mix(in oklab,var(--ok) 10%,transparent), transparent 60%), var(--bg);
  color: var(--ink);
  font-family: var(--sans);
  -webkit-font-smoothing: antialiased;
}

.la-01,
.la-01 *,
.la-01 *::before,
.la-01 *::after {
  box-sizing: border-box;
}

@supports (color: oklch(0 0 0)) {
  .la-01 {
    --acc: oklch(54% .25 285);
    --ok: oklch(62% .16 158);
    --ink: oklch(15% .02 265);
    --muted: oklch(52% .015 265);
  }
}

.la-01__card {
  position: relative;
  inline-size: min(24rem,100%);
  padding: 2.1rem 1.6rem 1.5rem;
  display: grid;
  justify-items: center;
  gap: 1.15rem;
  border-radius: 1.75rem;
  background: linear-gradient(180deg, color-mix(in oklab,var(--surface) 94%,var(--acc)), var(--surface));
  border: 1px solid var(--line);
  container-type: inline-size;
  box-shadow: 0 1px 0 color-mix(in oklab,white 80%,transparent) inset, 0 40px 70px -40px var(--shadow), 0 2px 8px -4px var(--shadow);
}

@supports (corner-shape: squircle) {
  .la-01__card {
    corner-shape: squircle;
    border-radius: 2.2rem;
  }
}

.la-01__stage {
  position: relative;
  inline-size: 7.5rem;
  block-size: 7.5rem;
  display: grid;
  place-items: center;
}

.la-01__stage::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 999px;
  background: conic-gradient(from var(--la-01-spin), color-mix(in oklab,var(--acc) 70%,transparent), transparent 42%, color-mix(in oklab,var(--acc) 70%,transparent));
  mask: radial-gradient(closest-side, transparent 76%, black 77%);
  animation: la-01-spin 3.2s linear infinite;
}

@keyframes la-01-spin {
  to {
    --la-01-spin: 360deg;
  }
}

.la-01__cart,
.la-01__seal {
  position: absolute;
  inline-size: 3.5rem;
  block-size: 3.5rem;
  fill: none;
  stroke: var(--ink);
  stroke-width: 3.4;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.la-01__seal {
  stroke: var(--ok);
  opacity: 0;
  scale: .7;
  transition: opacity .35s ease, scale .45s cubic-bezier(.16,1,.3,1);
}

.la-01__tick {
  stroke-dasharray: 32;
  stroke-dashoffset: 32;
  transition: stroke-dashoffset .5s .12s ease;
}

.la-01__item {
  position: absolute;
  inline-size: .85rem;
  block-size: .85rem;
  border-radius: .28rem;
  background: var(--acc);
  box-shadow: 0 4px 10px -4px var(--acc);
  animation: la-01-drop 1.8s cubic-bezier(.5,0,.6,1) infinite;
}

.la-01__item--a {
  --x: -1.5rem;
  background: var(--acc);
  animation-delay: 0s;
}

.la-01__item--b {
  --x: 0rem;
  background: color-mix(in oklab,var(--acc) 55%,var(--ok));
  animation-delay: .6s;
}

.la-01__item--c {
  --x: 1.5rem;
  background: color-mix(in oklab,var(--acc) 40%,black);
  animation-delay: 1.2s;
}

@keyframes la-01-drop {
  0% {
    translate: var(--x) -3.4rem;
    opacity: 0;
    rotate: -25deg;
  }

  22% {
    opacity: 1;
  }

  60% {
    translate: var(--x) -.1rem;
    opacity: 1;
    rotate: 8deg;
  }

  74%,
    100% {
    translate: var(--x) .5rem;
    opacity: 0;
    rotate: 14deg;
  }
}

.la-01__copy {
  display: grid;
  gap: .3rem;
  justify-items: center;
  text-align: center;
}

.la-01__label {
  margin: 0;
  font-size: 1.12rem;
  font-weight: 600;
  letter-spacing: -.02em;
}

.la-01__ready {
  display: none;
}

.la-01__meta {
  margin: 0;
  font-family: var(--mono);
  font-size: .78rem;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

.la-01__amt {
  color: var(--ink);
  font-weight: 500;
}

.la-01__steps {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  gap: 1rem;
  flex-wrap: wrap;
  justify-content: center;
  font-size: .75rem;
  letter-spacing: .02em;
  color: var(--muted);
}

.la-01__step {
  display: flex;
  align-items: center;
  gap: .4rem;
  opacity: .38;
  animation: la-01-step var(--dur) linear infinite;
}

.la-01__step::before {
  content: "";
  inline-size: .42rem;
  block-size: .42rem;
  border-radius: 999px;
  background: currentColor;
}

.la-01__step:nth-child(1) {
  animation-delay: 0s;
}

.la-01__step:nth-child(2) {
  animation-delay: 1.8s;
}

.la-01__step:nth-child(3) {
  animation-delay: 3.6s;
}

@keyframes la-01-step {
  0%,
    3% {
    opacity: .34;
    color: var(--muted);
  }

  8%,
    30% {
    opacity: 1;
    color: var(--acc);
  }

  35%,
    100% {
    opacity: .34;
    color: var(--muted);
  }
}

.la-01__track {
  inline-size: 100%;
  block-size: .5rem;
  border-radius: 999px;
  overflow: hidden;
  background: color-mix(in oklab,var(--ink) 9%,transparent);
}

.la-01__fill {
  display: block;
  block-size: 100%;
  border-radius: inherit;
  inline-size: calc(var(--la-01-p, 68) * 1%);
  background: linear-gradient(90deg, color-mix(in oklab,var(--acc) 65%,white), var(--acc));
  animation: la-01-count var(--dur) linear infinite;
}

.la-01__pct {
  margin: 0;
  font-family: var(--mono);
  font-size: .72rem;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
  counter-reset: la-01-pct var(--la-01-p, 68);
  animation: la-01-count var(--dur) linear infinite;
}

.la-01__pct::after {
  content: counter(la-01-pct) "%";
}

@keyframes la-01-count {
  from {
    --la-01-p: 0;
  }

  to {
    --la-01-p: 100;
  }
}
/* ── completed state: what your success callback switches on ── */

.la-01.is-ready .la-01__cart {
  opacity: 0;
  scale: .8;
}

.la-01.is-ready .la-01__seal {
  opacity: 1;
  scale: 1;
}

.la-01.is-ready .la-01__tick {
  stroke-dashoffset: 0;
}

.la-01.is-ready .la-01__item,
.la-01.is-ready .la-01__stage::before {
  animation: none;
  opacity: 0;
}

.la-01.is-ready .la-01__load {
  display: none;
}

.la-01.is-ready .la-01__ready {
  display: inline;
}

.la-01.is-ready .la-01__step {
  animation: none;
  opacity: 1;
  color: var(--ok);
}

.la-01.is-ready .la-01__fill {
  animation: none;
  inline-size: 100%;
  background: var(--ok);
}

.la-01.is-ready .la-01__pct {
  animation: none;
  --la-01-p: 100;
}

.la-01:has(.la-01__done[aria-pressed="true"]) .la-01__card {
  border-color: color-mix(in oklab,var(--ok) 40%,var(--line));
}

@supports (transition-behavior: allow-discrete) {
  .la-01__seal {
    transition-behavior: allow-discrete;
  }

  @starting-style {
    .la-01.is-ready .la-01__seal {
      opacity: 0;
      scale: .7;
    }
  }
}
/* ── chrome ── */

.la-01__chrome {
  position: absolute;
  inset-block-start: clamp(.75rem,2.5vw,1.5rem);
  inset-inline-end: clamp(.75rem,2.5vw,1.5rem);
  z-index: 20;
  display: flex;
  gap: .5rem;
}

.la-01__btn {
  inline-size: 2.75rem;
  block-size: 2.75rem;
  display: grid;
  place-items: center;
  cursor: pointer;
  padding: 0;
  border-radius: 999px;
  border: 1px solid color-mix(in oklab,var(--ink) 16%,transparent);
  color: var(--ink);
  background: color-mix(in oklab,var(--surface) 72%,transparent);
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
  transition: background .25s ease, transform .25s cubic-bezier(.16,1,.3,1), color .25s ease;
}

.la-01__btn:hover {
  background: var(--surface);
  transform: translateY(-1px);
}

.la-01__btn:focus-visible {
  outline: 2px solid var(--ring);
  outline-offset: 3px;
}

.la-01__btn svg {
  grid-area: 1/1;
  inline-size: 1.15rem;
  block-size: 1.15rem;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.la-01__sun {
  display: none;
}

.la-01__theme[aria-pressed="true"] .la-01__sun {
  display: block;
}

.la-01__theme[aria-pressed="true"] .la-01__moon {
  display: none;
}

.la-01__done[aria-pressed="true"] {
  background: var(--ok);
  border-color: transparent;
  color: #fff;
}
/* ── dark theme ── */

@media (prefers-color-scheme: dark) {
  .la-01:not([data-theme="light"]) {
    --bg: #080a0f;
    --surface: #12151d;
    --ink: #eef1f6;
    --muted: #98a2b3;
    --line: #232936;
    --acc: #8b78ff;
    --ok: #37d39a;
    --ring: #8b78ff;
    --shadow: rgba(0,0,0,.6);
  }

  .la-01:not([data-theme="light"]) .la-01__card {
    box-shadow: 0 1px 0 color-mix(in oklab,white 8%,transparent) inset, 0 40px 80px -40px #000;
  }
}

.la-01[data-theme="dark"] {
  --bg: #080a0f;
  --surface: #12151d;
  --ink: #eef1f6;
  --muted: #98a2b3;
  --line: #232936;
  --acc: #8b78ff;
  --ok: #37d39a;
  --ring: #8b78ff;
  --shadow: rgba(0,0,0,.6);
}

.la-01[data-theme="dark"] .la-01__card {
  box-shadow: 0 1px 0 color-mix(in oklab,white 8%,transparent) inset, 0 40px 80px -40px #000;
}

@media (prefers-reduced-motion: reduce) {
  .la-01 * {
    animation: none !important;
    transition: none !important;
  }

  .la-01__item {
    opacity: 1;
    translate: var(--x) .2rem;
  }

  .la-01__stage::before {
    opacity: .5;
  }

  .la-01__fill {
    inline-size: 68%;
  }

  .la-01__step {
    opacity: 1;
  }
}

@media (forced-colors: active) {
  .la-01__card {
    border: 1px solid CanvasText;
  }

  .la-01__fill {
    background: Highlight;
  }

  .la-01__btn {
    border: 1px solid ButtonText;
    background: ButtonFace;
    color: ButtonText;
  }
}

@container (max-width: 19rem) {
  .la-01__steps {
    gap: .65rem;
    font-size: .7rem;
  }
}
```

## JavaScript
```js
(() => {
  const root = document.querySelector('.la-01');
  if (!root) return;
  const theme = root.querySelector('.la-01__theme');
  const done = root.querySelector('.la-01__done');
  const bar = root.querySelector('[role="progressbar"]');
  const dark = () => (root.dataset.theme || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')) === 'dark';
  const sync = () => {
    theme.setAttribute('aria-pressed', String(dark()));
    theme.setAttribute('aria-label', dark() ? 'Switch to light theme' : 'Switch to dark theme');
  };
  theme.addEventListener('click', () => { root.dataset.theme = dark() ? 'light' : 'dark'; sync(); });
  matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { if (!root.dataset.theme) sync(); });
  done.addEventListener('click', () => {
    const ready = root.classList.toggle('is-ready');        // the one line your success callback needs
    root.dataset.state = ready ? 'ready' : 'loading';
    done.setAttribute('aria-pressed', String(ready));
    done.setAttribute('aria-label', ready ? 'Return to loading state' : 'Show completed state');
    bar.setAttribute('aria-valuenow', ready ? 100 : 68);
    bar.setAttribute('aria-valuetext', ready ? 'Order confirmed' : '68 percent complete');
  });
  sync();
})();
```

How this works

Custom properties are strings until you register them, which is why --p: 0 jumps instead of animating. Register it as an integer and CSS will interpolate it — and an interpolated integer is legal input for a counter, which is legal input for generated content:

@property --la-01-p{ syntax:'<integer>'; inherits:false; initial-value:0; }

.la-01__pct{ counter-reset: la-01-pct var(--la-01-p, 68); }
.la-01__pct::after{ content: counter(la-01-pct) "%"; }
@keyframes la-01-count{ from{ --la-01-p:0 } to{ --la-01-p:100 } }

The same property drives the bar (inline-size: calc(var(--la-01-p) * 1%)), so text and geometry can never disagree. The var(--la-01-p, 68) fallback is what browsers without @property render: a static, sensible 68%.

The three-stage indicator is one keyframe reused three times. Each step is lit for a third of the cycle purely through delay maths — add a fourth step and you only change the delays:

@keyframes la-01-step{ 0%,4%{ opacity:.38 } 9%,31%{ opacity:1 } 36%,100%{ opacity:.38 } }
.la-01__step:nth-child(1){ animation-delay: 0s }
.la-01__step:nth-child(2){ animation-delay: 1.8s }
.la-01__step:nth-child(3){ animation-delay: 3.6s }

The done state is not a second component. Adding .is-ready pauses every loop, swaps the label, draws the tick with stroke-dashoffset and pins the bar to 100% — exactly what your fetch().then() would do with one classList.add().

Make it yours

  • Rename the three <li> steps to match your real pipeline ("Authorising card", "Reserving stock", "Emailing receipt") and update the aria-label on the role="status" region to match.
  • Change the cycle length in one place — --dur on the root — and the cart drop, the counter and the step pipeline all re-time together.
  • Retint by editing --acc; the halo, bar, tick and step dots all derive from it via color-mix().
  • For a real checkout, drop the la-01-count animation and set --la-01-p from your upload/XHR progress event: el.style.setProperty('--la-01-p', pct) — the counter text follows for free.
  • Swap the price and order reference for template values; both use tabular-nums so changing digits never reflow the layout.

Gotchas — read before shipping

  • A looping loader must not lie to assistive tech: keep aria-valuenow in sync from script for real progress, or use aria-valuetext / an indeterminate role="progressbar" without aria-valuenow when the number is decorative.
  • counter-reset needs an integer. Registering --la-01-p as <number> yields fractional values and the counter silently drops.
  • Do not put aria-live on an element you also re-render wholesale; screen readers announce the whole subtree again. Keep the live region a stable wrapper and change only its text.
  • @starting-style only applies on first style resolution — it cannot animate an element that was already in the DOM and visible.
  • Never gate the animation on the real request. If the network stalls, a paused loader reads as a crashed page; keep the loop infinite and show a separate error state on failure.

Browser support

ChromeSafariFirefoxEdge
117+17.4+128+117+

Layout, cart motion, step pipeline and tick draw work in every evergreen browser. @property (Chrome 85 / Safari 16.4 / Firefox 128) is required for the animated counter — without it the readout shows the static var() fallback. @starting-style is Chrome 117 / Safari 17.4 / Firefox 129 and is polish only. color-mix() has hex fallbacks declared first.

Techniques used in this demo

Search CodeFronts

Loading…