Waving Flag
Cut a flag into columns, stagger the motion, and it starts to look like cloth.

Faisal Husain
Published
This technique is taken from Josh Comeau. I used it for the Indian flag in my footer.
Walk through the six steps. The playground is at the end.
Step 1 : One image
Step 1 · One image
No wave yet
Start with a real flag. I used the official Flag of India SVG from Wikimedia. Just an image. No motion. Looks correct. Feels dead.
<img src="/india-flag.svg" alt="" className="h-32 w-48" />Step 2 : Cut into columns
Step 2 · Cut into columns
24 slices, still
Pride flags can be painted with a linear gradient. I needed the Ashoka Chakra, so each column is a slice of the same image, shifted left.
{Array.from({ length: columns }, (_, index) => (
<span key={index} className="waving-flag-column">
<img
src="/india-flag.svg"
alt=""
className="waving-flag-slice"
style={{ left: `${-index * 100}%` }}
/>
</span>
))}Each column is a thin window with overflow: hidden. The image inside is columns * 100% wide.
- Column 0 shows the first strip (
left: 0%) - Column 1 is shifted
-100% - Column 23 is shifted
-2300%
Same flag. Many thin windows.
Step 3 : Oscillate
Step 3 · Oscillate
Every column moves the same
Every column uses the same keyframe. Ping-pong up and down. ease-in-out so it does not slam at the ends. alternate so it does not jump.
@keyframes oscillate {
from {
transform: translateY(var(--billow));
}
to {
transform: translateY(calc(var(--billow) * -1));
}
}
.column {
animation: oscillate 500ms ease-in-out infinite alternate backwards;
}Right now they all move together, same travel, same time. That is a trampoline, not a flag.
Step 4 : Stagger
Step 4 · Stagger
Delay creates the ripple
This is the whole illusion. Each column waits a little longer than the one on its left.
style={{
animationDelay: index * staggeredDelay + 'ms',
}}Set stagger to 0 and the wave dies. Add 50ms back and the ripple returns.
Use a negative delay on the first column so the animation starts mid-cycle. No awkward second where the last strip is just sitting there.
const firstColumnDelay = columns * staggeredDelay * -1
animationDelay: firstColumnDelay + index * staggeredDelay + 'ms'Step 5 : Billow
Step 5 · Billow
Same stagger, different travel
Stagger is when a column moves. Billow is how far.
Both flags above use the same stagger (50ms). The left one gives every column the same travel. It waves, but it is a ribbon. The right one grows travel with the column index. That is a flag on a pole.
A real flag is tied on the left. The pole barely moves. The free edge flies.
style={{
'--billow': index * billow + 'px',
}}The keyframe never changes. Only --billow does.
@keyframes oscillate {
from {
transform: translateY(var(--billow));
}
to {
transform: translateY(calc(var(--billow) * -1));
}
}With billow = 0.8:
- Column
0travels0px. Pinned. - Column
12travels9.6px. - Column
23travels18.4px. The fly.
Without billow, every column uses one number, so pole and fly look the same. Set the playground knob to 0 and the shape dies. Put 0.8 back and the cloth returns.
Step 6 : More slices
Step 6 · More slices
8 columns vs 24
Eight fat columns look like blinds. Twenty four thin ones look like fabric.
Round the flag width so each column is a whole pixel. 200 / 12 is 16.666. Firefox and Safari will show hairline gaps. 204 / 12 is 17. No gaps.
const friendlyWidth = Math.round(width / columns) * columnsI keep the footer flag at 24px by 16px with 24 columns. One device pixel per strip. On hover it grows and the same wave reads as cloth.
Playground
You now have the three knobs. Twist them. Break the wave on purpose.
- Columns toward 6: blinds. Toward 100: silk.
- Stagger at 0: trampoline again.
- Billow at 0: pole and fly look the same.
Playground
Break it on purpose. The wave is just these three knobs.
Ready component
This is the version I use next to "Lucknow, India". Gradients swapped for the real flag image.
'use client'
import { type CSSProperties } from 'react'
const FLAG_COLUMNS = 24
const FLAG_BILLOW = 0.8
const FLAG_STAGGER = 50
export function WavingFlag() {
const firstDelay = FLAG_COLUMNS * FLAG_STAGGER * -1
return (
<span
className="waving-flag"
style={{ '--columns': FLAG_COLUMNS } as CSSProperties}
aria-hidden="true"
>
{Array.from({ length: FLAG_COLUMNS }, (_, index) => (
<span
key={index}
className="waving-flag-column is-waving"
style={
{
'--billow': `${index * FLAG_BILLOW}px`,
animationDelay: `${firstDelay + index * FLAG_STAGGER}ms`,
} as CSSProperties
}
>
<img
src="/india-flag.svg"
alt=""
className="waving-flag-slice"
style={{ left: `${-index * 100}%` }}
/>
</span>
))}
</span>
)
}.waving-flag {
display: inline-flex;
width: 192px;
height: 128px;
}
.waving-flag-column {
position: relative;
flex: 1 0 0;
height: 100%;
overflow: hidden;
}
.waving-flag-column.is-waving {
animation: oscillate 500ms ease-in-out infinite alternate backwards;
}
.waving-flag-slice {
position: absolute;
top: 0;
height: 100%;
width: calc(var(--columns) * 100%);
max-width: none;
}
@keyframes oscillate {
from {
transform: translateY(var(--billow));
}
to {
transform: translateY(calc(var(--billow) * -1));
}
}Hence completed. Thank you for reading.
Let's work together
Have a product problem worth solving?
I help teams build fast, dependable products with thoughtful interfaces and strong engineering foundations.