Two portfolios, one process: where design, movement and code come together | Codrops

Two portfolios, one process: where design, movement and code come together | Codrops

12 minutes, 39 seconds Read

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


Free GSAP 3 Express Course

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

Check it out

Where design and code meet

Were Max Melkin And Olha Lazarievaa designer-developer duo whose collaboration began with curiosity, a shared desire to explore how far design and code can go if you treat them not as tools but as creative voices.

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

From our very first project, we have explored how typography, rhythm and movement can shape the feel of a digital space. Each 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, an idea that carries emotion and reflects individuality, whether it is a personal portfolio or a corporate identity.

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

In the following sections, we’ll look at how each idea took shape, from the initial concept to the movement, 3D elements and technical decisions that brought it all to life.

Design (appearance)

The design concept started with a feeling of lightness and playfulness, qualities that reflect my personality. Expanding on that idea created a black and white visual language, almost like a chessboard, with the animations creating a sense of movement and gentle interaction with the user. I didn’t even have to explain this concept to Max; he immediately felt the atmosphere behind it.

Because 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 started looking for a way to translate that same rhythm and lightness through code. My goal was not just to recreate the design, but to bring it to life through movement – ​​so that the interaction continues Olha’s idea.

Charger

The charger is the first thing the user sees, so we wanted it to be simple but expressive: minimal, calm and somewhat ‘alive’. The implementation: two transparent spheres with text texture, soft idle gestures, and a small GSAP sequence that controls how they appear and disappear.

Mouse-reactive camera path

To make the charger feel more lively, the camera responds smoothly to the user’s mouse movements. Instead of clicking directly, the camera interpolates in the direction of the target rotation, creating soft, cinematic parallax 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

GAP orchestrates the entire loader sequence: the orbs rise from below, sit idle for a while, and once loading is complete, they descend as the hero section disappears.

This creates a fluid 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 was modeled in Blender, with lighting, AO, and reflections baked right into the textures to keep the scene light and loading quickly.

When the user reaches this part of the page, GSAPScrollTrigger 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 { void } = 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 prevent the browser UI from resizing the scroller: window.innerWidth < 991 ? '.scroll-container': null }, }); return () => { tween.scrollTrigger?.kill(); tween.kill(); }; }, [sectionRef, invalidate]); yield (
    
      {children}[0.029," rotation="{[Math.PI" scale="{[0.1," length="{2}" radius="{0.45}" texture=""/img/beam_linear_1024x2048.png"" additive=""/>
    
  );
}

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; });

GSAP sequence and respiratory motion of the rings

Once the rings are in place, a GSAP timeline takes over the order. It fades the rings, shows a little timed indicator, and then fades everything out when the charger is done. At the same time, the PixiJS ticker animates a subtle “breathing” movement: 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. As 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.

Assembly paragraph: turning scattered letters into a single thought

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

To keep the typography pixel-perfect, the flying letters and the final paragraph use two separate layers:
the flying characters fade away, while the paragraph characters appear at the exact moment each path is completed.

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 last paragraph layer – characters placed in their exact position, but completely transparent;
  • the flying layer – letters displayed in the side columns using the renderColumn helper.

This separation makes the animation uncluttered and avoids visual jumps – the animated letters don’t have to land perfectly on the typographic grid, because the final characters come into view at the right time.

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:

  • his starting position
  • a curved control point
  • the final position within the paragraph

GAP onUpdate ensures a smooth transition of visibility:

  • flying letter: fades
  • paragraph letter: disappears

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

Everything starts with curiosity

We got here not through formal education, but through curiosity, experimentation, 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 trip taught us the most important things: love for the process is more important than any rules. If you are truly passionate about what you create, the right people will always appear around you.

This shared curiosity fueled our collaboration, a partnership that has become successful and led us to projects with well-known studios and companies.

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

#portfolios #process #design #movement #code #Codrops

Similar Posts

Leave a Reply

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