Waving Flag

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

User Avatar

Faisal Husain

Published

Indian flag24 columns

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

Without billow
With billow

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 0 travels 0px. Pinned.
  • Column 12 travels 9.6px.
  • Column 23 travels 18.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

8 columns
24 columns

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) * columns

I 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.
Cloth controlsWF–01

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.