16 CSS Pulse Animations13 / 16

Pure CSSMIT licensed

Load More Staggered Dots

An infinite-scroll feed pagination indicator — three horizontal slate dots pulsing in a staggered sequence at the bottom of a feed container, signaling "loading more content, hang on." Neutral off-white canvas (#fafafa) with slate (#475569) dots and light gray ghost cards above (representing already-loaded content). Twitter / Threads / Reddit infinite-scroll aesthetic — quiet, minimal, honest.

Published

Live Demo
Try it

The code

<div class="pl-13">
  <div class="pl-13__feed">
    <div class="pl-13__card">
      <div class="pl-13__card-head">
        <span class="pl-13__avatar" aria-hidden="true"></span>
        <div>
          <div class="pl-13__name">Priya Iyer</div>
          <div class="pl-13__stamp">2 min ago · Threads</div>
        </div>
      </div>
      <div class="pl-13__text pl-13__text--wide"></div>
      <div class="pl-13__text pl-13__text--mid"></div>
    </div>
    <div class="pl-13__card">
      <div class="pl-13__card-head">
        <span class="pl-13__avatar" aria-hidden="true" style="background:linear-gradient(135deg,#f97316,#ea580c)"></span>
        <div>
          <div class="pl-13__name">Marcus Chen</div>
          <div class="pl-13__stamp">8 min ago · Design</div>
        </div>
      </div>
      <div class="pl-13__text pl-13__text--wide"></div>
      <div class="pl-13__text pl-13__text--short"></div>
    </div>
    <div class="pl-13__card">
      <div class="pl-13__card-head">
        <span class="pl-13__avatar" aria-hidden="true" style="background:linear-gradient(135deg,#a78bfa,#7c3aed)"></span>
        <div>
          <div class="pl-13__name">Kodachi</div>
          <div class="pl-13__stamp">14 min ago · Ambient</div>
        </div>
      </div>
      <div class="pl-13__text pl-13__text--wide"></div>
      <div class="pl-13__text pl-13__text--mid"></div>
      <div class="pl-13__text pl-13__text--short"></div>
    </div>
    <div class="pl-13__loader" role="status" aria-live="polite" aria-label="Loading more posts">
      <div class="pl-13__dots" aria-hidden="true">
        <span></span>
        <span></span>
        <span></span>
      </div>
      <span class="pl-13__loader-text">Loading more</span>
    </div>
  </div>
</div>
.pl-13 {
  --page-bg: #fafafa;
  --card: #ffffff;
  --ink: #0f172a;
  --sub: #64748b;
  --dot: #475569;
  --placeholder: #e2e8f0;
  --line: #e5e5e5;
  font-family: -apple-system,BlinkMacSystemFont,'Inter','Segoe UI',sans-serif;
  width: 100%;
  min-height: 100vh;
  position: relative;
  display: grid;
  place-items: start center;
  padding: 32px;
  background: var(--page-bg);
  color: var(--ink);
}

.pl-13 *,
.pl-13 *::before,
.pl-13 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.pl-13 ::selection {
  background: var(--dot);
  color: #fff;
}

.pl-13__feed {
  width: min(500px,100%);
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.pl-13__card {
  padding: 14px 16px;
  border-radius: 10px;
  background: var(--card);
  border: 1px solid var(--line);
  box-shadow: 0 1px 2px rgba(15,23,42,.04);
  opacity: .85;
}

.pl-13__card-head {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 10px;
}

.pl-13__avatar {
  width: 32px;
  height: 32px;
  border-radius: 50%;
  flex-shrink: 0;
  background: linear-gradient(135deg,#3b82f6,#1e40af);
}

.pl-13__name {
  font-size: .82rem;
  font-weight: 700;
  color: var(--ink);
  letter-spacing: -.005em;
}

.pl-13__stamp {
  font-size: .7rem;
  color: var(--sub);
  margin-top: 1px;
}

.pl-13__text {
  height: 10px;
  border-radius: 3px;
  background: var(--placeholder);
  margin-bottom: 6px;
}

.pl-13__text--wide {
  width: 95%;
}

.pl-13__text--mid {
  width: 72%;
}

.pl-13__text--short {
  width: 48%;
}

.pl-13__loader {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  padding: 20px 0;
}

.pl-13__dots {
  display: flex;
  gap: 6px;
  height: 8px;
}

.pl-13__dots span {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--dot);
  animation: pl-13-dot 1.4s ease-in-out infinite;
}

.pl-13__dots span:nth-child(1) {
  animation-delay: 0s;
}

.pl-13__dots span:nth-child(2) {
  animation-delay: .2s;
}

.pl-13__dots span:nth-child(3) {
  animation-delay: .4s;
}

@keyframes pl-13-dot {
  0%,
    100% {
    transform: translateY(0);
    opacity: .4;
  }

  40% {
    transform: translateY(-8px);
    opacity: 1;
  }
}

.pl-13__loader-text {
  font-size: .75rem;
  font-weight: 600;
  color: var(--sub);
  letter-spacing: .02em;
}

@media (prefers-reduced-motion: reduce) {
  .pl-13__dots span {
    animation: none!important;
    transform: none!important;
    opacity: 1!important;
  }
}
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 Pulse 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: Load More Staggered Dots
Source: https://codefronts.com/motion/css-pulse-animation/load-more-staggered-dots/

An infinite-scroll feed pagination indicator — three horizontal slate dots pulsing in a staggered sequence at the bottom of a feed container, signaling "loading more content, hang on." Neutral off-white canvas (#fafafa) with slate (#475569) dots and light gray ghost cards above (representing already-loaded content). Twitter / Threads / Reddit infinite-scroll aesthetic — quiet, minimal, honest.
## HTML
```html
<div class="pl-13">
  <div class="pl-13__feed">
    <div class="pl-13__card">
      <div class="pl-13__card-head">
        <span class="pl-13__avatar" aria-hidden="true"></span>
        <div>
          <div class="pl-13__name">Priya Iyer</div>
          <div class="pl-13__stamp">2 min ago · Threads</div>
        </div>
      </div>
      <div class="pl-13__text pl-13__text--wide"></div>
      <div class="pl-13__text pl-13__text--mid"></div>
    </div>
    <div class="pl-13__card">
      <div class="pl-13__card-head">
        <span class="pl-13__avatar" aria-hidden="true" style="background:linear-gradient(135deg,#f97316,#ea580c)"></span>
        <div>
          <div class="pl-13__name">Marcus Chen</div>
          <div class="pl-13__stamp">8 min ago · Design</div>
        </div>
      </div>
      <div class="pl-13__text pl-13__text--wide"></div>
      <div class="pl-13__text pl-13__text--short"></div>
    </div>
    <div class="pl-13__card">
      <div class="pl-13__card-head">
        <span class="pl-13__avatar" aria-hidden="true" style="background:linear-gradient(135deg,#a78bfa,#7c3aed)"></span>
        <div>
          <div class="pl-13__name">Kodachi</div>
          <div class="pl-13__stamp">14 min ago · Ambient</div>
        </div>
      </div>
      <div class="pl-13__text pl-13__text--wide"></div>
      <div class="pl-13__text pl-13__text--mid"></div>
      <div class="pl-13__text pl-13__text--short"></div>
    </div>
    <div class="pl-13__loader" role="status" aria-live="polite" aria-label="Loading more posts">
      <div class="pl-13__dots" aria-hidden="true">
        <span></span>
        <span></span>
        <span></span>
      </div>
      <span class="pl-13__loader-text">Loading more</span>
    </div>
  </div>
</div>
```
## CSS
```css
.pl-13 {
  --page-bg: #fafafa;
  --card: #ffffff;
  --ink: #0f172a;
  --sub: #64748b;
  --dot: #475569;
  --placeholder: #e2e8f0;
  --line: #e5e5e5;
  font-family: -apple-system,BlinkMacSystemFont,'Inter','Segoe UI',sans-serif;
  width: 100%;
  min-height: 100vh;
  position: relative;
  display: grid;
  place-items: start center;
  padding: 32px;
  background: var(--page-bg);
  color: var(--ink);
}

.pl-13 *,
.pl-13 *::before,
.pl-13 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.pl-13 ::selection {
  background: var(--dot);
  color: #fff;
}

.pl-13__feed {
  width: min(500px,100%);
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.pl-13__card {
  padding: 14px 16px;
  border-radius: 10px;
  background: var(--card);
  border: 1px solid var(--line);
  box-shadow: 0 1px 2px rgba(15,23,42,.04);
  opacity: .85;
}

.pl-13__card-head {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 10px;
}

.pl-13__avatar {
  width: 32px;
  height: 32px;
  border-radius: 50%;
  flex-shrink: 0;
  background: linear-gradient(135deg,#3b82f6,#1e40af);
}

.pl-13__name {
  font-size: .82rem;
  font-weight: 700;
  color: var(--ink);
  letter-spacing: -.005em;
}

.pl-13__stamp {
  font-size: .7rem;
  color: var(--sub);
  margin-top: 1px;
}

.pl-13__text {
  height: 10px;
  border-radius: 3px;
  background: var(--placeholder);
  margin-bottom: 6px;
}

.pl-13__text--wide {
  width: 95%;
}

.pl-13__text--mid {
  width: 72%;
}

.pl-13__text--short {
  width: 48%;
}

.pl-13__loader {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  padding: 20px 0;
}

.pl-13__dots {
  display: flex;
  gap: 6px;
  height: 8px;
}

.pl-13__dots span {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--dot);
  animation: pl-13-dot 1.4s ease-in-out infinite;
}

.pl-13__dots span:nth-child(1) {
  animation-delay: 0s;
}

.pl-13__dots span:nth-child(2) {
  animation-delay: .2s;
}

.pl-13__dots span:nth-child(3) {
  animation-delay: .4s;
}

@keyframes pl-13-dot {
  0%,
    100% {
    transform: translateY(0);
    opacity: .4;
  }

  40% {
    transform: translateY(-8px);
    opacity: 1;
  }
}

.pl-13__loader-text {
  font-size: .75rem;
  font-weight: 600;
  color: var(--sub);
  letter-spacing: .02em;
}

@media (prefers-reduced-motion: reduce) {
  .pl-13__dots span {
    animation: none!important;
    transform: none!important;
    opacity: 1!important;
  }
}
```

How this works

Three <span> elements share the same 8px circular slate dot shape. Each dot animates transform: translateY(0) → translateY(-8px) → translateY(0) with a subtle opacity dip via @keyframes pl-13-dot over 1.4 seconds. The three dots have staggered animation-delay: dot 1 at 0s, dot 2 at 0.2s, dot 3 at 0.4s. This creates the classic "wave" motion — dots bob up one after another in sequence, then all settle before the wave repeats.

The stagger is 200ms not 100ms — this is intentional. At 100ms the dots feel simultaneously bobbing (chaotic), at 300ms+ they feel too disconnected (three separate pulses). 200ms is the wave-speed sweet spot that feels like a single traveling motion.

Above the dots, 4 ghost feed cards represent already-loaded content — each is a low-opacity rounded rectangle with faint text-block placeholders. This gives context ("you've been scrolling a feed, now more content is loading") rather than showing the dots in a void. The loader is positioned at the bottom of the feed container with a small "Loading more" label below the dots for extra clarity.

Semantic HTML uses role="status" + aria-live="polite" + aria-label="Loading more posts" so screen readers announce the state without reading the visual dots. The label text "Loading more" is redundant to sighted users but critical for screen-reader users if they explore the region.

Make it yours

  • Change the wave speed by editing animation-duration: 1.4s. Faster (1s) feels more urgent/responsive; slower (1.8s) feels calmer for read-heavy feeds. Below 0.8s reads as "jittery."
  • Change the vertical travel distance by editing translateY(-8px). Larger (12px+) feels bouncier / more playful; smaller (4px) feels more subtle. Match your product's motion vocabulary.
  • Add a fourth dot for a slightly slower wave feel — 4 dots at 150ms stagger reads as "gentle traveling wave." 5+ dots reads as "morse-code loader," which is chaotic.
  • For dark mode, use light dots on dark canvas — background: #cbd5e1 dots on #1e293b canvas. Same technique, inverted colors.
  • For platforms with progress info ("Loading item 42 of 150"), place the counter text below the dots — the dots become the visual anchor, the counter is the informational anchor.

Gotchas — read before shipping

  • The stagger delay MUST NOT exceed the animation duration. If duration is 1.4s and staggers are 200ms × 3 = 600ms, that's fine. If you set delay 800ms per dot and duration 1s, dot 3 starts after dot 1 finishes — no wave, just sequential blinks.
  • Do NOT stack 3+ concurrent load-more loaders on one page. If your feed has 4 columns each with its own "load more" state, you get 12 bobbing dots which is visually exhausting. Use a single global loader.
  • The dot travel distance must be SMALL — max 12px. Larger travel feels like bouncing balls, which is a different visual metaphor (playful, casual) than "loading."
  • For accessibility, put the loading announcement on the container (aria-live="polite") not on each dot. Screen readers should hear "Loading more posts" once, not three separate announcements.
  • Do NOT show this loader for loads under 500ms — the dots barely start bobbing before content arrives, which feels janky. For very fast loads (under 200ms), no loader at all is fine.
  • For infinite scroll, the loader should appear WHEN THE USER SCROLLS PAST THE THRESHOLD, not on page load. Otherwise the loader is visible while the initial content is loading, then continues bobbing after content arrives — confusing.
  • For prefers-reduced-motion: replace the bobbing dots with 3 STATIC dots + text "Loading more…". The information is conveyed by text; motion is optional.

Browser support

ChromeSafariFirefoxEdge
45+10+40+45+

transform + opacity + @keyframes. Universal, GPU-accelerated.

Techniques used in this demo

Search CodeFronts

Loading…