DEV Community

Cover image for How I Built 50 Web Interactions Without Turning My React App Into a Performance Nightmare
Mohamed Sadok
Mohamed Sadok

Posted on

How I Built 50 Web Interactions Without Turning My React App Into a Performance Nightmare

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:

MOTIONFORGE — Live Demo

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,
  }}
/>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

The component needs a meaningful static state.

For example:

Animated:

WORD → letters separate → letters rearrange → WORD
Enter fullscreen mode Exit fullscreen mode

Reduced motion:

WORD
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Explore MOTIONFORGE

If you want the complete collection and source:

MOTIONFORGE on Gumroad


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)