How I Built 50 Web Interactions Without Turning My React App Into a Performance Nightmare
I kept rebuilding the same small interaction ideas for different projects.
A magnetic button.
An image that reacts to the cursor.
A menu transition.
A scroll-controlled composition.
A little physics experiment for typography.
The individual effects were fun. The problem was that each project ended up with its own component, its own assumptions, and its own implementation details.
Eventually I realized I wasn't building new interactions.
I was rebuilding the same ones.
So I decided to build a proper system for them.
That became MOTIONFORGE: 50 interaction experiments built with React + TypeScript, organized across ten categories.
You can play with the live collection here:
The interesting part wasn't creating 50 animations.
It was figuring out how to make them reusable without making them feel identical.
The Goal
I started with a simple rule:
Every interaction should have a reason to exist.
I didn't want a collection of random "cool effects."
Each experiment needed to answer:
- What is the user doing?
- How does the interface respond?
- What makes the response feel intentional?
- Which parameters actually matter?
- What happens on touch?
- What happens with reduced motion?
- Can this realistically be integrated into another project?
The final collection contains:
- Hero effects
- Text reveals
- Magnetic buttons
- Cursor effects
- Image distortions
- Scroll animations
- Page transitions
- 3D interactions
- Liquid effects
- Navigation effects
Five experiments per category.
50 total.
The Architecture
The main abstraction is an InteractionStage.
Its job is to handle the things that shouldn't have to be implemented 50 times:
- Configuration
- Pointer state
- Pointer interpolation
- Reduced-motion detection
- CSS variable updates
- Animation lifecycle
- Cleanup
A simplified usage looks like this:
<InteractionStage
interaction={getInteraction("liquid-image")!}
config={{
...defaultConfig,
distortion: 68,
}}
/>
The important part is the separation.
The stage handles shared infrastructure.
The interaction handles the actual visual behavior.
That means a new experiment doesn't need to reinvent pointer handling, lifecycle management, or accessibility detection.
Why I Didn't Store Pointer Position in React State
One thing I wanted to avoid was turning every pointermove event into a React render.
For high-frequency interactions, that gets expensive quickly.
Instead, pointer input updates an internal target position.
A requestAnimationFrame loop interpolates toward that target and exposes the result through CSS variables.
Conceptually:
pointer event
↓
target position
↓
requestAnimationFrame
↓
interpolated position
↓
CSS variables
↓
visual interaction
React handles the component structure.
The browser handles the high-frequency visual updates.
That separation made the interactions much easier to reason about.
The Explorer Doesn't Run 50 Animations
This was another important decision.
Imagine opening a page containing 50 interactive demos and actually mounting all 50 animation systems.
It would work.
It would also be a terrible idea.
Instead, the explorer initially renders lightweight previews.
The real interaction is mounted only when the card becomes relevant:
- Hover
- Focus
- Tap
So the index page can display a large collection without running 50 independent animation systems in the background.
The same principle applies to Canvas-based interactions.
If there is nothing left to animate, the loop stops.
When the component unmounts:
- Animation frames are cancelled
- Event listeners are removed
- Observers are disconnected
- Canvas resources are cleaned up
The browser shouldn't keep working on an interaction nobody is looking at.
Choosing the Technology After the Interaction
I also wanted to avoid the classic creative-coding trap:
"I need an animation, therefore I need a big animation library."
Not necessarily.
Most interactions use browser primitives:
CSS
Transforms, transitions, perspective, clipping and compositing.
SVG
Useful for certain displacement and distortion effects.
Canvas
Used when the interaction actually needs individual particles, trails or ripple simulations.
GSAP
Used where a controlled timeline or complex choreography genuinely makes the implementation easier.
CSS 3D
Several interactions create a convincing sense of depth without requiring WebGL or Three.js.
That last one was intentional.
If CSS perspective communicates the idea, introducing a WebGL runtime is unnecessary complexity.
Reduced Motion Wasn't an Afterthought
This turned out to be one of the more interesting design constraints.
An interaction shouldn't depend on its animation to communicate the content.
If the movement disappears, the interface still needs to work.
So reduced motion isn't simply:
animation: none;
The component needs a meaningful static state.
For example:
Animated:
WORD → letters separate → letters rearrange → WORD
Reduced motion:
WORD
The information survives.
Only the unnecessary movement disappears.
That approach also made the components easier to integrate into real products.
Hover Isn't a Mobile Interaction
Cursor experiments created another problem.
Desktop gives us a pointer.
Touch doesn't.
A hover interaction therefore can't simply be copied to mobile and called responsive.
Depending on the interaction, I used:
- Tap states
- Press states
- Static compositions
- Simplified transitions
- Reduced interaction intensity
Sometimes the correct mobile implementation is not a smaller version of the desktop effect.
It's a different interaction.
The Difficult Part Was Timing
Making something move is easy.
Making it feel like it has weight is harder.
This became especially obvious with magnetic interactions.
Move an element toward the cursor.
Easy.
Make it return naturally.
Much harder.
Return too quickly:
It feels mechanical.
Return too slowly:
It feels broken.
Add too much interpolation:
It feels disconnected from the user's input.
Remove the interpolation:
It feels like a DOM element being dragged around.
The interesting part of these interactions often lives in the return phase.
The user doesn't consciously think about the easing curve.
But they definitely notice when something feels wrong.
Reusable Doesn't Mean 20 Configuration Options
Another problem I ran into was configuration.
It's tempting to make everything configurable.
Strength.
Speed.
Radius.
Delay.
Easing.
Scale.
Resistance.
Decay.
Noise.
Randomness.
Before long, you have 20 sliders and no idea what half of them actually do.
I tried to expose parameters that map directly to the behavior.
For example:
strength → how strongly the element reacts
radius → how far the interaction reaches
distortion → how much the surface changes
return → how quickly it settles
A configuration option should answer a question.
If I can't explain what a parameter changes, it probably shouldn't be exposed.
Performance Is Part of the Design
"60 FPS" is a useful target.
But it doesn't mean much if the demo is running on an empty page.
Real websites have:
- Images
- Fonts
- Layout work
- Other JavaScript
- Analytics
- Third-party scripts
- Multiple components
So I tried to keep each interaction reasonably isolated.
Some of the decisions were simple:
Don't render through React unnecessarily.
Stop Canvas loops when there's nothing to draw.
Prefer transforms and opacity when appropriate.
Avoid expensive rendering when a browser primitive can do the job.
Clean everything up on unmount.
The goal isn't to make every interaction magically "60 FPS."
The goal is to avoid wasting work.
What I Learned Building 50 of Them
After building enough of these, a few patterns became obvious.
1. Start with the input
Before thinking about the animation, decide what the user is doing.
Hover?
Move?
Click?
Scroll?
Drag?
Touch?
The input determines the interaction.
2. Motion needs a rule
If you can't describe why something moves, it's probably decoration.
3. The return matters
A believable interaction isn't just about the initial response.
How the element settles back into its normal state often determines whether it feels physical.
4. The simplest technology usually wins
CSS can do a lot.
SVG can do a lot.
Canvas can do a lot.
You don't automatically need WebGL because something looks "3D."
5. Accessibility changes the design
Reduced motion and touch aren't annoying edge cases to solve at the end.
They force you to understand what the interaction is actually communicating.
From 50 Experiments to a Reusable System
The biggest change happened when I stopped asking:
"How do I build this effect?"
and started asking:
"How do I make this effect easy to reuse?"
That changed the architecture.
The shared layer handles:
- Input
- Configuration
- Lifecycle
- Accessibility
- Cleanup
The individual experiments handle:
- Visual behavior
- Interaction rules
- Material
- Personality
That separation makes experimentation much cheaper.
And that's really what MOTIONFORGE became.
Not a collection of animations.
A system for experimenting with interaction.
Try It
All 50 experiments are available in the live explorer.
You can interact with them, browse the categories, and see how the ideas behave before deciding whether something is useful for your own project.
If you want the complete collection and source:
Final Thought
The hardest part wasn't making 50 things move.
It was making 50 different interactions share infrastructure without making them feel like the same interaction repeated 50 times.
The solution was to keep the boring parts consistent:
Input.
Lifecycle.
Configuration.
Accessibility.
Cleanup.
Then leave the creative layer free to experiment.
That's probably the biggest lesson I took from the project:
Don't build a library of effects. Build a system that makes experimentation cheap.
If you're building interaction-heavy interfaces, I'd be interested to know:
What's the hardest part for you to make reusable — input handling, animation, accessibility, performance, or integration?
Top comments (0)