π» Wright's Ferry Mansion ν΄λ‘ μ½λ©
Wright's Ferry Mansionλ λͺ¨λ 컨ν μΈ κ° κ°λ‘λ‘ νλ₯΄λ ν‘ μ€ν¬λ‘€ μ¬μ΄νΈλ€. μΈν°λμ ꡬνμ μν΄ GSAPκ³Ό sticky μμ±μ μ΄μ©νλλ°, λ°©λ²μ μλμ κ°λ€.
HTML
<div class="horizontal-container">
<div class="horizontal">
<div class="content-wrap">
<div class="content">content1</div>
<div class="content">content2</div>
<div class="content">content3</div>
<div class="content">content4</div>
<div class="content">content5</div>
<div class="content">content6</div>
</div>
</div>
</div>
CSS
.horizontal-container {
position: relative;
}
.horizontal {
position: sticky;
top: 0;
display: flex; /* content-wrapμ΄ contentμ μ΄ κ°λ‘ λλΉ μΈμν μ μλλ‘ λΆλͺ¨μλ flex μμ± μ£ΌκΈ° */
height: 100vh;
overflow: hidden; /* κ°λ‘ μ€ν¬λ‘€ μ κ±° */
}
.content-wrap {
display: flex;
}
.content {
display: flex;
justify-content: center;
align-items: center;
width: 100vw;
height: 100%;
font-size: 5vw;
font-weight: 700;
color: #fff;
}
JS
const horizontalMotion = gsap.to('.content-wrap', {
ease: 'none',
xPercent: -100, // μμκ°μ μ£Όμ΄ μΌμͺ½μΌλ‘ λ°μ΄λ²λ¦Ό. μ€ν¬λ‘€ μ μμκ° μΌμͺ½μΌλ‘ μ΄λ
x: function() {
return window.innerWidth; // λΈλΌμ°μ λλΉ λ§νΌ xPercentλ‘ λ°μ΄λ²λ¦° κ°μ λ€μ λΉκ²¨μ΄
},
scrollTrigger: {
trigger: '.horizontal-container',
start: '0% 0%',
end: '100% 100%',
scrub: 1, // νμ! μ€ν¬λ‘€μ΄ μ¬μ©λ λλ§ μ΄λ²€νΈκ° μ¬μ λλλ‘ λ§λ€μ΄μ£Όλ μμ±
invalidateOnRefresh: true, // νλ©΄ 리μ¬μ΄μ§ μ μλμ° λλΉ μ¬κ³μ°
// markers: true,
}
})
// μ€ν¬λ‘€ κ° λμ μΌλ‘ λ£μ΄μ£ΌκΈ°
setContentHeight();
window.addEventListener('resize', setContentHeight);
function setContentHeight() {
const content = document.querySelector('.content-wrap');
const contentWidth = content.getBoundingClientRect().width;
const contentWrapper = document.querySelector('.horizontal-container');
const windowWidth = window.innerWidth;
contentWrapper.style.height = contentWidth + 'px';
}
μ°μ , content-wrap μ 체λ₯Ό -100% λ§νΌ μΌμͺ½μΌλ‘ λ°μ΄λ²λ¦°λ€. κ·ΈλΌ νλ©΄μμ 보μ΄μ§ μκ² λλ©°, μμμ λμ μ΄ νλ©΄ κΈ°μ€ μΌμͺ½ λμ λΆμ΄μλ μνκ° λλ€.
const horizontalMotion = gsap.to('.content-wrap', {
ease: 'none',
xPercent: -100, // μμκ°μ μ£Όμ΄ μΌμͺ½μΌλ‘ λ°μ΄λ²λ¦Ό. μ€ν¬λ‘€ μ μμκ° μΌμͺ½μΌλ‘ μ΄λ
})
κ·Έκ±Έ.. μ΄λ»κ² νμΈνμ§?
κ°λ°μ λκ΅¬λ‘ -100%λ‘ μ΄λν κ°μ -99%λ‘ λ³κ²½ν΄λ³΄λ©΄ μλμ κ°μ΄ νλ©΄ μΌμͺ½μ λΉΌκΌΌ 보μΈλ€!
κ·Έ μνμμ λΈλΌμ°μ λλΉ λ§νΌ μ€λ₯Έμͺ½μΌλ‘ λ€μ λΉκΈ°κ² λλ©΄ μμ 맨 λ€ μμμ΄ νλ©΄μ 보μ΄λ μνκ° λλ€.
const horizontalMotion = gsap.to('.content-wrap', {
ease: 'none',
xPercent: -100, // μμκ°μ μ£Όμ΄ μΌμͺ½μΌλ‘ λ°μ΄λ²λ¦Ό. μ€ν¬λ‘€ μ μμκ° μΌμͺ½μΌλ‘ μ΄λ
x: function() {
return window.innerWidth; // λΈλΌμ°μ λλΉ λ§νΌ xPercentλ‘ λ°μ΄λ²λ¦° κ°μ λ€μ λΉκ²¨μ΄
},
})
κ·Έλ¦¬κ³ scrollTriggerμ μμμ κ³Ό λμ μ κ°κ° '0% 0%', '100% 100%'λ‘ μ£Όκ² λλ©΄ νλ©΄μλ μμ 맨 μλΆλΆ(content1)μ΄ λ³΄μ¬μ§κ² λλ€. μΆκ°λ‘ μ λλ©μ΄μ
μ¬μ¬μ©μ΄ κ°λ₯ν scrub
μμ±μ μΆκ°νλ©΄ μμ±μ΄λ€.
scrub μμ±μ μ€ν¬λ‘€μ΄ μμ μ΄μ μΌλ‘ λμκ°λ©΄ μ λλ©μ΄μ
μμ λλμκ°λ κΈ°λ₯μΌλ‘, μΌνμ± μ λλ©μ΄μ
ν¨κ³Όκ° μλ κ²½μ°μ μ¬μ©ν μ μλ€. scrub μμ±μ μ£Όμ§ μμ κ²½μ°, μ€ν¬λ‘€ μ start μ§μ λΆν° end μ§μ κΉμ§ νλ²μ μ΄λν΄λ²λ¦¬λ μ΄μκ° λ°μνλ―λ‘ λμκ² νμ μμ±μ΄λ€!
const horizontalMotion = gsap.to('.content-wrap', {
ease: 'none',
xPercent: -100, // μμκ°μ μ£Όμ΄ μΌμͺ½μΌλ‘ λ°μ΄λ²λ¦Ό. μ€ν¬λ‘€ μ μμκ° μΌμͺ½μΌλ‘ μ΄λ
x: function() {
return window.innerWidth; // λΈλΌμ°μ λλΉ λ§νΌ xPercentλ‘ λ°μ΄λ²λ¦° κ°μ λ€μ λΉκ²¨μ΄
},
scrollTrigger: {
trigger: '.horizontal-container',
start: '0% 0%',
end: '100% 100%',
scrub: 1, // νμ! μ€ν¬λ‘€μ΄ μ¬μ©λ λλ§ μ΄λ²€νΈκ° μ¬μ λλλ‘ λ§λ€μ΄μ£Όλ μμ±
invalidateOnRefresh: true, // νλ©΄ 리μ¬μ΄μ§ μ μλμ° λλΉ μ¬κ³μ°
}
})
κ·Έλ¦¬κ³ invalidateOnRefresh: true κ°μ μ£Όμ΄μΌ νλλ° trueλ‘ μ€μ ν κ²½μ° νλ©΄ 리μ¬μ΄μ§ μ window.innerWidth κ°μ΄ κ°±μ λλ€. ν΄λΉ μμ±μ scrollTrigger μμ μ μΈν΄μΌνλ€!
μ°Έκ³ λ‘, cssμ sticky λμ gsapμ μμλ₯Ό κ³ μ νλ pin μμ±μ μ¬μ©ν΄λ κ²°κ³Όλ λμΌνλ€. ex) pin: '.horizontal'
π μμ±μ½λ
HTML
λ°μ΄ν° μμ±μΌλ‘ λ°λ³΅λ¬Έμ λλ € ν¨κ³Όλ₯Ό μ μ©ν μμ μΌλ‘ μ λλ©μ΄μ μ΄λ¦κ³Ό μ μ©ν κ°μ μμ±μ μΆκ°νλ€.
<div class="horizontal-container">
<div class="horizontal">
<div class="content-wrap">
<div class="content">
<span class="text" data-motion="scale" data-motion-value="2">Hello !</span>
</div>
<div class="content">
<span class="text" data-motion="rotate" data-motion-value="360">Hello !</span>
</div>
<div class="content">
<div class="box" data-motion="scale" data-motion-value="4"></div>
</div>
<div class="content">
<span class="text" data-motion="fade-out">Hello !</span>
</div>
<div class="content">
<span class="text" data-motion="fade-in">Hello !</span>
</div>
<div class="content">
<span class="text" data-motion="rotate" data-motion-value="360">Hello !</span>
</div>
</div>
</div>
</div>
JS
[data-motion]
μμ±μ κ°μ§κ³ μλ μμλ₯Ό μ°Ύμ λ°λ³΅λ¬Έμ λλ¦° ν κ·Έ μμμ λ€μ 쑰건문μ λ§λ€μ΄ ν¨κ³Όλ₯Ό μ£Όμλ€.
data-motion-value κ°μ΄ μμ κ²½μ°μλ κΈ°λ³Έ κ°μΌλ‘ 0μ΄ μ μ©λλλ‘ νμλ€.
μ¬κΈ°μ κ°μ₯ μ€μν κ²μ, containerAnimationμ΄λ€. ν΄λΉ μμ±μ μμ§ μ€ν¬λ‘€μ μνμΌλ‘ μ λλ©μ΄μ
ν ν μ μκ² ν΄μ€λ€. μμ± κ°μ λΆλͺ¨ μ λλ©μ΄μ
(horizontalMotion)μ κ±Έμ΄μ£Όλ©΄ "μ°λ"μ΄ λμ΄ μ΄μ μν μ΄λμ λν΄ scrollTrigger μ€μ μ΄ κ°λ₯νλ€.
μ¬κΈ°μ markersλ₯Ό μ€μ νλ©΄ κ°λ‘ λͺ¨λμ λ§μΆμ΄ νμλλ€.
document.querySelectorAll('[data-motion]').forEach(function(element, index) {
const value = element.dataset.motionValue ? element.dataset.motionValue : 0;
if(element.dataset.motion === 'rotate') {
gsap.to(element, {
rotate: value,
duration: 1,
scrollTrigger: {
trigger: element.parentElement,
start: 'left center',
end: 'right left',
scrub: 0,
containerAnimation: horizontalMotion, // λΆλͺ¨ μ λλ©μ΄μ
μ°κ²°
// markers: true
}
})
}
if(element.dataset.motion === 'fade-in') {
gsap.from(element, {
opacity: value,
duration: 1,
scrollTrigger: {
trigger: element.parentElement,
start: 'left center',
end: 'center center',
scrub: 0,
containerAnimation: horizontalMotion, // λΆλͺ¨ μ λλ©μ΄μ
μ°κ²°
// markers: true
}
})
}
// ...μ΄ν μλ΅
π μμ±μ½λ
μ°μ μμ± νλ©΄μ λ¨Όμ 보μ.
μμ κ°μ΄ νλ²κ±° λ²νΌμ ν κΈν λλ§λ€ λ€λΉκ²μ΄μ
μμμ΄ xμΆ 100% <-> 0%λ‘ λ³κ²½ λλλ‘ μ€μ νλ€.
νμ§λ§ λ¬Έμ κ° μμλλ°, νλ©΄ μ¬μ΄μ¦κ° λ³κ²½λ κ²½μ° xPercent κ°μ΄ μ
λ°μ΄νΈ λμ΄ λ°μν λμμ΄ λμ§ μμλ€.
μμ μ μ½λ
const navTl = gsap.timeline({
paused: true,
defaults : {
ease: 'none',
duration: 0.3
},
});
navTl
.to('.gnb', {x: 0, xPercent: 0, 'pointer-events': 'auto'}, 'a') // λ¬Έμ μ μ½λ !
.to('.btn-nav .bar:nth-child(1)', {y: 6, rotate: 45}, 'a')
.to('.btn-nav .bar:nth-child(3)', {y: -6, rotate: -45}, 'a')
.to('.btn-nav .bar:nth-child(2)', {opacity: 0}, 'a')
.to('.gnb-container .dim', {opacity: 1, 'pointer-events': 'auto'}, 'a')
const navBtn = document.querySelector('.btn-nav');
navBtn.addEventListener('click', function() {
const headerEl = document.querySelector('.header');
const CLASSNAME = 'is-open';
headerEl.classList.toggle(CLASSNAME);
if(headerEl.classList.contains(CLASSNAME)) {
navTl.play();
} else {
navTl.reverse();
}
})
μλ₯Ό λ€μ΄, 1200px νλ©΄μμ λ²νΌμ μ΄κ³ λ€μ λ«μλ€. κ·Έλ¦¬κ³ λμ κ°λ°μ λκ΅¬λ‘ νμΈν΄λ³΄λ xPercent κ°μ΄ μλ μ΄λ―Έμ§μ κ°μ΄ pxλ‘ λ³κ²½λμ΄ μλ€.
κ·Έλ¦¬κ³ λμ νλ©΄μ 1920pxλ‘ λλ¦° ν λ€μ λ²νΌμ ν κΈνμ§λ§, κ°μ κ°±μ λμ§ μκ³ μ²μμ 600px κ°μΌλ‘ κ³ μ λμ΄ λ€λΉκ²μ΄μ
μμμ΄ κ³μ 보μ¬μ§κ³ μλ€. (transform: translateX(100%) κ° μ μ©λμ§ μμ νλ©΄μ κ³μ 보μ¬μ§κ³ μμ)
λ¬Έμ ν΄κ²°μ μν΄ κ΅¬κΈλ§μ νμλλ°, μλμ κ°μ΄ ν΄λ΅μ μ°Ύμλ€!
μ²μμ setμΌλ‘ μ΄κΈ° μΈν νλ κ²μ΄λ€. κ·ΈλΌ toggle νμ κ²½μ° pxλ‘ λ³νλμ§ μκ³ 100%λ‘ μ μ©λμ΄ λ¦¬μ¬μ΄μ§ μμλ μ΄μκ° λ°μνμ§ μλλ€.
gsap.set('.gnb', {x: 0, xPercent: 100});
μμ ν μ½λ
gsap.set('.gnb', {x: 0, xPercent: 100}); // λ°μν λμμ μν΄ μ΄κΈ°μΈν
const navTl = gsap.timeline({
paused: true,
defaults : {
ease: 'none',
duration: 0.3
},
});
navTl
.to('.gnb', {xPercent: 0, 'pointer-events': 'auto'}, 'a')
.to('.btn-nav .bar:nth-child(1)', {y: 6, rotate: 45}, 'a')
.to('.btn-nav .bar:nth-child(3)', {y: -6, rotate: -45}, 'a')
.to('.btn-nav .bar:nth-child(2)', {opacity: 0}, 'a')
.to('.gnb-container .dim', {opacity: 1, 'pointer-events': 'auto'}, 'a')
const navBtn = document.querySelector('.btn-nav');
navBtn.addEventListener('click', function() {
const headerEl = document.querySelector('.header');
const CLASSNAME = 'is-open';
headerEl.classList.toggle(CLASSNAME);
if(headerEl.classList.contains(CLASSNAME)) {
navTl.play();
} else {
navTl.reverse();
}
})
clamp()κ° νλ μΌμ μ μλ λ κ°μΈ μ΅μκ°κ³Ό μ΅λκ° μ¬μ΄μ κ°μ μ ννλ€.
3κ°μ 맀κ°λ³μ(μ΅μκ°, μ νΈκ°, μ΅λκ°)λ₯Ό μ¬μ©νλ€.
.item {
width: clamp(200px, 50%, 1000px);
}
μ μ½λμμ μ΅μκ°μ 200px, μ΅λκ°μ 1000pxμ΄λ©°, μ νΈνλ κ°μ widthμ 50%μ΄λ€.
ν΄λΉ κ°(50%)μ 200px ~ 1000px μ¬μ΄μλ§ μ‘΄μ¬νκ² λλ€.
MDNμ μλμ κ°μ΄ μ€λͺ νλ€.
.item {
width: clamp(200px, 50%, 1000px);
/* Is equivalent to the below */
width: max(200px, min(50%, 1000px));
}
50%λ λ·°ν¬νΈμ λλΉμ μν΄ κ²°μ λλ€.
λ·°ν¬νΈ λλΉκ° 1150px μ΄λΌκ³ νλ©΄ λ€μκ³Ό κ°μ λ¨κ³λ‘ κ³μ°λλ€.
.item {
width: max(200px, min(50%, 1000px));
/* Assuming the viewport width is 1150px */
width: max(200px, min(575px, 1000px));
/* Resolves to */
width: max(200px, 575px);
/* Resolves to */
width: 575px;
}
μ°Έκ³ μ¬μ΄νΈ
https://gsap.com/community/forums/topic/28215-prevent-gsap-from-converting-percentage-values-to-pixels/#comment-139360
https://itchallenger.tistory.com/921