Joffrey Spitzer Portfolio: a minimalist Astro + GSAP construction with reveals, flip transitions and subtle movements | Codrops

Joffrey Spitzer Portfolio: a minimalist Astro + GSAP construction with reveals, flip transitions and subtle movements | Codrops

I’ve been working in the web industry for seven years, but never took the time to build a really solid portfolio.

Well… that’s not quite right. I actually spent a lot of time starting portfolio drafts but never finished one. I kept repeating, chasing a result that felt “good enough,” and each attempt eventually turned into another restart.

Gradually I realized something: you can be your own most difficult customer.

To make it work this time, I decided to treat my portfolio like a real project, with a real constraint: a deadline. My goal is to launch in January 2026, which gives me three months to design and develop a finished version and, most importantly, ship it.

Design and inspiration

In this article I’ll mainly focus on the technical side of things and how these interactions are built, rather than the design process itself, but I’ll still share a few observations about the design choices along the way.

I designed my portfolio to feel understated and precise, combining minimalist clarity with brutalist clean, calm layouts with moments that feel raw and direct. I’m obsessed with finesse: smooth interactions, subtle transitions, and that extra attention to micro-detail that makes everything feel intentional without being loud.

The work of Brian Powell, Thomas Monavon, Greg LalleAnd Gil Huybrecht has had a major influence here.

Tech stack

I decided to use Astro: a framework that has become quite popular lately, and that I really enjoy because it makes it SSG simple, while still being able to keep things simple vanilla JavaScript (not React or other JS frameworks).

Of course I went along for animations and interactivity GAPan industry standard for web animation. I used Soft for smooth scrolling (it fits very well with GSAP), Three.js for WebGL, and Wave to handle page transitions. I used for styling and layout Wind at your back.

I won’t go too deep into the backend, but I have used Prismic as a CMS and hosted everything on it Netlify: a solid alternative to Vercel, which I’m choosing to move away from (for reasons you can probably guess).

Animations and interactions

High-quality, subtle animations that let me show off what I can do while keeping everything minimal and concise. The goal is to make everything go as smoothly as possible.

I have used GAP and it’s still my favorite tool for creative front-end work. With his plugin ecosystemwe can build all kinds of animations and interactions, while keeping things performant and smooth. Therefore, this article focuses on GSAP, and I will not discuss the 3D “rock” animation created with Three.js. That part could easily be a tutorial in itself.

These are the animation types we’re looking at:

  • Reveal animations
  • Page transitions
  • The vertical slider
  • The slider to grid switch
  • The front loader

Reveal animations

This part is crucial because it defines the rhythm of the site and how smooth everything feels.

For text, I’ve divided things into two categories: paragraphs (usually multiple lines) and titles (just one or a few words). Paragraphs are revealed line by line, while titles animate character by character.

To do that, we will use GSAPs Split text plugin and then animate each line with a simple mask, subtle spread, and consistent ease. This is probably one of the most important parts of motion design if you want everything to feel smooth.

When animating lines I use autoSplit: true so that the text can be re-split responsively as the layout changes. In that case the animation must be within the onSplit() called back and returned so GSAP can properly repair and rebuild it when resizing.

// Titles
this.split = new SplitText(this.element, {
  type: "words, chars",
  autoSplit: true,
  mask: "chars",
  charsClass: "char",
  onSplit: (self) => {
    return gsap.from(self.chars, {
      duration: 1,
      yPercent: -120,
      scale: 1.2,
      stagger: 0.01,
      ease: "expo.out"
    });
  }
});

// Paragraphs
this.split = new SplitText(this.element, {
  type: "lines, words",
  autoSplit: true,
  mask: "lines",
  linesClass: "line",
  onSplit: (self) => {
    return gsap.from(self.lines, {
      duration: 0.9,
      yPercent: 105,
      stagger: 0.04,
      ease: "expo.out"
    });
  }
});

As for images and videos, I opted for a subtle fade-up reveal, with a small spread to create a soft delay between the images in the galleries.

// Galleries
gsap.fromTo(
  this.images,
  { yPercent: 100, autoAlpha: 0 },
  {
    yPercent: 0,
    autoAlpha: 1,
    duration: 0.8,
    ease: "power3.out",
    stagger: 0.1
  }
)

Page transitions

Page transitions are also essential in creative development. They allow us to control the rhythm of the site and make the experience feel continuous. Instead of a hard separation between two screens, we can guide the content change with subtle gestures, making navigation smoother and avoiding the shock of a “new page”. There are several front-end libraries that help with this, and this is the project I chose Wave because it is easy to integrate and gives you enough freedom to orchestrate the animations.

When leaving the page, I keep it simple by reversing the reveal animations. This will clear the stage so the new page can animate upon arrival.

I’m a big fan of transitions that transfer an element from the outgoing page to the next. It adds a real sense of continuity to the navigation, and those kinds of details often make all the difference in how polished everything feels. That’s exactly what I wanted for the transition to the About page, where the “About” menu item becomes the page title and we obviously flip it over when we leave the page.

To achieve this, we use the Turn around plugin. It’s incredibly powerful and, in my opinion, still a bit underutilized. We first record the state of the link (position, size, typography, etc.) with Flip.getState(). We then move that same link to the title location and match its dimensions and typographic properties. Finally, Flip.from(state) animates it from its captured state to the new layout and scales it up until it becomes the page title.

// 1) Capture the link’s current state (small, in the header)
const state = Flip.getState(link)

// 2) Hide the title and fit the link into the title’s position/size
gsap.set(title, { opacity: 0 })
Flip.fit(link, title, {
  absolute: true,
  scale: false,
  props: "fontSize,lineHeight,letterSpacing"
})

// 3) Animate from the captured state to the current position (link “grows” into the title)
Flip.from(state, {
  absolute: false,
  simple: true,
  duration: 0.9,
  ease: "expo.inOut",
  onComplete: () => {
    gsap.set(title, { opacity: 1 });
    gsap.set(link, { opacity: 0 })
  }
})

I use the same approach to animate the transition from the work list to the detail page of each work.

The vertical slider

For the case list, the idea was to go for a very brutalist style interaction: a vertical slider that allows you to scroll through the works, while the active slider scales up to take the spotlight. I won’t go into every detail here because even though it looks quite simple, the animation is actually quite complex and ended up being quite a piece of code. Much of that complexity comes from performance choices: rather than animation width And heightI scale the images (which is much smoother), but that also means you have to carefully recalculate the positioning as you scroll.

To build this kind of scroll-driven interaction, I use ScrollTrigger a drive GAP timeline.

const timeline = gsap.timeline({
  scrollTrigger: {
    trigger: this.dom,
    start: "top-=6.5% center",
    end: "bottom center-=0.5%",
    scrub: true
  }
})

timeline.to(image, {
  scaleX: scaleExpanded,
  scaleY: scaleExpanded,
  force3D: true,
  duration: 0.8,
  ease: "none",
}, index)

timeline.to(image, {
  scaleX: 1,
  scaleY: 1,
  force3D: true,
  duration: 1.5,
  ease: "none",
  delay: 0.3,
}, ">")

For the work detail pages, I use the same approach to animate the thumbnail navigation, while also displaying the active image at a larger scale on the left: the largest image is always the currently active image.

From slider to grid

I really wanted to add something extra that doesn’t necessarily deliver much in terms of content (maybe nothing at all), but clearly smooths out the movement and is something you often see in portfolios: a layout switch, which moves the works from a vertical slider to a grid.

You probably guessed it from the transition we built earlier: Turn around is the perfect tool for this. I simply enable a CSS class on the wrapper, which completely redesigns the layout.

.--grid-mode {
  .projects__wrapper {
    @apply col-span-full grid grid-cols-6 gap-12;
  }

  .projects__item {    
    &:first-child {
      @apply col-span-2;
    }

    &:nth-child(4) {
      @apply col-start-4;
    }

    &:nth-child(6) {
      @apply col-start-1;
    }

    &:nth-child(8) {
      @apply col-start-5;
    }
  }
}

The idea is to capture the state of my images before switching the CSS class. Once the class is applied, I can just use it Flip.from(state) to animate each element from its captured state to its new size and position.

// 1) Capture current state (list layout)
const state = Flip.getState(targets)

// 2) Toggle layout: CSS does the rest (grid vs list)
projectsDOM.classList.add("--grid-mode")

// 3) Animate from captured state to new layout
Flip.from(state, {
  absolute: true,
  duration: 1,
  ease: "expo.inOut",
  nested: true,
  scale: true,
  simple: true
})

The front loader

Besides the purely creative side, the preloader is mainly here to preload media and important items so that the site feels faster and more reliable. That’s especially important in my case because the homepage opens directly on a video that weighs several megabytes, so loading must be anticipated.

A simple counter on top of a background that eventually changes into the video.

The counter animation itself is simple. It’s just a number that goes from 0 to 100 in fourteen steps and with GSAP you can get that “fat” progression by making a steps(14) ease.

gsap.to(progressVal, {
  duration: 3,
  ease: 'steps(14)',
  value: 100,
})

Then I use Turn around (again) to move the background image (which is the first frame of the video) to the exact size and position of the showreel video on the page.

const showreelState = Flip.getState(showreel)

showreel.classList.remove("--preloading-showreel")

Flip.from(showreelState, {
  absolute: true,
  duration: 1,
  ease: "expo.inOut",
  scale: true,
  simple: true
})

At the same time, I also animate the frame with the counter using clip-path.

gsap.fromTo(
  background,
  { clipPath: "inset(2.5rem 2.5rem 2.5rem 2.5rem)" },
  {
    clipPath: "inset(100% 0rem 0rem 0rem)",
    duration: 1,
    ease: "expo.inOut"
  }
)

Conclusion

I hope this article was clear and that you picked up a few useful insights along the way.

Ultimately, I am very proud of how it turned out and I am confident in the technical choices I made. Astro has been a real revelation for me, and GSAP is still a library you can always rely on: it’s incredibly versatile, yet remains powerful and easy to work with. And in practice, even with a good amount of GSAP animation and a few Three.js on top, the site still scores well on PageSpeed ​​Insights.

Thanks for reading, and see you soon 👋


#Joffrey #Spitzer #Portfolio #minimalist #Astro #GSAP #construction #reveals #flip #transitions #subtle #movements #Codrops

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *