Each sturdy collaboration begins the second a designer and a developer begin talking the identical language.
The place Design and Code Meet
We’re Max Milkin and Olha Lazarieva, a designer–developer duo whose collaboration started with curiosity, a shared need to discover how far design and code can go if you deal with them not as instruments however as artistic voices.
It was by no means nearly constructing web sites. It was about creating emotion—one thing alive, one thing that strikes and breathes.
From our very first challenge, we’ve been exploring how typography, rhythm, and movement can form the sensation of a digital house. Each challenge turns into a dwelling composition, born from professionalism, expertise, instinct, and belief, the place small concepts develop into one thing we each really feel.
Every challenge begins with a seek for a novel thought, one which carries emotion and displays individuality, whether or not it’s a private portfolio or a company identification.
Carrying these concepts ahead, we turned our consideration to our personal portfolios. Every one turned a mirrored image of the individual behind it, formed by means of the identical shared course of however expressed in utterly alternative ways.
Within the subsequent sections, we stroll by means of how every thought took form, from the preliminary idea to the movement, 3D components, and technical selections that introduced every thing to life.

Design (Olha)
The design idea started with a way of lightness and playfulness, qualities that replicate my character. Digging deeper into that concept led to a black-and-white visible language, nearly like a chessboard, the place the animations create a way of motion and a delicate interplay with the consumer. I didn’t even have to clarify this idea to Max; he immediately felt the vibe behind it.
Since minimal, spacious interfaces have at all times been a part of my imaginative and prescient, the beneficiant quantity of unfavorable house on the location makes it really feel just like the consumer is getting into my design world.
Improvement (Max)
As soon as the idea turned clear, I started searching for a method to translate that very same rhythm and lightness by means of code. My objective wasn’t simply to recreate the design, however to carry it to life by means of movement – in order that interplay continues Olha’s thought.
Loader
The loader is the very first thing the consumer sees, so we wished it to be easy however expressive: minimal, calm, and barely “alive”. The implementation: two clear spheres with a textual content texture, light idle movement, and a small GSAP sequence that controls how they seem and disappear.
Mouse-Reactive Digicam Orbit
To make the loader really feel extra alive, the digital camera easily reacts to the consumer’s mouse motion. As an alternative of snapping immediately, the digital camera interpolates towards the goal rotation, creating gentle, cinematic parallax even earlier than the primary content material seems.
perform CameraOrbit() {
const { digital camera } = useThree();
const mouse = useRef({ x: 0, y: 0 });
const goal = useRef({ x: 0, y: 0 });
useEffect(() => {
const handleMove = (e) => {
mouse.present.x = (e.clientX / window.innerWidth) * 2 - 1;
mouse.present.y = (e.clientY / window.innerHeight) * 2 - 1;
};
window.addEventListener('mousemove', handleMove);
return () => window.removeEventListener('mousemove', handleMove);
}, []);
useFrame(() => {
goal.present.x += (mouse.present.y * 0.6 - goal.present.x) * 0.05;
goal.present.y += (mouse.present.x * 0.6 - goal.present.y) * 0.05;
const r = 6;
const phi = Math.PI / 2 - goal.present.x;
const theta = goal.present.y + Math.PI;
digital camera.place.x = r * Math.sin(phi) * Math.cos(theta);
digital camera.place.y = r * Math.cos(phi);
digital camera.place.z = r * Math.sin(phi) * Math.sin(theta);
digital camera.lookAt(0, 0, 0);
});
return null;
}
GSAP Loader Sequence and Transition to Content material
GSAP orchestrates the total loader sequence: the spheres rise from beneath, idle for a second, and as soon as loading is full, transition downward whereas the hero part fades in.
This creates a easy narrative between the 3D scene and the DOM content material.
// Intro animation: spheres rise from beneath
useEffect(() => {
if (!mesh1.present || !mesh2.present) return;
mesh1.present.place.y = -8;
mesh2.present.place.y = -8;
const tl = gsap.timeline();
tl.to(mesh1.present.place, {
y: 0.18,
length: 2.5,
delay: 1,
ease: 'power4.out',
});
tl.to(
mesh2.present.place,
{ y: -0.18, length: 2, ease: 'power4.out' },
'-=2'
);
return () => tl.kill();
}, []);
// Exit animation: spheres depart, hero seems
useEffect(() => {
if (!exitTrigger || !mesh1.present || !mesh2.present) return;
const tl = gsap.timeline();
tl.to(mesh2.present.place, {
y: -10,
length: 1.2,
ease: 'power4.in',
});
tl.to(
mesh1.present.place,
{ y: -10, length: 1.2, ease: 'power4.in' },
'-=1.1'
);
tl.to('.hero-title .hero-letter', {
y: 0,
length: 1.7,
ease: 'power4.inOut',
stagger: { every: 0.03, from: 'middle' },
});
tl.to(
'.hero-designer, .hero-description, .hero-based',
{
opacity: 1,
length: 1,
ease: 'power4.out',
},
'-=1'
);
tl.to(
'fundamental',
{
opacity: 1,
length: 1,
ease: 'power4.out',
},
'-=0.5'
);
return () => tl.kill();
}, [exitTrigger]);
3D Gallery
One other part I wished to focus on is the 3D gallery. The whole room is modeled in Blender, with lighting, AO and reflections baked immediately into the textures to maintain the scene light-weight and quick to load.
When the consumer reaches this a part of the web page, GSAP ScrollTrigger animates a delicate rotation of the whole room, making a easy “entry” into the house as an alternative of a sudden reduce. This retains the transition calm and cinematic, matching the visible tone of the portfolio.
perform IntroRise({ sectionRef, kids }) {
const stageRef = useRef();
const { invalidate } = useThree();
useEffect(() => {
if (!stageRef.present || !sectionRef?.present) return;
// begin barely tilted
stageRef.present.rotation.set(1.5, 0, 0);
const tween = gsap.to(stageRef.present.rotation, {
x: 0,
ease: "none",
onUpdate: invalidate,
scrollTrigger: {
set off: sectionRef.present,
begin: "prime 40%",
finish: "+=200%",
scrub: 2,
invalidateOnRefresh: true,
// use a customized scroll container on cellular to keep away from browser UI resizing
scroller: window.innerWidth < 991 ? '.scroll-container' : null
},
});
return () => {
tween.scrollTrigger?.kill();
tween.kill();
};
}, [sectionRef, invalidate]);
return (
<group ref={stageRef}>
{kids}
<BeamCone
place={[0.029, 0.177, 0.37]}
rotation={[Math.PI / 2, 0, 0]}
scale={[0.1, 0.03, 0.1]}
size={2}
radius={0.45}
texture="/img/beam_linear_1024x2048.png"
additive
/>
</group>
);
}
Why I Use scroller
On cellular browsers, if you scroll a standard web page, the highest and backside browser bars cover and present. This consistently modifications the seen display peak, which makes 3D sections bounce or shift.
To keep away from this, I wrap the entire content material in a devoted .scroll-container and scroll that aspect as an alternative of the browser window. This retains the browser bars mounted and prevents them from hiding. Because of this, the structure stays secure, and the 3D scene doesn’t shift or resize throughout scrolling.
ScrollTrigger simply must know that we’re utilizing this wrapper, so the <a href="https://gsap.com/docs/v3/Plugins/ScrollTrigger/#scroller">scroller</a> possibility factors to .scroll-container.
Artistic Circulate: How a Path Is Born
“Minimal rhythm and calm movement – the inspiration of visible storytelling.”
Each challenge begins with a easy dialog. No screens, no mockups, simply ideas and emotions.
We ask one another: What emotion ought to the consumer really feel once they open the location? Calmness? Curiosity? Pleasure?
And from that emotion, we start looking for becoming patterns, metaphors, and concepts. We accumulate fragments—colours, references, phrases, textures—and progressively mix them.
Typically the idea seems immediately. Typically it takes hours of sketches, assessments, and even silence.
The most effective moments occur when one in all us shares an thought and the opposite instantly understands it.
Olha suggests an idea and I immediately think about the way it strikes. I suggest a transition and she or he instantly senses steadiness the composition.
It seems like a sequence response: one thought triggers one other, and out of the blue every thing begins forming a single complete. Every time we get caught, we name one another. Typically we sit in silence, generally laughing, however twenty minutes later a brand new route is born. And each time, it seems like we discovered it collectively.

Design (Olha)
The concept for Max’s website got here from a picture he as soon as despatched me, saying: “I don’t know why, however I like this.” That immediately sparked a circulate of concepts in my head. His response: “This picture is wonderful… let’s flip it into your web site idea.”
Each individual has traits and a worldview that reveal themselves in on a regular basis issues. My job was to translate Max’s character into visible type: his calmness, depth, precision. That’s how the design was born: minimal, managed, balanced—a mirrored image of who he’s.
Engaged on this web site felt like touring by means of a brand new world that you really want others to find. My mission is to make that world lovely, by creating work that evokes.
Improvement (Max)
The second I noticed the design, I fell in love with it. I wished to protect its minimalism not simply visually, however in movement, so that each animation expressed my character: exact, calm, intentional.
The Loader
At first, I thought of constructing this loader as a easy SVG animation alongside a round path. However after just a few experiments with a number of rings and loads of textual content, it shortly turned clear that animating all of that within the DOM was not ultimate for efficiency.
As an alternative, I switched to PixiJS and moved the entire thing to WebGL. This fashion I may maintain the loader easy, even with a number of animated rings of textual content respiration and rotating on the identical time.
Creating Rings in PixiJS
Step one is to create a PixiJS software and construct just a few rings of textual content.
Every phrase turns into a separate ring, and every letter is a PIXI.Textual content that we’ll later place alongside a round arc.
// PixiJS software
const app = new PIXI.Utility({
backgroundAlpha: 0,
antialias: true,
resizeTo: window,
});
doc.physique.appendChild(app.view);
const stage = app.stage;
const WORDS = ['MAX', 'MILKIN', 'DESIGN', 'CREATIVE', 'FRONTEND', 'DEVELOPER'];
const rings = [];
const middle = { x: window.innerWidth / 2, y: window.innerHeight / 2 };
// Create textual content rings
WORDS.forEach((phrase, i) => {
const ring = new PIXI.Container();
ring.x = middle.x;
ring.y = middle.y;
ring.alpha = 0;
stage.addChild(ring);
const fashion = new PIXI.TextStyle({
fontFamily: 'RF Dewi',
fontSize: 16,
fill: '#10120f',
fontWeight: 600,
});
const letters = phrase.break up('').map((ch) => {
const letter = new PIXI.Textual content(ch, fashion);
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 Respiratory Movement of the Rings
As soon as the rings are in place, a GSAP timeline takes over the sequence. It fades the rings in, runs a small timed indicator, after which fades every thing out when the loader is finished. On the identical time, the PixiJS ticker animates a delicate “respiration” movement: every letter slides alongside a round arc whereas the rings slowly rotate.
// GSAP controls the loader sequence
const tl = gsap.timeline({ defaults: { ease: 'power2.out' } });
// counterElement - a DOM aspect displaying the loading proportion
// onLoaderComplete - a callback that hides the loader and divulges the primary structure
// 1. Fade within the rings
tl.to(rings.map(r => r.ring), {
alpha: 1,
length: 1,
stagger: 0.12,
});
// 2. Timed indicator (visible, not actual loading progress)
tl.add(() => {
const indicator = { worth: 0 };
gsap.to(indicator, {
worth: 100,
length: 1.4,
ease: 'none',
onUpdate: () => {
counterElement.textContent = Math.spherical(indicator.worth);
},
onComplete: () => {
// 3. Fade out rings and counter, then proceed to the primary structure
gsap.to([...rings.map(r => r.ring), counterElement], {
opacity: 0,
length: 0.8,
stagger: 0.1,
onComplete: () => onLoaderComplete?.(),
});
},
});
});
// PixiJS “respiration” movement
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 begin = -extent / 2;
r.letters.forEach((letter, idx) => );
});
});
3D Parts

The 3D components have been modeled in Blender to match the minimal tone of the location. Identical to within the earlier challenge, the lighting and shadows have been baked into the textures, sufficient to present the objects depth with out including additional weight to the scene.
Assembling Paragraph: Turning Scattered Letters right into a Single Thought
The paragraph begins in fragments: teams of letters seem in two facet columns whereas just a few characters float randomly throughout the display. Because the consumer scrolls, GSAP and MotionPathPlugin information every letter alongside a curved path, progressively assembling them right into a clear, completely aligned paragraph on the middle.
To maintain the typography pixel-perfect, the flying letters and the ultimate paragraph use two separate layers:
the flying characters fade out, whereas the paragraph characters fade in on the precise second every path completes.
Creating the Scattered Letters
// Construct two facet columns and a set of random letters
const root = doc.querySelector('.ap');
const colLeft = root.querySelector('.ap__col--left');
const colRight = root.querySelector('.ap__col--right');
const targetBox = root.querySelector('.ap__target');
const targetChars = [];
strains.forEach(line => {
const lineEl = doc.createElement('div');
lineEl.className = 'ap__line';
[...line].forEach(ch => {
const wrap = doc.createElement('span');
const char = doc.createElement('span');
char.className = 'ap__char';
char.textContent = ch;
char.fashion.opacity = 0; // remaining paragraph is initially hidden
wrap.appendChild(char);
lineEl.appendChild(wrap);
targetChars.push(char);
});
targetBox.appendChild(lineEl);
});
// Facet columns: teams of flying letters
perform renderColumn(column, teams) {
teams.forEach(group => {
const row = doc.createElement('div');
group.forEach(ch => {
const span = doc.createElement('span');
span.className = 'ap__fly';
span.textContent = ch.ch;
row.appendChild(span);
});
column.appendChild(row);
});
}
This snippet units up the 2 layers utilized by the animation:
- the ultimate paragraph layer – characters positioned of their precise positions however totally clear;
- the flying layer – letters which might be rendered into the facet columns utilizing the
renderColumnhelper.
This separation makes the animation clear and avoids any visible jumps – the animated letters don’t must completely land on the typographic grid, as a result of the ultimate characters fade in on the proper second.
From Chaos to Construction
// GSAP setup
gsap.registerPlugin(ScrollTrigger, MotionPathPlugin);
// Timeline pushed by scroll
const tl = gsap.timeline({
defaults: { ease: 'power3.out' },
scrollTrigger: {
set off: '.about',
begin: '50% 100%',
finish: '50% -10%',
scrub: 1
}
});
// For every flying letter, generate a curved path towards its remaining place
flyers.forEach((f, i) => {
const targetEl = targetChars[f.item.idx];
const begin = getPos(f.span);
const finish = getPos(targetEl);
const cp = {
x: (begin.x + finish.x) / 2 + (f.facet === 'left' ? 90 : -90),
y: Math.max(begin.y, finish.y) + 160
};
const path = [start, cp, end];
tl.to(f.span, {
length: 1.25,
motionPath: { path, curviness: 0.85 },
onUpdate() {
const p = this.progress();
// reveal remaining paragraph letter close to the top of the curve
targetEl.fashion.opacity = p > 0.65 ? gsap.utils.mapRange(0.7, 1, 0, 1, p) : 0;
// cover flying letter as soon as it is near touchdown
f.span.fashion.opacity = p < 1 ? 1 - p : 0;
}
}, i * 0.025);
});
Every letter travels alongside a customized movement path generated from three factors:
- its preliminary place
- a curved management level
- its remaining place contained in the paragraph
GSAP onUpdate easily transitions visibility:
- flying letter: fades out
- paragraph letter: fades in
This creates the phantasm that every character snaps completely into place, although the flying components by no means must align pixel-perfectly with the ultimate typographic construction.
Every little thing Begins with Curiosity
We didn’t come to this by means of formal training, however by means of curiosity, experiments, and a continuing need to know make issues higher. We realized by means of our personal tasks, by means of trials, errors, and moments when one thing lastly clicked.
This journey taught us an important factor: love for the method issues greater than any guidelines. Once you’re genuinely obsessed with what you create, the appropriate folks at all times seem round you.
This shared curiosity is what sparked our collaboration, one which has grown profitable and led us to tasks with well-known studios and firms.
That’s why we sincerely encourage others to remain open to new connections, experiments, and collaborations. As a result of it’s on this mixture—design and code, totally different visions, totally different voices—that work with actual which means is born.


