Building a Flippy Grid With React and GreenSock
A simple idea from a television effect turned into a fun coding challenge: a grid of cards that flips to reveal different images, rippling outward from the point of interaction. The result is a React and GreenSock demo that demonstrates a few interesting techniques for CSS 3D transforms and animation distribution.
The effect works best in Chromium-based browsers, and the performance of a large grid is worth considering. The underlying principle is straightforward once broken down: a grid of flippable cards, each showing a slice of an image, with a click triggering a wave-like flip.
Setting Up the Grid
A 10 by 10 grid gives you 100 cards—a perfect use case for React to handle the rendering repetition. Each grid cell holds a card element with a front and back face.
flippy-card
The grid itself uses display: grid and a custom property for the size, defaulting to 10. While a grid-gap can be helpful during development to visualize the individual cells, the final demo doesn't use one.
The interesting part is displaying the correct portion of an image on each card face. Using inline custom properties for the card's x and y position in the grid, the background image is positioned with background-size set to the grid size multiplied by 100%, and background-position uses the negative x and y values multiplied by 100%. Each card shows a unique tile of the full image.
The back of each card is positioned using a combination of rotations via transform. Two image URLs are stored in custom properties (--current-image and --next-image), which are then set as the background-image for each card's front and back.
Flipping the Cards
To flip the cards, we introduce a --count custom property. Setting this property on the container updates a transform rule for all cards, rotating them on the x-axis. Key CSS properties here include transform-style: preserve-3d so the back faces are visible, and perspective to give the grid a genuine 3D feel.
A basic interaction is simply a click handler that increments the --count value. When --count is 1, the cards flip once to reveal the next image.
Moving to React
The React app is composed of two main parts: an App component and a FlippySnap component. The App component is responsible for fetching images—in this case from Unsplash—and passing them down to FlippySnap as props. While waiting for new images to load, it renders a "Loading..." message and disables the FlippySnap component to prevent redundant clicks. It controls the order of the snaps that FlippySnap displays based on the flip count.
FlippySnap is a relatively simple component. It renders the grid of cards, setting their inline custom properties. The container's click handler increments the count and calls an onFlip callback, unless the component is currently disabled. The cycle of disabling and fetching a new snap is what triggers the flip on re-render.
flippySnap.setSnaps = (currentSnap, nextSnap)
Note that App dictates the current and next snaps, passing them to the component. Setting the snaps and letting the component figure out the order would be an alternate approach.
Animating With GreenSock
The core CSS and React logic produce a functional flip. To make the transition more visually compelling, GreenSock’s distribute utility generates a ripple effect based on where the user clicks.
Instead of animating rotateX directly, it's cleaner to animate the --count custom property. This decouples the JavaScript animation from the styling. If we want to change the effect from rotateX to rotateY, we can modify only the CSS rules.
The updated flip function uses a new containerRef to target only the cards of the relevant FlippySnap instance. We read the clicked card’s -x and -y data attributes to determine the origin of the flip. Then, using gsap.to, we animate the --count property for all .flippy-card elements under the container.
flipSnap = (e) ->
count.current++
target = e.target.closest('[data-x]')
delay = gsap.utils.distribute({
base: 0,
amount: gridSize / 20,
from: [target.dataset.x / gridSize, target.dataset.y / gridSize],
grid: [gridSize, gridSize],
ease: 'power1.in'
})
animating.current = true
gsap.to(containerRef.current.querySelectorAll('.flippy-card'), {
'--count': count.current,
duration: 0.2,
delay,
onComplete: () => {
animating.current = false
onFlip(count.current)
}
})
This approach sets a base delay of 0 for the clicked card and uses amount: gridSize / 20 to determine the maximum delay. The distribution values—from, grid, and the distribution ease—are all given to the distribute function. The x and y coordinates from the click are divided by the grid size to get ratios within a 0 to 1 range. The animation uses a duration of 0.2 seconds and an onComplete callback.
One issue is that the grid can be "spam-clicked" while the animation is still running. The solution is an internal ref, animating, which is set to true at the start of the animation. A conditional check in the click handler prevents any subsequent flips from starting until onComplete has set it back to false.
Polish: Hover Effect and Loading Indicator
Two more details make the demo feel more interactive.
Interactive Hover
A hover effect raises the nearest cards toward the user in a similar ripple. This uses the gsap.utils.distribute utility again, this time animating a --hovered custom property on each card. The property's value goes from 0 to 1, and the CSS translates the card along the z-axis up to 5vmin based on that variable.
The onPointerOver prop handles a pointer entering the grid, setting the animation for cards around the pointer's location. The onPointerLeave prop resets all --hovered values back to 0.
Loading State
A loading spinner gives visual feedback while the next image is being fetched. A rotating circle is rendered conditionally when the FlippySnap component is in a disabled state, which occurs during an active fetch and animation.
This demo was taken further by the original author with additional parallax effects, audio, and configurable grid size. An obvious next challenge would be recreating it with Three.js for a potential performance boost on larger grids.



