Recreating Palmer’s Draggable Product Grid with GSAP

0
4


One of the best ways to learn is by recreating an interaction you’ve seen out in the wild and building it from scratch. It pushes you to notice the small details, understand the logic behind the animation, and strengthen your problem-solving skills along the way.

So today we’ll dive into rebuilding the smooth, draggable product grid from the Palmer website, originally crafted by Uncommon with Kevin Masselink, Alexis Sejourné, and Dylan Brouwer. The goal is to understand how this kind of interaction works under the hood and code the basics from scratch.

Along the way, you’ll learn how to structure a flexible grid, implement draggable navigation, and add smooth scroll-based movement. We’ll also explore how to animate products as they enter or leave the viewport, and finish with a polished product detail transition using Flip and SplitText for dynamic text reveals.

Let’s get started!

Grid Setup

The Markup

Let’s not try to be original and, as always, start with the basics. Before we get into the animations, we need a clear structure to work with — something simple, predictable, and easy to build upon.

<div class="container">
  <div class="grid">
    <div class="column">
      <div class="product">
        <div><img src="./public/img-3.png" /></div>
      </div>
      <div class="product">
        <div><img src="./public/img-7.png" /></div>
      </div>
      <!-- repeat -->
    </div>
    <!-- repeat -->
  </div>
</div>

What we have here is a .container that fills the viewport, inside of which sits a .grid divided into vertical columns. Each column stacks multiple .product elements, and every product wraps around an image. It’s a minimal setup, but it lays the foundation for the draggable, animated experience we’re about to create.

The Style

Now that we’ve got the structure, let’s add some styling to make the grid usable. We’ll keep things straightforward and use Flexbox instead of CSS Grid, since Flexbox makes it easier to handle vertical offsets for alternating columns. This approach keeps the layout flexible and ready for animation.

.container {
  position: fixed;
  width: 100vw;
  height: 100vh;
  top: 0;
  left: 0;
}

.grid {
  position: absolute;
  display: flex;
  gap: 5vw;
  cursor: grab;
}

.column {
  display: flex;
  flex-direction: column;
  gap: 5vw;
}

.column:nth-child(even) {
  margin-top: 10vw;  
}

.product {
  position: relative;
  width: 18.5vw;
  aspect-ratio: 1 / 1;

  div {
    width: 18.5vw;
    aspect-ratio: 1 / 1;
  }

  img {
    position: absolute;
    width: 100%;
    height: 100%;
    object-fit: contain;
  }
}

Animation

Okay, setup’s out of the way — now let’s jump into the fun part.

When developing interactive experiences, it helps to break things down into smaller parts. That way, each piece can be handled step by step without feeling overwhelming.

Here’s the structure I followed for this project:

1 – Introduction / Preloader
2 – Grid Navigation
3 – Product’s detail view transition

Introduction / Preloader

First, the grid isn’t centered by default, so we’ll fix that with a small utility function. This makes sure the grid always sits neatly in the middle of the screen, no matter the viewport size.

centerGrid() {
  const gridWidth = this.grid.offsetWidth
  const gridHeight = this.grid.offsetHeight
  const windowWidth = window.innerWidth
  const windowHeight = window.innerHeight

  const centerX = (windowWidth - gridWidth) / 2
  const centerY = (windowHeight - gridHeight) / 2

  gsap.set(this.grid, {
    x: centerX,
    y: centerY
  })
}

In the original Palmer reference, the experience starts with products appearing one by one in a slightly random order. After that reveal, the whole grid smoothly zooms into place.

To keep things simple, we’ll start with both the container and the products scaled down to 0.5 and the products fully transparent. Then we animate them back to full size and opacity, adding a random stagger so the images pop in at slightly different times.

The result is a dynamic but lightweight introduction that sets the tone for the rest of the interaction.

intro() {
  this.centerGrid()

  const timeline = gsap.timeline()

  timeline.set(this.dom, { scale: .5 })
  timeline.set(this.products, {
    scale: 0.5,
    opacity: 0,
  })

  timeline.to(this.products, {
    scale: 1,
    opacity: 1,
    duration: 0.6,
    ease: "power3.out",
    stagger: { amount: 1.2, from: "random" }
  })
  timeline.to(this.dom, {
    scale: 1,
    duration: 1.2,
    ease: "power3.inOut"
  })
}

Grid Navigation

The grid looks good. Next, we need a way to navigate it: GSAP’s Draggable plugin is just what we need.

setupDraggable() {
  this.draggable = Draggable.create(this.grid, {
    type: "x,y",
    bounds: {
      minX: -(this.grid.offsetWidth - window.innerWidth) - 200,
      maxX: 200,
      minY: -(this.grid.offsetHeight - window.innerHeight) - 100,
      maxY: 100
    },
    inertia: true,
    allowEventDefault: true,
    edgeResistance: 0.9,
  })[0]
}

It would be great if we could add scrolling too.

window.addEventListener("wheel", (e) => {
  e.preventDefault()

  const deltaX = -e.deltaX * 7
  const deltaY = -e.deltaY * 7

  const currentX = gsap.getProperty(this.grid, "x")
  const currentY = gsap.getProperty(this.grid, "y")

  const newX = currentX + deltaX
  const newY = currentY + deltaY

  const bounds = this.draggable.vars.bounds
  const clampedX = Math.max(bounds.minX, Math.min(bounds.maxX, newX))
  const clampedY = Math.max(bounds.minY, Math.min(bounds.maxY, newY))

  gsap.to(this.grid, {
    x: clampedX,
    y: clampedY,
    duration: 0.3,
    ease: "power3.out"
  })
}, { passive: false })

We can also make the products appear as we move around the grid.

const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.target === this.currentProduct) return
    if (entry.isIntersecting) {
      gsap.to(entry.target, {
        scale: 1,
        opacity: 1,
        duration: 0.5,
        ease: "power2.out"
      })
    } else {
      gsap.to(entry.target, {
        opacity: 0,
        scale: 0.5,
        duration: 0.5,
        ease: "power2.in"
      })
    }
  })
}, { root: null, threshold: 0.1 })

Product’s detail view transition

When you click on a product, an overlay opens and displays the product’s details.
During this transition, the product’s image animates smoothly from its position in the grid to its position inside the overlay.

We build a simple overlay with minimal structure and styling and add an empty <div> that will contain the product image.

<div class="details">
  <div class="details__title">
    <p>The title</p>
  </div>
  <div class="details__body">
    <div class="details__thumb"></div>
    <div class="details__texts">
      <p>Lorem ipsum dolor, sit amet consectetur adipisicing elit...</p>
    </div>
  </div>
</div>
.details {
  position: absolute;
  top: 0;
  left: 0;
  width: 50vw;
  height: 100vh;
  padding: 4vw 2vw;
  background-color: #FFF;

  transform: translate3d(50vw, 0, 0);
}

.details__thumb {
  position: relative;
  width: 25vw;
  aspect-ratio: 1 / 1;
  z-index: 3;
  will-change: transform;
}

/* etc */

To achieve this effect, we use GSAP’s Flip plugin. This plugin makes it easy to animate elements between two states by calculating the differences in position, size, scale, and other properties, then animating them seamlessly.

We capture the state of the product image, move it into the details thumbnail container, and then animate the transition from the captured state to its new position and size.

showDetails(product) {
  gsap.to(this.dom, {
    x: "50vw",
    duration: 1.2,
    ease: "power3.inOut",
  })

  gsap.to(this.details, {
    x: 0,
    duration: 1.2,
    ease: "power3.inOut",
  })

  this.flipProduct(product)
}

flipProduct(product) {
  this.currentProduct = product
  this.originalParent = product.parentNode

  if (this.observer) {
    this.observer.unobserve(product)
  }

  const state = Flip.getState(product)
  this.detailsThumb.appendChild(product)

  Flip.from(state, {
    absolute: true,
    duration: 1.2,
    ease: "power3.inOut",
  });
}

We can add different text-reveal animations when a product’s details are shown, using the SplitText plugin.

const splitTitles = new SplitText(this.titles, {
  type: "lines, chars",
  mask: "lines",
  charsClass: "char"
})

const splitTexts = new SplitText(this.texts, {
  type: "lines",
  mask: "lines",
  linesClass: "line"
})

gsap.to(splitTitles.chars, {
  y: 0,
  duration: 1.1,
  delay: 0.4,
  ease: "power3.inOut",
  stagger: 0.025
});

gsap.to(splitTexts.lines, {
  y: 0,
  duration: 1.1,
  delay: 0.4,
  ease: "power3.inOut",
  stagger: 0.05
});

Final thoughts

I hope you enjoyed following along and picked up some useful techniques. Of course, there’s always room for further refinement—like experimenting with different easing functions or timing—but the core ideas are all here.

With this approach, you now have a handy toolkit for building smooth, draggable product grids or even simple image galleries. It’s something you can adapt and reuse in your own projects, and a good reminder of how much can be achieved with GSAP and its plugins when used thoughtfully.

A huge thanks to Codrops and to Manoela for giving me the opportunity to share this first article here 🙏 I’m really looking forward to hearing your feedback and thoughts!

See you around 👋

Source link