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:
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.
The bug nobody writes on purpose
Here's the part that makes this one sneaky: both rules are completely correct on their own.
.card {
transition: transform 150ms ease;
}
.card:hover {
transform: scale(1.03);
}
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 fixes that don't actually fix it
The first instinct is !important:
.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:
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.
transform was never built to share
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:
.card {
scale: 1;
transition: scale 150ms ease;
}
.card:hover {
scale: 1.03;
}
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.
🎮 Try it yourself
▶️ Open the interactive playground →
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.
Where this hides beyond drag-and-drop
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
transformevery frame, on a card that also has a CSS:focusor:activemicro-interaction. - A FLIP-style animation library that writes a starting
transformvia 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.
🧠 Test yourself
Think it clicked? Take the 7-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
What this doesn't fix
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:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Top comments (0)