20 CSS Ribbons19 / 20
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
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><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;
}
}.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;
}
}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;
}
}
```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 usebox-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/scaleis compositor-friendly; animatingheightortopis 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
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 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.