A cook in a CSS hoodie flipping an @property pancake above a griddle with three more
A cartoon skull with a hotpink hat on.14 min read

PropFLIP

cssjs

Back on my FLIP obsessions: this time invert is 4 @property's and play is a transition.

Callin it PropFLIP
cuz it flips with props 😅

Punch it, mid-flight too!

Maybe that doesn't look that impressive, but it's packing a punch:

  • x, y, scalex and scaley get their own duration, easing, and delays
  • interruptible
  • layout animation
  • border radius maintained
  • CSS led

The effect is thanks to @property and some JS.

Layout doesn't animate #

So, something changes layout right?

A card moves to a new grid cell, a list reorders, a box grows. You want it to glide there, so you reach for:

.box {
  transition: left .5s, top .5s, width .5s, height .5s;
}

which runs layout every frame, aka janky.

The height being in there alone is asking for trouble, everything below it has to move every frame too.

Needless to say, animating layout related situations is tricky.

Shameless plug here for my new CSS library Anchored Auto Layout, which can animate a very cool amount of layout changes. Also see AIM.

Then there's transform, cheap to animate.

But a transform says "move by this much", not "go to wherever layout puts me next".

In FLIP fashion, what if we give CSS more of the work, what if we transition @property values inside transform?

FLIP it #

Here the box's layout is already at Last. Drag it home:

First
Last

At 0 the layout says Last but the pixels sit on First. Those 4 numbers are the typed props we'll set up.

4 typed props #

Build the transform from 4 custom properties:

.PropFLIP {
  transform:
    translate(calc(var(--tx) * 1px), calc(var(--ty) * 1px))
    scale(var(--sx), var(--sy));
  transform-origin: top left;
}

top left cuz that's where getBoundingClientRect() measures from, so the math stays simple.

Register all 4 as <number>'s, with x and y defaulted to 0 and scale x and y to 1:

@property --tx { syntax: "<number>"; inherits: false; initial-value: 0; }
@property --ty { syntax: "<number>"; inherits: false; initial-value: 0; }
@property --sx { syntax: "<number>"; inherits:  true; initial-value: 1; }
@property --sy { syntax: "<number>"; inherits:  true; initial-value: 1; }

The scales inherit on purpose, there's a trick/correction/perfection here we'll come back to 👀

Transitions rule #

Interruptible animation is the dream, CSS transitions deliver.

So next, lets turn each @property variable into a transition-able value:

.PropFLIP {
  transition: --tx, --ty, --sx, --sy;
  transition-duration: .75s;
  transition-timing-function: var(--ease-spring-3);
}

Since it's 4 separate properties, each one can have its own duration and easing.

Different timing on x and y is how you get arcs and swings 🤯

.PropFLIP {
  transition-duration: .6s, 1s, .4s, .4s; /* 👈 */
  transition-timing-function:
    var(--ease-out-3),    /* --tx */
    var(--ease-spring-3), /* --ty */
    …;
}

Have a play with a few Open Props easings:

There's a gotcha here we gotta talk about.

We're animating scale, that means squishing stuff. Which usually isn't beautiful.

UnSquish the Squish #

Scale a box and you scale everything in it. Corners go oval and text goes squishy.

Different timings per property make it worse. Got x and y scaling at different speeds, so the corners really stretch.

The following demo lets you check a box to see the fix and see the problem. Slow it down if you're not seein it.

The Fix: Set --r to your radius and divide it by the scale:

.PropFLIP {
  border-radius:
    calc(var(--r) / var(--sx)) / calc(var(--r) / var(--sy));
}

But I cheated a li'l bit, time to come clean.

Text content, if you have text inside the box it stretches too. And it's not on the .PropFLIP class, it's stuff inside the box.

You can fix it though!

For direct children, set --p to your padding and they undo the parent's scale:

.PropFLIP > * {
  transform-origin: top left;
  transform:
    translate(
      calc(var(--p) / var(--sx) - var(--p)),
      calc(var(--p) / var(--sy) - var(--p))
    )
    scale(calc(1 / var(--sx)), calc(1 / var(--sy)));
}

That's why --sx and --sy inherit. Only direct children need the fix, their kids ride along unsquished. It does assume they sit at the box's padding edge, anything fancier is more math.

The JS #

Phew, you made it this far.

Time for the classic FLIP JS ritual, with a new requirement to temporarily disable the transition.

  1. measure First
  2. turn the transition off
  3. change the DOM
  4. measure Last
const first = el.getBoundingClientRect()
el.style.transition = 'none'
updateDOM()
const last = el.getBoundingClientRect()

Now we do the I from FLIP "Invert" and set our props.

Translate is a difference, scale is a ratio:

el.style.setProperty('--tx', first.left - last.left)
el.style.setProperty('--ty', first.top - last.top)
el.style.setProperty('--sx', first.width / last.width)
el.style.setProperty('--sy', first.height / last.height)

Then, enable transitions again and set the props back:

el.offsetHeight // 👈 commit the lie
el.style.transition = ''
el.style.setProperty('--tx', 0)
el.style.setProperty('--ty', 0)
el.style.setProperty('--sx', 1)
el.style.setProperty('--sy', 1)

That offsetHeight line looks useless, but it's clutch, it forces the browser to compute style with the inverted values, so the transition has a "from". Delete it and nothing animates 😅

All together:

function animateLayout(el, updateDOM) {
  const first = el.getBoundingClientRect()
  el.style.transition = 'none'
  updateDOM()
  const last = el.getBoundingClientRect()

  el.style.setProperty('--tx', first.left - last.left)
  el.style.setProperty('--ty', first.top - last.top)
  el.style.setProperty('--sx', first.width / last.width)
  el.style.setProperty('--sy', first.height / last.height)

  el.offsetHeight
  el.style.transition = ''
  el.style.setProperty('--tx', 0)
  el.style.setProperty('--ty', 0)
  el.style.setProperty('--sx', 1)
  el.style.setProperty('--sy', 1)
}

That's the exact function running the demos:

button.onclick = () =>
  animateLayout(box, () =>
    stage.toggleAttribute('data-last')
  )

Is it fast? #

It depends.

Layout only happens once, up front, like any FLIP. But every frame still costs something:

  1. 4 custom properties change and transform gets recomputed. That's style recalc.
  2. --sx and --sy inherit, so every descendant gets invalidated too.
  3. As far as I know, no browser hands registered property animations to the compositor. It's a main thread job, so a busy main thread could easily mean a janky glide.

For a handful of things (a card, a dialog, a short list) it's great.

For hundreds of elements, or a page that's already struggling, reach for element.animate() on transform with absolute values.

What I wish existed #

I want typed custom properties that animate off the main thread.

The browser knows --tx is a <number>. It knows the duration and the easing. If the only place that number lands is a transform, it seems like the compositor could take the whole thing. As far as I know, today it doesn't.

Until then, PropFLIP is a trade. Way less JS, but maybe not always the performant choice. Same instinct as AIM though, let CSS do the playing.

Check out the playground where I formulated this here on codepen. Lemme know how it holds up on your stuff 🤓

With reduced motion on, the demos jump straight to the end. The Play slider still works.

3likes

Join the conversation on