9 CSS Sticky Header Effects — No JS, Scroll-Driven
A css sticky header is a header pinned with position: sticky so it locks to the top of the viewport once the page scrolls past it.
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Hide then reveal
- 02 Shadow appears on scroll
- 03 Color invert on scroll
- 04 Logo-only shrink
- 05 Progress bar attached to header
- 06 Sticky sub-nav below header
- 07 Sticky current section title
- 08 Backdrop-blur glass header
- 09 Header synced to scroll-snap
The order below moves from a header noticing scroll entirely on its own — hiding, shading, inverting — to a header holding still while one internal piece reacts. Finally, it moves to a second element altogether (a bar, a sub-nav row, a label, a set of dots) synced to whatever the header is doing. Each tile in the grid above loops on its own two-second timer inside an iframe with no scrollbar at all, so there is nothing to drag there — just watch it play through once. When scroll should move the page's layers instead of its header, the CSS parallax scrolling set is the same timeline put to different work, and a header whose contents keep drifting past rather than stacking is the CSS marquee infinite loop.
01Hide then reveal
The motion
The header slides fully off the top of the screen the moment you scroll down, and the instant the scroll direction flips upward it slides straight back, even from far down the page.

<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 · Hide then reveal — Measured area 3.501% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where a css sticky header on scroll fits
That direction check is the entire trick — no distance threshold decides when the header disappears, only which way the scroll just moved. It suits long article pages and mobile shop headers, where a header that stays visible the whole time eats space, while one that only reappears on demand does not.
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 });
02Shadow appears on scroll
What it does
At the very top of the page the header sits flush with no border at all; the first pixel of scroll then switches on a soft shadow layer that was already drawn into the markup, invisible until then.

<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 · Shadow appears on scroll — Measured area 15.0% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
When to use it
Because the shadow is a separate opacity-only layer rather than a live box-shadow value, toggling it costs almost nothing. Reach for it on documentation and blog headers over a pale background, where a shadow only needs to exist once there is something below it to separate from.
.head__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 $dur-quick $ease-out;
}
.head.is-scrolled .head__shadow { opacity: 1; }
03Color invert on scroll
What you see
Over a dark hero the header shows white text on a transparent background, and the moment the hero scrolls out of view a second, fully opaque layer crossfades in underneath it, flipping the header's whole palette at once.

<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">Hero section</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 · Color invert on scroll — Measured area 18.667% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where it fits
Nothing about the logo or nav markup changes — only which of the two stacked background layers happens to be opaque. It's built for landing pages with a full-bleed hero image, where one header needs to read against two completely different backgrounds without ever going invisible.
.head__layer--dark {
background: transparent;
opacity: 1;
transition: opacity $dur-base $ease-out;
}
.head__layer--light {
background: $subject-ink;
opacity: 0;
transition: opacity $dur-base $ease-out;
}
.head.is-inverted .head__layer--dark { opacity: 0; }
.head.is-inverted .head__layer--light { opacity: 1; }
04Logo-only shrink
The motion
Only the logo mark scales down as the page scrolls — the header's height, background, and every other child sit exactly where they started, so nothing around the logo ever has to reflow.

<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 · Logo-only shrink — Measured area 1.585% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
Good places for it
Where it's supported, the scale is driven straight off document scroll progress through animation-timeline: scroll(), with no scroll listener written at all. It's the shape to reach for on a SaaS landing page with a large brand mark, anywhere a shrinking header would otherwise cause layout shift.
.head {
height: 56px; // fixed height and background never move
@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(.72); }
}
05Progress bar attached to header
What it does
A three-pixel bar fused to the header's own bottom edge fills left to right as reading progresses, moving as one piece with the header because it is a child of the header rather than a separately positioned strip.

<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 · Progress bar attached to header — Measured area 1.0% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password
When to use it
Like the logo above, its fill is tied to animation-timeline: scroll(root) instead of a scroll handler recalculating a percentage on every frame. Long blog posts and tutorial docs are the natural home for it, giving a reader a sense of how much is left without a separate progress widget on the page.
.head__bar {
position: absolute;
left: 0;
bottom: -3px;
width: 100%;
height: 3px;
background: $subject-blue;
transform: scaleX(0);
transform-origin: left;
@supports (animation-timeline: scroll()) {
animation: fill-bar linear both;
animation-timeline: scroll(root);
}
}
@keyframes fill-bar {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
06Sticky sub-nav below header
What you see
A row of category tabs sits directly beneath the main header, and once it reaches the header's own height it locks into place as a second sticky row, traveling with the header instead of scrolling under it.

<main class="stage">
<header class="head">GODRICH</header>
<div class="sentinel"></div>
<nav class="subnav is-demo">
<span class="subnav__tab is-active">Women</span>
<span class="subnav__tab">Men</span>
<span class="subnav__tab">Kids</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 · Sticky sub-nav below header — Measured area 1.333% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where it fits
The two never fight over the same offset — the sub-nav's sticky top is set to the header's fixed height, not to zero. It's built for e-commerce category tabs and docs-site section tabs, anywhere a second navigation row needs to stay reachable the whole time the main header is visible.
.head { position: sticky; top: 0; z-index: 2; height: 56px; }
.subnav {
position: sticky;
top: 56px;
z-index: 1;
border-bottom: 4px solid transparent;
transition: border-color $dur-quick $ease-out;
&.is-stuck { border-bottom-color: $subject-cream; }
}
07Sticky current section title
The motion
Instead of a fixed header title, the header slot shows only the name of whichever section of a long list is currently passing underneath it, swapping labels the moment the next section starts.

<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 · Sticky current section title — Measured area 0.241% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
Good places for it
A thin sentinel element sits at the top of every section, and an observer watching each one decides which label counts as current. Alphabetized contact lists, glossaries, and long FAQ pages are where it earns its keep, replacing a static header with one that always names where the reader is.
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);
});
08Backdrop-blur glass header
What it does
Scrolling past the top does not turn this header opaque — it switches on a backdrop-filter blur instead, so whatever sits behind it keeps showing through, just softened into an iOS-style frosted pane.

<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 glass header — Measured area 18.283% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
When to use it
It belongs on headers layered over a hero image or video, anywhere painting the background flat white would hide too much of what's underneath it.
.head {
background: rgba(255, 255, 255, .08);
backdrop-filter: blur(0);
transition: background $dur-base $ease-out, backdrop-filter $dur-base $ease-out;
&.is-scrolled {
background: rgba(255, 255, 255, .55);
backdrop-filter: blur(10px);
}
}
09Header synced to scroll-snap
What you see
When the page snaps from section to section through scroll-snap-type, a row of dots inside the header advances to match whichever section just settled, growing the current dot and shrinking the one it replaces.

<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 synced to scroll-snap — Measured area 0.281% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where it fits
Nothing about the snapping itself lives in the header — an observer watching each section is what flips which dot counts as current. It suits full-page storytelling sites and slide-style product tours, where the header's only job is telling the visitor which slide they landed on.
.stage { scroll-snap-type: y mandatory; }
.head__dots i {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255, 255, 255, .3);
transform: scale(1);
transition: transform $dur-quick $ease-pop, background $dur-quick $ease-pop;
&.is-current { background: $subject-cream; transform: scale(1.4); }
}
Where it breaks — the traps
Two silent breakages
Two different breakages turn up across these nine, and neither one throws an error to tell you what happened. The sticky-dependent group — 01, 02, 03, 06, 08, and 09 — all rely on position: sticky finding room to move. Give any ancestor between the header and the actual scrolling element overflow: hidden or overflow: auto, or leave that ancestor without an explicit height, and the header just stops sticking, scrolling off with everything else as though the sticky rule was never written. Position: sticky itself has shipped in every modern browser for years now — when it stops working, the bug almost always sits in a parent's overflow or height, not in the sticky element itself. The second trap lives in items 04 and 05, both of which drive their transform off animation-timeline: scroll(root). That syntax only reached Chrome at version 115 and Safari at 26, with Firefox still shipping it behind a flag as of September 2026. Without the @supports (animation-timeline: scroll()) guard already sitting inside both demos above, a Firefox visitor just gets a logo that never shrinks and a bar that never fills, not a crash. Pull all nine together with every fallback already wired in from the archive, unzipped with u9rbcprm typed exactly as it reads on this line, no extra spaces added anywhere. Under a pinned header like this one, a body headline that decodes open instead of simply appearing is the trick the text reveal decode effects hold.
Accessibility
Reduced motion
None of the nine keep animating once prefers-reduced-motion is set, but each one settles on a different resting frame rather than freezing mid-loop. Header 01 stays fully visible with no slide-away at all; 02's shadow and 03's inverted colors both lock straight into their scrolled-past state instead of fading in gradually. 04's logo and 09's current dot hold their post-scroll size directly, skipping the scale animation between the two states entirely. 05's progress bar and 06's sub-nav border both settle full and lit rather than sweeping in over time, and 08's blur simply stays on rather than toggling with scroll position. 07 just stops sliding one label out from under the other, leaving whichever was already current sitting still. MDN's reference for prefers-reduced-motion covers how each operating system exposes the setting to the browser.
@media (prefers-reduced-motion: reduce) {
.head, .head__shadow, .head__layer--light, .head__logo,
.head__bar, .subnav, .head__label, .head__dots i {
transition: none;
animation: none !important;
}
}
More css sticky header example patterns and the rest of the css collection live at the css category hub, and what this site actually sells — zips, not tutorials — is explained on the about page.
FAQ
Why is my css sticky header not working partway down the page?
The most common cause is a parent element with overflow: hidden, overflow: auto, or overflow: scroll sitting between the sticky header and the page's real scroll container — position: sticky only sticks inside its nearest scrolling ancestor, and clipped overflow on that ancestor silently breaks it. The second most common cause is a parent that never gets an explicit height, leaving sticky no room to move inside. Check both of those before assuming the property itself is unsupported. Adding a search bar, CTA, or badge onto the pinned bar is the step the sticky header extras pick up from here.
Can I change a css sticky header's background on scroll without JavaScript?
Partly. Items 02 and 08 both use a pre-drawn layer whose opacity gets toggled, but the toggle itself still needs a few lines of JavaScript — an IntersectionObserver watching a sentinel element — because CSS alone has no built-in way to ask whether the user has scrolled past a given point. Items 04 and 05 get closer to JS-free by tying a transform straight to animation-timeline: scroll(), but that only handles continuous scroll-linked change, not an on/off swap at one fixed point.
Does a css sticky header table work inside a scrollable wrapper?
Yes, but the sticky context shifts: a header cell needs position: sticky with top: 0 set relative to the table's own scrolling wrapper, not to the page. None of the nine patterns above target that case directly, since all nine assume the header sticks to the page itself, but the same is-scrolled toggle from items 02 and 08 carries over cleanly once the sticky context becomes the table wrapper instead of the window.









