CSS Animated Backgrounds: 16 Examples That Keep FPS High

Most CSS animated gradients don't animate the gradient. They slide an oversized one with background-position, since browsers can't blend gradient values until @property registers them. Every major engine has supported that since July 2024.

Below are 16 CSS animated background examples to try and copy, followed by a guide to performance, seamless loops and accessibility.

By Bogdan Sandu · Updated October 28, 2026

Animated hero backgrounds

Four hero sections where the motion lives on its own layer and moves with transform, so the browser can composite it instead of repainting the background. Each one starts only when the visitor has no reduced-motion preference and has a small pause button, because the loop runs longer than five seconds next to real content.

Drifting dot grid

  • radial-gradient tile
  • translate one tile
  • animation-play-state

Ledgerly

Invoices that chase themselves

Send, remind and reconcile in one place. Clients pay on time and you stop writing follow-up emails.

Use the pause button in the corner to stop the loop.

The dots are a 24px radial-gradient tile on a pseudo-element that starts 24px outside the section. The layer translates by exactly one tile and restarts, so the loop has no seam, and because only transform changes the dots are painted once. The pause button flips aria-pressed and a :has() rule pauses the layer.

View code
.ab-dots {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  display: grid;
  place-items: center;
  min-height: 400px;
  padding: 56px 20px 72px;
  background: #f6f7fb;
  color: #141821;
  text-align: center;
}
.ab-dots::before {
  content: "";
  position: absolute;
  inset: -24px 0 0 -24px;
  z-index: -1;
  background-image: radial-gradient(circle, #aab2c8 1.6px, transparent 2px);
  background-size: 24px 24px;
  pointer-events: none;
}
.ab-dots__copy {
  max-width: 34rem;
  padding: 28px clamp(16px, 4vw, 36px);
  border-radius: 20px;
  background: rgba(246, 247, 251, .92);
}
.ab-dots__brand {
  margin: 0 0 14px;
  font-weight: 700;
  font-size: 15px;
  color: #3d4bd8;
}
.ab-dots__title {
  margin: 0 0 12px;
  font-family: "Fraunces", Georgia, serif;
  font-size: clamp(28px, 6vw, 46px);
  font-weight: 600;
  line-height: 1.08;
  letter-spacing: -.01em;
}
.ab-dots__text {
  margin: 0 auto 24px;
  max-width: 30rem;
  font-size: 16px;
  line-height: 1.55;
  color: #444b5c;
}
.ab-dots__actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 10px;
}
.ab-dots__cta,
.ab-dots__ghost {
  min-height: 44px;
  padding: 0 20px;
  border-radius: 999px;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
}
.ab-dots__cta {
  border: 0;
  background: #3d4bd8;
  color: #fff;
}
.ab-dots__ghost {
  border: 1.5px solid #c7cce0;
  background: #fff;
  color: #141821;
}
.ab-dots__pause {
  position: absolute;
  right: 12px;
  bottom: 12px;
  display: none;
  align-items: center;
  gap: 8px;
  min-height: 32px;
  padding: 0 12px;
  border: 1px solid #c7cce0;
  border-radius: 999px;
  background: #fff;
  color: #141821;
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
}
.ab-dots__pause::before {
  content: "";
  width: 3px;
  height: 10px;
  border-left: 3px solid currentColor;
  border-right: 3px solid currentColor;
}
.ab-dots__pause[aria-pressed="true"] {
  background: #141821;
  border-color: #141821;
  color: #fff;
}
.ab-dots__pause[aria-pressed="true"]::before {
  width: 9px;
  border: 0;
  background: currentColor;
  clip-path: polygon(0 0, 100% 50%, 0 100%);
}
.ab-dots button:focus-visible {
  outline: 3px solid #3d4bd8;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: no-preference) {
  .ab-dots::before {
    animation: ab-dots-drift 6s linear infinite;
  }
  .ab-dots__pause {
    display: inline-flex;
  }
  .ab-dots:has(.ab-dots__pause[aria-pressed="true"])::before {
    animation-play-state: paused;
  }
}
@keyframes ab-dots-drift {
  to { transform: translate(24px, 24px); }
}

Floating shapes on three layers

  • layered parallax
  • different durations
  • clip-path shapes

Studio Parallel

Brand systems that keep moving

Identity, motion and web design for teams who outgrew their first logo.

Use the pause button in the corner to stop the loop.

Each layer holds a few flat shapes and runs its own float loop: 9, 13 and 17 seconds. The durations share no common beat, so the composition never repeats in an obvious way, and every layer only changes transform. The shapes stay at the edges so the headline always sits on the plain cream surface.

View code
.ab-shapes {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  display: grid;
  place-items: center;
  min-height: 420px;
  padding: 72px 24px 80px;
  background: #fbf3e4;
  color: #1d1a16;
  text-align: center;
}
.ab-shapes__layer {
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
}
.ab-shapes__layer i {
  position: absolute;
  display: block;
}
.ab-shapes__circle {
  top: 8%;
  left: 6%;
  width: clamp(54px, 12vw, 110px);
  aspect-ratio: 1;
  border-radius: 50%;
  background: #ff6f4f;
}
.ab-shapes__tri {
  right: 9%;
  bottom: 10%;
  width: clamp(50px, 10vw, 96px);
  aspect-ratio: 1;
  background: #233d6e;
  clip-path: polygon(50% 0, 100% 100%, 0 100%);
}
.ab-shapes__square {
  top: 10%;
  right: 7%;
  width: clamp(40px, 8vw, 72px);
  aspect-ratio: 1;
  border-radius: 10px;
  background: #1f8a80;
  rotate: 18deg;
}
.ab-shapes__dot {
  left: 22%;
  bottom: 9%;
  width: 18px;
  aspect-ratio: 1;
  border-radius: 50%;
  background: #233d6e;
}
.ab-shapes__ring {
  left: 5%;
  bottom: 14%;
  width: clamp(48px, 9vw, 84px);
  aspect-ratio: 1;
  border: 10px solid #f2b134;
  border-radius: 50%;
}
.ab-shapes__pill {
  top: 6%;
  left: 52%;
  width: 70px;
  height: 22px;
  border-radius: 999px;
  background: #f2b134;
  rotate: -24deg;
}
.ab-shapes__copy {
  max-width: 30rem;
}
.ab-shapes__brand {
  margin: 0 0 14px;
  font-size: 14px;
  font-weight: 700;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: #a33a1f;
}
.ab-shapes__title {
  margin: 0 0 14px;
  font-family: "Fraunces", Georgia, serif;
  font-size: clamp(30px, 6.4vw, 50px);
  font-weight: 700;
  line-height: 1.04;
}
.ab-shapes__text {
  margin: 0 0 24px;
  font-size: 16px;
  line-height: 1.55;
  color: #4a443b;
}
.ab-shapes__cta {
  min-height: 44px;
  padding: 0 22px;
  border: 0;
  border-radius: 12px;
  background: #1d1a16;
  color: #fbf3e4;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.ab-shapes__pause {
  position: absolute;
  right: 12px;
  bottom: 12px;
  display: none;
  align-items: center;
  gap: 8px;
  min-height: 32px;
  padding: 0 12px;
  border: 1px solid #1d1a16;
  border-radius: 999px;
  background: #fbf3e4;
  color: #1d1a16;
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
}
.ab-shapes__pause::before {
  content: "";
  width: 3px;
  height: 10px;
  border-left: 3px solid currentColor;
  border-right: 3px solid currentColor;
}
.ab-shapes__pause[aria-pressed="true"] {
  background: #1d1a16;
  color: #fbf3e4;
}
.ab-shapes__pause[aria-pressed="true"]::before {
  width: 9px;
  border: 0;
  background: currentColor;
  clip-path: polygon(0 0, 100% 50%, 0 100%);
}
.ab-shapes button:focus-visible {
  outline: 3px solid #a33a1f;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: no-preference) {
  .ab-shapes__layer--a {
    animation: ab-shapes-float-a 9s ease-in-out infinite alternate;
  }
  .ab-shapes__layer--b {
    animation: ab-shapes-float-b 13s ease-in-out infinite alternate;
  }
  .ab-shapes__layer--c {
    animation: ab-shapes-float-c 17s ease-in-out infinite alternate;
  }
  .ab-shapes__pause {
    display: inline-flex;
  }
  .ab-shapes:has(.ab-shapes__pause[aria-pressed="true"]) .ab-shapes__layer {
    animation-play-state: paused;
  }
}
@keyframes ab-shapes-float-a {
  to { transform: translate(14px, 22px) rotate(6deg); }
}
@keyframes ab-shapes-float-b {
  to { transform: translate(-18px, 16px) rotate(-8deg); }
}
@keyframes ab-shapes-float-c {
  to { transform: translate(12px, -20px); }
}

SVG waves sliding sideways

  • repeat-x SVG tile
  • translateX loop
  • two speeds

Saltline Surf School

Your first green wave by Sunday

Two-day beginner camps with boards, wetsuits and one coach for every four surfers.

Use the pause button in the corner to stop the loop.

Each wave is a 400px wide SVG tile whose path starts and ends at the same height and slope, repeated with repeat-x on a layer that is 400px wider than the section. Translating that layer by one tile width loops without a seam. The back wave is slower and runs the other way, which is enough to read as depth.

View code
.ab-waves {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  min-height: 440px;
  padding: 56px 24px 170px;
  background: #d5ecf3;
  color: #0f2e44;
  text-align: center;
}
.ab-waves__copy {
  max-width: 30rem;
  margin: 0 auto;
}
.ab-waves__brand {
  margin: 0 0 12px;
  font-size: 14px;
  font-weight: 700;
  color: #1d5f86;
}
.ab-waves__title {
  margin: 0 0 12px;
  font-family: "Fraunces", Georgia, serif;
  font-size: clamp(28px, 6vw, 44px);
  font-weight: 700;
  line-height: 1.06;
}
.ab-waves__text {
  margin: 0 0 22px;
  font-size: 16px;
  line-height: 1.55;
  color: #24485f;
}
.ab-waves__cta {
  min-height: 44px;
  padding: 0 22px;
  border: 0;
  border-radius: 999px;
  background: #0f2e44;
  color: #fff;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.ab-waves__sea {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 140px;
  z-index: -1;
  pointer-events: none;
}
.ab-waves__wave {
  position: absolute;
  left: 0;
  width: calc(100% + 400px);
  height: 120px;
  background-repeat: repeat-x;
  background-size: 400px 120px;
}
.ab-waves__wave--back {
  bottom: 20px;
  left: -400px;
  background-image: url("ab-wave-back.svg");
}
.ab-waves__wave--front {
  bottom: 0;
  background-image: url("ab-wave-front.svg");
}
.ab-waves__pause {
  position: absolute;
  right: 12px;
  bottom: 12px;
  display: none;
  align-items: center;
  gap: 8px;
  min-height: 32px;
  padding: 0 12px;
  border: 0;
  border-radius: 999px;
  background: #f4f1ea;
  color: #0f2e44;
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
}
.ab-waves__pause::before {
  content: "";
  width: 3px;
  height: 10px;
  border-left: 3px solid currentColor;
  border-right: 3px solid currentColor;
}
.ab-waves__pause[aria-pressed="true"] {
  background: #0f2e44;
  color: #fff;
}
.ab-waves__pause[aria-pressed="true"]::before {
  width: 9px;
  border: 0;
  background: currentColor;
  clip-path: polygon(0 0, 100% 50%, 0 100%);
}
.ab-waves button:focus-visible {
  outline: 3px solid #0f2e44;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: no-preference) {
  .ab-waves__wave--front {
    animation: ab-waves-left 9s linear infinite;
  }
  .ab-waves__wave--back {
    animation: ab-waves-right 16s linear infinite;
  }
  .ab-waves__pause {
    display: inline-flex;
  }
  .ab-waves:has(.ab-waves__pause[aria-pressed="true"]) .ab-waves__wave {
    animation-play-state: paused;
  }
}
@keyframes ab-waves-left {
  to { transform: translateX(-400px); }
}
@keyframes ab-waves-right {
  to { transform: translateX(400px); }
}

Typing code behind a dark header

  • steps(var())
  • cover layer
  • staggered delays

Brightwire CLI 3.2

Ship config changes from your terminal

npm i -g brightwire

Use the pause button in the corner to stop the loop.

Every code line has a cover pseudo-element in the header color that slides right in steps, one step per character, so the line appears to type itself. The step count comes from a custom property per line and each line starts a little later. The code is decorative, so it is aria-hidden and kept at low contrast while the real text sits on a solid panel.

View code
.ab-code {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  display: grid;
  place-items: center;
  min-height: 400px;
  padding: 48px 20px 72px;
  background: #10141c;
  color: #e8edf5;
}
.ab-code__bg {
  position: absolute;
  inset: 22px auto auto 18px;
  z-index: -1;
  display: grid;
  gap: 18px;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 13px;
  line-height: 1.2;
  white-space: pre;
  color: #3a465c;
  pointer-events: none;
}
.ab-code__line {
  position: relative;
  width: max-content;
  --ab-code-n: 36;
}
.ab-code__line:nth-child(2) { --ab-code-n: 43; margin-left: 4ch; }
.ab-code__line:nth-child(3) { --ab-code-n: 42; margin-left: 4ch; }
.ab-code__line:nth-child(4) { --ab-code-n: 38; margin-left: 8ch; }
.ab-code__line:nth-child(5) { --ab-code-n: 43; }
.ab-code__line:nth-child(6) { --ab-code-n: 30; margin-left: 12ch; }
.ab-code__line:nth-child(7) { --ab-code-n: 32; margin-left: 2ch; }
.ab-code__line::after {
  content: "";
  position: absolute;
  inset: -2px -1ch -2px 0;
  background: #10141c;
  border-left: 2px solid #5ad1a4;
}
.ab-code__copy {
  max-width: 30rem;
  padding: 26px clamp(18px, 4vw, 34px);
  border: 1px solid #263044;
  border-radius: 16px;
  background: #10141c;
  text-align: center;
}
.ab-code__brand {
  margin: 0 0 12px;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 13px;
  color: #5ad1a4;
}
.ab-code__title {
  margin: 0 0 18px;
  font-size: clamp(26px, 5.6vw, 40px);
  font-weight: 700;
  line-height: 1.1;
  letter-spacing: -.02em;
}
.ab-code__cmd {
  margin: 0 0 20px;
}
.ab-code__cmd code {
  display: inline-block;
  padding: 8px 14px;
  border-radius: 8px;
  background: #1b2230;
  color: #e8edf5;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 14px;
}
.ab-code__cta {
  min-height: 44px;
  padding: 0 20px;
  border: 0;
  border-radius: 10px;
  background: #5ad1a4;
  color: #0b1f17;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
}
.ab-code__pause {
  position: absolute;
  right: 12px;
  bottom: 12px;
  display: none;
  align-items: center;
  gap: 8px;
  min-height: 32px;
  padding: 0 12px;
  border: 1px solid #3a465c;
  border-radius: 999px;
  background: #10141c;
  color: #e8edf5;
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
}
.ab-code__pause::before {
  content: "";
  width: 3px;
  height: 10px;
  border-left: 3px solid currentColor;
  border-right: 3px solid currentColor;
}
.ab-code__pause[aria-pressed="true"] {
  background: #e8edf5;
  color: #10141c;
}
.ab-code__pause[aria-pressed="true"]::before {
  width: 9px;
  border: 0;
  background: currentColor;
  clip-path: polygon(0 0, 100% 50%, 0 100%);
}
.ab-code button:focus-visible {
  outline: 3px solid #5ad1a4;
  outline-offset: 3px;
}
.ab-code__line::after {
  transform: translateX(100%);
}
@media (prefers-reduced-motion: no-preference) {
  .ab-code__line::after {
    transform: none;
    animation: ab-code-type 14s steps(var(--ab-code-n), end) infinite;
  }
  .ab-code__line:nth-child(2)::after { animation-delay: .8s; }
  .ab-code__line:nth-child(3)::after { animation-delay: 1.6s; }
  .ab-code__line:nth-child(4)::after { animation-delay: 2.4s; }
  .ab-code__line:nth-child(5)::after { animation-delay: 3.2s; }
  .ab-code__line:nth-child(6)::after { animation-delay: 4s; }
  .ab-code__line:nth-child(7)::after { animation-delay: 4.8s; }
  .ab-code__pause {
    display: inline-flex;
  }
  .ab-code:has(.ab-code__pause[aria-pressed="true"]) .ab-code__line::after {
    animation-play-state: paused;
  }
}
@keyframes ab-code-type {
  0%, 4% { transform: translateX(0); }
  22%, 100% { transform: translateX(100%); }
}

Login, pricing and dashboard panels

Backgrounds behind forms and numbers have to stay quiet. These four keep the motion slow, keep the text on solid surfaces, and show where a background animation costs paint and where it does not.

Parallax starfield behind a login panel

  • box-shadow stars
  • three speeds
  • seamless translateY

Sign in to Orbitdesk

Use the pause button to stop the loop.

Each star layer is one tiny element whose stars are box-shadows, drawn twice: once in the first 600px and again 600px lower. Translating the layer up by 600px therefore lands on an identical frame. The far layer takes 150 seconds, the near one 60, and the form sits on a solid card so its contrast never depends on where the stars are.

View code
.ab-stars {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  display: grid;
  place-items: center;
  min-height: 480px;
  padding: 40px 18px 64px;
  background: #0a0f24;
}
.ab-stars__sky {
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
}
.ab-stars__layer {
  position: absolute;
  top: 0;
  left: 0;
  display: block;
  border-radius: 50%;
}
.ab-stars__layer--far {
  width: 1px;
  height: 1px;
  box-shadow: 
    663px 154px #aab4e0,
    663px 754px #aab4e0,
    808px 49px #aab4e0,
    808px 649px #aab4e0,
    148px 548px #aab4e0,
    148px 1148px #aab4e0,
    192px 374px #aab4e0,
    192px 974px #aab4e0,
    1193px 59px #aab4e0,
    1193px 659px #aab4e0,
    1039px 219px #aab4e0,
    1039px 819px #aab4e0,
    76px 88px #aab4e0,
    76px 688px #aab4e0,
    888px 428px #aab4e0,
    888px 1028px #aab4e0,
    143px 246px #aab4e0,
    143px 846px #aab4e0,
    185px 564px #aab4e0,
    185px 1164px #aab4e0,
    869px 60px #aab4e0,
    869px 660px #aab4e0,
    1158px 126px #aab4e0,
    1158px 726px #aab4e0,
    457px 596px #aab4e0,
    457px 1196px #aab4e0,
    126px 590px #aab4e0,
    126px 1190px #aab4e0,
    1199px 406px #aab4e0,
    1199px 1006px #aab4e0,
    101px 226px #aab4e0,
    101px 826px #aab4e0,
    95px 570px #aab4e0,
    95px 1170px #aab4e0,
    272px 296px #aab4e0,
    272px 896px #aab4e0,
    858px 147px #aab4e0,
    858px 747px #aab4e0,
    1107px 120px #aab4e0,
    1107px 720px #aab4e0,
    1169px 315px #aab4e0,
    1169px 915px #aab4e0,
    1147px 185px #aab4e0,
    1147px 785px #aab4e0,
    211px 595px #aab4e0,
    211px 1195px #aab4e0,
    1169px 192px #aab4e0,
    1169px 792px #aab4e0,
    762px 99px #aab4e0,
    762px 699px #aab4e0,
    1121px 64px #aab4e0,
    1121px 664px #aab4e0,
    1155px 61px #aab4e0,
    1155px 661px #aab4e0,
    421px 508px #aab4e0,
    421px 1108px #aab4e0,
    1088px 437px #aab4e0,
    1088px 1037px #aab4e0,
    643px 476px #aab4e0,
    643px 1076px #aab4e0,
    1199px 464px #aab4e0,
    1199px 1064px #aab4e0,
    740px 306px #aab4e0,
    740px 906px #aab4e0,
    508px 184px #aab4e0,
    508px 784px #aab4e0,
    499px 83px #aab4e0,
    499px 683px #aab4e0,
    1176px 307px #aab4e0,
    1176px 907px #aab4e0,
    1075px 506px #aab4e0,
    1075px 1106px #aab4e0,
    703px 459px #aab4e0,
    703px 1059px #aab4e0,
    589px 74px #aab4e0,
    589px 674px #aab4e0,
    241px 524px #aab4e0,
    241px 1124px #aab4e0,
    856px 168px #aab4e0,
    856px 768px #aab4e0,
    700px 155px #aab4e0,
    700px 755px #aab4e0,
    1001px 431px #aab4e0,
    1001px 1031px #aab4e0,
    80px 79px #aab4e0,
    80px 679px #aab4e0,
    1142px 586px #aab4e0,
    1142px 1186px #aab4e0,
    642px 348px #aab4e0,
    642px 948px #aab4e0,
    717px 508px #aab4e0,
    717px 1108px #aab4e0,
    1187px 467px #aab4e0,
    1187px 1067px #aab4e0,
    140px 95px #aab4e0,
    140px 695px #aab4e0,
    552px 485px #aab4e0,
    552px 1085px #aab4e0,
    133px 62px #aab4e0,
    133px 662px #aab4e0,
    634px 591px #aab4e0,
    634px 1191px #aab4e0,
    912px 291px #aab4e0,
    912px 891px #aab4e0,
    790px 355px #aab4e0,
    790px 955px #aab4e0,
    46px 472px #aab4e0,
    46px 1072px #aab4e0,
    727px 172px #aab4e0,
    727px 772px #aab4e0,
    239px 505px #aab4e0,
    239px 1105px #aab4e0,
    120px 223px #aab4e0,
    120px 823px #aab4e0,
    588px 132px #aab4e0,
    588px 732px #aab4e0,
    507px 407px #aab4e0,
    507px 1007px #aab4e0,
    800px 508px #aab4e0,
    800px 1108px #aab4e0;
}
.ab-stars__layer--mid {
  width: 2px;
  height: 2px;
  box-shadow: 
    165px 170px #dde3ff,
    165px 770px #dde3ff,
    919px 411px #dde3ff,
    919px 1011px #dde3ff,
    1125px 284px #dde3ff,
    1125px 884px #dde3ff,
    280px 440px #dde3ff,
    280px 1040px #dde3ff,
    1126px 285px #dde3ff,
    1126px 885px #dde3ff,
    850px 367px #dde3ff,
    850px 967px #dde3ff,
    779px 236px #dde3ff,
    779px 836px #dde3ff,
    309px 84px #dde3ff,
    309px 684px #dde3ff,
    360px 154px #dde3ff,
    360px 754px #dde3ff,
    475px 238px #dde3ff,
    475px 838px #dde3ff,
    24px 496px #dde3ff,
    24px 1096px #dde3ff,
    373px 269px #dde3ff,
    373px 869px #dde3ff,
    577px 4px #dde3ff,
    577px 604px #dde3ff,
    298px 429px #dde3ff,
    298px 1029px #dde3ff,
    1094px 378px #dde3ff,
    1094px 978px #dde3ff,
    1159px 326px #dde3ff,
    1159px 926px #dde3ff,
    257px 527px #dde3ff,
    257px 1127px #dde3ff,
    110px 467px #dde3ff,
    110px 1067px #dde3ff,
    1145px 401px #dde3ff,
    1145px 1001px #dde3ff,
    815px 408px #dde3ff,
    815px 1008px #dde3ff,
    807px 106px #dde3ff,
    807px 706px #dde3ff,
    986px 410px #dde3ff,
    986px 1010px #dde3ff,
    127px 195px #dde3ff,
    127px 795px #dde3ff,
    137px 213px #dde3ff,
    137px 813px #dde3ff,
    902px 166px #dde3ff,
    902px 766px #dde3ff,
    225px 348px #dde3ff,
    225px 948px #dde3ff;
}
.ab-stars__layer--near {
  width: 3px;
  height: 3px;
  box-shadow: 
    107px 104px #fff,
    107px 704px #fff,
    0px 580px #fff,
    0px 1180px #fff,
    309px 549px #fff,
    309px 1149px #fff,
    207px 372px #fff,
    207px 972px #fff,
    52px 72px #fff,
    52px 672px #fff,
    425px 385px #fff,
    425px 985px #fff,
    304px 258px #fff,
    304px 858px #fff,
    711px 372px #fff,
    711px 972px #fff,
    971px 125px #fff,
    971px 725px #fff,
    236px 499px #fff,
    236px 1099px #fff;
}
.ab-stars__card {
  display: grid;
  width: min(100%, 340px);
  padding: 28px 24px 22px;
  border: 1px solid #2b3566;
  border-radius: 18px;
  background: #121935;
  color: #eef1ff;
}
.ab-stars__title {
  margin: 0 0 20px;
  font-size: 22px;
  font-weight: 700;
}
.ab-stars__label {
  margin: 0 0 6px;
  font-size: 14px;
  font-weight: 600;
  color: #c9d0f2;
}
.ab-stars__input {
  min-height: 44px;
  margin: 0 0 16px;
  padding: 0 12px;
  border: 1px solid #3a4580;
  border-radius: 10px;
  background: #0a0f24;
  color: #eef1ff;
  font: inherit;
}
.ab-stars__input::placeholder {
  color: #8a93bf;
}
.ab-stars__submit {
  min-height: 46px;
  margin-top: 4px;
  border: 0;
  border-radius: 10px;
  background: #ffd36e;
  color: #1a1407;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
}
.ab-stars__status {
  min-height: 20px;
  margin: 12px 0 0;
  font-size: 13px;
  color: #c9d0f2;
}
.ab-stars__input:focus-visible,
.ab-stars button:focus-visible {
  outline: 3px solid #ffd36e;
  outline-offset: 2px;
}
.ab-stars__pause {
  position: absolute;
  right: 12px;
  bottom: 12px;
  display: none;
  align-items: center;
  gap: 8px;
  min-height: 32px;
  padding: 0 12px;
  border: 1px solid #3a4580;
  border-radius: 999px;
  background: #121935;
  color: #eef1ff;
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
}
.ab-stars__pause::before {
  content: "";
  width: 3px;
  height: 10px;
  border-left: 3px solid currentColor;
  border-right: 3px solid currentColor;
}
.ab-stars__pause[aria-pressed="true"] {
  background: #eef1ff;
  color: #121935;
}
.ab-stars__pause[aria-pressed="true"]::before {
  width: 9px;
  border: 0;
  background: currentColor;
  clip-path: polygon(0 0, 100% 50%, 0 100%);
}
@media (prefers-reduced-motion: no-preference) {
  .ab-stars__layer--far {
    animation: ab-stars-rise 150s linear infinite;
  }
  .ab-stars__layer--mid {
    animation: ab-stars-rise 100s linear infinite;
  }
  .ab-stars__layer--near {
    animation: ab-stars-rise 60s linear infinite;
  }
  .ab-stars__pause {
    display: inline-flex;
  }
  .ab-stars:has(.ab-stars__pause[aria-pressed="true"]) .ab-stars__layer {
    animation-play-state: paused;
  }
}
@keyframes ab-stars-rise {
  to { transform: translateY(-600px); }
}

Pricing stripes, paint versus transform

  • background-position
  • translateX layer
  • hard-stop stripes
Most popular

background-position

Starter

$9 per month

  • 3 projects
  • Email support
Best value

transform on ::before

Studio

$29 per month

  • Unlimited projects
  • Priority chat

Use the pause button to stop the loop.

Both plan badges scroll the same 28px diagonal stripe tile. The left one animates background-position, which repaints the badge on every frame. The right one paints the stripes once on a pseudo-element that is one tile wider and moves it with translateX, which the compositor can handle. Turn on Paint flashing in DevTools to see the difference.

View code
.ab-stripes {
  display: grid;
  justify-items: center;
  gap: 16px;
  width: min(100%, 560px);
}
.ab-stripes__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr));
  gap: 16px;
  width: 100%;
}
.ab-stripes__plan {
  overflow: hidden;
  border: 1px solid #dde1ea;
  border-radius: 18px;
  background: #fff;
  color: #17181c;
}
.ab-stripes__band {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  display: grid;
  place-items: center;
  height: 56px;
  background-color: #ffd23f;
}
.ab-stripes__band span {
  padding: 5px 12px;
  border-radius: 999px;
  background: #17181c;
  color: #fff;
  font-size: 13px;
  font-weight: 700;
}
.ab-stripes__band--paint {
  background-image: linear-gradient(45deg, #f4b400 25%, transparent 25% 50%, #f4b400 50% 75%, transparent 75%);
  background-size: 28px 28px;
}
.ab-stripes__band--layer::before {
  content: "";
  position: absolute;
  inset: 0 0 0 -28px;
  z-index: -1;
  background-image: linear-gradient(45deg, #f4b400 25%, transparent 25% 50%, #f4b400 50% 75%, transparent 75%);
  background-size: 28px 28px;
}
.ab-stripes__how {
  margin: 14px 20px 0;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 12px;
  color: #5b6070;
}
.ab-stripes__name {
  margin: 6px 20px 0;
  font-size: 20px;
  font-weight: 700;
}
.ab-stripes__price {
  margin: 4px 20px 12px;
  color: #4a4f5c;
}
.ab-stripes__price b {
  font-size: 30px;
  color: #17181c;
}
.ab-stripes__list {
  margin: 0;
  padding: 0 20px 20px 38px;
  line-height: 1.7;
}
.ab-stripes__pause {
  display: none;
  align-items: center;
  gap: 8px;
  min-height: 32px;
  padding: 0 12px;
  border: 1px solid #c7ccd8;
  border-radius: 999px;
  background: #fff;
  color: #17181c;
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
}
.ab-stripes__pause::before {
  content: "";
  width: 3px;
  height: 10px;
  border-left: 3px solid currentColor;
  border-right: 3px solid currentColor;
}
.ab-stripes__pause[aria-pressed="true"] {
  background: #17181c;
  color: #fff;
}
.ab-stripes__pause[aria-pressed="true"]::before {
  width: 9px;
  border: 0;
  background: currentColor;
  clip-path: polygon(0 0, 100% 50%, 0 100%);
}
.ab-stripes__pause:focus-visible {
  outline: 3px solid #17181c;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: no-preference) {
  .ab-stripes__band--paint {
    animation: ab-stripes-position 1.6s linear infinite;
  }
  .ab-stripes__band--layer::before {
    animation: ab-stripes-translate 1.6s linear infinite;
  }
  .ab-stripes__pause {
    display: inline-flex;
  }
  .ab-stripes:has(.ab-stripes__pause[aria-pressed="true"]) .ab-stripes__band--paint,
  .ab-stripes:has(.ab-stripes__pause[aria-pressed="true"]) .ab-stripes__band--layer::before {
    animation-play-state: paused;
  }
}
@keyframes ab-stripes-position {
  to { background-position: 28px 0; }
}
@keyframes ab-stripes-translate {
  to { transform: translateX(28px); }
}

Moving grid lines in a dashboard header

  • 1px hard-stop lines
  • diagonal translate
  • scan line

Fleet overview

Live

On route
128
On time
96.4%
Alerts
3

Use the pause button to stop the loop.

Two 1px hard-stop linear-gradients draw the grid, and the layer drifts one 40px cell diagonally every 10 seconds. A separate 2px scan line travels down with translateY. Both layers sit under solid KPI tiles, so the numbers keep full contrast while the header still feels live.

View code
.ab-grid {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  padding: 28px 20px 64px;
  background: #0d1520;
  color: #e7eef8;
}
.ab-grid::before {
  content: "";
  position: absolute;
  inset: -40px 0 0 -40px;
  z-index: -2;
  background-image: linear-gradient(#1e2c3e 1px, transparent 1px), linear-gradient(90deg, #1e2c3e 1px, transparent 1px);
  background-size: 40px 40px;
  pointer-events: none;
}
.ab-grid__scan {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  z-index: -1;
  height: 2px;
  background: #2f6fd6;
  opacity: .7;
  pointer-events: none;
}
.ab-grid__top {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  max-width: 720px;
  margin: 0 auto 22px;
}
.ab-grid__title {
  margin: 0;
  font-size: 22px;
  font-weight: 700;
}
.ab-grid__live {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  margin: 0;
  padding: 4px 12px;
  border: 1px solid #27405c;
  border-radius: 999px;
  background: #0d1520;
  font-size: 13px;
  font-weight: 600;
  color: #9fe0b9;
}
.ab-grid__live i {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: #3ecf7a;
}
.ab-grid__kpis {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
  gap: 12px;
  max-width: 720px;
  margin: 0 auto;
}
.ab-grid__kpi {
  padding: 16px;
  border: 1px solid #22344a;
  border-radius: 14px;
  background: #121d2b;
}
.ab-grid__kpi dt {
  font-size: 13px;
  color: #a7b6c9;
}
.ab-grid__kpi dd {
  margin: 6px 0 0;
  font-size: 28px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}
.ab-grid__pause {
  position: absolute;
  right: 12px;
  bottom: 12px;
  display: none;
  align-items: center;
  gap: 8px;
  min-height: 32px;
  padding: 0 12px;
  border: 1px solid #27405c;
  border-radius: 999px;
  background: #121d2b;
  color: #e7eef8;
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
}
.ab-grid__pause::before {
  content: "";
  width: 3px;
  height: 10px;
  border-left: 3px solid currentColor;
  border-right: 3px solid currentColor;
}
.ab-grid__pause[aria-pressed="true"] {
  background: #e7eef8;
  color: #0d1520;
}
.ab-grid__pause[aria-pressed="true"]::before {
  width: 9px;
  border: 0;
  background: currentColor;
  clip-path: polygon(0 0, 100% 50%, 0 100%);
}
.ab-grid__pause:focus-visible {
  outline: 3px solid #6aa5ff;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: no-preference) {
  .ab-grid::before {
    animation: ab-grid-drift 10s linear infinite;
  }
  .ab-grid__scan {
    animation: ab-grid-scan 7s ease-in-out infinite;
  }
  .ab-grid__pause {
    display: inline-flex;
  }
  .ab-grid:has(.ab-grid__pause[aria-pressed="true"])::before,
  .ab-grid:has(.ab-grid__pause[aria-pressed="true"]) .ab-grid__scan {
    animation-play-state: paused;
  }
}
@keyframes ab-grid-drift {
  to { transform: translate(40px, 40px); }
}
@keyframes ab-grid-scan {
  0% { transform: translateY(0); opacity: 0; }
  10%, 85% { opacity: .7; }
  100% { transform: translateY(300px); opacity: 0; }
}

Hue shift on a solid color with @property

  • @property <number>
  • oklch()
  • solid color only

Fieldnotes Conf 2027

Early-bird tickets close Friday

Two days of talks on research, writing and design ops. Lisbon, March 11 and 12.

€240 instead of €320

Use the pause button to stop the loop.

A registered number property holds the hue, and the ticket background is oklch() with a fixed lightness, so every hue has about the same contrast against the white text. Without the registration the browser could only flip between keyframe values. The sweep stays between blue and magenta, where this lightness looks clean, and background-color still repaints, so keep it to a small surface like this ticket.

View code
@property --ab-hue {
  syntax: "<number>";
  inherits: true;
  initial-value: 265;
}
.ab-hue {
  position: relative;
  width: min(100%, 440px);
  padding: 26px 24px 64px;
  border-radius: 22px;
  background-color: oklch(44% .13 var(--ab-hue));
  color: #fff;
}
.ab-hue__event {
  margin: 0 0 10px;
  font-size: 14px;
  font-weight: 700;
  letter-spacing: .06em;
  text-transform: uppercase;
}
.ab-hue__title {
  margin: 0 0 10px;
  font-family: "Fraunces", Georgia, serif;
  font-size: clamp(26px, 5.4vw, 34px);
  font-weight: 700;
  line-height: 1.1;
}
.ab-hue__meta {
  margin: 0 0 20px;
  font-size: 15px;
  line-height: 1.5;
}
.ab-hue__row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding-top: 18px;
  border-top: 2px dashed rgba(255, 255, 255, .55);
}
.ab-hue__price {
  margin: 0;
}
.ab-hue__price b {
  font-size: 28px;
}
.ab-hue__price span {
  font-size: 14px;
  text-decoration: line-through;
}
.ab-hue__cta {
  min-height: 44px;
  padding: 0 20px;
  border: 0;
  border-radius: 999px;
  background: #fff;
  color: oklch(32% .12 var(--ab-hue));
  font: inherit;
  font-weight: 700;
  cursor: pointer;
}
.ab-hue__pause {
  position: absolute;
  right: 14px;
  bottom: 14px;
  display: none;
  align-items: center;
  gap: 8px;
  min-height: 32px;
  padding: 0 12px;
  border: 1px solid rgba(255, 255, 255, .7);
  border-radius: 999px;
  background: transparent;
  color: #fff;
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
}
.ab-hue__pause::before {
  content: "";
  width: 3px;
  height: 10px;
  border-left: 3px solid currentColor;
  border-right: 3px solid currentColor;
}
.ab-hue__pause[aria-pressed="true"] {
  background: #fff;
  color: oklch(32% .12 var(--ab-hue));
}
.ab-hue__pause[aria-pressed="true"]::before {
  width: 9px;
  border: 0;
  background: currentColor;
  clip-path: polygon(0 0, 100% 50%, 0 100%);
}
.ab-hue button:focus-visible {
  outline: 3px solid #fff;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: no-preference) {
  .ab-hue {
    animation: ab-hue-cycle 16s ease-in-out infinite alternate;
  }
  .ab-hue__pause {
    display: inline-flex;
  }
  .ab-hue:has(.ab-hue__pause[aria-pressed="true"]) {
    animation-play-state: paused;
  }
}
@keyframes ab-hue-cycle {
  from { --ab-hue: 200; }
  to { --ab-hue: 345; }
}

Empty states, 404 pages and confirmations

Small screens that need a bit of character: an empty inbox, a lost page, a finished order and a device search. The confetti runs once for under five seconds, so it needs a replay button instead of a pause button.

Morphing blob behind an empty state

  • 8-value border-radius
  • rotate
  • alternate

No messages yet

When a client replies to a proposal, the thread shows up here.

The blob is one solid element whose eight-value border-radius moves between three shapes while it slowly rotates. The radius change repaints the blob, so it stays small and slow: a 160px shape on a 12 second alternate loop. The icon and message sit on top, and the pause button stops both changes.

View code
.ab-blob {
  position: relative;
  display: grid;
  justify-items: center;
  width: min(100%, 420px);
  padding: 32px 24px 64px;
  border-radius: 22px;
  background: #fff;
  color: #1a1d2e;
  text-align: center;
  box-shadow: 0 12px 34px rgba(20, 26, 60, .08);
}
.ab-blob__art {
  position: relative;
  display: grid;
  place-items: center;
  width: 180px;
  height: 170px;
  margin-bottom: 8px;
}
.ab-blob__shape {
  position: absolute;
  width: 160px;
  height: 150px;
  background: #dfe5ff;
  border-radius: 42% 58% 63% 37% / 41% 44% 56% 59%;
}
.ab-blob__icon {
  position: relative;
}
.ab-blob__title {
  margin: 0 0 8px;
  font-size: 22px;
  font-weight: 700;
}
.ab-blob__text {
  margin: 0 0 20px;
  max-width: 28ch;
  font-size: 15px;
  line-height: 1.5;
  color: #4b5068;
}
.ab-blob__cta {
  min-height: 44px;
  padding: 0 20px;
  border: 0;
  border-radius: 12px;
  background: #2b3a8c;
  color: #fff;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.ab-blob__pause {
  position: absolute;
  right: 14px;
  bottom: 14px;
  display: none;
  align-items: center;
  gap: 8px;
  min-height: 32px;
  padding: 0 12px;
  border: 1px solid #c9cee4;
  border-radius: 999px;
  background: #fff;
  color: #1a1d2e;
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
}
.ab-blob__pause::before {
  content: "";
  width: 3px;
  height: 10px;
  border-left: 3px solid currentColor;
  border-right: 3px solid currentColor;
}
.ab-blob__pause[aria-pressed="true"] {
  background: #1a1d2e;
  color: #fff;
}
.ab-blob__pause[aria-pressed="true"]::before {
  width: 9px;
  border: 0;
  background: currentColor;
  clip-path: polygon(0 0, 100% 50%, 0 100%);
}
.ab-blob button:focus-visible {
  outline: 3px solid #2b3a8c;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: no-preference) {
  .ab-blob__shape {
    animation: ab-blob-morph 12s ease-in-out infinite alternate;
  }
  .ab-blob__pause {
    display: inline-flex;
  }
  .ab-blob:has(.ab-blob__pause[aria-pressed="true"]) .ab-blob__shape {
    animation-play-state: paused;
  }
}
@keyframes ab-blob-morph {
  50% {
    border-radius: 63% 37% 45% 55% / 55% 61% 39% 45%;
    transform: rotate(40deg) scale(1.04);
  }
  100% {
    border-radius: 38% 62% 52% 48% / 62% 38% 62% 38%;
    transform: rotate(90deg);
  }
}

Rotating sunburst on a 404 page

  • repeating-conic-gradient
  • rotate one ray pair
  • solid card

404

This page wandered off

The link may be old, or the page moved during our spring cleanup.

The sunburst is a repeating-conic-gradient with hard 15 degree stops on an oversized square. Because the pattern repeats every 30 degrees, turning it by 30 degrees lands on an identical frame, so a short loop looks like endless rotation. The square only rotates, and the message sits on a solid card.

View code
.ab-burst {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  display: grid;
  place-items: center;
  min-height: 420px;
  padding: 40px 18px 68px;
  background: #f9c74f;
}
.ab-burst::before {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  z-index: -1;
  width: 1600px;
  height: 1600px;
  margin: -800px 0 0 -800px;
  background: repeating-conic-gradient(#f9c74f 0deg 15deg, #f6ad3c 15deg 30deg);
  pointer-events: none;
}
.ab-burst__card {
  max-width: 26rem;
  padding: 28px 24px;
  border: 3px solid #2a1d0a;
  border-radius: 20px;
  background: #fffaf0;
  color: #2a1d0a;
  text-align: center;
  box-shadow: 8px 8px 0 #2a1d0a;
}
.ab-burst__code {
  margin: 0;
  font-family: "Fraunces", Georgia, serif;
  font-size: clamp(64px, 16vw, 104px);
  font-weight: 800;
  line-height: .95;
}
.ab-burst__title {
  margin: 8px 0;
  font-size: 22px;
  font-weight: 700;
}
.ab-burst__text {
  margin: 0 0 20px;
  font-size: 15px;
  line-height: 1.5;
  color: #5a4526;
}
.ab-burst__cta {
  min-height: 44px;
  padding: 0 20px;
  border: 0;
  border-radius: 12px;
  background: #2a1d0a;
  color: #fffaf0;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
}
.ab-burst__pause {
  position: absolute;
  right: 12px;
  bottom: 12px;
  display: none;
  align-items: center;
  gap: 8px;
  min-height: 32px;
  padding: 0 12px;
  border: 2px solid #2a1d0a;
  border-radius: 999px;
  background: #fffaf0;
  color: #2a1d0a;
  font: inherit;
  font-size: 13px;
  font-weight: 700;
  cursor: pointer;
}
.ab-burst__pause::before {
  content: "";
  width: 3px;
  height: 10px;
  border-left: 3px solid currentColor;
  border-right: 3px solid currentColor;
}
.ab-burst__pause[aria-pressed="true"] {
  background: #2a1d0a;
  color: #fffaf0;
}
.ab-burst__pause[aria-pressed="true"]::before {
  width: 9px;
  border: 0;
  background: currentColor;
  clip-path: polygon(0 0, 100% 50%, 0 100%);
}
.ab-burst button:focus-visible {
  outline: 3px solid #2a1d0a;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: no-preference) {
  .ab-burst::before {
    animation: ab-burst-turn 8s linear infinite;
  }
  .ab-burst__pause {
    display: inline-flex;
  }
  .ab-burst:has(.ab-burst__pause[aria-pressed="true"])::before {
    animation-play-state: paused;
  }
}
@keyframes ab-burst-turn {
  to { transform: rotate(30deg); }
}

One-shot confetti on an order confirmation

  • many small elements
  • nth-child timing
  • replay class

Order confirmed

Order 20418 ships tomorrow. We sent the receipt to your email.

Items
3
Total
$84.50

Eighteen small elements fall once, each with its own start point, delay, drift and spin set through nth-child rules. The whole burst finishes in under four seconds, so it does not need a pause control, and the pieces end at opacity 0 so nothing stays on top of the receipt. Replay removes and re-adds the class that starts the animation.

View code
.ab-confetti {
  position: relative;
  overflow: hidden;
  display: grid;
  justify-items: center;
  width: min(100%, 400px);
  padding: 34px 24px 26px;
  border-radius: 22px;
  background: #fff;
  color: #18201b;
  text-align: center;
  box-shadow: 0 12px 34px rgba(16, 40, 24, .08);
}
.ab-confetti__burst {
  position: absolute;
  inset: 0;
  pointer-events: none;
}
.ab-confetti__burst i {
  position: absolute;
  top: -16px;
  left: 50%;
  width: 8px;
  height: 14px;
  border-radius: 2px;
  background: #ff5d73;
  opacity: 0;
}
.ab-confetti__burst i:nth-child(3n+2) { background: #2bb673; width: 10px; height: 10px; border-radius: 50%; }
.ab-confetti__burst i:nth-child(3n) { background: #3d6bff; }
.ab-confetti__burst i:nth-child(4n) { background: #ffc233; }
.ab-confetti__burst i:nth-child(1) { left: 6%; }
.ab-confetti__burst i:nth-child(2) { left: 12%; }
.ab-confetti__burst i:nth-child(3) { left: 18%; }
.ab-confetti__burst i:nth-child(4) { left: 24%; }
.ab-confetti__burst i:nth-child(5) { left: 30%; }
.ab-confetti__burst i:nth-child(6) { left: 36%; }
.ab-confetti__burst i:nth-child(7) { left: 42%; }
.ab-confetti__burst i:nth-child(8) { left: 47%; }
.ab-confetti__burst i:nth-child(9) { left: 52%; }
.ab-confetti__burst i:nth-child(10) { left: 57%; }
.ab-confetti__burst i:nth-child(11) { left: 62%; }
.ab-confetti__burst i:nth-child(12) { left: 67%; }
.ab-confetti__burst i:nth-child(13) { left: 72%; }
.ab-confetti__burst i:nth-child(14) { left: 77%; }
.ab-confetti__burst i:nth-child(15) { left: 82%; }
.ab-confetti__burst i:nth-child(16) { left: 87%; }
.ab-confetti__burst i:nth-child(17) { left: 92%; }
.ab-confetti__burst i:nth-child(18) { left: 96%; }
.ab-confetti__check {
  position: relative;
  width: 64px;
  height: 64px;
  margin-bottom: 14px;
  border-radius: 50%;
  background: #1f8f55;
}
.ab-confetti__check::after {
  content: "";
  position: absolute;
  left: 22px;
  top: 16px;
  width: 14px;
  height: 26px;
  border: solid #fff;
  border-width: 0 5px 5px 0;
  transform: rotate(45deg);
}
.ab-confetti__title {
  position: relative;
  margin: 0 0 8px;
  font-size: 24px;
  font-weight: 700;
}
.ab-confetti__text {
  position: relative;
  margin: 0 0 18px;
  font-size: 15px;
  line-height: 1.5;
  color: #49554d;
}
.ab-confetti__sum {
  position: relative;
  display: grid;
  gap: 8px;
  width: 100%;
  margin: 0 0 20px;
  padding: 14px 16px;
  border-radius: 12px;
  background: #f1f6f2;
}
.ab-confetti__sum div {
  display: flex;
  justify-content: space-between;
}
.ab-confetti__sum dd {
  margin: 0;
  font-weight: 700;
}
.ab-confetti__replay {
  position: relative;
  display: none;
  min-height: 44px;
  padding: 0 18px;
  border: 1.5px solid #1f8f55;
  border-radius: 12px;
  background: #fff;
  color: #17683e;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.ab-confetti__replay:focus-visible {
  outline: 3px solid #1f8f55;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: no-preference) {
  .ab-confetti__replay {
    display: inline-block;
  }
  .ab-confetti.is-on .ab-confetti__burst i {
    animation: ab-confetti-fall 2.4s cubic-bezier(.25, .6, .45, 1) both;
    --ab-confetti-x: 30px;
    --ab-confetti-r: 520deg;
  }
  .ab-confetti.is-on .ab-confetti__burst i:nth-child(2n) { --ab-confetti-x: -36px; --ab-confetti-r: -420deg; animation-duration: 2.8s; }
  .ab-confetti.is-on .ab-confetti__burst i:nth-child(3n) { animation-delay: .25s; }
  .ab-confetti.is-on .ab-confetti__burst i:nth-child(5n) { animation-delay: .5s; --ab-confetti-x: 54px; }
  .ab-confetti.is-on .ab-confetti__burst i:nth-child(7n) { animation-delay: .7s; animation-duration: 2.2s; }
}
@keyframes ab-confetti-fall {
  0% { opacity: 1; transform: translate(0, 0) rotate(0); }
  80% { opacity: 1; }
  100% { opacity: 0; transform: translate(var(--ab-confetti-x), 420px) rotate(var(--ab-confetti-r)); }
}

Radar rings on a device pairing screen

  • scale and opacity
  • staggered delays
  • role=status

Looking for your speaker

Hold the button on the back of Aurel One until the light blinks white.

Three rings share one keyframe that scales from 20% to full size while fading out, and each ring starts one second after the previous one, so a new pulse always leaves the center. Scale and opacity are both compositor-friendly. The searching message is a live status region and the pause button stops the rings.

View code
.ab-radar {
  position: relative;
  display: grid;
  align-content: center;
  justify-items: center;
  height: 100%;
  padding: 24px 24px 64px;
  box-sizing: border-box;
  background: #0e2a2f;
  color: #e6f4f1;
  text-align: center;
}
.ab-radar__scope {
  position: relative;
  display: grid;
  place-items: center;
  width: 220px;
  height: 220px;
  margin-bottom: 18px;
}
.ab-radar__ring {
  position: absolute;
  inset: 0;
  border: 2px solid #6fd3bf;
  border-radius: 50%;
  opacity: .35;
}
.ab-radar__ring:nth-child(2) {
  inset: 40px;
}
.ab-radar__ring:nth-child(3) {
  inset: 75px;
}
.ab-radar__core {
  position: relative;
  display: grid;
  place-items: center;
  width: 68px;
  height: 68px;
  border-radius: 50%;
  background: #6fd3bf;
}
.ab-radar__title {
  margin: 0 0 8px;
  font-size: 21px;
  font-weight: 700;
}
.ab-radar__text {
  margin: 0 0 22px;
  max-width: 28ch;
  font-size: 15px;
  line-height: 1.5;
  color: #b9d6d0;
}
.ab-radar__cta {
  min-height: 44px;
  padding: 0 20px;
  border: 1.5px solid #6fd3bf;
  border-radius: 999px;
  background: transparent;
  color: #e6f4f1;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.ab-radar__pause {
  position: absolute;
  right: 14px;
  bottom: 14px;
  display: none;
  align-items: center;
  gap: 8px;
  min-height: 32px;
  padding: 0 12px;
  border: 1px solid #3c6a68;
  border-radius: 999px;
  background: #0e2a2f;
  color: #e6f4f1;
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
}
.ab-radar__pause::before {
  content: "";
  width: 3px;
  height: 10px;
  border-left: 3px solid currentColor;
  border-right: 3px solid currentColor;
}
.ab-radar__pause[aria-pressed="true"] {
  background: #e6f4f1;
  color: #0e2a2f;
}
.ab-radar__pause[aria-pressed="true"]::before {
  width: 9px;
  border: 0;
  background: currentColor;
  clip-path: polygon(0 0, 100% 50%, 0 100%);
}
.ab-radar button:focus-visible {
  outline: 3px solid #6fd3bf;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: no-preference) {
  .ab-radar__ring,
  .ab-radar__ring:nth-child(2),
  .ab-radar__ring:nth-child(3) {
    inset: 0;
    opacity: 0;
    animation: ab-radar-pulse 3s cubic-bezier(.2, .6, .4, 1) infinite;
  }
  .ab-radar__ring:nth-child(2) {
    animation-delay: 1s;
  }
  .ab-radar__ring:nth-child(3) {
    animation-delay: 2s;
  }
  .ab-radar__pause {
    display: inline-flex;
  }
  .ab-radar:has(.ab-radar__pause[aria-pressed="true"]) .ab-radar__ring {
    animation-play-state: paused;
  }
}
@keyframes ab-radar-pulse {
  0% { transform: scale(.2); opacity: .9; }
  100% { transform: scale(1); opacity: 0; }
}

Footers, scroll-driven and toggled backgrounds

Backgrounds that react to something: rising bubbles behind a signup form, film grain on a footer, a scroll timeline that wipes in each chapter color, and a day to night switch driven by a toggle button.

Rising bubbles behind a newsletter form

  • outline circles
  • negative delays
  • translate only

Fizzwell

New flavors, first sip

One email a month with launches, recipes and early access to limited cans.

Twelve outlined circles rise from below the section, each with its own size, column and duration. Negative animation delays start every bubble mid-flight, so the panel is already full on first paint instead of filling up slowly. The bubbles are thin outlines at low opacity, which keeps the form labels well above 4.5:1.

View code
.ab-fizz {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  display: grid;
  place-items: center;
  min-height: 420px;
  padding: 48px 20px 72px;
  background: #0f6e69;
  color: #fff;
}
.ab-fizz__bubbles {
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
}
.ab-fizz__bubbles i {
  position: absolute;
  bottom: 12%;
  left: 8%;
  width: 22px;
  height: 22px;
  border: 2px solid rgba(255, 255, 255, .32);
  border-radius: 50%;
}
.ab-fizz__bubbles i:nth-child(2) { left: 17%; bottom: 60%; width: 12px; height: 12px; }
.ab-fizz__bubbles i:nth-child(3) { left: 26%; bottom: 30%; width: 34px; height: 34px; }
.ab-fizz__bubbles i:nth-child(4) { left: 35%; bottom: 80%; width: 16px; height: 16px; }
.ab-fizz__bubbles i:nth-child(5) { left: 44%; bottom: 8%; width: 26px; height: 26px; }
.ab-fizz__bubbles i:nth-child(6) { left: 53%; bottom: 70%; width: 10px; height: 10px; }
.ab-fizz__bubbles i:nth-child(7) { left: 62%; bottom: 40%; width: 40px; height: 40px; }
.ab-fizz__bubbles i:nth-child(8) { left: 71%; bottom: 20%; width: 14px; height: 14px; }
.ab-fizz__bubbles i:nth-child(9) { left: 80%; bottom: 76%; width: 28px; height: 28px; }
.ab-fizz__bubbles i:nth-child(10) { left: 88%; bottom: 46%; width: 18px; height: 18px; }
.ab-fizz__bubbles i:nth-child(11) { left: 4%; bottom: 84%; width: 30px; height: 30px; }
.ab-fizz__bubbles i:nth-child(12) { left: 94%; bottom: 14%; width: 12px; height: 12px; }
.ab-fizz__copy {
  width: min(100%, 30rem);
  text-align: center;
}
.ab-fizz__brand {
  margin: 0 0 10px;
  font-family: "Fraunces", Georgia, serif;
  font-size: 18px;
  font-style: italic;
  font-weight: 600;
}
.ab-fizz__title {
  margin: 0 0 10px;
  font-size: clamp(28px, 6vw, 42px);
  font-weight: 800;
  line-height: 1.05;
  letter-spacing: -.02em;
}
.ab-fizz__text {
  margin: 0 0 22px;
  font-size: 16px;
  line-height: 1.5;
  color: #e3f3f1;
}
.ab-fizz__form {
  text-align: left;
}
.ab-fizz__label {
  display: block;
  margin-bottom: 6px;
  font-size: 14px;
  font-weight: 600;
}
.ab-fizz__row {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}
.ab-fizz__input {
  flex: 1 1 180px;
  min-width: 0;
  min-height: 46px;
  padding: 0 14px;
  border: 0;
  border-radius: 12px;
  background: #fff;
  color: #10302e;
  font: inherit;
}
.ab-fizz__input::placeholder {
  color: #5f7472;
}
.ab-fizz__submit {
  flex: 0 0 auto;
  min-height: 46px;
  padding: 0 20px;
  border: 0;
  border-radius: 12px;
  background: #ffcf4a;
  color: #2a1e00;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
}
.ab-fizz__status {
  min-height: 20px;
  margin: 10px 0 0;
  font-size: 14px;
}
.ab-fizz__input:focus-visible,
.ab-fizz button:focus-visible {
  outline: 3px solid #ffcf4a;
  outline-offset: 2px;
}
.ab-fizz__pause {
  position: absolute;
  right: 12px;
  bottom: 12px;
  display: none;
  align-items: center;
  gap: 8px;
  min-height: 32px;
  padding: 0 12px;
  border: 1px solid rgba(255, 255, 255, .6);
  border-radius: 999px;
  background: #0f6e69;
  color: #fff;
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
}
.ab-fizz__pause::before {
  content: "";
  width: 3px;
  height: 10px;
  border-left: 3px solid currentColor;
  border-right: 3px solid currentColor;
}
.ab-fizz__pause[aria-pressed="true"] {
  background: #fff;
  color: #0f6e69;
}
.ab-fizz__pause[aria-pressed="true"]::before {
  width: 9px;
  border: 0;
  background: currentColor;
  clip-path: polygon(0 0, 100% 50%, 0 100%);
}
@media (prefers-reduced-motion: no-preference) {
  .ab-fizz__bubbles i {
    bottom: -48px;
    animation: ab-fizz-rise 9s linear infinite;
  }
  .ab-fizz__bubbles i:nth-child(2) { animation-duration: 7s; animation-delay: -5s; }
  .ab-fizz__bubbles i:nth-child(3) { animation-duration: 12s; animation-delay: -2s; }
  .ab-fizz__bubbles i:nth-child(4) { animation-duration: 8s; animation-delay: -6s; }
  .ab-fizz__bubbles i:nth-child(5) { animation-duration: 10s; animation-delay: -1s; }
  .ab-fizz__bubbles i:nth-child(6) { animation-duration: 6s; animation-delay: -3s; }
  .ab-fizz__bubbles i:nth-child(7) { animation-duration: 14s; animation-delay: -9s; }
  .ab-fizz__bubbles i:nth-child(8) { animation-duration: 7.5s; animation-delay: -4s; }
  .ab-fizz__bubbles i:nth-child(9) { animation-duration: 11s; animation-delay: -7s; }
  .ab-fizz__bubbles i:nth-child(10) { animation-duration: 9.5s; animation-delay: -8s; }
  .ab-fizz__bubbles i:nth-child(11) { animation-duration: 13s; animation-delay: -11s; }
  .ab-fizz__bubbles i:nth-child(12) { animation-duration: 6.5s; animation-delay: -2.5s; }
  .ab-fizz__pause {
    display: inline-flex;
  }
  .ab-fizz:has(.ab-fizz__pause[aria-pressed="true"]) .ab-fizz__bubbles i {
    animation-play-state: paused;
  }
}
@keyframes ab-fizz-rise {
  0% { transform: translate(0, 0); }
  50% { transform: translate(12px, -260px); }
  100% { transform: translate(0, -540px); }
}

Chapter colors that wipe in on scroll

  • animation-timeline: view()
  • @supports
  • scaleX wipe

Harvest

Picked ripe in the hills above Huila

Our partner farm picks each tree three times a season, so only red cherries go to the mill.

Roast

Twelve minutes in a small drum

We roast on Tuesdays and ship on Wednesdays, so beans reach you within a week.

Brew

Fifteen grams, two hundred fifty of water

A simple ratio for any pour-over. Adjust the grind before you change anything else.

Scroll inside the panel.

Each chapter has a solid color layer that scales from 0 to full width as the chapter enters the scroller, driven by animation-timeline: view() instead of the clock. The rule sits inside @supports and the motion query, and browsers without scroll-driven animations simply show the colors. The shorthand comes first because animation resets animation-timeline.

View code
.ab-chap {
  width: min(100%, 440px);
  height: 380px;
  overflow-y: auto;
  border: 1px solid #ddd5c7;
  border-radius: 20px;
  background: #fffdf8;
  color: #221d16;
  overscroll-behavior: contain;
}
.ab-chap:focus-visible {
  outline: 3px solid #6b4a1f;
  outline-offset: 3px;
}
.ab-chap__ch {
  position: relative;
  isolation: isolate;
  min-height: 340px;
  padding: 36px 26px;
  box-sizing: border-box;
}
.ab-chap__ch::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  transform-origin: left center;
}
.ab-chap__ch--1::before { background: #f5e3c4; }
.ab-chap__ch--2::before { background: #dcebd5; }
.ab-chap__ch--3::before { background: #f3d6d0; }
.ab-chap__step {
  margin: 0 0 10px;
  font-size: 14px;
  font-weight: 700;
  color: #6b4a1f;
}
.ab-chap__title {
  margin: 0 0 12px;
  font-family: "Fraunces", Georgia, serif;
  font-size: clamp(24px, 5vw, 30px);
  font-weight: 700;
  line-height: 1.12;
}
.ab-chap__text {
  margin: 0;
  max-width: 34ch;
  font-size: 16px;
  line-height: 1.55;
  color: #463b2c;
}
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .ab-chap__ch::before {
      animation: ab-chap-wipe linear both;
      animation-timeline: view();
      animation-range: entry 0% cover 45%;
    }
  }
}
@keyframes ab-chap-wipe {
  from { transform: scaleX(0); }
  to { transform: scaleX(1); }
}

Day to night sky on a toggle

  • aria-pressed toggle
  • color transition
  • translate sun and moon

Cabin at Lake Varn

Check-in after 3 pm. The deck faces west, so sunsets are part of the stay.

$148 per night

Press Night view.

One aria-pressed button drives the whole scene through :has(). The sky and hills change color with a plain transition, the sun sinks and the moon rises with translate, and the stars fade in on opacity. With reduced motion the scene still switches, only without the movement.

View code
.ab-sky {
  width: min(100%, 400px);
  overflow: hidden;
  border-radius: 22px;
  background: #fff;
  color: #1b2030;
  box-shadow: 0 12px 34px rgba(20, 26, 50, .1);
}
.ab-sky__scene {
  position: relative;
  overflow: hidden;
  height: 200px;
  background-color: #9fd8f2;
  transition: background-color 1.2s ease;
}
.ab-sky__scene i {
  position: absolute;
  display: block;
}
.ab-sky__stars {
  top: 0;
  left: 0;
  width: 2px;
  height: 2px;
  border-radius: 50%;
  box-shadow: 30px 24px #fff, 80px 60px #fff, 140px 18px #fff, 190px 70px #fff, 250px 30px #fff, 310px 54px #fff, 360px 20px #fff, 60px 110px #fff, 220px 100px #fff, 340px 96px #fff;
  opacity: 0;
  transition: opacity .8s ease;
}
.ab-sky__sun {
  top: 34px;
  right: 22%;
  width: 54px;
  height: 54px;
  border-radius: 50%;
  background: #ffcf4d;
}
.ab-sky__moon {
  top: 30px;
  left: 18%;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background: #f3efe0;
  box-shadow: inset -12px -4px 0 #d9d3bd;
  transform: translateY(190px);
}
.ab-sky__hill {
  bottom: -60px;
  border-radius: 50%;
  transition: background-color 1.2s ease;
}
.ab-sky__hill--back {
  left: -20%;
  width: 90%;
  height: 150px;
  background-color: #6fb68a;
}
.ab-sky__hill--front {
  right: -25%;
  width: 100%;
  height: 130px;
  background-color: #3f8a5f;
}
.ab-sky:has(.ab-sky__toggle[aria-pressed="true"]) .ab-sky__scene { background-color: #17204a; }
.ab-sky:has(.ab-sky__toggle[aria-pressed="true"]) .ab-sky__stars { opacity: 1; }
.ab-sky:has(.ab-sky__toggle[aria-pressed="true"]) .ab-sky__sun { transform: translateY(190px); }
.ab-sky:has(.ab-sky__toggle[aria-pressed="true"]) .ab-sky__moon { transform: none; }
.ab-sky:has(.ab-sky__toggle[aria-pressed="true"]) .ab-sky__hill--back { background-color: #24375a; }
.ab-sky:has(.ab-sky__toggle[aria-pressed="true"]) .ab-sky__hill--front { background-color: #15233f; }
.ab-sky__body {
  padding: 20px 22px 22px;
}
.ab-sky__head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: 8px;
}
.ab-sky__title {
  margin: 0;
  font-size: 20px;
  font-weight: 700;
}
.ab-sky__toggle {
  min-height: 40px;
  padding: 0 14px;
  border: 1.5px solid #17204a;
  border-radius: 999px;
  background: #fff;
  color: #17204a;
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
}
.ab-sky__toggle[aria-pressed="true"] {
  background: #17204a;
  color: #fff;
}
.ab-sky__toggle:focus-visible {
  outline: 3px solid #3d6bff;
  outline-offset: 3px;
}
.ab-sky__text {
  margin: 0 0 12px;
  font-size: 15px;
  line-height: 1.5;
  color: #4a5065;
}
.ab-sky__price {
  margin: 0;
}
.ab-sky__price b {
  font-size: 22px;
}
@media (prefers-reduced-motion: no-preference) {
  .ab-sky__sun,
  .ab-sky__moon {
    transition: transform 1.2s cubic-bezier(.45, 0, .2, 1);
  }
  .ab-sky:has(.ab-sky__toggle[aria-pressed="true"]) .ab-sky__moon {
    transition-delay: .3s;
  }
}

What is a CSS animated background and which properties can it animate?

Anything on the background layer can move or change: the color, a gradient, an image or a pattern. Keyframes or transitions drive it, and no JavaScript, GIF or video file is involved.

Hero sections, landing pages and cards are where it gets used most, since a slow loop adds motion without a media request. A gradient version adds no file request and scales to any viewport.

Both CSS animations and transitions can drive it. MDN’s list of animatable properties covers the ones behind most background effects, and the table below has them.

PropertyWhat it changesTypical use
background-colorBlends one color into anotherColor cycling, hover shifts
background-positionSlides the image or gradient layerMoving gradients, scrolling patterns
background-sizeStretches or zooms the layerPulsing glows, slow zoom

Gradients don’t follow that pattern. The browser reads a gradient as an image, so a transition between two gradients snaps instead of blending, as web.dev’s @property article explains.

Sliding is allowed. Blending has to wait until the browser knows what the values inside the gradient mean.

How to make an animated gradient background with keyframes

Stretch a gradient to 200% to 600% of the element, then slide it back and forth with background-position inside a keyframe animation. One keyframe rule with stops at the start, the midpoint and the end handles the whole effect.

Build steps

  1. Set a fallback background-color on the element.
  2. Add a linear-gradient with three or more color stops as the background-image.
  3. Set background-size to 600% 600% (common examples use anywhere from 200% to 600%).
  4. Write a @keyframes rule that moves background-position from 0% 50% to 100% 50% and back to 0% 50%.
  5. Attach it with the animation shorthand and set the iteration count to infinite.
  6. Check the first and last frame. Both must show the same position, or the loop jumps on restart.
.hero {
  background-color: #18a2f0;
  background-image: linear-gradient(270deg, #18f0b8, #18a2f0, #db5640);
  background-size: 600% 600%;
  animation: drift 20s linear infinite;
}

@keyframes drift {
  0%   { background-position: 0% 50%; }
  50%  { background-position: 100% 50%; }
  100% { background-position: 0% 50%; }
}

A gradient generator speeds up picking color stops, though three stops cover most designs.

Duration, easing and direction

Start around 15 to 20 seconds and shorten it only if the motion looks frozen. Fast loops suit flat color changes, not gradient drift.

On easing, MDN lists ease as the initial value, and it applies between each pair of keyframes. A three-keyframe loop therefore softens at every stop, so linear gives a steadier drift.

Direction is its own setting. The alternate value reverses every cycle and plays forward first (MDN), which lets a two-keyframe version skip the return trip. Set the iteration count to infinite and the loop keeps running.

Slow and linear beats fast and eased for anything sitting behind text.

How to animate gradient colors and angles with @property

Register the gradient’s variable with @property, declare its type, and animate the variable instead of the gradient. Once the browser knows the type is a color or an angle, it can interpolate the value, which is how a gradient finally blends instead of snapping (web.dev, 2024).

@property --angle {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}

.card {
  background: conic-gradient(from var(--angle), #ff6b6b, #4ecdc4, #ff6b6b);
  animation: spin 6s linear infinite;
}

@keyframes spin {
  to { --angle: 360deg; }
}

The rule takes syntax, inherits and initial-value descriptors, and syntax is the one that makes the animation possible. It belongs to the Houdini specifications (the CSS Properties and Values API).

Angle variables rotate a conic-gradient or tilt a linear one, and color variables blend one stop into another. A number variable can also feed hsl() if you want hue cycling.

Browser support and fallback

Firefox 128 (July 9, 2024) was the last of the major engines to ship @property, after Chrome and Edge 85 (August 2020) and Safari 16.4 (March 2023). That put it in Baseline Newly Available status in the web-features project data.

Older browsers are the failure case. The rule gets ignored, the variable stays an untyped string, and the gradient jumps between values instead of blending.

Design the static gradient so it looks finished on its own, and treat the motion as a bonus.

How to loop a moving image or pattern background without a seam

Move the background by exactly one tile length and the loop point disappears. Any other shift distance leaves a visible jump at restart, because the pattern lands mid-tile.

Noble Desktop’s stripe tutorial uses a 45-degree repeating-linear-gradient with a background-size of 198px, and the keyframe moves background-position vertically by 198px. The tutorial found that number by experimenting in Chrome’s DevTools, but it isn’t arbitrary. Stripes of 35px plus 35px repeat every 70px along the gradient line, which comes to about 99px along either axis (70 x 1.414), so 198px holds two full repeats.

When the shift matches the tile, the loop restarts on an identical frame and the pattern appears to scroll forever. When it doesn’t, you get a visible jump at every restart, stripes or shapes that seem to stutter, and an error that grows with the size of the mismatch.

Repeating gradients work well for this since they need no image file, and they make good background patterns built from CSS.

A raster tile follows the same rule. Shift by the image’s width or height, and make sure the tile’s edges match.

Noble Desktop also calls a full-page version distracting and sluggish on some devices, so scope the loop to a hero block or a card.

Layered CSS animated backgrounds: waves, particles, stars and SVG patterns

Effects that one background property can’t draw go on a separate layer (a pseudo-element or an absolutely positioned child) that moves with transform. Moving a layer leaves the background property itself untouched, and the parent clips the overflow.

Pseudo-element layers

The layer is a ::before or ::after with empty content and position absolute, and pointer-events none lets clicks through to the content. Use inset 0 for a static layer and width 200% for a sliding one.

Stacking is where this fails most often. Give the parent position relative, overflow hidden and either z-index 0 or isolation isolate, or a negative z-index layer slips behind the parent’s own background.

Waves and SVG

Waves are SVG paths drawn wider than the container and moved with translateX, so the path repeats before the layer runs out. Animating SVG with CSS follows the same keyframe rules as any other element.

A tool like an SVG waves generator outputs the path, and the loop distance is the width of one wave period.

An SVG tile also works as the layer’s background-image, and then the position loop from the previous section applies.

Particles and stars

Box-shadow stars need only one tiny element, since every star is a box-shadow and one animation moves them all. Separate elements give each particle its own size, delay and duration, which adds variety but also more DOM nodes.

Hundreds of particles belong on a canvas, not in CSS.

Why does a CSS animated background lag, and which properties cost the least?

Lag comes from every frame pushing the browser back into Style, Layout or Paint instead of Composite.

Chrome’s Lighthouse documentation defines a non-composited animation as any animation that triggers one of those earlier steps. The web.dev animation guide names transform and opacity as the properties that stay on the compositing stage.

Which stage each property triggers

Transform and opacity stay on the composite stage. Background-position, background-size and background-color trigger paint, and none of them is on the compositor-only list. The registered custom property behind an @property gradient is the one to verify first, since Unlighthouse’s summary of the Lighthouse audit lists it among properties that cannot be composited, which leaves it on the main thread.

For background-position the cost is a repaint on every frame. The property is not compositor-only, so each change makes the browser repaint the affected area on the main thread.

Sources don’t fully agree here. 21st.dev’s survey of React background components says a gradient on CSS keyframes costs almost nothing because no JavaScript runs. web.dev says anything that triggers paint or layout needs the main thread, and that the paint overhead outweighs the CSS versus JavaScript question.

Both are right about different properties. No JavaScript doesn’t mean no main-thread work.

Converting a background-position loop to a transform loop

Move the gradient onto its own layer, then translate that layer instead of sliding the background.

.shimmer {
  position: relative;
  overflow: hidden;
}

.shimmer::before {
  content: "";
  position: absolute;
  inset: 0;
  background-image: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
  animation: slide 2s linear infinite;
}

@keyframes slide {
  from { transform: translateX(-100%); }
  to   { transform: translateX(100%); }
}

The coder/mux repository (since renamed coder/xum) made the same swap in pull request 980 (December 8, 2025). A shimmer moved from background-position to a translateX transform with a new shimmer-slide keyframe, to stop frame drops while a summary streamed in.

The pull request publishes no frame-rate figures, so treat the gain as directional until your own trace confirms it.

A drift built on a 600% stretch needs a layer six times wider, so shrink the stretch when you convert.

Measuring in Chrome DevTools and Lighthouse

Record the loop in the Performance panel and read the frame timeline for dropped frames. Paint flashing in the Rendering tab shows which regions repaint each frame. Lighthouse lists every DOM node with a non-composited animation, along with the failure reason for each.

As a last resort there’s will-change. MDN calls it a last resort for existing performance problems, warns against putting it on many elements, and notes that values which create a stacking context create it up front.

That stacking context side effect can change which layer sits behind your text, so recheck the layering after adding it.

Technique comparison by cost, support and best use

A transform-based layer is the cheapest choice for a full-screen backdrop, and the shifting gradient is enough for a small hero block.

TechniqueAnimated propertyRendering stageBest use
Shifting gradientbackground-positionPaintHero block, card
Typed gradientRegistered variableMain threadBlending colors, rotating angles
Scrolling patternbackground-positionPaintStripes, textures on small areas
Transform layertransformCompositeFull-screen backdrop, waves, shimmer
Many small elementstransform per elementComposite, many layersSparse stars, bokeh dots
  • The shifting gradient needs two or three keyframes and no extra markup, but it repaints on every frame.
  • A typed gradient gives true color blending and rotation. It needs @property support and runs on the main thread.
  • Scrolling patterns skip the image file when built from repeating gradients, as long as the shift distance matches the tile.
  • A transform layer stays on the compositor, though it needs a pseudo-element, clipping and the stacking rules from earlier.
  • Many small elements give varied, organic motion. Every one of them adds DOM weight and a layer.

The popular pick, a background-position gradient, is the right call for a hero block and the wrong one for a backdrop that stays on screen.

If the brief only says color cycling, animate background-color and skip @property. Reserve the typed gradient for effects where the stops must blend or the angle must turn.

Reduced motion, pause controls and text contrast for animated backgrounds

A moving background needs a prefers-reduced-motion rule and a pause control once it runs past five seconds. The text on top also has to hold its WCAG contrast on every frame.

prefers-reduced-motion

The setting reflects a request from the user’s device to minimize motion, and reading it takes one media query. MDN names scaling and panning of large objects as vestibular triggers, and a gradient drifting across the whole viewport is a large pan.

@media (prefers-reduced-motion: no-preference) {
  .hero {
    animation: drift 20s linear infinite;
  }
}

Putting the animation inside the no-preference query makes motion opt-in. Everyone else gets the static gradient.

Safari 10.1, Firefox 63 and Chrome 74 support the feature (MDN). Apple.com used it to switch off a scaling effect and background video motion, as WebKit’s Responsive Design for Motion post describes.

Pause, stop, hide

WCAG 2.2 Success Criterion 2.2.2 (Level A) asks for a pause, stop or hide mechanism when motion starts automatically, lasts more than five seconds and sits alongside other content, per W3C. A looping background meets all of those conditions.

The simplest control is one button that adds a class setting animation-play-state to paused on every moving layer. W3C’s understanding document says one control per moving element passes the criterion but gives a poor experience, because users must stop each element one by one, so one toggle for the whole page is the better build.

Text contrast over motion

W3C sets 4.5:1 as the Success Criterion 1.4.3 minimum for normal text and 3:1 for large-scale text.

A moving background changes the pixels behind each letter, so color contrast has to hold on the lightest and darkest frames, not on one screenshot. Pause the loop at its lightest frame and measure, then do the same at the darkest. If either fails, add a semi-opaque scrim under the text block.

The scrim works because W3C’s G18 technique lets the area around the letters carry the ratio, even when the full background does not.

When a CSS animated background is the wrong choice

Switch away from CSS when the background has to react to input or play filmed footage, or when gradients and layers can’t draw what you need.

Cursor trails and flow fields read pointer position, so they need a script. For filmed or photographic motion, Onderhond’s CSS versus video comparison picks video when presentation matters most. On reading-heavy pages a static background usually serves the text better than any loop.

Google’s web.dev guide to CSS versus JavaScript animations puts stop, pause, slow down and reverse on the JavaScript side. That overstates the case, since animation-play-state already pauses in plain CSS. The real JavaScript cases are reversing and sequencing, through the Web Animations API or a library such as GSAP.

Canvas animations redraw every frame in JavaScript, so cost scales with particle count and canvas size, according to 21st.dev’s component survey.

For GPU-heavy scenes, WebGL takes over. Thirty of the 155 background components in that survey use it in some form.

Stay with CSS while the effect is gradients, patterns and a handful of layers, with no input to read.

CSS Animated Background FAQ

Does a CSS animated background affect Core Web Vitals?

Not through layout shift. Cumulative Layout Shift counts elements that move, and Chrome’s Lighthouse documentation ties it to non-composited animations that shift elements.

A background slid by background-position or transform moves no element, so any cost shows up as jank in the Lighthouse audit instead.

Does an animated background drain mobile battery?

Yes, any loop that runs forever keeps the browser producing frames.

Chrome’s Lighthouse documentation notes that non-composited animations force extra rendering work, so a transform loop costs less than a paint-bound one. A pause toggle or a reduced-motion rule stops the work entirely.

How do you animate a background color on hover?

Add a transition on background-color to the base element, then set a new background-color in the :hover rule. No keyframes are needed.

Start near 0.3 seconds and adjust by eye.

Can a background animate on scroll instead of on a timer?

Yes. Chrome 115 introduced scroll timelines (Chrome for Developers), and the animation-timeline property with scroll() ties a normal keyframe animation to scroll position instead of the clock.

Safari 26 supports them too, and Firefox 159 (October 27, 2026) turns them on by default. Older versions still lack them, so wrap animations driven by scroll position in an @supports check and keep a static background as the fallback.

Does Tailwind CSS include background animation utilities?

No. Tailwind’s documentation lists four built-in animations (spin, ping, pulse and bounce) plus animate-none, and none of them animates a background.

A drifting gradient needs custom @keyframes defined in the theme, then a utility that references them.

Is there a generator for CSS background animations?

Yes, in two steps. A gradient generator outputs the static gradient, and a keyframe animation generator builds the @keyframes block.

You still choose the background-size and position values that make the gradient move.

Where to Place Your First CSS Animated Background

Put the first one on one hero block and finish it as a static design before any motion gets added. Each step can then be tested alone.

Get the static gradient approved in review, add the loop behind the reduced-motion query, then trace it and convert to a transform layer only if frames drop.

A loop added last never blocks the launch, and a failed trace costs one conversion instead of a redesign.

The web-features project expects @property to reach Baseline Widely Available on January 9, 2027 (checked October 2026).

Revisit the typed-gradient decision then. Until that date, a blend-based design needs a static fallback that looks finished on its own.