사용자가 화면 앞에서 가장 많이 하는 몸짓은 스크롤입니다. 그 발걸음 위에 부드러운 이동, 붙어 따라오는 헤더, 딱딱 맞춰 서는 캐러셀, 스크롤에 맞물려 떠오르는 요소를 어떻게 CSS로 얹는지 코드로 보여드리겠습니다. JS는 정말 필요한 곳에만 몇 줄로 줄입니다.

83.1 부드럽게 미끄러지는 이동

화면 안 링크가 목적지로 순간이동하듯 튀면 사용자는 방향 감각을 잃습니다. 한 줄이면 그 이동이 스르르 미끄러지게 바뀝니다.


html {
scroll-behavior: smooth;
}
:target {
scroll-margin-top: 72px;
}


scroll-behavior: smooth 하나로 앵커 링크가 부드럽게 흘러갑니다. scroll-margin-top은 위에 붙은 헤더가 목적지를 덮지 않도록, 도착 지점을 헤더 높이만큼 아래로 밀어 줍니다.

83.2 위에 붙어 따라오는 헤더

긴 화면을 내려가도 메뉴가 위에 붙어 함께 내려오면 사용자는 언제든 손을 뻗을 수 있습니다. sticky는 이걸 JS 없이 한 속성으로 해냅니다.


.site-header {
position: sticky;
top: 0;
z-index: 10;
background: #fff;
box-shadow: 0 1px 0 #e5e7eb;
}


평소엔 제자리에 있다가 화면 꼭대기에 닿는 순간 top: 0에 들러붙어 따라옵니다. z-index로 본문 위에 얹고 배경색을 채워 두어야, 아래 내용이 헤더를 뚫고 비쳐 보이지 않습니다.

83.3 딱딱 맞춰 서는 캐러셀

사진을 옆으로 넘겨 볼 때 손을 떼면 어중간한 자리에 서는 대신, 가장 가까운 장의 한가운데로 자리를 잡게 하고 싶습니다. 스크롤 스냅이 그 맞춤을 맡습니다.


.carousel {
display: flex;
gap: 12px;
overflow-x: auto;
scroll-snap-type: x mandatory;
}
.carousel > .slide {
flex: 0 0 80%;
scroll-snap-align: center;
}


가로 스크롤 상자에 scroll-snap-type: x mandatory를 걸면 스크롤이 스냅 지점에 반드시 멈춥니다. 각 장의 scroll-snap-align: center가 그 멈추는 지점을 장의 한가운데로 정하니, 손짓이 대충이어도 화면은 반듯하게 정돈됩니다.

83.4 스크롤에 맞춰 떠오르기

요소가 화면에 들어서는 순간 부드럽게 떠오르면 생기가 돕니다. 실제 움직임은 CSS 트랜지션이 맡고, JS는 .visible 클래스만 붙이는 몇 줄로 줄입니다.


.reveal {
opacity: 0;
transform: translateY(24px);
transition: opacity .5s ease, transform .5s ease;
}
.reveal.visible {
opacity: 1;
transform: translateY(0);
}


화면에 들어왔는지를 지켜보는 IntersectionObserver가 클래스만 토글합니다.


const io = new IntersectionObserver((entries) => {
entries.forEach((e) => {
if (e.isIntersecting) e.target.classList.add("visible");
});
}, { threshold: .2 });
document.querySelectorAll(".reveal").forEach((el) => io.observe(el));


요소가 20% 들어오면 visible이 붙고, 그 순간 CSS가 opacitytranslateY를 부드럽게 넘깁니다. JS는 감시와 클래스 부착만 하고 움직임에는 손대지 않으니, 무거운 계산이 스크롤에 딸려 오지 않습니다.

83.5 스크롤 자체를 타임라인으로

이제는 JS 없이 스크롤 진행에 애니메이션을 직접 묶는 표준 문법이 있습니다. animation-timeline이 시간 대신 스크롤을 재생축으로 삼습니다.


@keyframes fade-up {
from {
opacity: 0;
transform: translateY(40px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card {
animation: fade-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}


view()는 그 요소가 화면 안으로 들어오는 구간을 재생축으로 삼습니다. animation-range: entry 0% entry 60%는 요소가 나타나기 시작해 60% 들어올 때까지 애니메이션을 다 진행하라는 뜻입니다. 문서 전체의 스크롤에 묶으려면 scroll()을 씁니다.


@keyframes grow {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
.progress {
position: fixed;
top: 0;
left: 0;
height: 3px;
width: 100%;
background: #2563eb;
transform-origin: 0 50%;
animation: grow linear both;
animation-timeline: scroll(root block);
}


scroll(root block)는 문서를 위에서 아래로 굴리는 진행을 축으로 잡습니다. 맨 위에서 scaleX(0)이던 막대가 바닥에 이를수록 꽉 차오르니, 얼마나 읽었는지 보여 주는 진행 표시가 JS 한 줄 없이 완성됩니다.

83.6 맨 위로 버튼 등장

한참 내려간 사용자에게 처음으로 돌아갈 지름길을 열어 둡니다. 어느 정도 내려갔을 때만 버튼이 살며시 나타나게 합니다.


@keyframes pop-in {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.to-top {
position: fixed;
right: 20px;
bottom: 20px;
opacity: 0;
pointer-events: none;
transition: opacity .3s ease;
}
.to-top.show {
opacity: 1;
pointer-events: auto;
animation: pop-in .3s ease;
}


평소엔 투명하고 pointer-events: none으로 눌리지도 않다가, 아래로 내려가면 show가 붙어 톡 나타납니다. 클래스 토글은 btn.classList.toggle("show", scrollY > 400) 한 줄이면 되고, 클릭 시 이동은 위에서 만든 scroll-behavior: smooth가 부드럽게 처리합니다.

83.7 움직임을 원하지 않는 사용자 존중하기

스크롤에 맞물린 움직임은 멀미와 비슷한 어지럼을 부르는 사용자가 있습니다. 움직임만 걷어 내되, 감춰 두었던 요소는 반드시 보이게 되돌려 놓아야 합니다.


@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
.reveal,
.to-top.show,
.card,
.progress {
transition: none;
animation: none;
}
.reveal {
opacity: 1;
transform: none;
}
}


부드러운 스크롤과 떠오름, 스크롤 애니메이션이 모두 멈춥니다. 특히 .revealopacity: 1로 되살려, 등장 반응이 사라져도 내용이 영영 감춰지는 일이 없게 밑바탕을 깔아 둡니다.

83.8 정리

부드러운 이동은 scroll-behavior, 따라오는 헤더는 position: sticky, 캐러셀은 스크롤 스냅으로 JS 없이 됩니다. 등장 반응은 IntersectionObserver로 클래스만 토글해 CSS에 움직임을 맡기고, 더 나아가 animation-timeline: view()scroll()로 스크롤 자체를 재생축 삼되, 움직임을 싫어하는 사용자에게는 내용을 온전히 되돌려 줍니다.