Home Blog Two Portfolios, One Process: Where Design, Motion, and Code Come Together | Codrops

Two Portfolios, One Process: Where Design, Motion, and Code Come Together | Codrops

0
Two Portfolios, One Process: Where Design, Motion, and Code Come Together | Codrops

Every strong collaboration begins the moment a designer and a developer start speaking the same language.


Free GSAP 3 Express Course


Learn modern web animation using GSAP 3 with 34 hands-on video lessons and practical projects — perfect for all skill levels.


Check it out

Where Design and Code Meet

We’re Max Milkin and Olha Lazarieva, a designer–developer duo whose collaboration began with curiosity, a shared desire to explore how far design and code can go when you treat them not as tools but as creative voices.

It was never just about building websites. It was about creating emotion—something alive, something that moves and breathes.

From our very first project, we’ve been exploring how typography, rhythm, and motion can shape the feeling of a digital space. Every project becomes a living composition, born from professionalism, experience, intuition, and trust, where small ideas grow into something we both feel.

Each project begins with a search for a unique idea, one that carries emotion and reflects individuality, whether it’s a personal portfolio or a corporate identity.

Carrying these ideas forward, we turned our attention to our own portfolios. Each one became a reflection of the person behind it, shaped through the same shared process but expressed in completely different ways.

In the next sections, we walk through how each idea took shape, from the initial concept to the motion, 3D elements, and technical decisions that brought everything to life.

Design (Olha)

The design concept began with a sense of lightness and playfulness, qualities that reflect my personality. Digging deeper into that idea led to a black-and-white visual language, almost like a chessboard, where the animations create a sense of movement and a gentle interaction with the user. I didn’t even have to explain this concept to Max; he instantly felt the vibe behind it.

Since minimal, spacious interfaces have always been part of my vision, the generous amount of negative space on the site makes it feel like the user is entering my design world.

Development (Max)

Once the concept became clear, I began looking for a way to translate that same rhythm and lightness through code. My goal wasn’t just to recreate the design, but to bring it to life through motion – so that interaction continues Olha’s idea.

Loader

The loader is the first thing the user sees, so we wanted it to be simple but expressive: minimal, calm, and slightly “alive”. The implementation: two transparent spheres with a text texture, gentle idle motion, and a small GSAP sequence that controls how they appear and disappear.

Mouse-Reactive Camera Orbit

To make the loader feel more alive, the camera smoothly reacts to the user’s mouse movement. Instead of snapping directly, the camera interpolates toward the target rotation, creating soft, cinematic parallax even before the main content appears.

function CameraOrbit() {
  const { camera } = useThree();
  const mouse = useRef({ x: 0, y: 0 });
  const target = useRef({ x: 0, y: 0 });

  useEffect(() => {
    const handleMove = (e) => {
      mouse.current.x = (e.clientX / window.innerWidth) * 2 - 1;
      mouse.current.y = (e.clientY / window.innerHeight) * 2 - 1;
    };
    window.addEventListener('mousemove', handleMove);
    return () => window.removeEventListener('mousemove', handleMove);
  }, []);

  useFrame(() => {
    target.current.x += (mouse.current.y * 0.6 - target.current.x) * 0.05;
    target.current.y += (mouse.current.x * 0.6 - target.current.y) * 0.05;

    const r = 6;
    const phi = Math.PI / 2 - target.current.x;
    const theta = target.current.y + Math.PI;

    camera.position.x = r * Math.sin(phi) * Math.cos(theta);
    camera.position.y = r * Math.cos(phi);
    camera.position.z = r * Math.sin(phi) * Math.sin(theta);

    camera.lookAt(0, 0, 0);
  });

  return null;
}

GSAP Loader Sequence and Transition to Content

GSAP orchestrates the full loader sequence: the spheres rise from below, idle for a moment, and once loading is complete, transition downward while the hero section fades in.

This creates a smooth narrative between the 3D scene and the DOM content.

// Intro animation: spheres rise from below
  useEffect(() => {
    if (!mesh1.current || !mesh2.current) return;

    mesh1.current.position.y = -8;
    mesh2.current.position.y = -8;

    const tl = gsap.timeline();
    tl.to(mesh1.current.position, {
      y: 0.18,
      duration: 2.5,
      delay: 1,
      ease: 'power4.out',
    });
    tl.to(
      mesh2.current.position,
      { y: -0.18, duration: 2, ease: 'power4.out' },
      '-=2'
    );

    return () => tl.kill();
  }, []);

  // Exit animation: spheres leave, hero appears
  useEffect(() => {
    if (!exitTrigger || !mesh1.current || !mesh2.current) return;

    const tl = gsap.timeline();

    tl.to(mesh2.current.position, {
      y: -10,
      duration: 1.2,
      ease: 'power4.in',
    });

    tl.to(
      mesh1.current.position,
      { y: -10, duration: 1.2, ease: 'power4.in' },
      '-=1.1'
    );

    tl.to('.hero-title .hero-letter', {
      y: 0,
      duration: 1.7,
      ease: 'power4.inOut',
      stagger: { each: 0.03, from: 'center' },
    });

    tl.to(
      '.hero-designer, .hero-description, .hero-based',
      {
        opacity: 1,
        duration: 1,
        ease: 'power4.out',
      },
      '-=1'
    );

    tl.to(
      'main',
      {
        opacity: 1,
        duration: 1,
        ease: 'power4.out',
      },
      '-=0.5'
    );

    return () => tl.kill();
  }, [exitTrigger]);

3D Gallery

Another section I wanted to highlight is the 3D gallery. The entire room is modeled in Blender, with lighting, AO and reflections baked directly into the textures to keep the scene lightweight and fast to load.

When the user reaches this part of the page, GSAP ScrollTrigger animates a gentle rotation of the entire room, creating a smooth “entry” into the space instead of a sudden cut. This keeps the transition calm and cinematic, matching the visual tone of the portfolio.

function IntroRise({ sectionRef, children }) {
  const stageRef = useRef();
  const { invalidate } = useThree();

  useEffect(() => {
    if (!stageRef.current || !sectionRef?.current) return;

    // start slightly tilted
    stageRef.current.rotation.set(1.5, 0, 0);

    const tween = gsap.to(stageRef.current.rotation, {
      x: 0,
      ease: "none",
      onUpdate: invalidate,
      scrollTrigger: {
        trigger: sectionRef.current,
        start: "top 40%",
        end: "+=200%",
        scrub: 2,
        invalidateOnRefresh: true,
        // use a custom scroll container on mobile to avoid browser UI resizing
        scroller: window.innerWidth < 991 ? '.scroll-container' : null
      },
    });

    return () => {
      tween.scrollTrigger?.kill();
      tween.kill();
    };
  }, [sectionRef, invalidate]);

  return (
    
      {children}
      
    
  );
}

Why I Use scroller

On mobile browsers, when you scroll a normal page, the top and bottom browser bars hide and show. This constantly changes the visible screen height, which makes 3D sections jump or shift.

To avoid this, I wrap the whole content in a dedicated .scroll-container and scroll that element instead of the browser window. This keeps the browser bars fixed and prevents them from hiding. As a result, the layout stays stable, and the 3D scene doesn’t shift or resize during scrolling.

ScrollTrigger just needs to know that we’re using this wrapper, so the scroller option points to .scroll-container.

Creative Flow: How a Direction Is Born

“Minimal rhythm and calm motion – the foundation of visual storytelling.”

Every project begins with a simple conversation. No screens, no mockups, just thoughts and feelings.

We ask each other: What emotion should the user feel when they open the site? Calmness? Curiosity? Excitement?

And from that emotion, we begin searching for fitting patterns, metaphors, and ideas. We collect fragments—colors, references, words, textures—and gradually combine them.

Sometimes the concept appears instantly. Sometimes it takes hours of sketches, tests, or even silence.

The best moments happen when one of us shares an idea and the other immediately understands it.

Olha suggests a concept and I instantly imagine how it moves. I propose a transition and she immediately senses how to balance the composition.

It feels like a chain reaction: one thought triggers another, and suddenly everything starts forming a single whole. Whenever we get stuck, we call each other. Sometimes we sit in silence, sometimes laughing, but twenty minutes later a new direction is born. And every time, it feels like we found it together.

Design (Olha)

The idea for Max’s site came from an image he once sent me, saying: “I don’t know why, but I like this.” That instantly sparked a flow of ideas in my head. His reaction: “This image is amazing… let’s turn it into your website concept.”

Every person has traits and a worldview that reveal themselves in everyday things. My task was to translate Max’s personality into visual form: his calmness, depth, precision. That’s how the design was born: minimal, controlled, balanced—a reflection of who he is.

Working on this website felt like traveling through a new world that you want others to discover. My mission is to make that world beautiful, by creating work that inspires.

Development (Max)

The moment I saw the design, I fell in love with it. I wanted to preserve its minimalism not just visually, but in motion, so that every animation expressed my personality: precise, calm, intentional.

The Loader

At first, I thought about building this loader as a simple SVG animation along a circular path. But after a few experiments with multiple rings and a lot of text, it quickly became clear that animating all of that in the DOM was not ideal for performance.

Instead, I switched to PixiJS and moved the whole thing to WebGL. This way I could keep the loader smooth, even with several animated rings of text breathing and rotating at the same time.

Creating Rings in PixiJS

The first step is to create a PixiJS application and build a few rings of text.

Each word becomes a separate ring, and each letter is a PIXI.Text that we will later position along a circular arc.

// PixiJS application
const app = new PIXI.Application({
  backgroundAlpha: 0,
  antialias: true,
  resizeTo: window,
});
document.body.appendChild(app.view);

const stage = app.stage;

const WORDS = ['MAX', 'MILKIN', 'DESIGN', 'CREATIVE', 'FRONTEND', 'DEVELOPER'];
const rings = [];
const center = { x: window.innerWidth / 2, y: window.innerHeight / 2 };

// Create text rings
WORDS.forEach((word, i) => {
  const ring = new PIXI.Container();
  ring.x = center.x;
  ring.y = center.y;
  ring.alpha = 0;
  stage.addChild(ring);

  const style = new PIXI.TextStyle({
    fontFamily: 'RF Dewi',
    fontSize: 16,
    fill: '#10120f',
    fontWeight: 600,
  });

  const letters = word.split('').map((ch) => {
    const letter = new PIXI.Text(ch, style);
    letter.anchor.set(0.5);
    ring.addChild(letter);
    return letter;
  });

  rings.push({
    ring,
    letters,
    radius: 80 + i * 18,
    rotationSpeed: 0.0002 + i * 0.0003,
    timeOffset: i * 0.4,
  });
});

GSAP Sequence and Breathing Motion of the Rings

Once the rings are in place, a GSAP timeline takes over the sequence. It fades the rings in, runs a small timed indicator, and then fades everything out when the loader is done. At the same time, the PixiJS ticker animates a subtle “breathing” motion: each letter slides along a circular arc while the rings slowly rotate.

// GSAP controls the loader sequence
const tl = gsap.timeline({ defaults: { ease: 'power2.out' } });

// counterElement - a DOM element displaying the loading percentage
// onLoaderComplete - a callback that hides the loader and reveals the main layout

// 1. Fade in the rings
tl.to(rings.map(r => r.ring), {
  alpha: 1,
  duration: 1,
  stagger: 0.12,
});

// 2. Timed indicator (visual, not real loading progress)
tl.add(() => {
  const indicator = { value: 0 };

  gsap.to(indicator, {
    value: 100,
    duration: 1.4,
    ease: 'none',
    onUpdate: () => {
      counterElement.textContent = Math.round(indicator.value);
    },
    onComplete: () => {
      // 3. Fade out rings and counter, then continue to the main layout
      gsap.to([...rings.map(r => r.ring), counterElement], {
        opacity: 0,
        duration: 0.8,
        stagger: 0.1,
        onComplete: () => onLoaderComplete?.(),
      });
    },
  });
});

// PixiJS “breathing” motion
let time = 0;
app.ticker.add(() => {
  time += 0.02;

  rings.forEach((r) => {
    r.ring.rotation += r.rotationSpeed;

    const extent = Math.PI + Math.sin(time - r.timeOffset) * 0.5;
    const start = -extent / 2;

    r.letters.forEach((letter, idx) => {
      const angle = start + (idx / (r.letters.length - 1 || 1)) * extent;
      letter.x = Math.cos(angle) * r.radius;
      letter.y = Math.sin(angle) * r.radius;
      letter.rotation = angle + Math.PI / 2;
    });
  });
});

3D Elements

The 3D elements were modeled in Blender to match the minimal tone of the site. Just like in the previous project, the lighting and shadows were baked into the textures, enough to give the objects depth without adding extra weight to the scene.

Assembling Paragraph: Turning Scattered Letters into a Single Thought

The paragraph begins in fragments: groups of letters appear in two side columns while a few characters float randomly across the screen. As the user scrolls, GSAP and MotionPathPlugin guide each letter along a curved path, gradually assembling them into a clean, perfectly aligned paragraph at the center.

To keep the typography pixel-perfect, the flying letters and the final paragraph use two separate layers:
the flying characters fade out, while the paragraph characters fade in at the exact moment each path completes.

Creating the Scattered Letters

// Build two side columns and a set of random letters
const root = document.querySelector('.ap');
const colLeft = root.querySelector('.ap__col--left');
const colRight = root.querySelector('.ap__col--right');
const targetBox = root.querySelector('.ap__target');

const targetChars = [];
lines.forEach(line => {
  const lineEl = document.createElement('div');
  lineEl.className = 'ap__line';

  [...line].forEach(ch => {
    const wrap = document.createElement('span');
    const char = document.createElement('span');
    char.className = 'ap__char';
    char.textContent = ch;
    char.style.opacity = 0; // final paragraph is initially hidden

    wrap.appendChild(char);
    lineEl.appendChild(wrap);
    targetChars.push(char);
  });

  targetBox.appendChild(lineEl);
});

// Side columns: groups of flying letters
function renderColumn(column, groups) {
  groups.forEach(group => {
    const row = document.createElement('div');
    group.forEach(ch => {
      const span = document.createElement('span');
      span.className = 'ap__fly';
      span.textContent = ch.ch;
      row.appendChild(span);
    });
    column.appendChild(row);
  });
}

This snippet sets up the two layers used by the animation:

  • the final paragraph layer – characters placed in their exact positions but fully transparent;
  • the flying layer – letters that are rendered into the side columns using the renderColumn helper.

This separation makes the animation clean and avoids any visual jumps – the animated letters don’t need to perfectly land on the typographic grid, because the final characters fade in at the right moment.

From Chaos to Structure

// GSAP setup
gsap.registerPlugin(ScrollTrigger, MotionPathPlugin);

// Timeline driven by scroll
const tl = gsap.timeline({
  defaults: { ease: 'power3.out' },
  scrollTrigger: {
    trigger: '.about',
    start: '50% 100%',
    end: '50% -10%',
    scrub: 1
  }
});

// For each flying letter, generate a curved path toward its final position
flyers.forEach((f, i) => {
  const targetEl = targetChars[f.item.idx];

  const start = getPos(f.span);
  const end = getPos(targetEl);

  const cp = {
    x: (start.x + end.x) / 2 + (f.side === 'left' ? 90 : -90),
    y: Math.max(start.y, end.y) + 160
  };

  const path = [start, cp, end];

  tl.to(f.span, {
    duration: 1.25,
    motionPath: { path, curviness: 0.85 },
    onUpdate() {
      const p = this.progress();

      // reveal final paragraph letter near the end of the curve
      targetEl.style.opacity = p > 0.65 ? gsap.utils.mapRange(0.7, 1, 0, 1, p) : 0;

      // hide flying letter once it's close to landing
      f.span.style.opacity = p < 1 ? 1 - p : 0;
    }
  }, i * 0.025);
});

Each letter travels along a custom motion path generated from three points:

  • its initial position
  • a curved control point
  • its final position inside the paragraph

GSAP onUpdate smoothly transitions visibility:

  • flying letter: fades out
  • paragraph letter: fades in

This creates the illusion that each character snaps perfectly into place, even though the flying elements never need to align pixel-perfectly with the final typographic structure.

Everything Begins with Curiosity

We didn’t come to this through formal education, but through curiosity, experiments, and a constant desire to understand how to make things better. We learned through our own projects, through trials, mistakes, and moments when something finally clicked.

This journey taught us the most important thing: love for the process matters more than any rules. When you’re genuinely passionate about what you create, the right people always appear around you.

This shared curiosity is what sparked our collaboration, one that has grown successful and led us to projects with well-known studios and companies.

That’s why we sincerely encourage others to stay open to new connections, experiments, and collaborations. Because it’s in this combination—design and code, different visions, different voices—that work with real meaning is born.

#Portfolios #Process #Design #Motion #Code #Codrops

LEAVE A REPLY

Please enter your comment!
Please enter your name here