Table of Contents
- 1. The Beginning
- 2. The Line Glare Effect
- 3. useImperativeHandle() and Async Animation
- 4. Problems with the Tilt Component
- 5. Reinventing a Better Wheel (Tilt Component)
- 6. The Spot Glare Effect
- 7. requestAnimationFrame() and will-change Property
- 8. Testing
- 9. Storybook
- 10. Flip-Back Direction
- 11. Control Element
- 12. JSDoc Descriptions
- 13. The Final Note
This will be a long post in which I go over why and how I made these components/packages.
tl;dr: I spent a lot of time on them and learned a lot in the process. Have fun using them :)
The past few months have been a fun ride for me for sure. After doing many coding challenges on frontendmentor.io, I started working on my portfolio website in Next.js. I began implementing the features I always wanted to work on in my portfolio, like Next.js App directory, i18n (Internationalization), react-hook-form, email.js, and many cool CSS/3D/Animation ideas I've had for a while that you'll see once it's finished.
While working on my portfolio, I came across a website (https://evolany.com/) which I found inspiring for its sleek design and interactive elements. What they use for the members section is an image that flips and turns into a tilt element on hover. I thought to myself: "That looks cool, I should be able to do that in React."
At this point, I was already using a tilt component (react-parallax-tilt) in my portfolio, and after checking this page I had the general idea of how it should be done, a container with
transform-style: preserve-3d, with two children, one tilt and the other an image, both having
backface-visibility: hidden and the tilt being rotated to face backward at the start. For the animation, I decided to use
framer-motion because I was already familiar with it and knew it had what was needed to get the job done (and I'm glad I did because later I needed to
await the animation and it was easy with
So I started making the component and after some trouble as usual (has anything worked well the first few times, ever?!), like
motionValue having the
NaN value sometimes and breaking everything when the animation type was
spring and there was a set
duration (which to fix, I needed to remove
duration and use
stiffness instead to control the duration), I managed to make a working component that flipped on hover and showed the tilt component.
One of the features of the element on the mentioned site is the line glare that shows when the tilt element is at a certain angle to give the impression of light hitting it which gives it a nice 3D feel, but the tilt component I was using (and any other ones that I checked) lacked this feature. so I decided to make the effect myself and inject it into the tilt component I was using. At first, I used
createPortal() to do this but later switched to the
Here, I would like to do a small rant about the advice floating around that is: "You just need to code enough to get something working right now and not think about the future". Had I not figured out my own way of doing things and settled on something that just worked for now, I wouldn't have been able to complete the other parts of this project nor gain the knowledge I now have. I believe it's better to deeply understand the problem and the solution and learn something from it rather than just hacking it to work for now.
Anyway, back to the topic, the line glare element is twice the size of the main element and placed on top of it with only the part inside visible. It will move only left or right depending on the sum of
offsetX/Y is a number between 0 and 1 depending on the distance of the pointer/touch from the edge of the element) but since it's a diagonal line, it gives the impression of it going from top to bottom as well.
Because the line glare element is twice the size and translate is relative to the size of the line glare element, not the main element, at first it has
translateX of -100% so it is placed outside the component, and at
translateX of 50% it will exit the main element from the right side:
The line glare should be visible only when the component is tilted so when the pointer is at the center (
offsetY(0.5) = 1) it should be outside the main element. Let's say we want the line glare to be visible while the component is being tilted to the top left corner (
offsetY(0)=0), then we need to map the
offsetX + offsetY ([1 - 0]) to the line glare element position/translateX or [-100% - 50%]:
To map [1 - 0] to [-100 - 50], we can do:
* -1 = [-1 - 0] -> * 3/2 = [-1.5 - 0] -> + 0.5 = [-1 - 0.5] -> * 100 = [-100 - 50]
((offsetX + offsetY) * (-3 / 2) + 0.5) * 100
Even though it took me a day or two to come up with this method, after that, everything made sense and I also implemented customizations like reverse movement, different line angle, width, color, etc.
At this point, I had a working component and I really liked the flip-tilt effect, so I decided to make it into an NPM package so others can use it as well. for that, I needed to make a demo page to showcase the component and how it worked. the best way to showcase it was by having multiple components side by side, similar to how the original element was presented, but I also wanted to add customization selection elements to showcase the power of React and that while it may take longer to make the component at first, after that, you can easily integrate as many of it as you want into your application with different props/customizations.
While making the demo page and working on the component, I thought it'd be cool if I could showcase the components on the demo page by animating them in a pattern/sequence to showcase the flip animation. I tried flipping the component programmatically by changing its
flipped state. and while the component did flip, changing the component's state re-rendered it, meaning the animation got interrupted (not to mention the performance impact of having to constantly re-render a lot of components while doing animations). I needed a way to run the flip animation inside of my component without re-rendering it.
After doing some research I came across the
useImperativeHandle() hook. It lets you customize the ref handle of your component and expose internal properties/functions to the outside so a parent component can call a child function without having to re-render it. This was exactly what I was looking for and after learning and using it, I could achieve the animation effect I wanted without re-rendering. It also allowed me to expose more needed functions at the later stages of development.
I also exported the current flipped state of the component (
isFlipped()) using the same method (I use a
ref to keep track of the flipped state instead of
state to minimize re-rendering, and it took a while to figure out that I needed to use a getter function to export its value).
After testing the
isFlipped() state and
flip() function, I came across a new problem. After running the following code:
I expected the component to flip and then flip back but nothing happened on the screen. this was because the functions ran instantly without waiting for the previous one to finish. to fix the problem I needed to make the animation and the
flip() function asynchronous so they could be
awaited. Thankfully, the only thing I needed to do to convert my
flip() function to an async function was adding the
await keyword to my
animate() call. after that, I could
flip() function and the result was the expected animation:
await ref.flip(); await ref.flip();
While refactoring and polishing the component and playing around with it more, I noticed some problems with the tilt component I was using, including:
- After the flip animation, the tilt component was always straight (not tilted) while I wanted it to be tilted at the start depending on the pointer position. to fix it, I tried:
- Calculating the rotation angle myself in my component based on pointer position and setting the transform rotation of the tilt element. which didn't work because the transform got overridden by the tilt component.
mouseMoveevents to the tilt element after running the flip animation which kind of worked and the tilt component appeared tilted after the flip animation. but I didn't like this method as it was too hacky and the multiple random events it dispatched could interfere with the final application that was going to use my component.
It's possible to set the start tilt angle using
initialAngleX/Yprops, but then it changes the rest/reset position as well and re-renders the component, and using the
tiltAngleX/YManualprops disables the tilt on hover.
Changing some of the props didn't immediately take effect and I had to set them as the component
keyto force an update. (example)
The tilt component I was using (and all the other ones I checked), attaches the event handlers to the element that is being tilted itself, so after tilting, the element goes out of the cursor position and resets back, and then comes under the cursor and so on. this caused jittery movement around the edges:
Again at this point, I felt like I was spending too much time trying to make something work that even if it did, I would've been limited by its features and functionality and I wouldn't have been able to fully customize it. So even though I was halfway through writing the readme for the package before publishing it, after giving it some thought, I decided to scrap it all and start over and make my own tilt component from scratch.
I separated all the logic related to the tilt component into another project and started making my own tilt component. I already knew how to calculate the tilt angle from the offset from before when I was trying to set the start angle of the previous tilt component, I just needed to write all the other parts and customizations...
After coming this far, I wanted to make the best component I can with my current skills and also learn in the process so I didn't want to skimp on anything.
I finished the tilt logic, added touch and gyroscope support, scale, reverse, reset, initial angle, and many more customizations and exported the needed functions and properties using the
useimperative() hook, all the while trying to minimize component re-renders by using refs,
useCallback(), and keeping performance in mind overall.
Also from my experience with the previous tilt component in my portfolio, there was an annoyance in mobile/touch that when trying to tilt the component, the page scrolled at the same time. I addressed that and added a "disable scroll on touch" option as well.
While the time I spent before may have seemed like lost time, the knowledge I gained doing all that, helped me greatly in making the tilt component and the customizations.
To implement the spot glare effect, I used an element twice the size of the main element with a radial gradient background which is placed so that its center is at the corner of the main element and depending on the pointer/touch position moves to the other corner giving the impression of light hitting the element at different angles:
For the movement and customizations, I used the same mapping method that I used for the line glare effect. (
offsetX([0 - 1]) to
translateX([0% - 50%]))
Most of the other tilt components seem to be based on
tilt.js. while taking a look at it, I noticed that it boasts about being "requestAnimationFrame powered". this piqued my interest and I started looking at what it is and how to implement it in my component.
Once I knew how it worked, I implemented it in my component by putting the parts of my code that updated the animation/transform in the
requestAnimationFrame() function. at first, I tried batching all the animation updates in one call, but later decided against it as it meant if there was going to be a frame drop because the calculations couldn't be made before the next frame, the frame drop would affect all the tilt, line, and glare elements. by separating it into different calls for each element, if one of them lagged a frame, the others would still render which results in smoother animations overall. (of course, this could not work in another case in which the animations are in a sequence or depend on each other)
will-change CSS property is another performance optimization used by other components. it lets the browser know which properties are about to be changed so it can be prepared for it which usually means not cleaning up the memory as fast as it can and and holding on to it. this can cause higher memory usage if you just randomly apply it to your elements.
The proper way to use the
will-change property is to set it before starting your animation (changing the
transform in this case) and remove it after you are done. The way I implemented it in my component is that when the pointer/touch enters the component, I add the property to the tilt and glare elements and when the pointer/touch leaves it, I remove it. this lets the browser know that while the pointer/touch is inside the component, it should expect changes to the transform property.
After more refactoring, I was writing my new readme files for the now two packages that I had made, but even though I had tested the functionality by manually changing different props and playing around with it, I've not had written any tests for the components yet. And after coming so far, I didn't want to publish an NPM package without proper tests and just say: "Trust me, it works!".
I knew Jest from before when I learned it to write the tests for my previous NPM packages. but at this point even though it was on my "to learn" list, I didn't know how to write tests for React components.
- React testing Library
The most popular library for testing React components is the React Testing Library and I started learning it. because I was using Vite.js for development, I had to set up and use Vitest instead of Jest which I did without much trouble since Vitest is made on top of Jest and has a similar syntax.
I spent some time before adjusting the CSS style of the component to make the internal elements respect the width/height set as props so I decided to write the first test for the
width property to see if the component actually rendered at the given width when provided with a bigger-size image as child.
And you guessed it, I ran into yet another problem. the returned computed width (getBoundingClientRect().width) was an empty string. At first, I thought something was wrong with the component but after investigating further, I figured out the reason.
React testing library uses jsdom (or others like happy-dom) under the hood to emulate the browser environment and enable testing of different properties. but it doesn't render the component in an actual browser meaning the styles can't be computed and the returned computed style is just an object with the correct keys but empty strings as values.
This would've been fine if it was only limited to the width/height props but I was using computed styles (getBoundingClientRect()) inside my component to calculate the offsets and all the positions/movements which meant it broke everything.
The solution was to use a testing method that actually rendered the component in a browser so I had access to the computed styles. two of the most used testing libraries for this purpose are Cypress and Selenium. I decided on Cypress (while reading on Reddit that they both suck and we should use playwright instead...) and started learning it.
Cypress has a jQuery-like syntax (actually incorporates jQuery itself) and uses Chai (not Jest/Vitest) for assertions. it also has its own implementation of promises that can't be used with async/await...
Another interesting thing about Cypress is that it's asynchronous, meaning when you say "expect an element to have a certain style property" it doesn't just check it there and then. it waits (by default 4 seconds) for the element to have/attain that style and if the condition passes within the timeout, the test passes. this is especially useful for testing things like
fetching a resource that could take a variable amount of time to complete.
To my surprise, learning and writing the tests in Cypress went rather smoothly, probably because I was already familiar with React Testing Library and Jest/Vitest. I only touched the component testing part of Cypress though and look forward to writing integration/e2e tests using it for my future applications.
The only problem I had with Cypress was that when testing for the tilt angle after triggering the event at certain positions like
"topRight", the component's tilt angle was short of what it needed to be. at first, I thought it was a problem with my rotation calculation and implemented some workarounds like rounding the numbers and it fixed the problem but left a bad taste in my mouth because I wasn't sure what the root cause of the problem was. After revisiting my calculations later on, I came to the conclusion that my math was correct and there was no reason for rounding so I undid it and investigated further. it turned out that the position Cypress triggered the event at, was off by "1px" and it was causing the problem. so I provided my own correct trigger positions instead of relying on the built-in positions and it fixed everything.
Overall, even though setting up and running the tests took some time (mainly because I was learning at the same time), it let me catch some bugs that I missed and also helped test the new features and changes later on.
Storybook was another tool/library on my "to learn" list. It provides an environment where you can render your component in different states and with different props (a story) and preview, test, or play around with it. it's also good for showcasing/documenting different features of a component and the effect of changing individual props on how the component looks and behaves.
I started learning it by watching a youtube video but when it came to using it, I noticed that in the new version, the syntax was changed and most of what I learned was useless... still it was well documented and after reading the docs I got the hang of it. I enjoyed using it and it is something I plan to use in the future as well.
The only thing that took some time was figuring out how to do the customizations I wanted to make, like:
- Hiding the actions I didn't want/need
- Changing the input type shown for each prop
- Showing only a few selected props for a selected story
- Showing/Hiding a prop in the prop table
- Setting the component window background to a dark color by default
- Setting the whole page's background to a dark background by default
In the end, I implemented all the customizations I wanted to make.
You can check it out here: Storybook
I set up the flip animation in a way that allowed the flip direction to be changed by passing a prop (
flipDirection). Depending on the prop's value I set the rest/initial rotation to either 180 or -180 degrees and rotated to 0 on hover/touch. (the reason for it being this way not the other way around is that I wanted the tilt (back) element to be at 0 rotation when being interacted with so the tilt angle and glare movements would work the proper way and not in reverse). and when the pointer/touch left the component the rotation was set back to it's initial rotation. meaning it flipped from one side and flipped back to that side as well. (the original element the idea came from works in this way as well)
This wasn't easy to notice when using a flat image and was best seen when using a 3d/parallax element. so I set up a demo page with parallax elements to showcase this feature and just when I was almost done with it, it occurred to me: "Wouldn't it be cool if I could implement a 360-degree continues rotation so if the flip animation starts from one side, it ends by going to the other side instead of back to where it started?"
The challenge I faced when implementing this feature was that for a 360 degree rotation, the element's rotation needed to start at 180° and go to 0° and then to -180°, but when it animated again, the rotation was at -180° not 180°. so when rotating to 0° again, it started animating from the wrong side.
To fix the problem I needed to negate the rotation value after the element finished animating so if it was -180°, it needed to be switched back to 180°. but changing the rotation caused the element to do a whole unwanted animation from -180° to 180°. I needed a way to just change the rotation value without actually animating the element.
After a little bit of tinkering, I could achieve this using
jump() function. After doing the needed adjustments to the flip function to get it to work properly, I was really happy with the end result and added the
flipBackDirection property to the component and set the 360° rotation as the default.
You can check it out here: Parallax Demo
Another idea I came up with during later stages of development was adding the ability to attach the component's event handlers to other elements and let them control it. this allowed for some cool effects to be implemented as well as opened the door to adding the full-page listening option to the element (since the page/document is just another element that the event handlers should be attached to). It also let me learn more about the difference between Synthetic Events and Native Events and how they worked.
I implemented this feature for both the react-next-tilt and react-flip-tilt components while allowing for customizations and also accepting refs as well as elements as input. And of course, added the
fullPageListening option too. Making the demo pages took a while, but I'm really happy with the end result.
I was already familiar with JSDoc from writing prop descriptions for my previous NPM packages, but I decided to go all in and provide more/better descriptions. I added the following information for the props of both components:
- Description of the prop
- Note (any extra information)
- Default value
- Link to a relevant source for more information which can be a demo page, storybook page, or a website like MDN
- Parameter information for functions
This lets developers know everything there is to know about the prop from inside their IDE by just hovering over it:
Even though I spent a lot of time and ran into many problems while making these components/packages, and most likely because of that, I learned many new things and solidified my knowledge on what I already knew which were my main goals when I started this project.
If you're interested in trying these packages, you can find them here:
Hopefully, you've learned something new by reading this article and knowing about the problems I've encountered. However, the best way to learn is to run into the problems yourself and solve them after putting some time and thought, only then will the knowledge stick in your mind. So I suggest pursuing the next wild idea you have and trying to implement it. even if you don't finish it or no one uses it, you will learn invaluable lessons along the way.
Next, I will focus on finishing my portfolio before I get distracted again...
P.S. I skipped over many parts in this article like designing and making the demo pages mobile friendly, making the images used in the demo pages, customizing the sliders for the tilt demo page, adding a11y to the demo pages, etc.