Pixu

Emiliano Pisu

Father of 2 lovely "monsters"

D&D master, player ... whatever

Senior Design Engineer, Sensei & Co-Host
@ Dev Dojo IT

pixu1980@linkedin linkedin.com/in/pixu1980
pixu1980@github github.com/pixu1980
pixu1980@codepen codepen.io/pixu1980
devdojo@linkedin linkedin.com/company/dev-dojo-it/
devdojo@youtube youtube.com/@devdojo_it
devdojo@twitch twitch.tv/devdojo_it
devdojo@telegram @devdojo_it

Modern CSS motion APIs for choreography without JavaScript

A fast tour through the new CSS primitives that power transitions, timelines, and reactive motion.

What are we going to
talk about today?

Agenda

  • @starting-style for entry animations
  • A motion architecture blueprint
  • Performance rules for smooth motion
  • Scroll driven reveals with animation-timeline: view()
  • A gaussian curve with exp() and pow()
  • sibling-index() and sibling-count() for layout math
  • Trigonometric positioning with sin()
  • A layered tilt card (pointer driven 3D)

Starting with Our Sponsors

A staggered entrance animation done with @starting-style and a simple per-item delay based on sibling-index() with a custom property fallback.
      
.sponsor-logo-container {
  --stagger-base: 0.25s;

  grid-template-columns: repeat(4, 1fr);
  grid-auto-rows: 1fr;
  
  ...
}

.sponsor-logo {
  ...
  
  opacity: 1;
  transform: scale(1) translateY(0);

  transition:
    opacity 0.3s ease-out,
    transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);

  @starting-style {
    opacity: 0;
    transform: scale(0.5) translateY(40px);
  }

  @supports (--index: calc(sibling-index() * 1s)) {
    transition-delay: 
      calc(sibling-index() * var(--stagger-base));
  }

  @supports not (--index: calc(sibling-index() * 1s)) {
    /*
      Fallback: explicit --i on elements 
      (or via nth-child rules elsewhere).
    */
    transition-delay: 
      calc((var(--i, 0) + 1) * var(--stagger-base));
  }
}
      
    

@starting-style

Entry animations without JS are finally possible thanks to @starting-style at-rule: the CSS selector defines a starting style, then transitions it to the normal styles.

Motion Architecture

A "blueprint" about how CSS motion APIs layer together into a coherent system.
Foundation @property, Custom Properties, Design Tokens
Primitives translate, scale, rotate, offset-path, filter
Triggers :hover, :focus, @starting-style, scroll(), view()
Composition animation-composition, View Transitions, timeline ranges
Guardians prefers-reduced-motion, container queries, @supports

Performance

A set of CSS properties to know what triggers layout, paint, and composition.
Composition
  • transform
  • opacity
  • filter (GPU)
  • clip-path (simple)
60fps
Paint
  • background
  • color
  • box-shadow
  • border-radius
Careful
Layout
  • width, height
  • margin, padding
  • top, left
  • font-size
Avoid

Scroll-driven Container
with Appearing Cards Logos

42DE
42DE
Andrea Carratta
Andrea Carratta
Arrow Worm
Arrow Worm
Ciabot
Ciabot
Fattutto
Fattutto
Geeks Creations
Geeks Creations
Hotel Cube
Hotel Cube
Kaysoft
Kaysoft
Namirial
Namirial
Nopos
Nopos
Orbyta Tech
Orbyta Tech
Quantik Mind
Quantik Mind
Storyblok
Storyblok
Taktik Flowr
Taktik Flowr
Torino .NET
Torino .NET
Welyk
Welyk
Using animation-timeline: view() to drive reveal animations based on viewport intersection.

@supports (animation-timeline: view(inline)) {
  .scroll-view-card {
    animation: reveal-and-elevate 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) both;

    /* Precise control over animation range */
    animation-range:
      entry 35% /* start after element enters from right by its 35% */
      cover 70%; /* end 70% through cover phase */

    animation-timeline: view(inline);
  }
}

@supports not (animation-timeline: view(inline)) {
  .scroll-view-card {
    opacity: 1;
    transform: scale(1) translateY(0);
  }
}

@keyframes reveal-and-elevate {
  0% {
    opacity: 0;
    transform: scale(0.85) translateY(8rem);
    filter: blur(8px);
  }

  50% { filter: blur(4px); }

  100% {
    opacity: 1;
    transform: scale(1) translateY(0);
    filter: blur(0);
  }
}
    

scroll-driven Animations

The animation-timeline, scroll-timeline, and view-timeline CSS properties advance animations based on the user's scroll position. Here using animation-timeline: view(inline) for native scroll-driven effects, no JavaScript required.

Gaussian-like Curve with our
"breathing" 16 Sponsors

Runtime gaussian calculation using sibling-index(), sibling-count(), exp(), and pow().
      
.gaussian-container {
  --curve-width: 90%;
  --curve-height: 35rem;
  --logo-size: 7rem;

  /* Gaussian parameters: μ = 50 (center), σ = 18 (spread) */
  --mu: 50;
  --sigma: 18;

  position: relative;
  width: var(--curve-width);
  height: calc(var(--curve-height) + var(--logo-size) + 2.5rem);
}

/* Individual logo - uses sibling-index() and sibling-count() for dynamic positioning
  Gaussian y-value computed at runtime using the same formula as the curve shape */
.gaussian-logo {
  /* Calculate x position as percentage (0 to 100) based on index
    sibling-index() is 1-based, so subtract 1 for 0-based positioning */
  --x-percent: calc((sibling-index() - 1) / (sibling-count() - 1) * 100);

  /* Calculate gaussian y-value at runtime: f(x) = e^(-(x-μ)²/(2σ²)) */
  --gaussian-y: exp(calc(-1 * pow(var(--x-percent) - var(--mu), 2) / (2 * pow(var(--sigma), 2))));

  position: absolute;
  left: calc(var(--x-percent) * 1%);
  bottom: calc(var(--gaussian-y) * var(--curve-height));
  width: var(--logo-size);
  height: var(--logo-size);
  transform: 
    translate(-50%, 50%) 
    scale(calc(
      (0.7 + var(--gaussian-y) * 0.3) * 
      var(--logo-breath-scale, 1)
    ));
    
  ...
}
      
    
The breathing" animation transitions a registered @property for scaling up the logos and sibling-index() for properly delaying
      
.gaussian-logo {
  ...
  
  transform: 
    translate(-50%, 50%) 
    scale(calc(
      (0.7 + var(--gaussian-y) * 0.3) * 
      var(--logo-breath-scale, 1)
    ));

  animation: gaussian-breath 4s linear infinite;
  animation-delay: calc((sibling-index() - 1) * .5s);
  animation-fill-mode: both;
}

@property --logo-breath-scale {
  syntax: '<number>';
  initial-value: 1;
  inherits: false;
}

@keyframes gaussian-breath {
  0%,
  100% {
    --logo-breath-scale: 1;
  }

  50% {
    --logo-breath-scale: 1.2;
  }
}
      
    

Advanced with CSS Math

Position elements along a bell curve using CSS exp() and pow() functions: zero SVG, zero JavaScript... just pure stylesheet magic.

Our Sponsors along a
sneaking sinusoidal wave

Position elements along a sinusoidal wave using sin() with animated phase offset.
      
.trigonometric-sponsors-wave {
  --wave-length: 360deg;
  --wave-amplitude: 15rem;
  --steps: calc(sibling-count() - 1);

  --scale-min: 0.8;
  --scale-max: 1.25;

  --logo-size-min: 12rem;
  --logo-gap: clamp(0.4rem, 0.9vw, 0.6rem);

  ...
}

.sponsor-wave-item {
  --index: calc(sibling-index() - 1);
  
  width: var(--logo-size-min);
  height: var(--logo-size-min);
  left: calc((var(--index) / var(--steps)) * 100%);

  transform: 
    translateX(calc(-50% + var(--index) * var(--logo-gap)))
    translateY(-50%);
  will-change: left, transform;

  animation: sponsor-travel 12s ease-in-out infinite;
}

/* Move each logo along the wave by shifting its parameter (index + travel).
  We animate real properties (left/transform/filter), so it stays smooth without @property registration. */
@keyframes sponsor-travel {
  0%,
  100% {
    /* travel = -3 (can go out of bounds, container clips it) */
    left: calc(((var(--index) - 3) / var(--steps)) * 100%);
    transform: 
      translateX(calc(-50% + var(--index) * var(--logo-gap)))
      translateY(calc(-50% + var(--wave-amplitude) * sin(calc((var(--index) - 3) / var(--steps) * var(--wave-length)))))
      scale(calc(var(--scale-min) + (var(--scale-max) - var(--scale-min)) * ((sin(calc((var(--index) - 3) / var(--steps) * var(--wave-length))) + 1) / 2)));
  }

  50% {
    /* travel = +3 */
    left: calc(((var(--index) + 3) / var(--steps)) * 100%);
    transform: 
      translateX(calc(-50% + var(--index) * var(--logo-gap)))
      translateY(calc(-50% + var(--wave-amplitude) * sin(calc((var(--index) + 3) / var(--steps) * var(--wave-length)))))
      scale(calc(var(--scale-min) + (var(--scale-max) - var(--scale-min)) * ((sin(calc((var(--index) + 3) / var(--steps) * var(--wave-length))) + 1) / 2)));
  }
}
      
    

Trigonometric Positioning

Use sin(), cos(), and tan() to create circular orbits, wave patterns, and dynamic spatial arrangements. Transform Cartesian geometry into visual choreography.

Sponsors in two circles
(NOTE: works well in chromium based browsers only)

A circular arrangement calculated purely with sibling-index() and sibling-count(). Positioning items in circles is done using asin() and sibling-count() functions.
      
.sibling-logo {
  /* arc angle per logo uses asin() to calculate
    angle subtended by each logo+gap at circle center */
  --sibling-arc-angle: calc(2 * asin((var(--sibling-logo-size) + var(--sibling-logo-gap)) / (2 * var(--sibling-circle-radius))) - 1turn / sibling-count());

  /* logo circle radius calculated responsively
    using min() and container query units */
  --sibling-circle-radius: min(50cqw - var(--sibling-logo-size) / 2, (var(--sibling-logo-size) + var(--sibling-logo-gap)) / (2 * sin(0.5turn / sibling-count())));

  /* current logo angle dividing full rotation
    by sibling-count() to distribute items evenly */
  --sibling-current-angle: calc(1turn * sibling-index() / sibling-count() + var(--sibling-expand-offset, 0deg));

  /* next logo angle used to create smooth visual
    transition between adjacent logos */
  --sibling-next-angle: calc(1turn * (sibling-index() + 1) / sibling-count() + var(--sibling-next-expand-offset, 0deg));

  /* transform chain: 
    rotate → translate to radius → counter-rotate
    this positions element on circle while keeping image upright */
  transform: 
    rotate(calc(-1 * var(--sibling-current-angle)))
    translate(var(--sibling-circle-radius))
    rotate(var(--sibling-current-angle));

  /* smooth animation of position and next-angle on hover */
  transition:
    --sibling-current-angle 0.4s linear,
    --sibling-next-angle 0.4s linear;

  /* radial mask creates smooth circle boundary without harsh edges
    uses trigonometric coordinates (cos/sin) to calculate
    mask gradient position based on current and next element angles */
  mask: radial-gradient(
    50% 50% at calc(50% + var(--sibling-circle-radius) * (cos(var(--sibling-next-angle)) - cos(var(--sibling-current-angle)))) calc(50% + var(--sibling-circle-radius) * (sin(var(--sibling-current-angle)) - sin(var(--sibling-next-angle)))),
    #fff0 calc(100% - 1px + var(--sibling-logo-gap)),
    #fff calc(100% + var(--sibling-logo-gap))
  );

  ...
}
      
    

sibling-index() and sibling-count()

Sibling functions allow CSS to know the position and total count of elements in their parent. No JavaScript needed

All I have left is to wish you...

Bonanni a tutti

2026

Bonanni a tutti

2026

Bonanni a tutti

2026

Bonanni a tutti

2026

Bonanni a tutti

2026

Bonanni a tutti

2026

Bonanni a tutti

2026

Bonanni a tutti

2026

Bonanni a tutti

2026

Bonanni a tutti

2026

Te pensavi
eh?

A stack of translucent layers, layered by translating z-axis with sibling-index(), driven by registered @property using CSS trigonometric functions and peek into perspective/perspective-origin.
      
@property --tilt-card--pos-x {
  syntax: '<number>';
  inherits: true;
  initial-value: 0;
}

@property --tilt-card--pos-y {
  syntax: '<number>';
  inherits: true;
  initial-value: 0;
}

.card-container {
  --tilt-card--perspective: 50rem;
  --tilt-card--pos-x: 0;
  --tilt-card--pos-y: 0;

  perspective: var(--tilt-card--perspective);
  
  transition:
    --tilt-card--pos-x 800ms cubic-bezier(0.2, 0.85, 0.3, 1),
    --tilt-card--pos-y 800ms cubic-bezier(0.2, 0.85, 0.3, 1);
  
  ...
}

.card-container * {
  transform-style: preserve-3d;
}

.card {
  --tilt-card--x: calc(var(--tilt-card--pos-y, 0.1) * -120deg);
  --tilt-card--y: calc(var(--tilt-card--pos-x, 0.1) * 120deg);

  transform: rotateX(var(--tilt-card--x)) rotateY(var(--tilt-card--y));
  transition: transform 800ms cubic-bezier(0.2, 0.85, 0.3, 1);
  
  ...
}

.card-content {
  overflow: clip;
  backface-visibility: hidden;

  filter: drop-shadow(0 0 15px rgba(0, 245, 255, 0.6));

  perspective: calc(cos(var(--tilt-card--x)) * cos(var(--tilt-card--y)) * var(--tilt-card--perspective));
  perspective-origin: calc(50% - cos(var(--tilt-card--x)) * sin(var(--tilt-card--y)) * var(--tilt-card--perspective)) calc(50% + sin(var(--tilt-card--x)) * var(--tilt-card--perspective));
  
  ...
}

.card-layer {
  transform: translateZ(calc((sibling-index() - 1) * -2rem));
  
  ...
}
      
    

registered @property

Differently than normal custom properties, the ones registered via @property at-rule are transitionable and animatable, allowing to calculate interpolation between all the specified values.

Wrapping up

With modern CSS Motion APIs it's possible to create small scenes.

Motion is choreography: state, timing, and meaning.

Use CSS as the primary language, then add JavaScript only for input and orchestration.

  • Start with entrance, scroll reveal, or state change
  • Make values editable with custom properties
  • Add progressive enhancement with @supports

Slides Links

Slides
Slides
Slides Repo on Github
Slides Repo
© 1987-2025 - Warner Bros. Entertainment Inc.
Pixu

Grazie ❤️!

Dev Dojo IT Logo