// Motion layer for the v2 homepage. Every effect keeps the content readable without it:
// hidden-until-revealed styles only apply under `.js`, and reduced motion skips travel.
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { SplitText } from 'gsap/SplitText';
import Lenis from 'lenis';
gsap.registerPlugin(ScrollTrigger, SplitText);
const root = document.documentElement;
root.classList.add('js');
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// ---------------------------------------------------------------- smooth scroll
if (!reduced) {
const lenis = new Lenis({ lerp: 0.11 });
lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add((t) => lenis.raf(t * 1000));
gsap.ticker.lagSmoothing(0);
document.querySelectorAll<HTMLAnchorElement>('a[href^="#"]').forEach((a) => {
a.addEventListener('click', (e) => {
const target = document.querySelector(a.getAttribute('href') || '');
if (!target) return;
e.preventDefault();
lenis.scrollTo(target as HTMLElement, { offset: 0, duration: 1.4 });
});
});
}
// ---------------------------------------------------------------- signature: the poles glitch in
const glitchColors = ['#ff2b1c', '#ffffff', '#00e5d4'];
function glitch(el: HTMLElement, times = 6) {
if (reduced) return;
const tl = gsap.timeline();
for (let i = 0; i < times; i++) {
tl.set(el, {
x: gsap.utils.random(-14, 14),
skewX: gsap.utils.random(-12, 12),
color: gsap.utils.random(glitchColors),
clipPath: `inset(${gsap.utils.random(0, 40)}% 0 ${gsap.utils.random(0, 40)}% 0)`,
}).to({}, { duration: 0.045 });
}
tl.set(el, { x: 0, skewX: 0, clearProps: 'color,clipPath' });
return tl;
}
document.fonts.ready.then(() => {
const poles = gsap.utils.toArray<HTMLElement>('[data-glitch]');
if (!reduced) {
poles.forEach((pole, i) => {
const split = SplitText.create(pole, { type: 'chars' });
gsap.from(split.chars, {
yPercent: i === 0 ? -110 : 110,
opacity: 0,
duration: 0.55,
ease: 'expo.out',
stagger: 0.04,
delay: 0.05 + i * 0.12,
onComplete: () => { glitch(pole, 8); },
});
});
// A short burst every few seconds keeps the signal "live" without looping noise.
const burst = () => {
glitch(gsap.utils.random(poles), 5);
gsap.delayedCall(gsap.utils.random(3.5, 7), burst);
};
gsap.delayedCall(4, burst);
}
// Headlines that fill word by word as they scroll through the screen.
gsap.utils.toArray<HTMLElement>('[data-words]').forEach((el) => {
const split = SplitText.create(el, { type: 'words', wordsClass: 'word' });
if (reduced) return;
gsap.fromTo(
split.words,
{ color: '#b3a9a4' },
{
color: '#ffffff',
stagger: 0.12,
ease: 'none',
scrollTrigger: { trigger: el, start: 'top 85%', end: 'top 40%', scrub: true },
},
);
});
// Big names and verbs slide up by character the first time they enter.
if (!reduced) {
gsap.utils.toArray<HTMLElement>('.verb__word, .ticket__title, .contact__number, .reel__half--top').forEach((el) => {
const split = SplitText.create(el, { type: 'words,chars', mask: 'chars' });
gsap.from(split.chars, {
yPercent: 100,
duration: 0.8,
ease: 'expo.out',
stagger: 0.035,
scrollTrigger: { trigger: el, start: 'top 80%', once: true },
});
});
}
});
// ---------------------------------------------------------------- hero: the one-person team at work
const team = document.querySelector<HTMLElement>('.team');
const heroTitle = document.querySelector<HTMLElement>('.hero__lead');
if (team && heroTitle) {
const panel = team.parentElement as HTMLElement;
const select = team.querySelector<HTMLElement>('.team__select')!;
const cursors = gsap.utils.toArray<HTMLElement>('.team__cursor', team);
const msgs = gsap.utils.toArray<HTMLElement>('[data-msg]', team);
const bubbles = gsap.utils.toArray<HTMLElement>('.team__bubble', team);
const lines = [
'Hagamos que tu marca se mueva.',
'Render del león: listo.',
'Subí el corte final del reel.',
'El sitio ya carga en menos de 2 segundos.',
'Cuatro roles, una sola persona.',
'Nada se pierde entre equipos.',
];
// The selection box hugs the headline, like a layer picked in a design tool.
const placeSelect = () => {
const p = panel.getBoundingClientRect();
const r = heroTitle.getBoundingClientRect();
gsap.set(select, { x: r.left - p.left, y: r.top - p.top, width: r.width, height: r.height });
};
placeSelect();
window.addEventListener('resize', placeSelect);
// Cursors roam the panel but never park on the copy, so the words stay readable.
const copy = panel.querySelector<HTMLElement>('.hero__copy')!;
const randomSpot = () => {
const p = panel.getBoundingClientRect();
const c = copy.getBoundingClientRect();
for (let tries = 0; tries < 20; tries++) {
const x = gsap.utils.random(p.width * 0.06, p.width * 0.8);
const y = gsap.utils.random(p.height * 0.18, p.height * 0.9);
const inCopy = x > c.left - p.left - 160 && x < c.right - p.left + 20 && y > c.top - p.top - 40 && y < c.bottom - p.top + 20;
if (!inCopy) return { x, y };
}
return { x: p.width * 0.1, y: p.height * 0.85 };
};
// Motion layer for the v2 homepage. Every effect keeps the content readable without it:
// hidden-until-revealed styles only apply under `.js`, and reduced motion skips travel.
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { SplitText } from 'gsap/SplitText';
import Lenis from 'lenis';
gsap.registerPlugin(ScrollTrigger, SplitText);
const root = document.documentElement;
root.classList.add('js');
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// ---------------------------------------------------------------- smooth scroll
if (!reduced) {
const lenis = new Lenis({ lerp: 0.11 });
lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add((t) => lenis.raf(t * 1000));
gsap.ticker.lagSmoothing(0);
document.querySelectorAll<HTMLAnchorElement>('a[href^="#"]').forEach((a) => {
a.addEventListener('click', (e) => {
const target = document.querySelector(a.getAttribute('href') || '');
if (!target) return;
e.preventDefault();
lenis.scrollTo(target as HTMLElement, { offset: 0, duration: 1.4 });
});
});
}
// ---------------------------------------------------------------- signature: the poles glitch in
const glitchColors = ['#ff2b1c', '#ffffff', '#00e5d4'];
function glitch(el: HTMLElement, times = 6) {
if (reduced) return;
const tl = gsap.timeline();
for (let i = 0; i < times; i++) {
tl.set(el, {
x: gsap.utils.random(-14, 14),
skewX: gsap.utils.random(-12, 12),
color: gsap.utils.random(glitchColors),
clipPath: `inset(${gsap.utils.random(0, 40)}% 0 ${gsap.utils.random(0, 40)}% 0)`,
}).to({}, { duration: 0.045 });
}
tl.set(el, { x: 0, skewX: 0, clearProps: 'color,clipPath' });
return tl;
}
document.fonts.ready.then(() => {
const poles = gsap.utils.toArray<HTMLElement>('[data-glitch]');
if (!reduced) {
poles.forEach((pole, i) => {
const split = SplitText.create(pole, { type: 'chars' });
gsap.from(split.chars, {
yPercent: i === 0 ? -110 : 110,
opacity: 0,
duration: 0.55,
ease: 'expo.out',
stagger: 0.04,
delay: 0.05 + i * 0.12,
onComplete: () => { glitch(pole, 8); },
});
});
// A short burst every few seconds keeps the signal "live" without looping noise.
const burst = () => {
glitch(gsap.utils.random(poles), 5);
gsap.delayedCall(gsap.utils.random(3.5, 7), burst);
};
gsap.delayedCall(4, burst);
}
// Headlines that fill word by word as they scroll through the screen.
gsap.utils.toArray<HTMLElement>('[data-words]').forEach((el) => {
const split = SplitText.create(el, { type: 'words', wordsClass: 'word' });
if (reduced) return;
gsap.fromTo(
split.words,
{ color: '#b3a9a4' },
{
color: '#ffffff',
stagger: 0.12,
ease: 'none',
scrollTrigger: { trigger: el, start: 'top 85%', end: 'top 40%', scrub: true },
},
);
});
// Big names and verbs slide up by character the first time they enter.
if (!reduced) {
gsap.utils.toArray<HTMLElement>('.verb__word, .ticket__title, .contact__number, .reel__half--top').forEach((el) => {
const split = SplitText.create(el, { type: 'words,chars', mask: 'chars' });
gsap.from(split.chars, {
yPercent: 100,
duration: 0.8,
ease: 'expo.out',
stagger: 0.035,
scrollTrigger: { trigger: el, start: 'top 80%', once: true },
});
});
}
});
// ---------------------------------------------------------------- hero: the one-person team at work
const team = document.querySelector<HTMLElement>('.team');
const heroTitle = document.querySelector<HTMLElement>('.hero__lead');
if (team && heroTitle) {
const panel = team.parentElement as HTMLElement;
const select = team.querySelector<HTMLElement>('.team__select')!;
const cursors = gsap.utils.toArray<HTMLElement>('.team__cursor', team);
const msgs = gsap.utils.toArray<HTMLElement>('[data-msg]', team);
const bubbles = gsap.utils.toArray<HTMLElement>('.team__bubble', team);
const lines = [
'Hagamos que tu marca se mueva.',
'Render del león: listo.',
'Subí el corte final del reel.',
'El sitio ya carga en menos de 2 segundos.',
'Cuatro roles, una sola persona.',
'Nada se pierde entre equipos.',
];
// The selection box hugs the headline, like a layer picked in a design tool.
const placeSelect = () => {
const p = panel.getBoundingClientRect();
const r = heroTitle.getBoundingClientRect();
gsap.set(select, { x: r.left - p.left, y: r.top - p.top, width: r.width, height: r.height });
};
placeSelect();
window.addEventListener('resize', placeSelect);
// Cursors roam the panel but never park on the copy, so the words stay readable.
const copy = panel.querySelector<HTMLElement>('.hero__copy')!;
const randomSpot = () => {
const p = panel.getBoundingClientRect();
const c = copy.getBoundingClientRect();
for (let tries = 0; tries < 20; tries++) {
const x = gsap.utils.random(p.width * 0.06, p.width * 0.8);
const y = gsap.utils.random(p.height * 0.18, p.height * 0.9);
const inCopy = x > c.left - p.left - 160 && x < c.right - p.left + 20 && y > c.top - p.top - 40 && y < c.bottom - p.top + 20;
if (!inCopy) return { x, y };
}
return { x: p.width * 0.1, y: p.height * 0.85 };
};