20 CSS Ribbons19 / 20

Pure CSSMIT licensed

Waving Flag Ribbon Animation

A flag that actually ripples: fourteen vertical slices, each running the same keyframe with a progressively offset animation-delay, so a wave travels along the cloth. Shading is part of the animation — every slice brightens as it rises and darkens as it falls — which is what turns a row of moving rectangles into fabric catching light.

Published

Live Demo
Try it

The code

<section class="rib-19" aria-label="Waving flag ribbon animation demo">
  <div class="rib-19__stage">
    <div class="rib-19__scene">
      <span class="rib-19__pole" aria-hidden="true"></span>
      <div class="rib-19__flag" role="img" aria-label="Waving ribbon flag reading: Now shipping worldwide">
        <div class="rib-19__cloth" aria-hidden="true">
          <span class="rib-19__slice" style="--rib-19-i:0"></span><span class="rib-19__slice" style="--rib-19-i:1"></span>
          <span class="rib-19__slice" style="--rib-19-i:2"></span><span class="rib-19__slice" style="--rib-19-i:3"></span>
          <span class="rib-19__slice" style="--rib-19-i:4"></span><span class="rib-19__slice" style="--rib-19-i:5"></span>
          <span class="rib-19__slice" style="--rib-19-i:6"></span><span class="rib-19__slice" style="--rib-19-i:7"></span>
          <span class="rib-19__slice" style="--rib-19-i:8"></span><span class="rib-19__slice" style="--rib-19-i:9"></span>
          <span class="rib-19__slice" style="--rib-19-i:10"></span><span class="rib-19__slice" style="--rib-19-i:11"></span>
          <span class="rib-19__slice" style="--rib-19-i:12"></span><span class="rib-19__slice" style="--rib-19-i:13"></span>
        </div>
        <p class="rib-19__label"><span class="rib-19__kicker">Now shipping</span><span class="rib-19__big">Worldwide</span></p>
      </div>
    </div>
    <p class="rib-19__caption">14 slices · one keyframe · negative <code>animation-delay</code> per slice · amplitude grows toward the fly edge</p>
    <p class="rib-19__chip" aria-hidden="true">translate + scale + brightness in one keyframe · calc() phase offsets</p>
  </div>
</section>
.rib-19,
.rib-19 *,
.rib-19 *::before,
.rib-19 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.rib-19 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  --rib-19-bg: #0d1522;
  --rib-19-bg: oklch(0.2 0.024 250);
  --rib-19-ink: #eef4ff;
  --rib-19-ink: oklch(0.96 0.012 250);
  --rib-19-mut: #8996ad;
  --rib-19-mut: oklch(0.66 0.026 250);
  --rib-19-a: #3b82f6;
  --rib-19-a: oklch(0.62 0.19 258);
  --rib-19-b: #22d3ee;
  --rib-19-b: oklch(0.79 0.14 205);
  --rib-19-h: clamp(110px,20vw,168px);
  background: var(--rib-19-bg);
  color: var(--rib-19-ink);
  font-family: system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;
  -webkit-font-smoothing: antialiased;
}

.rib-19__stage {
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  align-content: center;
  gap: clamp(18px,3vw,28px);
  padding: clamp(20px,5vw,56px);
  background: radial-gradient(75% 60% at 50% 40%,color-mix(in oklch,var(--rib-19-a) 16%,transparent),transparent 72%);
}

.rib-19__scene {
  position: relative;
  display: flex;
  align-items: flex-start;
  width: min(100%,720px);
  padding-block: clamp(16px,4vw,32px);
}

.rib-19__pole {
  flex: none;
  inline-size: 9px;
  block-size: calc(var(--rib-19-h) + 76px);
  border-radius: 99px 99px 3px 3px;
  background: linear-gradient(90deg,oklch(0.42 0.02 250),oklch(0.72 0.02 250) 40%,oklch(0.38 0.02 250));
  box-shadow: 0 10px 20px -10px oklch(0 0 0/.8);
}

.rib-19__pole::before {
  content: '';
  position: absolute;
  inset-block-start: -9px;
  inset-inline-start: -4px;
  inline-size: 17px;
  block-size: 17px;
  border-radius: 50%;
  background: radial-gradient(circle at 34% 30%,oklch(0.92 0.05 90),oklch(0.62 0.1 88));
}

.rib-19__flag {
  position: relative;
  flex: 1;
  block-size: var(--rib-19-h);
  display: grid;
  place-items: center;
  margin-inline-start: -1px;
  filter: drop-shadow(0 16px 26px oklch(0.12 0.05 250/.6));
}

.rib-19__cloth {
  position: absolute;
  inset: 0;
  display: flex;
}

.rib-19__slice {
  flex: 1;
  margin-inline-end: -1px;
  background-image: linear-gradient(115deg,var(--rib-19-a),var(--rib-19-b));
  background-size: 1400% 100%;
  background-position: calc(var(--rib-19-i) * 7.7%) 0;
  animation: rib-19-wave 1.9s ease-in-out infinite;
  animation-delay: calc(var(--rib-19-i) * -0.11s);
  --rib-19-amp: calc(3px + var(--rib-19-i) * 0.85px);
}

@keyframes rib-19-wave {
  0%,
    100% {
    translate: 0 0;
    scale: 1 1;
    filter: brightness(1);
  }

  25% {
    translate: 0 calc(var(--rib-19-amp) * -1);
    scale: 1 .972;
    filter: brightness(1.17);
  }

  50% {
    translate: 0 0;
    scale: 1 1;
    filter: brightness(1);
  }

  75% {
    translate: 0 var(--rib-19-amp);
    scale: 1 1.03;
    filter: brightness(.85);
  }
}

.rib-19__label {
  position: relative;
  z-index: 1;
  display: grid;
  justify-items: center;
  gap: 2px;
  text-align: center;
  padding-inline: 14px;
  color: oklch(0.17 0.05 250);
  animation: rib-19-ride 1.9s ease-in-out infinite;
  animation-delay: -0.77s;
}

@keyframes rib-19-ride {
  0%,
    100% {
    translate: 0 0;
    rotate: 0deg;
  }

  25% {
    translate: 0 -3px;
    rotate: -.5deg;
  }

  75% {
    translate: 0 3px;
    rotate: .5deg;
  }
}

.rib-19__kicker {
  font-size: clamp(11px,2.2vw,13px);
  font-weight: 800;
  letter-spacing: .28em;
  text-transform: uppercase;
  opacity: .72;
}

.rib-19__big {
  font-size: clamp(28px,6.4vw,52px);
  font-weight: 850;
  letter-spacing: -.045em;
  line-height: 1;
  text-shadow: 0 1px 0 oklch(1 0 0/.28);
}

.rib-19__caption {
  max-width: 56ch;
  text-align: center;
  font-size: 13px;
  line-height: 1.6;
  color: var(--rib-19-mut);
  text-wrap: pretty;
}

.rib-19__caption code {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 12px;
  padding: 1px 5px;
  border-radius: 5px;
  background: oklch(1 0 0/.08);
}

.rib-19__chip {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  color: var(--rib-19-mut);
  padding: 8px 15px;
  border: 1px solid oklch(1 0 0/.14);
  border-radius: 99px;
  text-align: center;
}

@media (prefers-reduced-motion: reduce) {
  .rib-19__slice,
    .rib-19__label {
    animation: none;
  }
}
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 Ribbon 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: Waving Flag Ribbon Animation
Source: https://codefronts.com/snippets/css-ribbons/waving-flag-ribbon-animation/

A flag that actually ripples: fourteen vertical slices, each running the same keyframe with a progressively offset animation-delay, so a wave travels along the cloth. Shading is part of the animation — every slice brightens as it rises and darkens as it falls — which is what turns a row of moving rectangles into fabric catching light.
## HTML
```html
<section class="rib-19" aria-label="Waving flag ribbon animation demo">
  <div class="rib-19__stage">
    <div class="rib-19__scene">
      <span class="rib-19__pole" aria-hidden="true"></span>
      <div class="rib-19__flag" role="img" aria-label="Waving ribbon flag reading: Now shipping worldwide">
        <div class="rib-19__cloth" aria-hidden="true">
          <span class="rib-19__slice" style="--rib-19-i:0"></span><span class="rib-19__slice" style="--rib-19-i:1"></span>
          <span class="rib-19__slice" style="--rib-19-i:2"></span><span class="rib-19__slice" style="--rib-19-i:3"></span>
          <span class="rib-19__slice" style="--rib-19-i:4"></span><span class="rib-19__slice" style="--rib-19-i:5"></span>
          <span class="rib-19__slice" style="--rib-19-i:6"></span><span class="rib-19__slice" style="--rib-19-i:7"></span>
          <span class="rib-19__slice" style="--rib-19-i:8"></span><span class="rib-19__slice" style="--rib-19-i:9"></span>
          <span class="rib-19__slice" style="--rib-19-i:10"></span><span class="rib-19__slice" style="--rib-19-i:11"></span>
          <span class="rib-19__slice" style="--rib-19-i:12"></span><span class="rib-19__slice" style="--rib-19-i:13"></span>
        </div>
        <p class="rib-19__label"><span class="rib-19__kicker">Now shipping</span><span class="rib-19__big">Worldwide</span></p>
      </div>
    </div>
    <p class="rib-19__caption">14 slices · one keyframe · negative <code>animation-delay</code> per slice · amplitude grows toward the fly edge</p>
    <p class="rib-19__chip" aria-hidden="true">translate + scale + brightness in one keyframe · calc() phase offsets</p>
  </div>
</section>
```
## CSS
```css
.rib-19,
.rib-19 *,
.rib-19 *::before,
.rib-19 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.rib-19 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  --rib-19-bg: #0d1522;
  --rib-19-bg: oklch(0.2 0.024 250);
  --rib-19-ink: #eef4ff;
  --rib-19-ink: oklch(0.96 0.012 250);
  --rib-19-mut: #8996ad;
  --rib-19-mut: oklch(0.66 0.026 250);
  --rib-19-a: #3b82f6;
  --rib-19-a: oklch(0.62 0.19 258);
  --rib-19-b: #22d3ee;
  --rib-19-b: oklch(0.79 0.14 205);
  --rib-19-h: clamp(110px,20vw,168px);
  background: var(--rib-19-bg);
  color: var(--rib-19-ink);
  font-family: system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;
  -webkit-font-smoothing: antialiased;
}

.rib-19__stage {
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  align-content: center;
  gap: clamp(18px,3vw,28px);
  padding: clamp(20px,5vw,56px);
  background: radial-gradient(75% 60% at 50% 40%,color-mix(in oklch,var(--rib-19-a) 16%,transparent),transparent 72%);
}

.rib-19__scene {
  position: relative;
  display: flex;
  align-items: flex-start;
  width: min(100%,720px);
  padding-block: clamp(16px,4vw,32px);
}

.rib-19__pole {
  flex: none;
  inline-size: 9px;
  block-size: calc(var(--rib-19-h) + 76px);
  border-radius: 99px 99px 3px 3px;
  background: linear-gradient(90deg,oklch(0.42 0.02 250),oklch(0.72 0.02 250) 40%,oklch(0.38 0.02 250));
  box-shadow: 0 10px 20px -10px oklch(0 0 0/.8);
}

.rib-19__pole::before {
  content: '';
  position: absolute;
  inset-block-start: -9px;
  inset-inline-start: -4px;
  inline-size: 17px;
  block-size: 17px;
  border-radius: 50%;
  background: radial-gradient(circle at 34% 30%,oklch(0.92 0.05 90),oklch(0.62 0.1 88));
}

.rib-19__flag {
  position: relative;
  flex: 1;
  block-size: var(--rib-19-h);
  display: grid;
  place-items: center;
  margin-inline-start: -1px;
  filter: drop-shadow(0 16px 26px oklch(0.12 0.05 250/.6));
}

.rib-19__cloth {
  position: absolute;
  inset: 0;
  display: flex;
}

.rib-19__slice {
  flex: 1;
  margin-inline-end: -1px;
  background-image: linear-gradient(115deg,var(--rib-19-a),var(--rib-19-b));
  background-size: 1400% 100%;
  background-position: calc(var(--rib-19-i) * 7.7%) 0;
  animation: rib-19-wave 1.9s ease-in-out infinite;
  animation-delay: calc(var(--rib-19-i) * -0.11s);
  --rib-19-amp: calc(3px + var(--rib-19-i) * 0.85px);
}

@keyframes rib-19-wave {
  0%,
    100% {
    translate: 0 0;
    scale: 1 1;
    filter: brightness(1);
  }

  25% {
    translate: 0 calc(var(--rib-19-amp) * -1);
    scale: 1 .972;
    filter: brightness(1.17);
  }

  50% {
    translate: 0 0;
    scale: 1 1;
    filter: brightness(1);
  }

  75% {
    translate: 0 var(--rib-19-amp);
    scale: 1 1.03;
    filter: brightness(.85);
  }
}

.rib-19__label {
  position: relative;
  z-index: 1;
  display: grid;
  justify-items: center;
  gap: 2px;
  text-align: center;
  padding-inline: 14px;
  color: oklch(0.17 0.05 250);
  animation: rib-19-ride 1.9s ease-in-out infinite;
  animation-delay: -0.77s;
}

@keyframes rib-19-ride {
  0%,
    100% {
    translate: 0 0;
    rotate: 0deg;
  }

  25% {
    translate: 0 -3px;
    rotate: -.5deg;
  }

  75% {
    translate: 0 3px;
    rotate: .5deg;
  }
}

.rib-19__kicker {
  font-size: clamp(11px,2.2vw,13px);
  font-weight: 800;
  letter-spacing: .28em;
  text-transform: uppercase;
  opacity: .72;
}

.rib-19__big {
  font-size: clamp(28px,6.4vw,52px);
  font-weight: 850;
  letter-spacing: -.045em;
  line-height: 1;
  text-shadow: 0 1px 0 oklch(1 0 0/.28);
}

.rib-19__caption {
  max-width: 56ch;
  text-align: center;
  font-size: 13px;
  line-height: 1.6;
  color: var(--rib-19-mut);
  text-wrap: pretty;
}

.rib-19__caption code {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 12px;
  padding: 1px 5px;
  border-radius: 5px;
  background: oklch(1 0 0/.08);
}

.rib-19__chip {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  color: var(--rib-19-mut);
  padding: 8px 15px;
  border: 1px solid oklch(1 0 0/.14);
  border-radius: 99px;
  text-align: center;
}

@media (prefers-reduced-motion: reduce) {
  .rib-19__slice,
    .rib-19__label {
    animation: none;
  }
}
```

How this works

One keyframe, fourteen slices, one negative delay per slice. The negative delay is the whole trick: it starts each slice partway through the animation, so the wave exists at the very first frame instead of building up over one cycle.

.slice {
  animation: wave 1.9s ease-in-out infinite;
  animation-delay: calc(var(--i) * -0.11s);   /* NEGATIVE: pre-seeded phase */
}
@keyframes wave {
  0%, 100% { translate: 0 0;     scale: 1 1;    filter: brightness(1); }
  25%      { translate: 0 calc(var(--amp) * -1);
             scale: 1 .972; filter: brightness(1.17); }
  50%      { translate: 0 0;    scale: 1 1;    filter: brightness(1); }
  75%      { translate: 0 var(--amp);
             scale: 1 1.03; filter: brightness(.85); }
}

Three properties move together and that is what sells it. Translation alone gives a caterpillar; adding a slight vertical scale makes the cloth stretch and compress as it flexes, and tying brightness to the phase means the crests catch light exactly where a real flag would.

.slice:nth-child(1)  { --i: 0 }
.slice:nth-child(2)  { --i: 1 }   /* … or emit --i in your template */

With --i as an index, delay, amplitude and hue can all be derived from one number. This demo also scales amplitude along the flag — calc(4px + var(--i) * .9px) — because a flag is pinned at the mast and free at the fly end, so the far edge always moves more. Uniform amplitude is the single biggest giveaway of a fake wave.

The label rides a gentler version of the same wave on its own element, phase-locked to the middle slices, so the type moves with the cloth without shearing the letterforms. Under prefers-reduced-motion everything stops flat and the flag stays perfectly legible.

Make it yours

  • Speed and wavelength are two numbers: the duration (1.9 s) sets the flutter rate, the per-slice delay (−0.11 s) sets how many crests are visible at once.
  • Add a horizontal component with rotate: calc(var(--i) * .2deg) in the keyframe for a flag catching a crosswind.
  • More slices = smoother cloth, at a linear cost. Fourteen is the sweet spot; above about 24 you are paying for detail nobody perceives.
  • Make it a hero banner by swapping the flag's aspect ratio and putting a full headline on the label — the wave maths is size-independent.

Gotchas — read before shipping

  • Positive delays make the wave assemble itself over the first cycle, which looks broken on load. Always use negative delays to pre-seed the phase.
  • Gaps between slices appear at fractional device pixel ratios. Overlap each slice by 1px (margin-inline-end: -1px) or use box-shadow: 1px 0 0 currentColor.
  • filter: brightness() per slice creates a stacking context each — cheap at 14 elements, costly at 100. Do not scale this pattern to a whole page.
  • Animating translate/scale is compositor-friendly; animating height or top is not. Never wave a flag with layout properties.
  • An infinite ripple is motion that never stops. Honour prefers-reduced-motion — this demo freezes to a clean static flag rather than hiding it.

Browser support

ChromeSafariFirefoxEdge
114+17.5+121+114+

Floor set by oklch(), color-mix(), text-wrap as this demo is written. The core technique itself goes back further — Chrome 104+.

Individual translate/scale properties need Chrome 104 / Safari 14.1 / Firefox 72; substituting transform: translateY() scaleY() works back to IE10. calc() in animation-delay is universal.

Techniques used in this demo

Search CodeFronts

Loading…