CSS 헤더 고정 스티키 9종 — AOS·GSAP 없이, JS 0줄
CSS 헤더 고정 스티키는 페이지를 스크롤해도 헤더가 화면 위에 계속 붙어 있게 만드는 방식입니다. 아홉 개 중 넷(04·05·08·09 중 04·05)은 자바스크립트 없이 CSS만으로 움직입니다. 아래 격자에서 자동으로 도는 아홉 개를 보고 지금 만드는 화면에 맞는 것을 찾으십시오.
01 sticky-header-hide-reveal
02 sticky-header-shadow-on-scroll
03 sticky-header-color-invert
04 sticky-header-logo-shrink
05 sticky-header-progress-bar
06 sticky-header-sub-nav-sticky
07 sticky-header-section-title-sticky
08 sticky-header-backdrop-blur
09 sticky-header-scroll-snap
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 숨김 후 재등장
- 02 스크롤 시 그림자 생김
- 03 색 반전
- 04 로고만 축소
- 05 진행바 결합
- 06 서브 내비 스티키
- 07 섹션 제목 스티키
- 08 backdrop blur 헤더
- 09 스크롤 스냅 헤더
아홉 개는 흔한 인기순이 아니라 헤더가 스크롤 속에서 겪는 순서로 놓았습니다. 방향을 아는 것(01) → 위험 신호를 알리는 것(02) → 정체성을 바꾸는 것(03) → 무게를 줄이는 것(04) → 진행을 안내하는 것(05) → 계층을 넓히는 것(06) → 맥락을 갱신하는 것(07) → 배경을 투과시키는 것(08) → 위치를 알리는 것(09) 순입니다. 아래 코드는 켜고 끄는 핵심 규칙만 뽑았고, 완결된 파일은 zip 안에 있습니다. 스크롤이 헤더가 아니라 본문 레이어를 움직이게 두고 싶으면 CSS 패럴랙스 스크롤 9종이 같은 타임라인의 다른 쓰임이고, 헤더 안 내용을 계속 흘려 보여야 한다면 CSS 무한 롤링 배너가 그 자리의 답입니다.
01숨김 후 재등장
화면에서 일어나는 일
아래로 스크롤하면 헤더가 위로 사라지고, 위로 스크롤하는 순간에만 다시 내려와 콘텐츠를 오래 가리지 않습니다.

<main class="stage">
<header class="head is-demo">
<span class="head__logo">GODRICH</span>
<nav class="head__nav" aria-hidden="true"><span></span><span></span><span></span></nav>
</header>
<div class="page">
<p class="page__line"></p>
<p class="page__line"></p>
<p class="page__line short"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 ──────────────────────────
$duration: $dur-base; // 숨고 나타나는 시간
$easing: $ease-out;
$color: $stage-ink; // 헤더 배경
// ── 데모 무대 ──────────────────────────────────────────
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;
}
.head {
position: sticky;
top: 0;
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 clamp(16px, 5vw, 24px);
background: $color;
transition: transform $duration $easing;
&__logo { color: $subject-cream; font-weight: 800; font-size: 18px; line-height: 1; font-family: inherit; letter-spacing: .04em; }
&__nav { display: flex; gap: 10px; span { width: 24px; height: 8px; border-radius: 4px; background: rgba(255, 255, 255, .35); } }
// 실제 사용: 아래로 스크롤하면 위로 숨는다 (JS 가 .is-hidden 을 토글)
&.is-hidden { transform: translateY(-100%); }
}
// ── 데모(.is-demo): 숨었다 나타났다 2초 루프 ──────────────
.head.is-demo {
animation: hide-reveal-loop $dur-loop $easing infinite;
}
@keyframes hide-reveal-loop {
0%, 20% { transform: translateY(0); }
45%, 70% { transform: translateY(-100%); }
95%, 100% { transform: translateY(0); }
}
// ── 배경: 문서처럼 보이는 자리 ─────────────────────────
.page { padding: 24px 24px 0; }
.page__line {
height: 12px;
margin: 0 0 14px;
border-radius: 4px;
background: rgba(255, 255, 255, .12);
&.short { width: 60%; }
}
// ── 접근성: 움직임 줄이기 설정이면 즉시 상태만 바뀐다 ────────
@media (prefers-reduced-motion: reduce) {
.head { transition: none; }
.head.is-demo { animation: none !important; transform: translateY(0); }
}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;
}
.head {
position: sticky;
top: 0;
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 clamp(16px, 5vw, 24px);
background: #17141a;
transition: transform 300ms ease-out;
}
.head__logo {
color: #fff7e6;
font-weight: 800;
font-size: 18px;
line-height: 1;
font-family: inherit;
letter-spacing: 0.04em;
}
.head__nav {
display: flex;
gap: 10px;
}
.head__nav span {
width: 24px;
height: 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.35);
}
.head.is-hidden {
transform: translateY(-100%);
}
.head.is-demo {
animation: hide-reveal-loop 2s ease-out infinite;
}
@keyframes hide-reveal-loop {
0%, 20% {
transform: translateY(0);
}
45%, 70% {
transform: translateY(-100%);
}
95%, 100% {
transform: translateY(0);
}
}
.page {
padding: 24px 24px 0;
}
.page__line {
height: 12px;
margin: 0 0 14px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.12);
}
.page__line.short {
width: 60%;
}
@media (prefers-reduced-motion: reduce) {
.head {
transition: none;
}
.head.is-demo {
animation: none !important;
transform: translateY(0);
}
}// 실제 페이지: 아래로 스크롤하면 숨기고 위로 스크롤하는 순간에만 다시 보인다 (방향 감지, 8줄)
var head = document.querySelector('.head');
var lastY = window.scrollY;
window.addEventListener('scroll', function () {
var y = window.scrollY;
head.classList.toggle('is-hidden', y > lastY && y > 40);
lastY = y;
}, { passive: true });import { useEffect, useRef } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./HideRevealHeader.module.scss";
export type HideRevealHeaderProps = {
logo: string;
children?: ReactNode;
/** 숨고 나타나는 시간 (기본 300ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function HideRevealHeader({
logo,
children,
duration = 300,
easing = "ease-out",
color = "#17141a",
}: HideRevealHeaderProps) {
const ref = useRef<HTMLElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
let lastY = window.scrollY;
function onScroll() {
const y = window.scrollY;
ref.current?.classList.toggle("is-hidden", y > lastY && y > 40);
lastY = y;
}
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<header ref={ref} className={styles.head} style={vars}>
<span className={styles.logo}>{logo}</span>
<nav className={styles.nav}>{children}</nav>
</header>
);
}01 · 숨김 후 재등장 — 측정 누적면적 3.501% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
css 스티키 헤더는 어디에 쓰나
긴 기사 페이지 헤더나 모바일 쇼핑몰 상단바에 씁니다.
var lastY = window.scrollY;
window.addEventListener('scroll', function () {
var y = window.scrollY;
head.classList.toggle('is-hidden', y > lastY && y > 40);
lastY = y;
}, { passive: true });
02스크롤 시 그림자 생김
어떤 움직임인가
맨 위에서는 그림자가 없다가 조금이라도 스크롤하면 헤더 아래 그라데이션 레이어의 opacity가 켜집니다.

<main class="stage">
<div class="sentinel"></div>
<header class="head is-demo">
<span class="head__logo">GODRICH</span>
<span class="head__shadow" aria-hidden="true"></span>
</header>
<div class="page">
<p class="page__line"></p>
<p class="page__line"></p>
<p class="page__line short"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 그림자가 번지는 시간
$easing: $ease-out;
$color: $stage-paper; // 헤더 배경
// ── 데모 무대 ──────────────────────────────────────────
html, body { margin: 0; overflow: hidden; }
.stage {
min-height: 100vh;
margin: 0;
background: $stage-yellow;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sentinel { position: absolute; top: 0; height: 1px; width: 1px; }
.head {
position: sticky;
top: 0;
height: 56px;
display: flex;
align-items: center;
padding: 0 clamp(16px, 5vw, 24px);
background: $color;
&__logo { color: $stage-ink; font-weight: 800; font-size: 18px; line-height: 1; font-family: inherit; letter-spacing: .04em; }
// 그림자는 요소를 새로 그리지 않고 미리 그려둔 그라데이션 레이어의 opacity 만 켠다 (범위·대비를 키워 눈에 띄게)
&__shadow {
position: absolute;
left: 0; right: 0; bottom: -48px;
height: 48px;
background: linear-gradient(rgba($stage-ink, .85), rgba($stage-ink, 0));
opacity: 0;
transition: opacity $duration $easing;
}
// 실제 사용: 센티넬이 사라지면 JS 가 .is-scrolled 를 붙인다
&.is-scrolled &__shadow { opacity: 1; }
}
// ── 데모(.is-demo): 그림자가 켜졌다 꺼졌다 2초 루프 ────────
.head.is-demo .head__shadow {
animation: shadow-loop $dur-loop $easing infinite;
}
@keyframes shadow-loop {
0%, 5% { opacity: 0; }
40%, 60% { opacity: 1; }
95%, 100% { opacity: 0; }
}
// ── 배경: 문서처럼 보이는 자리 ─────────────────────────
.page { padding: 24px 24px 0; }
.page__line {
height: 12px;
margin: 0 0 14px;
border-radius: 4px;
background: rgba($stage-ink, .12);
&.short { width: 60%; }
}
@media (prefers-reduced-motion: reduce) {
.head__shadow { transition: none; }
.head.is-demo .head__shadow { animation: none !important; opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
min-height: 100vh;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sentinel {
position: absolute;
top: 0;
height: 1px;
width: 1px;
}
.head {
position: sticky;
top: 0;
height: 56px;
display: flex;
align-items: center;
padding: 0 clamp(16px, 5vw, 24px);
background: #ffffff;
}
.head__logo {
color: #17141a;
font-weight: 800;
font-size: 18px;
line-height: 1;
font-family: inherit;
letter-spacing: 0.04em;
}
.head__shadow {
position: absolute;
left: 0;
right: 0;
bottom: -48px;
height: 48px;
background: linear-gradient(rgba(23, 20, 26, 0.85), rgba(23, 20, 26, 0));
opacity: 0;
transition: opacity 220ms ease-out;
}
.head.is-scrolled .head__shadow {
opacity: 1;
}
.head.is-demo .head__shadow {
animation: shadow-loop 2s ease-out infinite;
}
@keyframes shadow-loop {
0%, 5% {
opacity: 0;
}
40%, 60% {
opacity: 1;
}
95%, 100% {
opacity: 0;
}
}
.page {
padding: 24px 24px 0;
}
.page__line {
height: 12px;
margin: 0 0 14px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.12);
}
.page__line.short {
width: 60%;
}
@media (prefers-reduced-motion: reduce) {
.head__shadow {
transition: none;
}
.head.is-demo .head__shadow {
animation: none !important;
opacity: 1;
}
}// 실제 페이지: 맨 위 1px 센티넬이 화면을 벗어나는 순간 그림자를 켠다 (IntersectionObserver, 6줄)
var head = document.querySelector('.head');
new IntersectionObserver(function (es) {
head.classList.toggle('is-scrolled', !es[0].isIntersecting);
}).observe(document.querySelector('.sentinel'));import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./ShadowOnScrollHeader.module.scss";
export type ShadowOnScrollHeaderProps = {
logo: string;
/** 그림자가 번지는 시간 (기본 220ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ShadowOnScrollHeader({
logo,
duration = 220,
easing = "ease-out",
color = "#fff",
}: ShadowOnScrollHeaderProps) {
const head = useRef<HTMLElement>(null);
const sentinel = useRef<HTMLDivElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
if (!sentinel.current) return;
const io = new IntersectionObserver(([e]) => {
head.current?.classList.toggle("is-scrolled", !e.isIntersecting);
});
io.observe(sentinel.current);
return () => io.disconnect();
}, []);
return (
<>
<div ref={sentinel} className={styles.sentinel} />
<header ref={head} className={styles.head} style={vars}>
<span className={styles.logo}>{logo}</span>
<span className={styles.shadow} aria-hidden="true" />
</header>
</>
);
}02 · 스크롤 시 그림자 생김 — 측정 누적면적 15.0% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
언제 쓰나
문서·블로그 헤더처럼 옅은 배경 위 내비게이션에 어울립니다.
.head__shadow {
position: absolute;
left: 0; right: 0; bottom: -36px;
height: 36px;
background: linear-gradient(rgba($ink, .22), rgba($ink, 0));
opacity: 0;
transition: opacity $duration $easing;
}
.head.is-scrolled .head__shadow { opacity: 1; }
03색 반전
무엇이 보이나
어두운 히어로 위에서는 투명 배경에 흰 글자였다가 히어로를 벗어나면 배경과 글자색이 통째로 뒤집힙니다.

<main class="stage">
<header class="head is-demo">
<span class="head__layer head__layer--dark"></span>
<span class="head__layer head__layer--light"></span>
<span class="head__logo">GODRICH</span>
</header>
<div class="hero">
<div class="hero__sentinel"></div>
<p class="hero__title">히어로 영역</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 색이 뒤집히는 시간
$easing: $ease-out;
$color: $subject-ink; // 반전 뒤 헤더 배경
// ── 데모 무대 ──────────────────────────────────────────
html, body { margin: 0; overflow: hidden; }
.stage {
min-height: 100vh;
margin: 0;
background: $stage-orange;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.head {
position: sticky;
top: 0;
height: 56px;
display: flex;
align-items: center;
padding: 0 clamp(16px, 5vw, 24px);
// 두 배경 레이어를 겹쳐 두고 opacity 만 크로스페이드한다 (배경색 자체는 애니메이션하지 않는다)
&__layer { position: absolute; inset: 0; }
&__layer--dark { background: transparent; opacity: 1; transition: opacity $duration $easing; }
&__layer--light { background: $color; opacity: 0; transition: opacity $duration $easing; }
&.is-inverted &__layer--dark { opacity: 0; }
&.is-inverted &__layer--light { opacity: 1; }
// 로고도 헤더와 같이 뒤집힌다 — 주황(투명) 구간에선 잉크, 먹 구간에선 크림이라야 둘 다 AA 를 넘는다
&__logo { position: relative; color: $subject-ink; font-weight: 800; font-size: 18px; line-height: 1; font-family: inherit; letter-spacing: .04em; transition: color $duration $easing; }
&.is-inverted &__logo { color: $subject-cream; }
}
// ── 데모(.is-demo): 뒤집혔다 돌아왔다 2초 루프 ─────────────
.head.is-demo .head__layer--light {
animation: invert-loop $dur-loop $easing infinite;
}
.head.is-demo .head__logo {
animation: logo-invert $dur-loop $easing infinite;
}
@keyframes invert-loop {
0%, 15% { opacity: 0; }
45%, 70% { opacity: 1; }
95%, 100% { opacity: 0; }
}
@keyframes logo-invert {
0%, 15% { color: $subject-ink; }
45%, 70% { color: $subject-cream; }
95%, 100% { color: $subject-ink; }
}
// ── 히어로: 어두운 배경 위 흰 글자 자리 ───────────────────
.hero { position: relative; height: clamp(100px, 40vh, 220px); display: grid; place-items: center; }
.hero__sentinel { position: absolute; bottom: 0; height: 1px; width: 1px; }
.hero__title { color: $subject-ink; font-weight: 700; font-size: clamp(14px, 4vw, 18px); line-height: 1; font-family: inherit; margin: 0; }
@media (prefers-reduced-motion: reduce) {
.head__layer { transition: none; }
.head__logo { transition: none; }
.head.is-demo .head__layer--light { animation: none !important; opacity: 1; }
.head.is-demo .head__logo { animation: none !important; color: $subject-cream; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
min-height: 100vh;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.head {
position: sticky;
top: 0;
height: 56px;
display: flex;
align-items: center;
padding: 0 clamp(16px, 5vw, 24px);
}
.head__layer {
position: absolute;
inset: 0;
}
.head__layer--dark {
background: transparent;
opacity: 1;
transition: opacity 300ms ease-out;
}
.head__layer--light {
background: #17141a;
opacity: 0;
transition: opacity 300ms ease-out;
}
.head.is-inverted .head__layer--dark {
opacity: 0;
}
.head.is-inverted .head__layer--light {
opacity: 1;
}
.head__logo {
position: relative;
color: #17141a;
font-weight: 800;
font-size: 18px;
line-height: 1;
font-family: inherit;
letter-spacing: 0.04em;
transition: color 300ms ease-out;
}
.head.is-inverted .head__logo {
color: #fff7e6;
}
.head.is-demo .head__layer--light {
animation: invert-loop 2s ease-out infinite;
}
.head.is-demo .head__logo {
animation: logo-invert 2s ease-out infinite;
}
@keyframes invert-loop {
0%, 15% {
opacity: 0;
}
45%, 70% {
opacity: 1;
}
95%, 100% {
opacity: 0;
}
}
@keyframes logo-invert {
0%, 15% {
color: #17141a;
}
45%, 70% {
color: #fff7e6;
}
95%, 100% {
color: #17141a;
}
}
.hero {
position: relative;
height: clamp(100px, 40vh, 220px);
display: grid;
place-items: center;
}
.hero__sentinel {
position: absolute;
bottom: 0;
height: 1px;
width: 1px;
}
.hero__title {
color: #17141a;
font-weight: 700;
font-size: clamp(14px, 4vw, 18px);
line-height: 1;
font-family: inherit;
margin: 0;
}
@media (prefers-reduced-motion: reduce) {
.head__layer {
transition: none;
}
.head__logo {
transition: none;
}
.head.is-demo .head__layer--light {
animation: none !important;
opacity: 1;
}
.head.is-demo .head__logo {
animation: none !important;
color: #fff7e6;
}
}// 실제 페이지: 히어로 하단 센티넬을 지나면 색이 뒤집힌다 (IntersectionObserver, 6줄)
var head = document.querySelector('.head');
new IntersectionObserver(function (es) {
head.classList.toggle('is-inverted', !es[0].isIntersecting);
}).observe(document.querySelector('.hero__sentinel'));import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./ColorInvertHeader.module.scss";
export type ColorInvertHeaderProps = {
logo: string;
heroSentinelRef: { current: HTMLElement | null };
/** 색이 뒤집히는 시간 (기본 300ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ColorInvertHeader({
logo,
heroSentinelRef,
duration = 300,
easing = "ease-out",
color = "#17141a",
}: ColorInvertHeaderProps) {
const head = useRef<HTMLElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
if (!heroSentinelRef.current) return;
const io = new IntersectionObserver(([e]) => {
head.current?.classList.toggle("is-inverted", !e.isIntersecting);
});
io.observe(heroSentinelRef.current);
return () => io.disconnect();
}, [heroSentinelRef]);
return (
<header ref={head} className={styles.head} style={vars}>
<span className={styles.layerDark} />
<span className={styles.layerLight} />
<span className={styles.logo}>{logo}</span>
</header>
);
}03 · 색 반전 — 측정 누적면적 18.667% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디에 쓰나
풀스크린 히어로가 있는 랜딩 페이지, 포트폴리오 상단에 씁니다.
.head__layer--dark {
opacity: 1;
transition: opacity $duration $easing;
}
.head__layer--light {
background: $color;
opacity: 0;
transition: opacity $duration $easing;
}
.head.is-inverted .head__layer--dark { opacity: 0; }
.head.is-inverted .head__layer--light { opacity: 1; }
04로고만 축소
화면에서 일어나는 일
헤더 높이와 배경은 그대로 두고 로고 마크만 scale로 작아져 스크롤해도 레이아웃이 흔들리지 않습니다.

<main class="stage">
<header class="head is-demo">
<span class="head__logo"><span class="head__mark" aria-hidden="true"></span>GODRICH</span>
<nav class="head__nav" aria-hidden="true"><span></span><span></span><span></span></nav>
</header>
<div class="page">
<p class="page__line"></p>
<p class="page__line"></p>
<p class="page__line short"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter; // 로고가 줄어드는 시간
$easing: $ease-out;
$scale-to: .72; // 줄어든 비율
// ── 데모 무대 ──────────────────────────────────────────
html, body { margin: 0; overflow: hidden; }
.stage {
min-height: 100vh;
margin: 0;
background: $stage-paper;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
// 헤더 높이·배경은 고정. 로고만 scale 로 줄어든다 (레이아웃은 절대 바뀌지 않는다)
.head {
position: sticky;
top: 0;
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 clamp(16px, 5vw, 24px);
background: $subject-blue;
&__logo {
display: flex; align-items: center; gap: 10px;
color: $subject-cream; font-weight: 800; font-size: 20px; line-height: 1; font-family: inherit; letter-spacing: .04em;
transform-origin: left center; // 규칙8: 헤더 좌변 붙은 로고 축소
}
&__mark { width: 40px; height: 40px; border-radius: $r-control; background: $subject-cream; }
&__nav { display: flex; gap: 10px; span { width: 24px; height: 8px; border-radius: 4px; background: rgba(255, 255, 255, .35); } }
// 실제 사용: 문서 스크롤이 진행되는 동안 로고만 줄어든다
@supports (animation-timeline: scroll()) {
.head__logo { animation: logo-shrink linear both; animation-timeline: scroll(root); animation-range: 0 150px; }
}
}
@keyframes logo-shrink {
from { transform: scale(1); }
to { transform: scale($scale-to); }
}
// ── 데모(.is-demo): 로고가 줄었다 늘었다 2초 루프 ──────────
.head.is-demo .head__logo {
animation: logo-shrink-loop $dur-loop $easing infinite;
}
@keyframes logo-shrink-loop {
0%, 15% { transform: scale(1); }
45%, 70% { transform: scale($scale-to); }
95%, 100% { transform: scale(1); }
}
// ── 배경: 문서처럼 보이는 자리 ─────────────────────────
.page { padding: 24px 24px 0; }
.page__line {
height: 12px;
margin: 0 0 14px;
border-radius: 4px;
background: rgba($stage-ink, .1);
&.short { width: 60%; }
}
@media (prefers-reduced-motion: reduce) {
.head.is-demo .head__logo { animation: none !important; transform: scale($scale-to); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
min-height: 100vh;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.head {
position: sticky;
top: 0;
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 clamp(16px, 5vw, 24px);
background: #2f6df6;
}
.head__logo {
display: flex;
align-items: center;
gap: 10px;
color: #fff7e6;
font-weight: 800;
font-size: 20px;
line-height: 1;
font-family: inherit;
letter-spacing: 0.04em;
transform-origin: left center;
}
.head__mark {
width: 40px;
height: 40px;
border-radius: 12px;
background: #fff7e6;
}
.head__nav {
display: flex;
gap: 10px;
}
.head__nav span {
width: 24px;
height: 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.35);
}
@supports (animation-timeline: scroll()) {
.head .head__logo {
animation: logo-shrink linear both;
animation-timeline: scroll(root);
animation-range: 0 150px;
}
}
@keyframes logo-shrink {
from {
transform: scale(1);
}
to {
transform: scale(0.72);
}
}
.head.is-demo .head__logo {
animation: logo-shrink-loop 2s ease-out infinite;
}
@keyframes logo-shrink-loop {
0%, 15% {
transform: scale(1);
}
45%, 70% {
transform: scale(0.72);
}
95%, 100% {
transform: scale(1);
}
}
.page {
padding: 24px 24px 0;
}
.page__line {
height: 12px;
margin: 0 0 14px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.1);
}
.page__line.short {
width: 60%;
}
@media (prefers-reduced-motion: reduce) {
.head.is-demo .head__logo {
animation: none !important;
transform: scale(0.72);
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./LogoShrinkHeader.module.scss";
export type LogoShrinkHeaderProps = {
logo: string;
children?: ReactNode;
/** 로고가 줄어드는 시간 (기본 600ms) — 실제 사용은 scroll() 타임라인이 대신한다 */
duration?: number;
easing?: string;
/** 줄어든 비율 (기본 .72) */
scaleTo?: number;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--scale-to"?: number };
export default function LogoShrinkHeader({
logo,
children,
duration = 600,
easing = "ease-out",
scaleTo = 0.72,
}: LogoShrinkHeaderProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--scale-to": scaleTo };
return (
<header className={styles.head} style={vars}>
<span className={styles.logo}>
<span className={styles.mark} aria-hidden="true" />
{logo}
</span>
<nav className={styles.nav}>{children}</nav>
</header>
);
}04 · 로고만 축소 — 측정 누적면적 1.585% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
쓰는 자리
브랜드 로고가 큰 SaaS 랜딩에 씁니다.
.head__logo {
transform-origin: left center;
@supports (animation-timeline: scroll()) {
animation: logo-shrink linear both;
animation-timeline: scroll(root);
animation-range: 0 150px;
}
}
@keyframes logo-shrink {
from { transform: scale(1); }
to { transform: scale($scale-to); }
}
05진행바 결합
어떤 움직임인가
고정 헤더의 맨 아래 테두리에 읽은 만큼 채워지는 얇은 진행바가 붙어 헤더와 한 덩어리로 움직입니다.

<main class="stage">
<header class="head">
<span class="head__logo">GODRICH</span>
<span class="head__bar is-demo" aria-hidden="true"></span>
</header>
<div class="page">
<p class="page__line"></p>
<p class="page__line"></p>
<p class="page__line short"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$thickness: 3px; // 바 두께. 굵게 6px
$color: $subject-blue; // 바 색
$track: rgba(255, 255, 255, .14); // 안 읽은 구간
// ── 데모 무대 ──────────────────────────────────────────
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;
}
.head {
position: sticky;
top: 0;
height: 56px;
display: flex;
align-items: center;
padding: 0 clamp(16px, 5vw, 24px);
background: $stage-ink;
border-bottom: 1px solid $track;
&__logo { color: $subject-cream; font-weight: 800; font-size: 18px; line-height: 1; font-family: inherit; letter-spacing: .04em; }
// 진행 바는 헤더의 자식 요소라 헤더와 한 덩어리로 스크롤을 따라간다
&__bar {
position: absolute;
left: 0; bottom: -$thickness;
width: 100%;
height: $thickness;
background: $color;
transform: scaleX(0);
transform-origin: left; // 규칙8: 스크롤 진행띠 좌측 채움
// 실제 사용: 문서 전체 스크롤 진행률이 곧 바 길이다
@supports (animation-timeline: scroll()) {
animation: fill-bar linear both;
animation-timeline: scroll(root);
}
}
}
@keyframes fill-bar {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
// ── 데모(.is-demo): 2초 루프로 흉내 ────────────────────
.head__bar.is-demo {
animation: fill-loop $dur-loop linear infinite;
}
@keyframes fill-loop {
0% { transform: scaleX(0); }
90% { transform: scaleX(1); }
100% { transform: scaleX(1); }
}
// ── 배경: 문서처럼 보이는 자리 ─────────────────────────
.page { padding: 28px 24px 0; }
.page__line {
height: 12px;
margin: 0 0 14px;
border-radius: 4px;
background: rgba(255, 255, 255, .12);
&.short { width: 60%; }
}
@media (prefers-reduced-motion: reduce) {
.head__bar, .head__bar.is-demo { animation: none !important; transform: scaleX(1); }
}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;
}
.head {
position: sticky;
top: 0;
height: 56px;
display: flex;
align-items: center;
padding: 0 clamp(16px, 5vw, 24px);
background: #17141a;
border-bottom: 1px solid rgba(255, 255, 255, 0.14);
}
.head__logo {
color: #fff7e6;
font-weight: 800;
font-size: 18px;
line-height: 1;
font-family: inherit;
letter-spacing: 0.04em;
}
.head__bar {
position: absolute;
left: 0;
bottom: -3px;
width: 100%;
height: 3px;
background: #2f6df6;
transform: scaleX(0);
transform-origin: left;
}
@supports (animation-timeline: scroll()) {
.head__bar {
animation: fill-bar linear both;
animation-timeline: scroll(root);
}
}
@keyframes fill-bar {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
.head__bar.is-demo {
animation: fill-loop 2s linear infinite;
}
@keyframes fill-loop {
0% {
transform: scaleX(0);
}
90% {
transform: scaleX(1);
}
100% {
transform: scaleX(1);
}
}
.page {
padding: 28px 24px 0;
}
.page__line {
height: 12px;
margin: 0 0 14px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.12);
}
.page__line.short {
width: 60%;
}
@media (prefers-reduced-motion: reduce) {
.head__bar, .head__bar.is-demo {
animation: none !important;
transform: scaleX(1);
}
}import type { CSSProperties } from "react";
import styles from "./HeaderProgressBar.module.scss";
export type HeaderProgressBarProps = {
logo: string;
/** 바 두께 (기본 3px) */
thickness?: number;
color?: string;
track?: string;
};
type Vars = CSSProperties & { "--thickness"?: string; "--color"?: string; "--track"?: string };
export default function HeaderProgressBar({
logo,
thickness = 3,
color = "#2f6df6",
track = "rgba(255,255,255,.14)",
}: HeaderProgressBarProps) {
const vars: Vars = { "--thickness": `${thickness}px`, "--color": color, "--track": track };
return (
<header className={styles.head} style={vars}>
<span className={styles.logo}>{logo}</span>
{/* scroll() 타임라인만으로 채워진다 — JS 없이 */}
<span className={styles.bar} aria-hidden="true" />
</header>
);
}05 · 진행바 결합 — 측정 누적면적 1.0% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
언제 쓰나
긴 블로그 글 헤더, 튜토리얼 문서 상단에 씁니다.
.head__bar {
position: absolute;
left: 0; bottom: -$thickness;
width: 100%; height: $thickness;
background: $color;
transform: scaleX(0);
transform-origin: left;
animation: fill-bar linear both;
animation-timeline: scroll(root);
}
06서브 내비 스티키
무엇이 보이나
메인 헤더 아래 카테고리 탭이 스크롤하면 헤더 바로 밑에 두 번째 줄로 붙어 함께 고정됩니다.

<main class="stage">
<header class="head">GODRICH</header>
<div class="sentinel"></div>
<nav class="subnav is-demo">
<span class="subnav__tab is-active">여성</span>
<span class="subnav__tab">남성</span>
<span class="subnav__tab">키즈</span>
</nav>
<div class="page">
<p class="page__line"></p>
<p class="page__line"></p>
<p class="page__line short"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$head-h: 56px; // 메인 헤더 높이 — 서브 내비가 이 값 아래 붙는다
$duration: $dur-quick; // 테두리가 켜지는 시간
$color: $subject-ink; // 서브 내비 배경
// ── 데모 무대 ──────────────────────────────────────────
html, body { margin: 0; overflow: hidden; }
.stage {
min-height: 100vh;
margin: 0;
background: $stage-yellow;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sentinel { height: 1px; }
.head {
position: sticky;
top: 0;
z-index: 2;
height: $head-h;
display: flex;
align-items: center;
padding: 0 clamp(16px, 5vw, 24px);
background: $stage-ink;
color: $subject-cream;
font-weight: 800;
font-size: 18px;
line-height: 1;
font-family: inherit;
letter-spacing: .04em;
}
// 서브 내비는 메인 헤더 바로 아래에 두 번째 줄로 붙는다 (이중 sticky, 겹침 없음)
.subnav {
position: sticky;
top: $head-h;
z-index: 1;
display: flex;
gap: 4px;
padding: clamp(6px, 3vh, 10px) clamp(16px, 5vw, 24px);
background: $color;
border-bottom: 4px solid transparent;
transition: border-color $duration $ease-out;
&.is-stuck { border-bottom-color: $subject-cream; }
&__tab {
padding: 6px 14px;
border-radius: $r-pill;
font-weight: 600;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: rgba(255, 255, 255, .6);
&.is-active { color: $stage-ink; background: $subject-cream; }
}
}
// ── 데모(.is-demo): 테두리가 켜졌다 꺼졌다 2초 루프 ────────
.subnav.is-demo {
animation: stuck-loop $dur-loop $ease-out infinite;
}
@keyframes stuck-loop {
0%, 5% { border-bottom-color: transparent; }
40%, 60% { border-bottom-color: $subject-cream; }
95%, 100% { border-bottom-color: transparent; }
}
// ── 배경: 문서처럼 보이는 자리 (칸이 좁으면 줄인다) ────────────
.page { padding: clamp(8px, 4vh, 20px) 24px 0; }
.page__line {
height: 10px;
margin: 0 0 clamp(6px, 3vh, 14px);
border-radius: 4px;
background: rgba($stage-ink, .14);
&.short { width: 60%; }
}
@media (prefers-reduced-motion: reduce) {
.subnav { transition: none; }
.subnav.is-demo { animation: none !important; border-bottom-color: $subject-cream; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
min-height: 100vh;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sentinel {
height: 1px;
}
.head {
position: sticky;
top: 0;
z-index: 2;
height: 56px;
display: flex;
align-items: center;
padding: 0 clamp(16px, 5vw, 24px);
background: #17141a;
color: #fff7e6;
font-weight: 800;
font-size: 18px;
line-height: 1;
font-family: inherit;
letter-spacing: 0.04em;
}
.subnav {
position: sticky;
top: 56px;
z-index: 1;
display: flex;
gap: 4px;
padding: clamp(6px, 3vh, 10px) clamp(16px, 5vw, 24px);
background: #17141a;
border-bottom: 4px solid transparent;
transition: border-color 220ms ease-out;
}
.subnav.is-stuck {
border-bottom-color: #fff7e6;
}
.subnav__tab {
padding: 6px 14px;
border-radius: 999px;
font-weight: 600;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: rgba(255, 255, 255, 0.6);
}
.subnav__tab.is-active {
color: #17141a;
background: #fff7e6;
}
.subnav.is-demo {
animation: stuck-loop 2s ease-out infinite;
}
@keyframes stuck-loop {
0%, 5% {
border-bottom-color: transparent;
}
40%, 60% {
border-bottom-color: #fff7e6;
}
95%, 100% {
border-bottom-color: transparent;
}
}
.page {
padding: clamp(8px, 4vh, 20px) 24px 0;
}
.page__line {
height: 10px;
margin: 0 0 clamp(6px, 3vh, 14px);
border-radius: 4px;
background: rgba(23, 20, 26, 0.14);
}
.page__line.short {
width: 60%;
}
@media (prefers-reduced-motion: reduce) {
.subnav {
transition: none;
}
.subnav.is-demo {
animation: none !important;
border-bottom-color: #fff7e6;
}
}// 실제 페이지: 서브 내비가 헤더 밑에 닿는 순간 테두리를 켠다 (IntersectionObserver, 6줄)
var sub = document.querySelector('.subnav');
new IntersectionObserver(function (es) {
sub.classList.toggle('is-stuck', !es[0].isIntersecting);
}, { rootMargin: '-56px 0px 0px 0px', threshold: 1 }).observe(document.querySelector('.sentinel'));import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./StickySubNav.module.scss";
export type StickySubNavProps = {
tabs: string[];
activeIndex?: number;
/** 메인 헤더 높이 (기본 56px) — 서브 내비가 이 값 아래 붙는다 */
headHeight?: number;
duration?: number;
color?: string;
};
type Vars = CSSProperties & { "--head-h"?: string; "--duration"?: string; "--color"?: string };
export default function StickySubNav({
tabs,
activeIndex = 0,
headHeight = 56,
duration = 220,
color = "#17141a",
}: StickySubNavProps) {
const nav = useRef<HTMLElement>(null);
const sentinel = useRef<HTMLDivElement>(null);
const vars: Vars = { "--head-h": `${headHeight}px`, "--duration": `${duration}ms`, "--color": color };
useEffect(() => {
if (!sentinel.current) return;
const io = new IntersectionObserver(
([e]) => nav.current?.classList.toggle("is-stuck", !e.isIntersecting),
{ rootMargin: `-${headHeight}px 0px 0px 0px`, threshold: 1 }
);
io.observe(sentinel.current);
return () => io.disconnect();
}, [headHeight]);
return (
<>
<div ref={sentinel} className={styles.sentinel} />
<nav ref={nav} className={styles.subnav} style={vars}>
{tabs.map((t, i) => (
<span key={t} className={i === activeIndex ? `${styles.tab} ${styles.active}` : styles.tab}>{t}</span>
))}
</nav>
</>
);
}06 · 서브 내비 스티키 — 측정 누적면적 1.333% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디에 쓰나
쇼핑몰 카테고리 탭, 문서 사이트 목차 탭에 씁니다.
.subnav {
position: sticky;
top: $head-h;
border-bottom: 2px solid transparent;
transition: border-color $duration $ease-out;
}
.subnav.is-stuck { border-bottom-color: rgba(255, 255, 255, .25); }
07섹션 제목 스티키
화면에서 일어나는 일
긴 목록을 스크롤하는 동안 헤더 자리에 지금 지나가는 구간의 제목만 표시되고 다음 구간에서 교체됩니다.

<main class="stage">
<header class="head is-demo">
<span class="head__label head__label--a">A</span>
<span class="head__label head__label--b">B</span>
</header>
<div class="list">
<p class="list__sentinel" data-label="A"></p>
<p class="list__row">Amy</p>
<p class="list__row">Anna</p>
<p class="list__sentinel" data-label="B"></p>
<p class="list__row">Ben</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 제목이 교체되는 시간
$easing: $ease-out;
$color: $stage-orange; // 헤더 배경
// ── 데모 무대 ──────────────────────────────────────────
html, body { margin: 0; overflow: hidden; }
.stage {
min-height: 100vh;
margin: 0;
background: $stage-orange;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.head {
position: sticky;
top: 0;
height: 56px;
display: grid;
place-items: center;
background: $subject-ink;
overflow: hidden;
// 두 라벨을 겹쳐 두고 opacity + translateY 로 교체한다
&__label {
grid-area: 1 / 1;
color: $subject-cream;
font-weight: 800;
font-size: 20px;
line-height: 1;
font-family: inherit;
letter-spacing: .06em;
opacity: 0;
transform: translateY(8px);
transition: opacity $duration $easing, transform $duration $easing;
}
&__label.is-current { opacity: 1; transform: translateY(0); }
&__label--a.is-current ~ &__label--b { opacity: 0; }
}
// 기본 상태: A 가 보인다
.head__label--a { opacity: 1; transform: translateY(0); }
// ── 데모(.is-demo): A ↔ B 2초 루프로 교체 ─────────────────
.head.is-demo .head__label--a { animation: label-a-loop $dur-loop $easing infinite; }
.head.is-demo .head__label--b { animation: label-b-loop $dur-loop $easing infinite; }
@keyframes label-a-loop {
0%, 20% { opacity: 1; transform: translateY(0); }
45%, 75% { opacity: 0; transform: translateY(-8px); }
95%, 100% { opacity: 1; transform: translateY(0); }
}
@keyframes label-b-loop {
0%, 20% { opacity: 0; transform: translateY(8px); }
45%, 75% { opacity: 1; transform: translateY(0); }
95%, 100% { opacity: 0; transform: translateY(8px); }
}
// ── 목록: 알파벳 구간이 있는 연락처 흉내 (칸이 좁으면 줄인다) ───
.list { padding: clamp(4px, 3vh, 16px) 24px 0; }
.list__sentinel { height: 1px; margin: 0; }
.list__row {
margin: 0 0 clamp(3px, 2vh, 12px);
padding: clamp(3px, 2vh, 10px) 14px;
border-radius: $r-control;
background: rgba($subject-ink, .06);
color: $subject-ink;
font-weight: 600;
font-size: 12px;
line-height: 1.1;
font-family: inherit;
}
@media (prefers-reduced-motion: reduce) {
.head__label { transition: none; }
.head.is-demo .head__label--a, .head.is-demo .head__label--b { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
min-height: 100vh;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.head {
position: sticky;
top: 0;
height: 56px;
display: grid;
place-items: center;
background: #17141a;
overflow: hidden;
}
.head__label {
grid-area: 1/1;
color: #fff7e6;
font-weight: 800;
font-size: 20px;
line-height: 1;
font-family: inherit;
letter-spacing: 0.06em;
opacity: 0;
transform: translateY(8px);
transition: opacity 220ms ease-out, transform 220ms ease-out;
}
.head__label.is-current {
opacity: 1;
transform: translateY(0);
}
.head__label--a.is-current ~ .head__label--b {
opacity: 0;
}
.head__label--a {
opacity: 1;
transform: translateY(0);
}
.head.is-demo .head__label--a {
animation: label-a-loop 2s ease-out infinite;
}
.head.is-demo .head__label--b {
animation: label-b-loop 2s ease-out infinite;
}
@keyframes label-a-loop {
0%, 20% {
opacity: 1;
transform: translateY(0);
}
45%, 75% {
opacity: 0;
transform: translateY(-8px);
}
95%, 100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes label-b-loop {
0%, 20% {
opacity: 0;
transform: translateY(8px);
}
45%, 75% {
opacity: 1;
transform: translateY(0);
}
95%, 100% {
opacity: 0;
transform: translateY(8px);
}
}
.list {
padding: clamp(4px, 3vh, 16px) 24px 0;
}
.list__sentinel {
height: 1px;
margin: 0;
}
.list__row {
margin: 0 0 clamp(3px, 2vh, 12px);
padding: clamp(3px, 2vh, 10px) 14px;
border-radius: 12px;
background: rgba(23, 20, 26, 0.06);
color: #17141a;
font-weight: 600;
font-size: 12px;
line-height: 1.1;
font-family: inherit;
}
@media (prefers-reduced-motion: reduce) {
.head__label {
transition: none;
}
.head.is-demo .head__label--a, .head.is-demo .head__label--b {
animation: none !important;
}
}// 실제 페이지: 지금 통과 중인 구간 센티넬의 글자를 헤더 라벨에 올린다 (IntersectionObserver, 7줄)
var labels = document.querySelectorAll('.head__label');
document.querySelectorAll('.list__sentinel').forEach(function (s) {
new IntersectionObserver(function (es) {
if (es[0].isIntersecting) labels.forEach(function (l) { l.classList.toggle('is-current', l.textContent === s.dataset.label); });
}, { rootMargin: '-56px 0px -80% 0px' }).observe(s);
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SectionTitleHeader.module.scss";
export type SectionTitleHeaderProps = {
/** 구간 이름과, 그 구간이 시작하는 지점의 센티넬 ref */
sections: { label: string; sentinelRef: { current: HTMLElement | null } }[];
duration?: number;
easing?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string };
export default function SectionTitleHeader({ sections, duration = 220, easing = "ease-out" }: SectionTitleHeaderProps) {
const [current, setCurrent] = useState(sections[0]?.label ?? "");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing };
const observers = useRef<IntersectionObserver[]>([]);
useEffect(() => {
observers.current = sections.map(({ label, sentinelRef }) => {
const io = new IntersectionObserver(
([e]) => { if (e.isIntersecting) setCurrent(label); },
{ rootMargin: "-56px 0px -80% 0px" }
);
if (sentinelRef.current) io.observe(sentinelRef.current);
return io;
});
return () => observers.current?.forEach((io) => io.disconnect());
}, [sections]);
return (
<header className={styles.head} style={vars}>
<span key={current} className={styles.label}>{current}</span>
</header>
);
}07 · 섹션 제목 스티키 — 측정 누적면적 0.241% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
쓰는 자리
연락처·용어 사전처럼 알파벳 구간이 있는 목록에 씁니다.
document.querySelectorAll('.list__sentinel').forEach(function (s) {
new IntersectionObserver(function (es) {
if (es[0].isIntersecting) {
labels.forEach(function (l) {
l.classList.toggle('is-current', l.textContent === s.dataset.label);
});
}
}, { rootMargin: '-56px 0px -80% 0px' }).observe(s);
});
08backdrop blur 헤더
어떤 움직임인가
스크롤하면 헤더 배경이 불투명해지는 대신 backdrop-filter 블러가 켜져 뒤 콘텐츠가 흐릿하게 비칩니다.

<main class="stage">
<div class="photo"></div>
<div class="sentinel"></div>
<header class="head is-demo">
<span class="head__logo">GODRICH</span>
</header>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 블러가 켜지는 시간
$easing: $ease-out;
$blur: 10px; // 블러 강도
// ── 데모 무대 ──────────────────────────────────────────
html, body { margin: 0; overflow: hidden; }
.stage {
position: relative;
min-height: 100vh;
margin: 0;
background: $stage-paper;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sentinel { position: absolute; top: 0; height: 1px; width: 1px; }
// 뒤 화면 흉내 — 단색 배경만으로는 블러가 안 보여 줄무늬를 둔다
.photo {
position: absolute; inset: 0;
background: repeating-linear-gradient(120deg, $subject-blue 0 18px, $subject-cream 18px 36px);
}
.head {
position: sticky;
top: 0;
height: 56px;
display: flex;
align-items: center;
padding: 0 clamp(16px, 5vw, 24px);
background: rgba(255, 255, 255, .08);
backdrop-filter: blur(0);
transition: background $duration $easing, backdrop-filter $duration $easing;
&__logo { color: $subject-ink; font-weight: 800; font-size: 18px; line-height: 1; font-family: inherit; letter-spacing: .04em; }
// 실제 사용: 센티넬이 사라지면 반투명 유리 배경이 켜진다
&.is-scrolled { background: rgba(255, 255, 255, .55); backdrop-filter: blur($blur); }
}
// ── 데모(.is-demo): 블러가 켜졌다 꺼졌다 2초 루프 ──────────
.head.is-demo {
animation: blur-loop $dur-loop $easing infinite;
}
@keyframes blur-loop {
0%, 15% { background: rgba(255, 255, 255, .08); backdrop-filter: blur(0); }
45%, 70% { background: rgba(255, 255, 255, .55); backdrop-filter: blur($blur); }
95%, 100% { background: rgba(255, 255, 255, .08); backdrop-filter: blur(0); }
}
@media (prefers-reduced-motion: reduce) {
.head { transition: none; }
.head.is-demo { animation: none !important; background: rgba(255, 255, 255, .55); backdrop-filter: blur($blur); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
position: relative;
min-height: 100vh;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sentinel {
position: absolute;
top: 0;
height: 1px;
width: 1px;
}
.photo {
position: absolute;
inset: 0;
background: repeating-linear-gradient(120deg, #2f6df6 0 18px, #fff7e6 18px 36px);
}
.head {
position: sticky;
top: 0;
height: 56px;
display: flex;
align-items: center;
padding: 0 clamp(16px, 5vw, 24px);
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(0);
transition: background 300ms ease-out, backdrop-filter 300ms ease-out;
}
.head__logo {
color: #17141a;
font-weight: 800;
font-size: 18px;
line-height: 1;
font-family: inherit;
letter-spacing: 0.04em;
}
.head.is-scrolled {
background: rgba(255, 255, 255, 0.55);
backdrop-filter: blur(10px);
}
.head.is-demo {
animation: blur-loop 2s ease-out infinite;
}
@keyframes blur-loop {
0%, 15% {
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(0);
}
45%, 70% {
background: rgba(255, 255, 255, 0.55);
backdrop-filter: blur(10px);
}
95%, 100% {
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(0);
}
}
@media (prefers-reduced-motion: reduce) {
.head {
transition: none;
}
.head.is-demo {
animation: none !important;
background: rgba(255, 255, 255, 0.55);
backdrop-filter: blur(10px);
}
}// 실제 페이지: 센티넬을 지나면 블러 유리 배경을 켠다 (IntersectionObserver, 6줄)
var head = document.querySelector('.head');
new IntersectionObserver(function (es) {
head.classList.toggle('is-scrolled', !es[0].isIntersecting);
}).observe(document.querySelector('.sentinel'));import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./BackdropBlurHeader.module.scss";
export type BackdropBlurHeaderProps = {
logo: string;
duration?: number;
easing?: string;
/** 블러 강도 (기본 10px) */
blur?: number;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--blur"?: string };
export default function BackdropBlurHeader({
logo,
duration = 300,
easing = "ease-out",
blur = 10,
}: BackdropBlurHeaderProps) {
const head = useRef<HTMLElement>(null);
const sentinel = useRef<HTMLDivElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--blur": `${blur}px` };
useEffect(() => {
if (!sentinel.current) return;
const io = new IntersectionObserver(([e]) => {
head.current?.classList.toggle("is-scrolled", !e.isIntersecting);
});
io.observe(sentinel.current);
return () => io.disconnect();
}, []);
return (
<>
<div ref={sentinel} className={styles.sentinel} />
<header ref={head} className={styles.head} style={vars}>
<span className={styles.logo}>{logo}</span>
</header>
</>
);
}08 · backdrop blur 헤더 — 측정 누적면적 18.283% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
언제 쓰나
이미지·영상 위에 얹는 헤더, iOS 스타일 앱 상단바에 씁니다.
.head {
background: rgba(255, 255, 255, .08);
backdrop-filter: blur(0);
transition: background $duration $easing, backdrop-filter $duration $easing;
}
.head.is-scrolled {
background: rgba(255, 255, 255, .55);
backdrop-filter: blur($blur);
}
09스크롤 스냅 헤더
무엇이 보이나
본문이 섹션 단위로 스크롤 스냅될 때 헤더 안의 점 인디케이터가 지금 멈춘 섹션 번호에 맞춰 함께 넘어갑니다.

<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 · 스크롤 스냅 헤더 — 측정 누적면적 0.281% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디에 쓰나
풀페이지 스토리텔링 사이트, 슬라이드형 제품 소개에 씁니다.
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);
});
어디서 깨지는가 — 함정 셋
문서 스크롤이 있어야 움직인다
04와 05가 쓰는 animation-timeline: scroll(root)는 문서 자체가 스크롤될 때만 움직입니다. 이 zip의 데모처럼 html, body에 overflow: hidden을 걸어 iframe 안에서 스크롤바를 없앤 페이지에 그대로 붙여 넣으면 진행바가 0%에서 멈춥니다. 실제 페이지에 옮길 때는 이 두 줄부터 지워야 합니다. 두 속성 다 아직 지원이 갈리는데, caniuse의 scroll-driven animations 지원표를 보면 Chrome·Edge는 115부터, Safari는 26부터 지원하고 Firefox는 2026년 9월 현재도 정식판이 아니라 플래그 뒤에 있습니다.
rootMargin 하드코딩
02·03·06·08이 쓰는 IntersectionObserver 센티넬도 함정이 하나 있습니다. rootMargin에 헤더 높이를 하드코딩해 두면(예: -56px) 헤더 높이를 나중에 바꿨을 때 센티넬이 계속 옛 값으로 판정합니다. 헤더 높이가 반응형으로 바뀌는 페이지라면 getBoundingClientRect().height를 읽어 매번 다시 계산해야 합니다. MDN의 IntersectionObserver 문서에 rootMargin 문법이 정리돼 있습니다.
겹치는 sticky
마지막은 겹치는 css 스티키입니다. 06처럼 헤더 아래 서브 내비를 또 sticky로 붙이면 두 요소의 top 값이 같을 때 서로를 덮습니다. 서브 내비의 top은 반드시 메인 헤더의 실제 높이와 같아야 하고, 09처럼 헤더 안에 인디케이터를 두는 경우도 헤더 자체의 z-index가 본문 섹션보다 높아야 점이 가려지지 않습니다. 이 값들이 이미 맞춰진 아홉 개 파일의 압축 비밀번호는 u9rbcprm이고, 아래 버튼으로 받을 수 있습니다. 고정된 헤더 아래에서 본문 제목이 풀리는 연출을 더하고 싶다면, 글자가 풀리는 방식 자체는 해킹처럼 풀리는 디코드 텍스트 효과에 모여 있습니다.
접근성
동작 줄이기를 켰을 때
아홉 개 전부 prefers-reduced-motion: reduce를 처리해서, 운영체제에서 "동작 줄이기"를 켠 사람에게는 전환 없이 상태만 즉시 바뀝니다. 헤더가 고정되는 기능 자체는 어떤 경우에도 지우지 않는 게 원칙입니다. 01은 전환 없이 숨거나 나타나고, 03은 즉시 반전된 상태로, 04와 09는 즉시 목표 크기로 고정됩니다. 06·07처럼 순서가 있는 요소에는 aria-current 같은 상태 속성을 얹어 스크린 리더 사용자도 지금 어느 구간인지 알 수 있게 합니다. 이 미디어 쿼리를 실제로 어디에 걸어야 하는지는 MDN 문서의 예제를 참고하십시오.
@media (prefers-reduced-motion: reduce) {
.head { transition: none !important; }
.head.is-demo { animation: none !important; }
}
헤더 축소처럼 여기서 다루지 않은 스크롤 반응은 CSS 스크롤 애니메이션 9종을 먼저 보십시오. 나머지 CSS 주제 전체 목록과 운영자 소개는 CSS 애니메이션 카테고리와 소개 페이지에서 각각 찾을 수 있습니다.
FAQ
스티키 헤더에 꼭 JS가 필요합니까?
아닙니다. 04와 05는 animation-timeline: scroll() 하나로 JS 없이 문서 스크롤에 반응합니다. 다만 caniuse의 scroll-driven animations 지원표를 먼저 확인하십시오. 나머지 항목은 방향 판정이나 임계값 판정에 짧은 IntersectionObserver 몇 줄을 씁니다. 고정된 헤더에 검색바·CTA·배지까지 얹는 응용은 CSS 헤더 고정 9가지 응용이 이어지는 편입니다.
position: sticky가 안 먹힙니다.
가장 흔한 원인은 헤더의 조상 요소 중 하나에 overflow: hidden이나 overflow: auto가 걸려 있는 경우입니다. sticky는 그 조상의 스크롤 영역 안에서만 동작하므로, 헤더부터 문서 루트까지 사이에 overflow가 잘린 요소가 없는지 먼저 확인하십시오.
06 서브 내비와 09 인디케이터를 같은 헤더에 합쳐도 됩니까?
네, 자주 쓰는 조합입니다. 다만 겹치는 함정에서 다뤘듯 두 요소의 sticky top 값과 z-index를 서로 다르게 줘야 하고, zip의 react/ 폴더는 각각 별도 컴포넌트라 필요한 것만 골라 조합하면 됩니다.









