A New Wave of CSS

Sine waves are one of the most recognizable mathematical patterns. On the web, their smooth progression can shape animation, typography, spatial layouts, and much more.

Until recently, coordinating a continuous wave across multiple elements meant choosing between frame-by-frame calculations in JavaScript with requestAnimationFrame and rigid CSS offsets generated by preprocessor loops.

Modern CSS lets us express these relationships directly in a stylesheet. Trigonometric functions can shape values, while sibling-index() and sibling-count() let each element use its position in the DOM without manual class names or data attributes.

In this deep dive, we start with two ways to animate a sine wave: a direct trigonometric approach using sin(), and a compositor-friendly technique using staggered animation-delay values. Then we use the same CSS capabilities beyond motion to create harmonic typography and other spatial patterns.

The Structural Baseline

For both techniques, our HTML remains minimal, semantic, and decoupled from styling logic. A simple container holds identical sibling elements:

<div class="container">
  <span class="element"></span>
  <span class="element"></span>
  <span class="element"></span>
  <span class="element"></span>
  <span class="element"></span>
  <!-- ... as many elements as needed ... -->
</div>

Because CSS can now dynamically query how many siblings exist and determine each element’s exact index, we no longer need inline custom properties such as style="--i: 0", index data attributes, or server-rendered counters.

Approach 1: Map the Sine Equation Directly

Start with JavaScript

The easiest way to understand this approach is to begin with a familiar JavaScript expression. For each element, a sine wave can be reduced to one line:

const y = amplitude * Math.sin(time + (index * frequency));

Here, y is the vertical position we want to calculate for the current element. Each value has one job:

  • amplitude scales the -1 to 1 output of Math.sin(). An amplitude of 40 produces positions from -40 to 40.
  • time is the phase that changes as the animation runs. Here, it represents an angle in radians rather than elapsed time in seconds.
  • index identifies the current element and keeps its position fixed within the sequence.
  • frequency controls the phase gap between neighboring elements. Increase it to fit more wave cycles into the sequence, or decrease it to stretch the wave out.

We do not animate y directly. We advance time, recalculate y for every element, and apply the result as its vertical translation. If time stops changing, the elements still form a sine curve; it simply becomes a static one.

Let CSS Map the Same Equation

This is where modern CSS becomes exciting. Native sin() gives us the curve, while sibling-index() supplies the index of each element directly from the DOM. The stylesheet now has everything it needs to map the same equation:

@property --angle {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}

.element {
  --speed: 1s;
  --amplitude: 40px;
  --frequency: 0.45rad;

  translate: 0 calc(
    sin(var(--angle) + (sibling-index() * var(--frequency))) * var(--amplitude)
  );

  /* Keep the static wave, and animate it only when motion is welcome. */
  @media (prefers-reduced-motion: no-preference) {
    animation: rotate-angle var(--speed) linear infinite;
  }
}

@keyframes rotate-angle {
  to {
    --angle: 360deg;
  }
}

The mapping is almost one-to-one. In CSS, var(--amplitude) takes the role of amplitude, var(--angle) takes the role of time, sibling-index() provides index, and var(--frequency) provides frequency. Strictly speaking, --frequency is a spatial phase step here rather than a temporal frequency; its 0.45rad value moves each sibling 0.45 radians farther along the curve.

To animate the wave, we only need to animate the value playing the role of time. Registering --angle with @property gives the browser a typed angle it can interpolate from 0deg to 360deg. On every frame, CSS combines that changing angle with each element’s fixed sibling index, runs the result through sin(), and maps the new y value to translate.

The Performance Cost: Main-Thread Style Recalculation

This mathematical approach is undeniably elegant. However, animating a custom property that feeds into a trigonometric translation expression comes with a rendering trade-off.

Even with inherits: false preventing inheritance down the tree, the browser must update the computed translate value because it depends on the animated --angle inside calc(sin(...)). In this demo, that work appears as a Recalculate Style task for the elements on each animation frame instead of a translation animation that can run independently on the compositor.

Bramus provides more background on style invalidation and recalculation in his analysis of CSS @property performance.

When profiled in the browser’s DevTools Performance panel, the main thread remains constantly occupied with style calculations and rendering updates:

DevTools Performance timeline showing heavy main-thread style recalculation and CPU activity

Enabling DevTools Paint Flashing further reveals persistent green rectangles flashing across every element on every tick, confirming that the main thread is repeatedly invalidating and painting the scene:

Approach 2: Staggered Timing with animation-delay

For a compositor-friendly alternative that scales better across many elements, we can use a classic animation mechanic: staggered timing delays.

Instead of recalculating trigonometric positions on every frame, each element runs the same vertical translation. Distributing an animation-delay across siblings with sibling-index() and sibling-count() turns that shared motion into a convincing sine-like wave.

.container {
  /* Choose how many complete waves appear across the container. */
  --waves-visible: 2;
}

.element {
  --duration: 0.45s;
  --amplitude: 40px;

  /* An alternate animation travels forward and back to complete a cycle. */
  --cycle-time: calc(var(--duration) * 2);

  /* Spread those cycles across the gaps between the siblings. */
  --delay-step: calc(
    (var(--cycle-time) * var(--waves-visible)) / (sibling-count() - 1)
  );

  /* Use negative delays so the complete wave is visible immediately. */
  --animation-delay: calc(sibling-index() * var(--delay-step) * -1);

  /* Animate only when the user has not requested reduced motion. */
  @media (prefers-reduced-motion: no-preference) {
    animation: wave var(--duration) ease-in-out var(--animation-delay) alternate infinite;
  }
}

@keyframes wave {
  from {
    translate: 0 var(--amplitude);
  }
  to {
    translate: 0 calc(var(--amplitude) * -1);
  }
}

Map the timing

Each value has one job:

  • --amplitude sets how far each element moves from the center. A value of 40px produces positions from -40px to 40px on the Y-axis.
  • --cycle-time sets the duration of one complete up-and-down cycle. Because alternate runs the animation forward and then backward, the cycle takes --duration * 2.
  • --delay-step sets the time gap between neighboring elements. To fit --waves-visible cycles across N elements, it divides the time covered by those cycles by the number of gaps between them: sibling-count() - 1.
  • * -1 turns the delay negative, starting every element partway through its animation. This makes the complete wave visible as soon as the page loads.
  • ease-in-out controls how each element moves between its two endpoints. It is a cubic Bézier curve rather than a sine function, but it creates a similar rhythm: slow near the turning points and fastest through the center.

The Compositor Advantage

Why can this approach outperform the trigonometry version?

Because the keyframes animate the standard translate property directly, without relying on an animated custom property, modern browsers can usually run the animation on the compositor.

After the initial layout and paint, the compositor can move the existing layers without recalculating each element’s position in CSS. In this demo’s performance profile, the main thread remains available for other work:

DevTools Performance timeline showing an idle main thread with zero style recalculations during the delay animation

Turning on DevTools Paint Flashing visually confirms the result in this demo: there are no green paint flashes while the wave animates:

Beyond Animation: Where CSS Trigonometry Shines

Does this mean native CSS sin() should be avoided? Not at all.

The performance bottleneck shown here comes from continuously animating a custom property that affects a calculated translation. For static spatial distribution, native CSS trigonometry is extraordinarily capable.

In static scenarios, the browser evaluates the trigonometric formula when styles are resolved and again only when a relevant input changes. Unlike the continuously animated formula, it does not deliberately trigger a new calculation on every frame.

A striking example is harmonic typography: distributing variable font weights across characters in a word to create an organic half-wave arch:

Variable font weight distributed along a half-sine wave arch across the letters of the word Magenta

By mapping each character’s position to a normalized angle between 0° and 180°, sin() smoothly ramps the font weight from 200 at the edges to 600 in the center:

.char {
  --min-weight: 200;
  --max-weight: 600;
  --amplitude: calc(var(--max-weight) - var(--min-weight));

  /* Map the first and last letters from 0 to 1. */
  --progress: progress(sibling-index(), 1, sibling-count());

  /* Convert that progress to a 180deg half-sine arch. */
  --angle: calc(var(--progress) * 180deg);

  /* Apply the result statically, with no per-frame animation work. */
  font-weight: calc(
    var(--min-weight) + (sin(var(--angle)) * var(--amplitude))
  );
}

Notice how the modern progress() function simplifies the mapping. Instead of manual arithmetic like calc((sibling-index() - 1) / (sibling-count() - 1)), progress(sibling-index(), 1, sibling-count()) computes the normalized ratio from 1 to sibling-count() directly.

Beyond typography, this pattern opens up:

  • Distributing navigation elements along circular or elliptical curves without SVG or Canvas.
  • Designing wave-like card cascades and staggered editorial grids.
  • Building radial menus where every child computes its angular offset automatically.

Interactive Demos

To see each of these architectural patterns in action and experiment with the parameters directly, check out the CodePen demos:

If you’ve made it this far, I hope these examples open your eyes to how much expressive power has found its way into CSS. With sibling-index() and sibling-count() now available across all three major browser engines, this feels like a great moment to explore what becomes possible when CSS can count, calculate, and shape values on its own.

That’s all I’ve got for now… thanks for reading! If you have any questions or ideas, I’d love to hear them. Feel free to reach out to me on Bluesky or anywhere else linked in the footer, and play around with the CodePen demos to see where these patterns take you.