← blog
CSSOctober 22, 2026 · 5 min read

Stop Jamming Translate, Rotate, and Scale Into One transform

A CSS :hover scale and a JS drag handler both write to `transform` — and the moment JS sets it, the hover rule silently stops working, no error, no warning. The `translate`, `rotate`, and `scale` properties exist so this never happens.

Parsa Jiravand · Frontend engineer · building bestpractic
Stop Jamming Translate, Rotate, and Scale Into One `transform`

You build a product card. On hover, it should lift a little — transform: scale(1.03), a transition, done in five minutes. Then the ticket comes in: cards need to be draggable, so you add a pointermove handler that keeps the card glued to the cursor:

JavaScript
card.style.transform = `translate(${x}px, ${y}px)`;

You test the drag. It works. You test the hover — on a card you haven't dragged yet — and it works too. Then you drag a card, let go, and move your mouse back over it.

Nothing happens. The lift is just gone. No console error, no warning, and the line that broke it isn't even in your CSS file.

Here's the part that makes this one sneaky: both rules are completely correct on their own.

CSS
1
2
3
4
5
6
.card { transition: transform 150ms ease; } .card:hover { transform: scale(1.03); }
JavaScript
1
2
3
function onDrag(card, x, y) { card.style.transform = `translate(${x}px, ${y}px)`; }

Nobody misspelled a selector. The specificity math even looks like it should favor the pseudo-class most of the time. But specificity isn't what decides this fight — an element's own style attribute beats any selector-based rule for the same property, :hover included, !important aside. The instant onDrag runs once, card.style.transform is set, and from that point forward your stylesheet's .card:hover { transform: scale(1.03) } has nothing left to write to. It still matches. It still "wins" by every specificity rule you learned. The browser just has nowhere to put its answer, because the inline declaration already owns the property.

Open DevTools on a dragged card and look at the Styles panel: you'll see the :hover rule listed, not crossed out, not overridden by anything visible — and still not applied. That's what makes it brutal to debug. Nothing in the inspector tells you an inline style from a completely unrelated JS file is the reason.

The first instinct is !important:

CSS
1
2
3
.card:hover { transform: scale(1.03) !important; }

Now hover wins again — but it wins by replacing the transform outright, not by adding to it. Hover a card mid-drag and it snaps back to wherever scale(1.03) alone would place it, then snaps away again on mouseout. You didn't fix the conflict, you just picked a different single winner for a property that was only ever built to hold one value at a time.

The next instinct is to stop fighting CSS and do it all in JS: track mouseenter/mouseleave, keep the current drag offset and the current hover state in two variables, and rebuild the whole string on every change:

JavaScript
1
2
3
function render() { card.style.transform = `translate(${x}px, ${y}px) scale(${hovering ? 1.03 : 1})`; }

This works. It also means you've reimplemented :hover in JavaScript, with your own event listeners, your own state, and your own transition timing — for a one-line CSS feature the browser already does for free. Add a focus style or a prefers-reduced-motion check and the string-building function keeps growing.

The actual problem is one level up: transform is a single property, and a single property can only hold one value. Whoever writes to it last — JS or CSS — wins the whole thing, not just their part of it. There was no way to say "I only care about position, someone else can own the scale" because position and scale lived inside the exact same string.

That stopped being true in 2022. translate, rotate, and scale are now their own CSS properties, independent of transform and independent of each other, supported in every major engine (Chrome and Edge 104+, Firefox 72+, Safari 14.1+) — which by now means every browser your analytics will show you. Give the drag handler one property and the hover rule a different one:

CSS
1
2
3
4
5
6
7
.card { scale: 1; transition: scale 150ms ease; } .card:hover { scale: 1.03; }
JavaScript
1
2
3
function onDrag(card, x, y) { card.style.translate = `${x}px ${y}px`; }

No shared string, no state to track, no !important. JS writes translate every pointermove; the browser handles :hover writing scale the way it always has. Drag a card and its hover lift still fires, because for the first time the two concerns were never fighting for the same property to begin with.

Runs right in your browser — poke at it and watch the concept react live.

The browser applies these in one fixed order — translate, then rotate, then scale, then whatever's left in transform — no matter what order you declare them in your code. That's worth knowing if you still reach for transform for something the individual properties don't cover, like skew() or a raw matrix(): it always applies last, on top of the other three, not wherever you happened to write it.

Dragging is the clearest version, but the same collision shows up anywhere JS and CSS both think they own motion on the same element:

  • A parallax or scroll-linked effect that sets transform every frame, on a card that also has a CSS :focus or :active micro-interaction.
  • A FLIP-style animation library that writes a starting transform via JS, racing a stylesheet transition meant to animate it away.
  • A carousel or swiper that positions slides with inline transform, on a component whose design system also wants a CSS-only hover state on each slide.

The symptom is always the same: a CSS rule that's "obviously" correct, matches, shows up in DevTools — and just doesn't apply, because something else got to the property first.

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

Individual transform properties don't replace transform — skew(), matrix(), and matrix3d() still only exist inside it, and if two different things both need to animate the same axis (two JS handlers both wanting to own translate, say), you're back to needing one source of truth for that value. What they fix is the specific, common case of position/rotation/scale each belonging to a different piece of code — which covers most of the JS-meets-CSS collisions you'll actually hit.

Next time a hover or focus style "randomly" stops working on an element some other script touches, check whether that script is writing to transform — and if it is, see if giving it translate instead buys your CSS room to breathe again. What's the weirdest "it matches in DevTools but doesn't apply" bug you've chased down? I'd like to hear it.


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Keep reading

One post a day, in your inbox

Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.

0 comments

Sign in to join the discussion, like comments, and save articles for later.