09Header đồng bộ scroll-snap
Khi nội dung cuộn snap theo từng section, chấm chỉ báo trong header nhảy sang đúng số thứ tự của section vừa dừng lại.
Dùng choTrang kể chuyện dạng full-page scroll-snap, giới thiệu sản phẩm kiểu slide
scroll-snap-typeIntersectionObserver인디케이터 동기화

<main class="stage">
<header class="head is-demo">
<span class="head__logo">GODRICH</span>
<span class="head__dots"><i class="is-current"></i><i></i><i></i></span>
</header>
<section class="snap-section" data-i="0"><p>01</p></section>
<section class="snap-section" data-i="1"><p>02</p></section>
<section class="snap-section" data-i="2"><p>03</p></section>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 점이 옮겨가는 시간
$easing: $ease-pop;
$color: $subject-cream; // 활성 점 색
// ── 데모 무대 ──────────────────────────────────────────
html, body { margin: 0; overflow: hidden; }
.stage {
min-height: 100vh;
margin: 0;
background: $stage-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
scroll-snap-type: y mandatory; // 본문이 섹션 단위로 멈춘다
}
.head {
position: sticky;
top: 0;
z-index: 1;
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 clamp(16px, 5vw, 24px);
background: $stage-ink;
&__logo { color: $subject-cream; font-weight: 800; font-size: 18px; line-height: 1; font-family: inherit; letter-spacing: .04em; }
// 인디케이터: 점 3개 중 지금 멈춘 섹션의 것만 커진다
&__dots {
display: flex; gap: 8px;
i {
width: 8px; height: 8px; border-radius: 50%;
background: rgba(255, 255, 255, .3);
transform: scale(1);
transition: transform $duration $easing, background $duration $easing;
&.is-current { background: $color; transform: scale(1.4); }
}
}
}
// ── 데모(.is-demo): 점이 1→2→3→1 순서로 2초 루프 ──────────
.head.is-demo .head__dots i:nth-child(1) { animation: dot-1 $dur-loop $easing infinite; }
.head.is-demo .head__dots i:nth-child(2) { animation: dot-2 $dur-loop $easing infinite; }
.head.is-demo .head__dots i:nth-child(3) { animation: dot-3 $dur-loop $easing infinite; }
@mixin dot-phase($on-from, $on-to) {
0%, #{$on-from - 1}#{'%'} { background: rgba(255, 255, 255, .3); transform: scale(1); }
#{$on-from}#{'%'}, #{$on-to}#{'%'} { background: $color; transform: scale(1.4); }
#{$on-to + 1}#{'%'}, 100% { background: rgba(255, 255, 255, .3); transform: scale(1); }
}
@keyframes dot-1 { @include dot-phase(5, 25); }
@keyframes dot-2 { @include dot-phase(38, 58); }
@keyframes dot-3 { @include dot-phase(71, 91); }
// ── 섹션: 순서를 알아볼 수 있는 카드 3개 ───────────────────
// 실제 페이지에서는 섹션 하나가 화면 높이만큼(scroll-snap-align: start) 이어진다.
// 데모 무대에서는 세 칸을 압축해 보여준다 — 320×200 렌더에서도 넘치면 안 되니 vh 로 줄인다
.snap-section {
height: clamp(24px, 13vh, 78px);
display: grid;
place-items: center;
scroll-snap-align: start;
border-top: 1px solid rgba(255, 255, 255, .08);
p { margin: 0; color: $subject-cream; font-weight: 800; font-size: 28px; line-height: 1; font-family: inherit; opacity: .35; }
}
@media (prefers-reduced-motion: reduce) {
.head__dots i { transition: none; }
.head.is-demo .head__dots i { animation: none !important; }
.head.is-demo .head__dots i:first-child { background: $color; transform: scale(1.4); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
min-height: 100vh;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
scroll-snap-type: y mandatory;
}
.head {
position: sticky;
top: 0;
z-index: 1;
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 clamp(16px, 5vw, 24px);
background: #17141a;
}
.head__logo {
color: #fff7e6;
font-weight: 800;
font-size: 18px;
line-height: 1;
font-family: inherit;
letter-spacing: 0.04em;
}
.head__dots {
display: flex;
gap: 8px;
}
.head__dots i {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
transform: scale(1);
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), background 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.head__dots i.is-current {
background: #fff7e6;
transform: scale(1.4);
}
.head.is-demo .head__dots i:nth-child(1) {
animation: dot-1 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.head.is-demo .head__dots i:nth-child(2) {
animation: dot-2 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.head.is-demo .head__dots i:nth-child(3) {
animation: dot-3 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes dot-1 {
0%, 4% {
background: rgba(255, 255, 255, 0.3);
transform: scale(1);
}
5%, 25% {
background: #fff7e6;
transform: scale(1.4);
}
26%, 100% {
background: rgba(255, 255, 255, 0.3);
transform: scale(1);
}
}
@keyframes dot-2 {
0%, 37% {
background: rgba(255, 255, 255, 0.3);
transform: scale(1);
}
38%, 58% {
background: #fff7e6;
transform: scale(1.4);
}
59%, 100% {
background: rgba(255, 255, 255, 0.3);
transform: scale(1);
}
}
@keyframes dot-3 {
0%, 70% {
background: rgba(255, 255, 255, 0.3);
transform: scale(1);
}
71%, 91% {
background: #fff7e6;
transform: scale(1.4);
}
92%, 100% {
background: rgba(255, 255, 255, 0.3);
transform: scale(1);
}
}
.snap-section {
height: clamp(24px, 13vh, 78px);
display: grid;
place-items: center;
scroll-snap-align: start;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.snap-section p {
margin: 0;
color: #fff7e6;
font-weight: 800;
font-size: 28px;
line-height: 1;
font-family: inherit;
opacity: 0.35;
}
@media (prefers-reduced-motion: reduce) {
.head__dots i {
transition: none;
}
.head.is-demo .head__dots i {
animation: none !important;
}
.head.is-demo .head__dots i:first-child {
background: #fff7e6;
transform: scale(1.4);
}
}// 실제 페이지: scroll-snap 으로 멈춘 섹션 번호에 맞춰 점을 옮긴다 (IntersectionObserver, 7줄)
var dots = document.querySelectorAll('.head__dots i');
document.querySelectorAll('.snap-section').forEach(function (s) {
new IntersectionObserver(function (es) {
if (es[0].isIntersecting) dots.forEach(function (d, i) { d.classList.toggle('is-current', i == s.dataset.i); });
}, { threshold: .6 }).observe(s);
});import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ScrollSnapHeader.module.scss";
export type ScrollSnapHeaderProps = {
logo: string;
/** 스냅 섹션 개수만큼의 ref */
sectionRefs: { current: HTMLElement | null }[];
};
type Vars = CSSProperties;
export default function ScrollSnapHeader({ logo, sectionRefs }: ScrollSnapHeaderProps) {
const [active, setActive] = useState(0);
const vars: Vars = {};
useEffect(() => {
const observers = sectionRefs.map((ref, i) => {
const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) setActive(i); }, { threshold: 0.6 });
if (ref.current) io.observe(ref.current);
return io;
});
return () => observers.forEach((io) => io.disconnect());
}, [sectionRefs]);
return (
<header className={styles.head} style={vars}>
<span className={styles.logo}>{logo}</span>
<span className={styles.dots}>
{sectionRefs.map((_, i) => (
<i key={i} className={i === active ? styles.current : undefined} />
))}
</span>
</header>
);
}09 Header đồng bộ scroll-snap — Diện tích đo được 0.281% · khung hình chuyển động 9/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · 9 CSS sticky header không cần JS
Bài viết chứa demo này
9 CSS sticky header không cần JSCss sticky header là kiểu header dùng position: sticky để dính lại trên đầu màn hình khi bạn cuộn trang, thay vì trôi mấ Xem tất cả demo trong bài này


