React styles update only after wheel event fulfilled - javascript

What I want to achieve is smoothly scaled div container while scrolling (using mouse wheel to be strict) so user can zoom in and out.
However, my styles are "applied" by the browser only either when I scroll really slow or scroll normally and then wait about 0.2 seconds (after that time the changes are "bunched up"). I would like for the changes to be visible even during "fast" scrolling, not at the end.
The element with listener:
<div onWheel={(event) => {
console.log("wheeling"); // this console log fires frequently,
// and I want to update styles at the same rate
changeZoom(event);
}}
>
<div ref={scaledItem}> // content div that will be scaled according to event.deltaY
... // contents
</div>
</div>
My React code:
const changeZoom = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
if (!scaledItem.current) return;
const newZoom = parseFloat(scaledItem.current.style.scale) + event.deltaY * 0.001;
console.log(newZoom); // logs as frequently as "wheeling" above
setCurrentZoom(newZoom);
}, []);
useEffect(() => {
if (!scaledItem.current) return;
scaledItem.current.style.scale = currentZoom.toString();
}, [currentZoom]);
useEffect(() => { // this is just for reproduction, needs to set initial scale to 1
if (!scaledItem.current) return;
scaledItem.current.style.scale = "1";
}, [])
What I have tried first was to omit all the React states, and edit scaledItem.current.style.scale directly from useCallback, but the changes took place in a bunch, after the wheeling events stopped coming. Then I moved zoom amount to currentZoom useState hook, but rerenders don't help either.
Edit:
I have also tried adding EventListener inside useEffect directly to the DOM Node:
useEffect(() => {
if (!scaledItemWrapper.current) return; // ref for wrapper of my scaled content
const container = scaledItemWrapper.current;
container.addEventListener("wheel", changeZoom);
return () => {
container.removeEventListener("wheel", changeZoom);
};
}, [changeZoom]);

Instead of setting up multiple states and observing can you try using a single state below is a working example. Try this if this works
https://codesandbox.io/s/wonderful-cerf-69doe?file=/src/App.js:0-727
export default () => {
const [pos, setPos] = useState({ x: 0, y: 0, scale: 1 });
const changeZoom = (e) => {
e.preventDefault();
const delta = e.deltaY * -0.01;
const newScale = pos.scale + delta;
const ratio = 1 - newScale / pos.scale;
setPos({
scale: newScale,
x: pos.x + (e.clientX - pos.x) * ratio,
y: pos.y + (e.clientY - pos.y) * ratio
});
};
return (
<div onWheelCapture={changeZoom}>
<img
src="https://source.unsplash.com/random/300x300?sky"
style={{
transformOrigin: "0 0",
transform: `translate(${pos.x}px, ${pos.y}px) scale(${pos.scale})`
}}
/>
</div>
);
};

Use a CSS transition
What I want to achieve is smoothly scaled div container while scrolling
The question's JavaScript
I didn't have to make changes to the posted JavaScript in my answer's code snippets, besides replacing scale with transform: scale() because it currently has incomplete browser support. Perhaps this can be written better but it does the job here, and is not the cause of the choppy behavior you observe.
Creating fluid motion from choppy input
Scroll events are by nature "bunched" because they arrive as the wheel is being turned "a notch". While that's not as true for all scrollable devices, it is for most people's mouse, so we have to deal with it for the foreseeable future. The browser also does additional bunching, in case of fast motion, but even without that the problem is already there.
So it's best to write code in a way that choppy input still results in a fluid motion, regardless of the step size. Once you have that, it automatically accounts for additional bunching by the browser.
You can add a CSS transition on the transform property to smooth out the scaling movement. It seems to work well with a value of 0.2 seconds, which I assume makes sense as it spreads the motion over the 0.2 seconds the browser is bunching up the changes in.
transition: transform 0.2s ease-out;
Performance implications
As a bonus, your app can keep rendering just 5 times a second.
Conversely, a solution that causes React to capture the maximum amount of fine grained scroll events will likely cause performance issues. A CSS transform is a lot cheaper then achieving the same effect through repeated renders.
Demonstration
You can observe the difference in the following 2 snippets.
It only runs properly if you open it full page. Otherwise it works but it will scroll the whole page too. I didn't want to make the code overly complex just to prevent that on SO.
Without transition (choppy)
const {useCallback, useEffect, useState, useRef} = React;
const minZoom = .01;
function App() {
const [currentZoom, setCurrentZoom] = useState("1");
const scaledItem = useRef();
const changeZoom = useCallback((event) => {
if (!scaledItem.current) return;
const scaleNumber = scaledItem.current.style.transform.replace('scale(','').replace(')','');
const newZoom = Math.max(minZoom, parseFloat(scaleNumber) + event.deltaY * 0.001);
console.log(newZoom); // logs as frequently as "wheeling" above
setCurrentZoom(newZoom);
}, []);
useEffect(() => {
if (!scaledItem.current) return;
scaledItem.current.style.transform = `scale(${currentZoom.toString()})`;
}, [currentZoom]);
useEffect(() => { // this is just for reproduction, needs to set initial scale to 1
if (!scaledItem.current) return;
scaledItem.current.style.transform = "scale(1)";
}, [])
return <div onWheel={(event) => {
console.log("wheeling");
changeZoom(event);
}}
>
<div class="scaled" ref={scaledItem}>
<p>Scale me up and down! (Use "Full page" link of snippet)</p>
</div>
</div>
}
ReactDOM.render(<App/>, document.getElementById('root'));
.scaled {
border: 2px solid lightgreen;
transform: scale(1);
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
<div id="root"></div>
<div id="root2"></div>
With transition (smooth)
const {useCallback, useEffect, useState, useRef} = React;
const minZoom = .01;
function App() {
const [currentZoom, setCurrentZoom] = useState("1");
const scaledItem = useRef();
const changeZoom = useCallback((event) => {
if (!scaledItem.current) return;
const scaleNumber = scaledItem.current.style.transform.replace('scale(','').replace(')','');
const newZoom = Math.max(minZoom, parseFloat(scaleNumber) + event.deltaY * 0.001);
console.log(newZoom); // logs as frequently as "wheeling" above
setCurrentZoom(newZoom);
}, []);
useEffect(() => {
if (!scaledItem.current) return;
scaledItem.current.style.transform = `scale(${currentZoom.toString()})`;
}, [currentZoom]);
useEffect(() => { // this is just for reproduction, needs to set initial scale to 1
if (!scaledItem.current) return;
scaledItem.current.style.transform = "scale(1)";
}, [])
return <div onWheel={(event) => {
console.log("wheeling");
changeZoom(event);
}}
>
<div class="scaled" ref={scaledItem}>
<p>Scale me up and down! (Use "Full page" link of snippet)</p>
</div>
</div>
}
ReactDOM.render(<App/>, document.getElementById('root'));
.scaled {
border: 2px solid lightgreen;
transform: scale(1);
transition: transform .2s ease-out;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
<div id="root"></div>

Related

requestAnimationFrame and getComputedStyle don't work as expected (following along Jake Archibalds "In the Loop" speech)

I am watching and following along Jake Archibald's speech about the Event Loop. At some point he attemps to move a box, first to 1000px and then back to 500px. You can also watch in the video here:
Jake Archibald: In The Loop - JSConf.Asia (22:10)
He offers two solutions and I couldn't get any of them to work for me, even though (I think) I am doing exactly the same. The first solution is this:
button.addEventListener("click", () => {
box.style.transform = "translateX(1000px)";
box.style.transition = "transform 1s ease-in-out";
requestAnimationFrame(() => {
requestAnimationFrame(() => {
box.style.transform = "translateX(500px)";
});
});
});
And the second "hacky" one, as he says, is this:
button.addEventListener("click", () => {
box.style.transform = "translateX(1000px)";
box.style.transition = "transform 1s ease-in-out";
getComputedStyle(box).transform;
box.style.transform = "translateX(500px)";
});
On both occasions the box just moves to 500px, while it should first move to 1000px and then back to 500px.
Am I missing something? Am I doing something wrong? Or have things changed that much since 2018 so that this solution doesn't work anymore?
P.S. I am using Chrome Version 102.0.5005.61 on Ubuntu 22.04.
What the video shows is wrong here.
For this explanation I will use the "hacky" way, since it seems easier to understand as it's synchronous and thus we don't have to wonder when the reflow happens. (And if you know what you're doing it's not actually more hacky than any async way).
For a transition to work, we need an initial value, a destination and a flag telling we're ok to transition a given property change.
When this flag is raised, any change to the value that can be transitioned will be used as the destination, and the current value will be used as the initial value of our transition.
In the example, before we click the button, we only have an initial value (none, i.e translateX(0px)).
button.addEventListener("click", () => {
Then we set both the new transform and transition values.
box.style.transform = "translateX(1000px)";
box.style.transition = "transform 1s ease-in-out";
At this point, the CSS layout hasn't been updated, it doesn't know we did change anything.
The next line, which forces the reflow, will take care of that.
getComputedStyle(box).transform;
And here the CSS engine sees that it has the transition flag for transform raised AND that the transform value has changed. It thus initiates a transition from the current value (0px) to the new one (1000px).
But even before it can start rendering that transition,
box.style.transform = "translateX(500px)";
changes the destination to be 500px. So it will update the transition to go from wherever it is at this point (still 0px) to that new destination (500px).
The same obviously happens with the async version, except that when we set the new value to 500px our element will already have moved a bit (by ~17px on a 60Hz monitor, while it should have only moved by half of that).
This isn't very noticeable with this value because it's only one frame and the speed difference between 500px/s and 1000px/s isn't very noticeable in a single frame, but if you have a much bigger difference, you can see it better:
const box = document.querySelector(".box");
const button = document.querySelector(".button");
button.addEventListener("click", () => {
// Forcing a huge value here will make the transition
// to start from much farther on the right
// because it will travel a quite big distance on the first frame
// and then start the new transition from the current position
// Depending on your monitor's refresh rate you may even see it
// coming from the right.
box.style.transform = "translateX(500000px)";
box.style.transition = "transform 1s ease-in-out";
requestAnimationFrame(() => {
requestAnimationFrame(() => {
box.style.transform = "translateX(500px)";
});
});
});
// So we can try multiple times
document.querySelector(".reset").onclick = (evt) => {
box.style.transform = "none";
box.style.transition = "none";
box.style.offsetWidth;
}
.box {
width: 50px;
height: 50px;
border: 1px solid;
}
<button class="button">click me</button>
<button class="reset">reset</button>
<div class="box"></div>
So once again I'll play the "hacky" advocate here. Using the synchronous way, you do have control over what happens. Just be very careful that you don't pollute the CSS layout anymore after you've forced that reflow and you'll be fine, the browser won't recalculate it in the rendering step.
As far as I can remember this has always been the expected and actual behavior, I highly doubt this has changed since this talk and you can even retrieve some comments from 2019 under the video that talk about this not working as explained in the video.
To get the expected behavior, you'd need to set the transition along with the setting of the destination value, after the reflow that calculated the new initial position.
const box = document.querySelector(".box");
const button = document.querySelector("button");
button.addEventListener("click", () => {
box.style.transform = "translateX(1000px)";
getComputedStyle(box).transform;
box.style.transform = "translateX(500px)";
box.style.transition = "transform 1s ease-in-out";
});
// So we can try multiple times
document.querySelector(".reset").onclick = (evt) => {
box.style.transform = "none";
box.style.transition = "none";
box.style.offsetWidth;
}
.box {
width: 50px;
height: 50px;
border: 1px solid;
}
<button class="button">click me</button>
<button class="reset">reset</button>
<div class="box"></div>
or
const box = document.querySelector(".box");
const button = document.querySelector("button");
button.addEventListener("click", () => {
box.style.transform = "translateX(1000px)";
requestAnimationFrame(() => {
requestAnimationFrame(() => {
box.style.transform = "translateX(500px)";
box.style.transition = "transform 1s ease-in-out";
});
});
});
// So we can try multiple times
document.querySelector(".reset").onclick = (evt) => {
box.style.transform = "none";
box.style.transition = "none";
box.style.offsetWidth;
}
.box {
width: 50px;
height: 50px;
border: 1px solid;
}
<button class="button">click me</button>
<button class="reset">reset</button>
<div class="box"></div>

Framer-motion drag not respecting previously updated props

A simple use-case is to allow a user to either click buttons to paginate in a slider, or drag. Both events call the same paginate function with a param to either go forward or back--simple stuff.
However, the trigger from drag seems to cause bizarre behavior where the slider wants to start the animation from several slides back as if it ignores the updated props. This doesn't happen when using the buttons and both use the same simple paginate call.
Any tips appreciated.
Minimal example:
export default function App() {
const [position, setPosition] = useState<number>(0);
const paginate = (direction: Direction) => {
setPosition((prev) => {
return direction === Direction.Forward
? Math.max(-800, prev - 200)
: Math.min(0, prev + 200);
});
};
return (
<div className="App">
<Slider>
<Wrapper
animate={{ x: position }}
transition={{
x: { duration: 1, type: "tween" }
}}
drag="x"
dragConstraints={{
top: 0,
left: 0,
right: 0,
bottom: 0
}}
onDragEnd={(e, { offset, velocity }) => {
const swipe = swipePower(offset.x, velocity.x);
if (swipe < -swipeConfidenceThreshold) {
paginate(Direction.Forward);
} else if (swipe > swipeConfidenceThreshold) {
paginate(Direction.Back);
}
}}
>
<Slide>1</Slide>
<Slide className="alt">2</Slide>
<Slide>3</Slide>
<Slide className="alt">4</Slide>
<Slide>5</Slide>
</Wrapper>
</Slider>
<button onClick={() => paginate(Direction.Back)}>prev</button>
<button onClick={() => paginate(Direction.Forward)}>next</button>
</div>
);
}
Codesandbox Demo
I have to say, this problem is quite interesting. However, I think I figured out a way for you to handle this. One thing I noticed is that if you comment out
onDragEnd={(e, { offset, velocity }) => {
// const swipe = swipePower(offset.x, velocity.x);
// if (swipe < -swipeConfidenceThreshold) {
// paginate(Direction.Forward);
// } else if (swipe > swipeConfidenceThreshold) {
// paginate(Direction.Back);
// }
}}
the entire onDragEnd prop function, this example still doesn't work, since by the looks of things, the draggable component is not respecting your offset.
I realized that at this point, the problem is the internal state of the component is out of sync with your state. And would you look at that, the Framer Motion API actually provides a way to inspect this.
https://www.framer.com/api/motion/motionvalue/#usemotionvalue
It's the hook useMotionValue() which allows us to see what's actually happening. Turns out, our value is being set wrong when the user starts dragging:
useEffect(
() =>
motionX.onChange((latest) => {
console.log("LATEST: ", latest);
}),
[]
);
We can see this, because the state "jumps" to 200 as soon as we start dragging.
So fixing in theory is easy, we just need to make sure to let that value "know" about our offset, and that way it's gonna start with the proper offset in mind!
Anyway, that was my thought process, here's the solution, all you need to do is set the left constraint to make it work:
dragConstraints={{
top: 0,
left: position,
right: 0,
bottom: 0
}}
And tada! This makes it work. Here's my working solution: https://codesandbox.io/s/lingering-waterfall-2tsfi?file=/src/App.tsx

Canvas not capturing mouse position when hovered over an <h1> on top of it

I'm using React Three Fiber to animate a 3d model looking at the mouse. This is creating a canvas element in the background of my page. I have a few divs and h1s layered on top of it, and whenever my mouse hovers over the divs/h1s, the canvas freezes, until I mouse out of it. This results in a choppy looking animation. How do I rectify this?
This is my React Component:
function Model() {
const { camera, gl, mouse, intersect, viewport } = useThree();
const { nodes } = useLoader(GLTFLoader, '/scene.glb');
const canvasRef = useRef(null);
const group = useRef();
useFrame(({ mouse }) => {
const x = (mouse.x * viewport.width) / 1200;
const y = (mouse.y * viewport.height) / 2;
group.current.rotation.y = x + 3;
});
return (
<mesh
receiveShadow
castShadow
ref={group}
rotation={[1.8, 40, 0.1]}
geometry={nodes.HEAD.geometry}
material={nodes.HEAD.material}
dispose={null}></mesh>
);
}
You'll need to assign a special CSS rule to your <h1> and <div>s so that they don't capture pointer events:
h1 {pointer-events: none;}
This means the element is never the target of pointer events, so the mousemove event sort of "passes through" to the element below it. See the MDN docs for further description.

CSS transform: scale very shaky/choppy/jerky in Safari

I'm trying to implement a scroll based shrinking effect on on a div element. As the user scrolls, the div shrinks in height (using scale) until it finally disappears. I'm using React to render the HTML and styled-components for the CSS.
I'm having problems with the performance of this effect, specifically only with Safari, Version 13.1 (15609.1.20.111.8). In Safari, the transition effect is very janky and does not happen smoothly.
Chrome and Firefox work perfectly.
Here is the React component:
const MyComponent = React.forwardRef((_, ref) => {
const [scaleOffset, setScaleOffset] = React.useState(1)
const onScroll = React.useCallback(debounceWindowEventCallback(() => {
const paddingOffset = window.scrollY - ref.current.offsetTop
if (paddingOffset >= 0) setScaleOffset(1 - (paddingOffset / 100))
}), [ref]);
React.useEffect(() => {
if (scaleOffset <= 0) {
window.removeEventListener('scroll', onScroll)
console.log('Border has been fully shrunk')
}
}, [onScroll, scaleOffset])
React.useEffect(() => {
window.addEventListener('scroll', onScroll)
return () => window.removeEventListener('scroll', onScroll)
}, [])
return (
<RootElement ref={ref}>
<ShrinkBorderTop scaleOffset={scaleOffset} />
</RootElement>
);
});
And the relevant styled components are:
const ShrinkBorderTop = styled.div`
position: absolute;
background-color: white;
transition: transform .175s ease-out;
width: 100%;
height: 200px;
transform: ${({ scaleOffset }) => `scale(1, ${scaleOffset})`};
`
What I've tried:
I initially thought its because my component was rendering too much (every scroll event) so I implemented the debounceWindowEventCallback function which uses window.requestAnimationFrame to ensure that there's only a single paint per frame. Unfortunately, that didn't help.
I then tried adding will-change: transition, transform, height, width; to ShrinkBorderTop, after googling the problem. This also didn't help.
Then I tried adding perspective: 1000; backface-visibility: hidden; to ShrinkBorderTop, this also didn't work.
I'm stumped as to what could be causing this problem, in my view its quite a standard animation and I've applied all the performance boosts I'm aware of, in addition to scale being hardware accelerated by default, but the problem still persists, only in safari.
What could be causing this?

Trigger and reverse CSS transition with Javascript

I am currently exploring the possibilities of the FLIP technique which reduces all CSS transitions only to transform and opacity (because of GPU-acceleration). It involves manipulating styles directly with Javascript. Although it is not very hard to trigger such a transition, I've found myself unable to reverse it. Normally a transition defined within CSS on e.g. hover is reversed automatically when you stop hovering. But CSS is not enough to trigger (and reverse) transition on clicks. I want to be able to do the following:
a) Click on an item (and trigger size change by e.g. CSS class toggling)
b) Calculate the difference between its initial and new size and trigger a transform with transition
c) Click on it again while it is changing its size
d) Reverse the transition from the position it was in when clicked on
My problem is with the d) step. For some reason the element abruptly changes its size to neither the old nor the new size but to a completely different size. I have an example of what I'm trying to do here:
https://codesandbox.io/s/flamboyant-snowflake-47t59
Click on a square and then click on it again while it's enlarging.
Is there any reliable way to properly do what I'm trying to do? Are there good alternatives?
I've managed to find a working solution (it imports rematrix library to make calculations with transformation matrices easier). Both this solution and the simpler one by Richard are capable of 60fps animation - I guess the difference is the amount of cases each approach can deal with.
https://codesandbox.io/s/working-transform-reverse-urc16
I believe this is what you're looking for. Also, it seems to me that you don't quite understand requestAnimationFrame fully. It's made to replace a technique that involves recursive calls of setInterval to change the CSS property of an element every few milliseconds (read more here). So I suggest reading more about requestAnimationFrame first.
I'd like to note that this animation below can be far more easily made simply using CSS transition. Furthermore, MDN mentions that both have more or less similar performance anyway (read here). Still, here's a minimal working example of what you want:
let clicked = false;
let square = document.querySelector(".small-sq");
let expandID
let retractID
square.style.transformOrigin = 'top left'
square.style.transform = 'scale(1)'
square.addEventListener('click', e => {
let timeStart = new Date()
let currentScale = parseFloat(square.style.transform.slice(6, -1))
if (!clicked) {
cancelAnimationFrame(retractID)
expandID = requestAnimationFrame(() => repeat(timeStart, currentScale, clicked))
}
else {
cancelAnimationFrame(expandID)
retractID = requestAnimationFrame(() => repeat(timeStart, currentScale, clicked))
}
clicked = !clicked
})
function repeat(timeStart, currentScale, status) {
let timeDifference = new Date() - timeStart
if (status) {
let scaleValue = currentScale + (1 * timeDifference / 5000) > 2 ? 2 : currentScale + (1 * timeDifference / 5000)
square.style.transform = `scale(${scaleValue})`
expandID = requestAnimationFrame(() => repeat(timeStart, scaleValue, status))
if (scaleValue >= 2)
cancelAnimationFrame(expandID)
}
else {
let scaleValue = currentScale - (1 * timeDifference / 5000) < 1 ? 1 : currentScale - (1 * timeDifference / 5000)
square.style.transform = `scale(${scaleValue})`
retractID = requestAnimationFrame(() => repeat(timeStart, scaleValue, status))
if (scaleValue <= 1)
cancelAnimationFrame(retractID)
}
}
.small-sq {
height: 100px;
width: 100px;
background-color: rgb(131, 42, 131);
}
.big-sq {
height: 500px;
width: 500px;
background-color: rgb(131, 42, 131);
}
<div id="app">
<div class="small-sq"></div>
</div>
If you use CSS transition, then it can be converted to the following:
let square = document.querySelector('.small-sq')
square.addEventListener('click', e => {
square.classList.toggle('toggled')
})
.small-sq {
height: 100px;
width: 100px;
background-color: rgb(131, 42, 131);
transform-origin: left top;
transition: transform 1s linear;
}
.small-sq.toggled {
transform: scale(2)
}
<div id="app">
<div class="small-sq"></div>
</div>

Categories