CSS 헤더 고정 9가지 응용 — 검색바·CTA·배지까지
CSS 헤더 고정은 스크롤해도 페이지 위쪽 메뉴 줄이 화면에 그대로 붙어 있게 만드는 기능으로, position: sticky 한 줄이면 충분합니다.
01 sticky-header-announcement-collapse
03 sticky-header-tab-underline-sync
04 sticky-header-search-bar-merge
05 sticky-header-search-expand
06 sticky-header-breadcrumb-reveal
07 sticky-header-user-menu-compact
08 sticky-header-cta-slide-in
09 sticky-header-notice-dot-pulse
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 공지 배너 접힘
- 02 아이콘만 남는 내비
- 03 탭 밑줄 스크롤 동기화
- 04 검색 바 흡수
- 05 검색창 확장
- 06 브레드크럼 등장
- 07 유저 메뉴 압축
- 08 CTA 버튼 슬라이드인
- 09 알림 뱃지 등장
아홉 개는 인기 순이 아니라 헤더 안에서 무엇이 바뀌는지에 따라 네 갈래로 묶입니다. 공간을 아끼려고 무언가 접히거나 좁아지는 쪽(01·02·07), 스크롤 위치에 맞춰 안내 정보가 움직이거나 드러나는 쪽(03·06), 검색 UI가 넓어지거나 다른 줄과 합쳐지는 쪽(04·05), 마지막으로 화면에 없던 요소가 새로 튀어나오는 쪽(08·09)입니다. 표 첫 줄만 붙여 두는 css 테이블 헤더 고정과 달리, 여기서는 페이지 맨 위 헤더 줄 전체를 다룹니다.
01공지 배너 접힘
배너가 위로 사라진다
페이지 맨 위 프로모션 배너 줄이 스크롤을 시작하자마자 레이아웃을 밀어내지 않고 위로 사라지고, 그 빈 자리로 로고 줄이 올라옵니다.

<main class="stage">
<div class="ahc is-demo">
<div class="ahc__slot">
<div class="ahc__bar">무료 배송 · 오늘까지</div>
<header class="ahc__head">
<span class="ahc__logo">GODRICH</span>
<nav class="ahc__nav" aria-hidden="true"><span></span><span></span><span></span></nav>
</header>
</div>
<div class="ahc__page">
<p class="ahc__line"></p>
<p class="ahc__line short"></p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 ──────────────────────────
$duration: $dur-base; // 배너가 접히고 헤더가 올라오는 시간
// 관성 lerp — p += (목표-p) * .32 를 20스텝 반복한 실제 계산 결과를 그대로 커브로 굽는다
// (형태 2차, 사용자 2026-09-14). ease-out 은 미리 그려둔 곡선이지만 이건 반복 계산의 산출값이다.
$easing: linear(0, .32, .538, .686, .786, .855, .901, .933, .954, .969, .979, .986, .99, .993, .995, .997, .998, .999, .999, .999, 1);
$easing-content: linear(0, .18, .328, .449, .548, .629, .696, .751, .796, .832, .863, .887, .908, .924, .938, .949, .958, .966, .972, .977, 1); // 알파 .18 — 헤더보다 느리게 목표를 따라잡아 시차가 생긴다
$dur-content: $dur-base * 1.4; // 콘텐츠가 헤더보다 늦게 도착
$color: $stage-ink; // 헤더·배너 배경
@include stage($stage-ink);
$bar-h: 32px;
$head-h: 56px;
.ahc { width: min(380px, 90vw); }
// ── 고정 높이 슬롯: 배너+헤더 두 층을 겹쳐 두고 transform 만 움직인다 (height 애니메이션 없음) ──
.ahc__slot {
position: relative;
height: $bar-h + $head-h;
}
.ahc__bar {
position: absolute; inset: 0 0 auto 0;
height: $bar-h;
display: flex; align-items: center; justify-content: center;
font-weight: 600;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1;
font-family: inherit;
color: $subject-cream;
background: $subject-blue-deep;
transform: translateY(0);
opacity: 1;
transition: transform $duration $easing, opacity $duration $easing;
}
.ahc__head {
position: absolute; left: 0; right: 0; top: $bar-h;
height: $head-h;
display: flex; align-items: center; justify-content: space-between;
padding: 0 $sp-4;
background: $color;
transform: translateY(0);
transition: transform $duration $easing;
}
.ahc__logo { color: $subject-cream; font-weight: 800; font-size: clamp(14px, 4vw, 18px); line-height: 1; font-family: inherit; letter-spacing: .04em; }
.ahc__nav { display: flex; gap: $sp-2; span { width: 20px; height: 6px; border-radius: $r-pill; background: rgba($subject-cream, .35); } }
// 실제 사용: 스크롤을 시작하면 배너가 위로 사라지고 헤더가 그 빈 자리로 올라온다
.ahc.is-scrolled {
.ahc__bar { transform: translateY(-100%); opacity: 0; }
.ahc__head { transform: translateY(-#{$bar-h}); }
// 콘텐츠는 헤더를 그대로 따라가지 않고 $easing-content 로 뒤처져 도착 — 시차 스크롤
.ahc__page { transform: translateY(-8px); }
}
// ── 데모(.is-demo): 접혔다 돌아오는 2초 루프 ──────────────
.ahc.is-demo {
.ahc__bar { animation: ahc-bar-loop $dur-loop $easing infinite; }
.ahc__head { animation: ahc-head-loop $dur-loop $easing infinite; }
.ahc__page { animation: ahc-page-loop $dur-loop $easing-content infinite; }
}
@keyframes ahc-bar-loop {
0%, 20% { transform: translateY(0); opacity: 1; }
45%, 70% { transform: translateY(-100%); opacity: 0; }
95%, 100% { transform: translateY(0); opacity: 1; }
}
@keyframes ahc-head-loop {
0%, 20% { transform: translateY(0); }
45%, 70% { transform: translateY(-#{$bar-h}); }
95%, 100% { transform: translateY(0); }
}
// 헤더보다 느린 $easing-content 로 같은 20~45%·70~95% 구간을 밟는다 — 관성이 덜 붙어 뒤처져 보인다
@keyframes ahc-page-loop {
0%, 20% { transform: translateY(0); }
45%, 70% { transform: translateY(-8px); }
95%, 100% { transform: translateY(0); }
}
// ── 배경: 문서처럼 보이는 자리 ─────────────────────────
.ahc__page {
padding: $sp-4 0 0;
transform: translateY(0);
transition: transform $dur-content $easing-content;
}
.ahc__line {
height: 10px;
margin: 0 0 $sp-3;
border-radius: $r-xs;
background: rgba($subject-cream, .12);
&.short { width: 55%; }
}
// ── 접근성: 움직임 줄이기 설정이면 즉시 상태만 바뀐다 ────────
@media (prefers-reduced-motion: reduce) {
.ahc__bar, .ahc__head, .ahc__page { transition: none; }
.ahc.is-demo {
.ahc__bar, .ahc__head, .ahc__page { animation: none !important; }
.ahc__bar { transform: translateY(0); opacity: 1; }
.ahc__head { transform: translateY(0); }
.ahc__page { transform: translateY(0); }
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ahc {
width: min(380px, 90vw);
}
.ahc__slot {
position: relative;
height: 88px;
}
.ahc__bar {
position: absolute;
inset: 0 0 auto 0;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1;
font-family: inherit;
color: #fff7e6;
background: #0a4fe8;
transform: translateY(0);
opacity: 1;
transition: transform 300ms linear(0, 0.32, 0.538, 0.686, 0.786, 0.855, 0.901, 0.933, 0.954, 0.969, 0.979, 0.986, 0.99, 0.993, 0.995, 0.997, 0.998, 0.999, 0.999, 0.999, 1), opacity 300ms linear(0, 0.32, 0.538, 0.686, 0.786, 0.855, 0.901, 0.933, 0.954, 0.969, 0.979, 0.986, 0.99, 0.993, 0.995, 0.997, 0.998, 0.999, 0.999, 0.999, 1);
}
.ahc__head {
position: absolute;
left: 0;
right: 0;
top: 32px;
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
background: #17141a;
transform: translateY(0);
transition: transform 300ms linear(0, 0.32, 0.538, 0.686, 0.786, 0.855, 0.901, 0.933, 0.954, 0.969, 0.979, 0.986, 0.99, 0.993, 0.995, 0.997, 0.998, 0.999, 0.999, 0.999, 1);
}
.ahc__logo {
color: #fff7e6;
font-weight: 800;
font-size: clamp(14px, 4vw, 18px);
line-height: 1;
font-family: inherit;
letter-spacing: 0.04em;
}
.ahc__nav {
display: flex;
gap: 8px;
}
.ahc__nav span {
width: 20px;
height: 6px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.35);
}
.ahc.is-scrolled .ahc__bar {
transform: translateY(-100%);
opacity: 0;
}
.ahc.is-scrolled .ahc__head {
transform: translateY(-32px);
}
.ahc.is-scrolled .ahc__page {
transform: translateY(-8px);
}
.ahc.is-demo .ahc__bar {
animation: ahc-bar-loop 2s linear(0, 0.32, 0.538, 0.686, 0.786, 0.855, 0.901, 0.933, 0.954, 0.969, 0.979, 0.986, 0.99, 0.993, 0.995, 0.997, 0.998, 0.999, 0.999, 0.999, 1) infinite;
}
.ahc.is-demo .ahc__head {
animation: ahc-head-loop 2s linear(0, 0.32, 0.538, 0.686, 0.786, 0.855, 0.901, 0.933, 0.954, 0.969, 0.979, 0.986, 0.99, 0.993, 0.995, 0.997, 0.998, 0.999, 0.999, 0.999, 1) infinite;
}
.ahc.is-demo .ahc__page {
animation: ahc-page-loop 2s linear(0, 0.18, 0.328, 0.449, 0.548, 0.629, 0.696, 0.751, 0.796, 0.832, 0.863, 0.887, 0.908, 0.924, 0.938, 0.949, 0.958, 0.966, 0.972, 0.977, 1) infinite;
}
@keyframes ahc-bar-loop {
0%, 20% {
transform: translateY(0);
opacity: 1;
}
45%, 70% {
transform: translateY(-100%);
opacity: 0;
}
95%, 100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes ahc-head-loop {
0%, 20% {
transform: translateY(0);
}
45%, 70% {
transform: translateY(-32px);
}
95%, 100% {
transform: translateY(0);
}
}
@keyframes ahc-page-loop {
0%, 20% {
transform: translateY(0);
}
45%, 70% {
transform: translateY(-8px);
}
95%, 100% {
transform: translateY(0);
}
}
.ahc__page {
padding: 16px 0 0;
transform: translateY(0);
transition: transform 420ms linear(0, 0.18, 0.328, 0.449, 0.548, 0.629, 0.696, 0.751, 0.796, 0.832, 0.863, 0.887, 0.908, 0.924, 0.938, 0.949, 0.958, 0.966, 0.972, 0.977, 1);
}
.ahc__line {
height: 10px;
margin: 0 0 12px;
border-radius: 4px;
background: rgba(255, 247, 230, 0.12);
}
.ahc__line.short {
width: 55%;
}
@media (prefers-reduced-motion: reduce) {
.ahc__bar, .ahc__head, .ahc__page {
transition: none;
}
.ahc.is-demo .ahc__bar, .ahc.is-demo .ahc__head, .ahc.is-demo .ahc__page {
animation: none !important;
}
.ahc.is-demo .ahc__bar {
transform: translateY(0);
opacity: 1;
}
.ahc.is-demo .ahc__head {
transform: translateY(0);
}
.ahc.is-demo .ahc__page {
transform: translateY(0);
}
}// 실제 페이지: 스크롤을 시작하는 순간 공지 배너가 위로 사라지고 헤더가 그 자리로 올라온다 (7줄)
var el = document.querySelector('.ahc');
window.addEventListener('scroll', function () {
el.classList.toggle('is-scrolled', window.scrollY > 20);
}, { passive: true });import { useEffect, useRef } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./AnnouncementCollapseHeader.module.scss";
export type AnnouncementCollapseHeaderProps = {
logo: string;
announcement: string;
children?: ReactNode;
/** 배너가 접히고 헤더가 올라오는 시간 (기본 300ms) */
duration?: number;
/** 관성 lerp 커브(p += (목표-p)*.32 를 20스텝 반복해 구운 linear()) — 기본값이 vanilla 와 같다 */
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const LERP_EASING = "linear(0, .32, .538, .686, .786, .855, .901, .933, .954, .969, .979, .986, .99, .993, .995, .997, .998, .999, .999, .999, 1)";
export default function AnnouncementCollapseHeader({
logo,
announcement,
children,
duration = 300,
easing = LERP_EASING,
color = "#17141a",
}: AnnouncementCollapseHeaderProps) {
const ref = useRef<HTMLDivElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
function onScroll() {
ref.current?.classList.toggle("is-scrolled", window.scrollY > 20);
}
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<div ref={ref} className={styles.slot} style={vars}>
<div className={styles.bar}>{announcement}</div>
<header className={styles.head}>
<span className={styles.logo}>{logo}</span>
<nav className={styles.nav}>{children}</nav>
</header>
</div>
);
}01 · 공지 배너 접힘 — 측정 누적면적 23.972% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
레이아웃이 출렁이지 않는 이유
배너와 헤더를 같은 슬롯에 절대 위치로 겹쳐 두고 transform만 움직이기 때문에 배너가 사라지는 동안 레이아웃이 출렁이지 않습니다.
.ahc__bar {
position: absolute; inset: 0 0 auto 0;
height: 32px;
transform: translateY(0);
opacity: 1;
transition: transform .3s ease-out, opacity .3s ease-out;
}
.ahc__head {
position: absolute; left: 0; right: 0; top: 32px;
height: 56px;
transform: translateY(0);
transition: transform .3s ease-out;
}
// 실제 사용: 스크롤을 시작하면 배너가 위로 사라지고 헤더가 그 빈 자리로 올라온다
.ahc.is-scrolled {
.ahc__bar { transform: translateY(-100%); opacity: 0; }
.ahc__head { transform: translateY(-32px); }
}
배너·헤더가 접히는 이징은 ease-out 대신 관성 lerp 커브 — p += (목표 - p) * α를 스텝마다 반복한 결과값을 CSS linear() 함수로 구워 넣어, 스크롤을 멈춘 뒤에도 헤더가 잠깐 더 밀리며 따라옵니다.
02아이콘만 남는 내비
글자 라벨이 사라진다
내비게이션 링크의 글자 라벨이 스크롤하면 폭과 함께 사라지고 아이콘만 남아, 좁아진 헤더 폭을 아낍니다.
<main class="stage">
<header class="ioc is-demo">
<span class="ioc__logo">G</span>
<nav class="ioc__nav">
<a class="ioc__item" href="#"><span class="ioc__icon ioc__icon--home" aria-hidden="true"></span><span class="ioc__label">홈</span></a>
<a class="ioc__item" href="#"><span class="ioc__icon ioc__icon--search" aria-hidden="true"></span><span class="ioc__label">탐색</span></a>
<a class="ioc__item" href="#"><span class="ioc__icon ioc__icon--save" aria-hidden="true"></span><span class="ioc__label">저장</span></a>
</nav>
</header>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 ──────────────────────────
$duration: $dur-base; // 라벨이 접히는 시간
// 관성 lerp — p += (목표-p) * .22 를 22스텝 반복한 계산 결과를 커브로 굽는다 (형태 2차, 사용자 2026-09-14)
$easing: linear(0, .22, .392, .525, .63, .711, .775, .824, .863, .893, .917, .935, .949, .96, .969, .976, .981, .985, .989, .991, .993, .995, 1);
$color: $stage-ink; // 아이콘·라벨 색
@include stage($stage-yellow);
// 320px 데모 스테이지: stage() 믹스인은 html,body 에만 overflow:hidden 을 준다 — html(root)은
// 자기 overflow:hidden 이 scrollWidth 를 뷰포트에 눌러주지만 body 는 보통 요소라 그대로 실제 콘텐츠
// 폭을 보고한다. 아이콘 3개 + 라벨(nav gap 포함) 이 90vw 보다 실측 폭이 넓어 320px 에서 13px 넘친다
// (128-02 실측, body.scrollWidth 333 vs innerWidth 320). .stage 를 직접 잘라 위로 전파를 막는다.
.stage { overflow: hidden; }
.ioc {
width: min(360px, 90vw);
display: flex; align-items: center; justify-content: space-between;
padding: $sp-3 $sp-4;
border-radius: $r-control;
background: rgba($stage-ink, .06);
&__logo { color: $color; font-weight: 800; font-size: clamp(15px, 4vw, 18px); line-height: 1; font-family: inherit; letter-spacing: .04em; }
&__nav { display: flex; align-items: center; gap: $sp-4; }
}
.ioc__item {
display: flex; align-items: center; gap: $sp-2;
text-decoration: none;
color: $color;
}
.ioc__icon {
flex: none;
width: 18px; height: 18px;
border-radius: $r-xs;
background: $color;
&--home { border-radius: 50%; }
&--search { border-radius: $r-pill; width: 14px; }
&--save { border-radius: $r-xs; }
}
// 라벨: 항목마다 폭이 다른 텍스트를 진짜로 비켜 줘야(width 축소) 헤더 폭을 아끼는 게 보인다.
// max-width 애니메이션은 이 작은 요소 안에서만 — blog-demo 규칙1 의 예외 (본문 원리 섹션 재료)
.ioc__label {
overflow: hidden;
white-space: nowrap;
max-width: 40px;
transform-origin: left center; // 규칙8: 라벨 좌측 기준 접힘
font-weight: 500;
font-size: clamp(12px, 3.4vw, 13px);
line-height: 1;
font-family: inherit;
color: $color;
transition: max-width $duration $easing, transform $duration $easing, opacity $duration $easing;
}
// 실제 사용: 스크롤하면 라벨이 폭과 함께 접혀 아이콘만 남는다
.ioc.is-scrolled .ioc__label { max-width: 0; transform: scaleX(0); opacity: 0; }
// ── 데모(.is-demo): 접혔다 펼쳐지는 2초 루프 ──────────────
.ioc.is-demo .ioc__label { animation: ioc-label-loop $dur-loop $easing infinite; }
@keyframes ioc-label-loop {
0%, 20% { max-width: 40px; transform: scaleX(1); opacity: 1; }
45%, 70% { max-width: 0; transform: scaleX(0); opacity: 0; }
95%, 100% { max-width: 40px; transform: scaleX(1); opacity: 1; }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.ioc__label { transition: none; }
.ioc.is-demo .ioc__label { animation: none !important; max-width: 40px; transform: scaleX(1); opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
overflow: hidden;
}
.ioc {
width: min(360px, 90vw);
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-radius: 12px;
background: rgba(23, 20, 26, 0.06);
}
.ioc__logo {
color: #17141a;
font-weight: 800;
font-size: clamp(15px, 4vw, 18px);
line-height: 1;
font-family: inherit;
letter-spacing: 0.04em;
}
.ioc__nav {
display: flex;
align-items: center;
gap: 16px;
}
.ioc__item {
display: flex;
align-items: center;
gap: 8px;
text-decoration: none;
color: #17141a;
}
.ioc__icon {
flex: none;
width: 18px;
height: 18px;
border-radius: 4px;
background: #17141a;
}
.ioc__icon--home {
border-radius: 50%;
}
.ioc__icon--search {
border-radius: 999px;
width: 14px;
}
.ioc__icon--save {
border-radius: 4px;
}
.ioc__label {
overflow: hidden;
white-space: nowrap;
max-width: 40px;
transform-origin: left center;
font-weight: 500;
font-size: clamp(12px, 3.4vw, 13px);
line-height: 1;
font-family: inherit;
color: #17141a;
transition: max-width 300ms linear(0, 0.22, 0.392, 0.525, 0.63, 0.711, 0.775, 0.824, 0.863, 0.893, 0.917, 0.935, 0.949, 0.96, 0.969, 0.976, 0.981, 0.985, 0.989, 0.991, 0.993, 0.995, 1), transform 300ms linear(0, 0.22, 0.392, 0.525, 0.63, 0.711, 0.775, 0.824, 0.863, 0.893, 0.917, 0.935, 0.949, 0.96, 0.969, 0.976, 0.981, 0.985, 0.989, 0.991, 0.993, 0.995, 1), opacity 300ms linear(0, 0.22, 0.392, 0.525, 0.63, 0.711, 0.775, 0.824, 0.863, 0.893, 0.917, 0.935, 0.949, 0.96, 0.969, 0.976, 0.981, 0.985, 0.989, 0.991, 0.993, 0.995, 1);
}
.ioc.is-scrolled .ioc__label {
max-width: 0;
transform: scaleX(0);
opacity: 0;
}
.ioc.is-demo .ioc__label {
animation: ioc-label-loop 2s linear(0, 0.22, 0.392, 0.525, 0.63, 0.711, 0.775, 0.824, 0.863, 0.893, 0.917, 0.935, 0.949, 0.96, 0.969, 0.976, 0.981, 0.985, 0.989, 0.991, 0.993, 0.995, 1) infinite;
}
@keyframes ioc-label-loop {
0%, 20% {
max-width: 40px;
transform: scaleX(1);
opacity: 1;
}
45%, 70% {
max-width: 0;
transform: scaleX(0);
opacity: 0;
}
95%, 100% {
max-width: 40px;
transform: scaleX(1);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.ioc__label {
transition: none;
}
.ioc.is-demo .ioc__label {
animation: none !important;
max-width: 40px;
transform: scaleX(1);
opacity: 1;
}
}// 실제 페이지: 스크롤하면 라벨 폭이 줄며 사라지고 아이콘만 남는다 (7줄)
var el = document.querySelector('.ioc');
window.addEventListener('scroll', function () {
el.classList.toggle('is-scrolled', window.scrollY > 20);
}, { passive: true });import { useEffect, useRef } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./IconOnlyNav.module.scss";
export type IconOnlyNavItem = {
key: string;
label: string;
icon: ReactNode;
href?: string;
};
export type IconOnlyNavProps = {
logo: string;
items: IconOnlyNavItem[];
/** 라벨이 접히는 시간 (기본 300ms) */
duration?: number;
/** 관성 lerp 커브(p += (목표-p)*.22 를 22스텝 반복해 구운 linear()) — 기본값이 vanilla 와 같다 */
easing?: string;
color?: string;
};
// vanilla 128 과 같은 관성 lerp 커브 — p += (목표-p)*.22 를 22스텝 반복해 구운 linear()
const LERP_EASING = "linear(0, .22, .392, .525, .63, .711, .775, .824, .863, .893, .917, .935, .949, .96, .969, .976, .981, .985, .989, .991, .993, .995, 1)";
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function IconOnlyNav({
logo,
items,
duration = 300,
easing = LERP_EASING,
color = "#17141a",
}: IconOnlyNavProps) {
const ref = useRef<HTMLElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
function onScroll() {
ref.current?.classList.toggle("is-scrolled", window.scrollY > 20);
}
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}>
{items.map((it) => (
<a key={it.key} className={styles.item} href={it.href ?? "#"}>
<span className={styles.icon}>{it.icon}</span>
<span className={styles.label}>{it.label}</span>
</a>
))}
</nav>
</header>
);
}02 · 아이콘만 남는 내비 — 측정 누적면적 1.844% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
max-width 도 같이 줄여야 한다
opacity만 바꾸면 빈 여백이 남으므로 max-width도 함께 0으로 줄여야 아이콘들이 실제로 왼쪽으로 붙습니다.
.ioc__label {
overflow: hidden;
white-space: nowrap;
max-width: 40px;
transform-origin: left center;
transition: max-width .3s ease-out, transform .3s ease-out, opacity .3s ease-out;
}
// 실제 사용: 스크롤하면 라벨이 폭과 함께 접혀 아이콘만 남는다
.ioc.is-scrolled .ioc__label { max-width: 0; transform: scaleX(0); opacity: 0; }
03탭 밑줄 스크롤 동기화
헤더 고정 css 안에서 밑줄이 탭을 따라간다
헤더 안에 나란히 놓인 앵커 탭들 아래로 밑줄 하나가, 지금 지나가는 섹션에 맞춰 옆 탭으로 미끄러져 갑니다.

<main class="stage">
<div class="tus is-demo">
<nav class="tus__nav" id="tusNav">
<a class="tus__tab is-active" href="#overview" data-i="0">개요</a>
<a class="tus__tab" href="#report" data-i="1">리포트</a>
<a class="tus__tab" href="#setting" data-i="2">설정</a>
<span class="tus__underline" aria-hidden="true"></span>
</nav>
<div class="tus__panel">스크롤이 지나는 섹션에 맞춰 밑줄이 옆 탭으로 미끄러진다</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 ──────────────────────────
$duration: $dur-base; // 밑줄이 미끄러지는 시간
$easing: $ease-spring;
$color: $subject-cream; // 밑줄·활성 탭 색
@include stage($stage-orange);
.tus { width: min(320px, 88vw); }
.tus__nav {
position: relative;
display: flex;
// 탭마다 내용 폭만큼만 차지해야 실측 offsetLeft·offsetWidth 가 의미 있다 (BD19) — 균등분배 금지
flex-wrap: nowrap;
}
.tus__tab {
flex: 0 0 auto;
padding: $sp-2 $sp-4;
text-decoration: none;
white-space: nowrap;
font-weight: 600;
font-size: clamp(13px, 3.6vw, 15px);
line-height: 1;
font-family: inherit;
color: $subject-ink; // 주황 무대 위 탭 글자 — AA 4.5 (rgba(cream,.65) 는 2.04:1 이었다)
&.is-active { color: $subject-ink; font-weight: 800; }
}
// 폭은 고정(칩 "translateX 고정폭") — 탭마다 넓어지지 않는다. translateX 만 탭 중심으로 움직인다
.tus__underline {
position: absolute; left: 0; bottom: 0;
width: 24px;
height: 3px;
background: $color;
border-radius: $r-pill;
transform: translateX(18.7265625px);
transition: transform $duration $easing;
}
// ── 데모(.is-demo): 세 탭 중심을 옮겨 다니는 2초 루프 ──────────────
// translateX 값 = 480×300 렌더에서 Playwright getBoundingClientRect 로 실측한 탭 left+width/2 - 밑줄폭/2
// (탭0 개요 left 0·폭 61.453125 / 탭1 리포트 left 61.453125·폭 76.171875 / 탭2 설정 left 137.625·폭 61.453125 — 짐작 56/76/56 류 금지, BD19)
.tus.is-demo .tus__underline { animation: tus-loop $dur-loop $easing infinite; }
@keyframes tus-loop {
0%, 15% { transform: translateX(18.7265625px); }
33%, 48% { transform: translateX(87.5390625px); }
66%, 81% { transform: translateX(156.3515625px); }
100% { transform: translateX(18.7265625px); }
}
.tus__panel {
margin-top: $sp-4;
min-height: 32px;
font-weight: 400;
font-size: clamp(12px, 3.2vw, 13px);
line-height: 1.5;
font-family: inherit;
color: $subject-ink; // 주황 무대 위 패널 글자 — AA 4.5
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.tus__underline { transition: none; }
.tus.is-demo .tus__underline { animation: none !important; transform: translateX(18.7265625px); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tus {
width: min(320px, 88vw);
}
.tus__nav {
position: relative;
display: flex;
flex-wrap: nowrap;
}
.tus__tab {
flex: 0 0 auto;
padding: 8px 16px;
text-decoration: none;
white-space: nowrap;
font-weight: 600;
font-size: clamp(13px, 3.6vw, 15px);
line-height: 1;
font-family: inherit;
color: #17141a;
}
.tus__tab.is-active {
color: #17141a;
font-weight: 800;
}
.tus__underline {
position: absolute;
left: 0;
bottom: 0;
width: 24px;
height: 3px;
background: #fff7e6;
border-radius: 999px;
transform: translateX(18.7265625px);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tus.is-demo .tus__underline {
animation: tus-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes tus-loop {
0%, 15% {
transform: translateX(18.7265625px);
}
33%, 48% {
transform: translateX(87.5390625px);
}
66%, 81% {
transform: translateX(156.3515625px);
}
100% {
transform: translateX(18.7265625px);
}
}
.tus__panel {
margin-top: 16px;
min-height: 32px;
font-weight: 400;
font-size: clamp(12px, 3.2vw, 13px);
line-height: 1.5;
font-family: inherit;
color: #17141a;
}
@media (prefers-reduced-motion: reduce) {
.tus__underline {
transition: none;
}
.tus.is-demo .tus__underline {
animation: none !important;
transform: translateX(18.7265625px);
}
}// 실제 페이지: IntersectionObserver 로 지금 통과 중인 섹션을 찾아 그 탭에 밑줄을 옮긴다.
// 탭 폭이 서로 달라 offsetLeft·offsetWidth 를 실측해 옮긴다 — 짐작 px 금지 (BD19)
var nav = document.getElementById('tusNav');
var tabs = nav.querySelectorAll('.tus__tab');
var underline = nav.querySelector('.tus__underline');
function moveTo(tab) {
var w = underline.offsetWidth;
underline.style.transform = 'translateX(' + (tab.offsetLeft + tab.offsetWidth / 2 - w / 2) + 'px)';
}
tabs.forEach(function (t) { t.addEventListener('click', function (e) { e.preventDefault(); document.querySelector('.tus').classList.remove('is-demo'); moveTo(t); }); });import { useEffect, useRef, useState } from "react";
import type { CSSProperties, MouseEvent } from "react";
import styles from "./TabUnderlineSync.module.scss";
export type TabUnderlineSyncTab = { key: string; label: string; href: string };
export type TabUnderlineSyncProps = {
tabs: TabUnderlineSyncTab[];
/** 밑줄이 미끄러지는 시간 (기본 300ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function TabUnderlineSync({
tabs,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: TabUnderlineSyncProps) {
const navRef = useRef<HTMLElement>(null);
const [active, setActive] = useState(0);
const [x, setX] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 탭 폭이 서로 달라 offsetLeft·offsetWidth 를 실측해 밑줄 중심을 옮긴다 — 짐작 px 금지 (BD19)
useEffect(() => {
const nav = navRef.current;
if (!nav) return;
const tab = nav.querySelectorAll("a")[active] as HTMLElement | undefined;
const underline = nav.querySelector<HTMLElement>("[data-underline]");
if (tab && underline) {
const w = underline.offsetWidth;
setX(tab.offsetLeft + tab.offsetWidth / 2 - w / 2);
}
}, [active, tabs]);
return (
<nav ref={navRef} className={styles.nav} style={vars}>
{tabs.map((t, i) => (
<a
key={t.key}
href={t.href}
className={i === active ? `${styles.tab} ${styles.isActive}` : styles.tab}
onClick={(e: MouseEvent<HTMLAnchorElement>) => { e.preventDefault(); setActive(i); }}
>
{t.label}
</a>
))}
<span data-underline className={styles.underline} style={{ transform: `translateX(${x}px)` }} />
</nav>
);
}03 · 탭 밑줄 스크롤 동기화 — 측정 누적면적 0.308% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
지금 섹션을 찾는 방법
실제 페이지에서는 IntersectionObserver로 통과 중인 섹션을 찾아 그 탭의 위치로 밑줄을 옮깁니다.
// 탭 폭이 서로 달라 offsetLeft·offsetWidth 를 실측해 옮긴다 — 짐작 px 금지
var nav = document.getElementById('tusNav');
var tabs = nav.querySelectorAll('.tus__tab');
var underline = nav.querySelector('.tus__underline');
function moveTo(tab) {
var w = underline.offsetWidth;
underline.style.transform =
'translateX(' + (tab.offsetLeft + tab.offsetWidth / 2 - w / 2) + 'px)';
}
04검색 바 흡수
검색 줄이 헤더로 합쳐진다
헤더 아래 따로 떠 있던 검색 입력 줄이 스크롤하면 위로 미끄러져 메인 헤더 한 줄 안으로 합쳐집니다.

<main class="stage">
<div class="sbm is-demo">
<div class="sbm__slot">
<header class="sbm__head">
<span class="sbm__logo">GODRICH</span>
<nav class="sbm__nav" aria-hidden="true"><span></span><span></span></nav>
</header>
<div class="sbm__search">
<span class="sbm__search-box">
<span class="sbm__search-icon" aria-hidden="true"></span>
<span class="sbm__search-text">검색어를 입력하세요</span>
</span>
</div>
</div>
<div class="sbm__page">
<p class="sbm__line"></p>
<p class="sbm__line short"></p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 ──────────────────────────
$duration: $dur-base; // 검색 줄이 위로 합쳐지는 시간
// 관성 lerp — p += (목표-p) * .32 를 20스텝 반복한 계산 결과를 커브로 굽는다 (형태 2차, 사용자 2026-09-14)
$easing: linear(0, .32, .538, .686, .786, .855, .901, .933, .954, .969, .979, .986, .99, .993, .995, .997, .998, .999, .999, .999, 1);
$easing-content: linear(0, .18, .328, .449, .548, .629, .696, .751, .796, .832, .863, .887, .908, .924, .938, .949, .958, .966, .972, .977, 1); // 알파 .18 — 헤더보다 느리게 따라잡아 시차가 생긴다
$dur-content: $dur-base * 1.4;
$color: $subject-blue; // 검색 아이콘·포커스 색
@include stage($stage-paper);
$row1: 56px;
$row2: 40px;
.sbm { width: min(380px, 90vw); }
// ── 고정 높이 슬롯: 헤더 줄 + 검색 줄 두 층을 겹쳐 두고 transform 만 움직인다 ──
.sbm__slot {
position: relative;
height: $row1 + $row2;
}
.sbm__head {
position: absolute; inset: 0 0 auto 0;
z-index: 2;
height: $row1;
display: flex; align-items: center; justify-content: space-between;
padding: 0 $sp-4;
}
.sbm__logo { color: $stage-ink; font-weight: 800; font-size: clamp(14px, 4vw, 18px); line-height: 1; font-family: inherit; letter-spacing: .04em; }
.sbm__nav {
display: flex; gap: $sp-2;
transition: opacity $duration $easing, transform $duration $easing;
span { width: 20px; height: 6px; border-radius: $r-pill; background: rgba($stage-ink, .2); }
}
// 검색 줄은 헤더의 nav 자리(오른쪽)와 같은 정렬로 둔다 — 위로 합쳐져도 로고와 겹치지 않는다
.sbm__search {
position: absolute; left: 0; right: 0; top: $row1;
z-index: 1;
height: $row2;
display: flex; align-items: center; justify-content: flex-end;
padding: 0 $sp-4;
border-top: 1px solid rgba($stage-ink, .08);
transform: translateY(0);
transition: transform $duration $easing;
&-box {
display: flex; align-items: center; gap: $sp-2;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba($color, .1);
}
&-icon { flex: none; width: 14px; height: 14px; border-radius: 50%; border: 2px solid $color; }
&-text { font-weight: 500; font-size: clamp(11px, 3vw, 12px); line-height: 1; font-family: inherit; color: rgba($stage-ink, .72); }
}
// 실제 사용: 스크롤을 시작하면 검색 줄이 위로 미끄러져 헤더 줄 안으로 합쳐진다
.sbm.is-scrolled {
.sbm__nav { opacity: 0; transform: translateX($sp-2); }
.sbm__search { transform: translateY(-#{$row1}); }
// 콘텐츠는 헤더 합쳐짐보다 늦게 도착 — 시차 스크롤
.sbm__page { transform: translateY(-8px); }
}
// ── 데모(.is-demo): 합쳐졌다 돌아오는 2초 루프 ──────────────
.sbm.is-demo {
.sbm__nav { animation: sbm-nav-loop $dur-loop $easing infinite; }
.sbm__search { animation: sbm-search-loop $dur-loop $easing infinite; }
.sbm__page { animation: sbm-page-loop $dur-loop $easing-content infinite; }
}
@keyframes sbm-nav-loop {
0%, 20% { opacity: 1; transform: translateX(0); }
45%, 70% { opacity: 0; transform: translateX(8px); }
95%, 100% { opacity: 1; transform: translateX(0); }
}
@keyframes sbm-search-loop {
0%, 20% { transform: translateY(0); }
45%, 70% { transform: translateY(-#{$row1}); }
95%, 100% { transform: translateY(0); }
}
// 헤더보다 느린 $easing-content 로 같은 구간을 밟는다 — 관성이 덜 붙어 뒤처져 보인다
@keyframes sbm-page-loop {
0%, 20% { transform: translateY(0); }
45%, 70% { transform: translateY(-8px); }
95%, 100% { transform: translateY(0); }
}
// ── 배경: 문서처럼 보이는 자리 ─────────────────────────
.sbm__page {
padding: $sp-4 0 0;
transform: translateY(0);
transition: transform $dur-content $easing-content;
}
.sbm__line {
height: 10px;
margin: 0 0 $sp-3;
border-radius: $r-xs;
background: rgba($stage-ink, .08);
&.short { width: 55%; }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.sbm__nav, .sbm__search, .sbm__page { transition: none; }
.sbm.is-demo {
.sbm__nav, .sbm__search, .sbm__page { animation: none !important; }
.sbm__nav { opacity: 1; transform: translateX(0); }
.sbm__search { transform: translateY(0); }
.sbm__page { transform: translateY(0); }
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sbm {
width: min(380px, 90vw);
}
.sbm__slot {
position: relative;
height: 96px;
}
.sbm__head {
position: absolute;
inset: 0 0 auto 0;
z-index: 2;
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
}
.sbm__logo {
color: #17141a;
font-weight: 800;
font-size: clamp(14px, 4vw, 18px);
line-height: 1;
font-family: inherit;
letter-spacing: 0.04em;
}
.sbm__nav {
display: flex;
gap: 8px;
transition: opacity 300ms linear(0, 0.32, 0.538, 0.686, 0.786, 0.855, 0.901, 0.933, 0.954, 0.969, 0.979, 0.986, 0.99, 0.993, 0.995, 0.997, 0.998, 0.999, 0.999, 0.999, 1), transform 300ms linear(0, 0.32, 0.538, 0.686, 0.786, 0.855, 0.901, 0.933, 0.954, 0.969, 0.979, 0.986, 0.99, 0.993, 0.995, 0.997, 0.998, 0.999, 0.999, 0.999, 1);
}
.sbm__nav span {
width: 20px;
height: 6px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.2);
}
.sbm__search {
position: absolute;
left: 0;
right: 0;
top: 56px;
z-index: 1;
height: 40px;
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 16px;
border-top: 1px solid rgba(23, 20, 26, 0.08);
transform: translateY(0);
transition: transform 300ms linear(0, 0.32, 0.538, 0.686, 0.786, 0.855, 0.901, 0.933, 0.954, 0.969, 0.979, 0.986, 0.99, 0.993, 0.995, 0.997, 0.998, 0.999, 0.999, 0.999, 1);
}
.sbm__search-box {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
border-radius: 999px;
background: rgba(47, 109, 246, 0.1);
}
.sbm__search-icon {
flex: none;
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid #2f6df6;
}
.sbm__search-text {
font-weight: 500;
font-size: clamp(11px, 3vw, 12px);
line-height: 1;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
}
.sbm.is-scrolled .sbm__nav {
opacity: 0;
transform: translateX(8px);
}
.sbm.is-scrolled .sbm__search {
transform: translateY(-56px);
}
.sbm.is-scrolled .sbm__page {
transform: translateY(-8px);
}
.sbm.is-demo .sbm__nav {
animation: sbm-nav-loop 2s linear(0, 0.32, 0.538, 0.686, 0.786, 0.855, 0.901, 0.933, 0.954, 0.969, 0.979, 0.986, 0.99, 0.993, 0.995, 0.997, 0.998, 0.999, 0.999, 0.999, 1) infinite;
}
.sbm.is-demo .sbm__search {
animation: sbm-search-loop 2s linear(0, 0.32, 0.538, 0.686, 0.786, 0.855, 0.901, 0.933, 0.954, 0.969, 0.979, 0.986, 0.99, 0.993, 0.995, 0.997, 0.998, 0.999, 0.999, 0.999, 1) infinite;
}
.sbm.is-demo .sbm__page {
animation: sbm-page-loop 2s linear(0, 0.18, 0.328, 0.449, 0.548, 0.629, 0.696, 0.751, 0.796, 0.832, 0.863, 0.887, 0.908, 0.924, 0.938, 0.949, 0.958, 0.966, 0.972, 0.977, 1) infinite;
}
@keyframes sbm-nav-loop {
0%, 20% {
opacity: 1;
transform: translateX(0);
}
45%, 70% {
opacity: 0;
transform: translateX(8px);
}
95%, 100% {
opacity: 1;
transform: translateX(0);
}
}
@keyframes sbm-search-loop {
0%, 20% {
transform: translateY(0);
}
45%, 70% {
transform: translateY(-56px);
}
95%, 100% {
transform: translateY(0);
}
}
@keyframes sbm-page-loop {
0%, 20% {
transform: translateY(0);
}
45%, 70% {
transform: translateY(-8px);
}
95%, 100% {
transform: translateY(0);
}
}
.sbm__page {
padding: 16px 0 0;
transform: translateY(0);
transition: transform 420ms linear(0, 0.18, 0.328, 0.449, 0.548, 0.629, 0.696, 0.751, 0.796, 0.832, 0.863, 0.887, 0.908, 0.924, 0.938, 0.949, 0.958, 0.966, 0.972, 0.977, 1);
}
.sbm__line {
height: 10px;
margin: 0 0 12px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.08);
}
.sbm__line.short {
width: 55%;
}
@media (prefers-reduced-motion: reduce) {
.sbm__nav, .sbm__search, .sbm__page {
transition: none;
}
.sbm.is-demo .sbm__nav, .sbm.is-demo .sbm__search, .sbm.is-demo .sbm__page {
animation: none !important;
}
.sbm.is-demo .sbm__nav {
opacity: 1;
transform: translateX(0);
}
.sbm.is-demo .sbm__search {
transform: translateY(0);
}
.sbm.is-demo .sbm__page {
transform: translateY(0);
}
}// 실제 페이지: 스크롤하면 아래 검색 줄이 위로 미끄러져 헤더 한 줄 안으로 합쳐진다 (7줄)
var el = document.querySelector('.sbm');
window.addEventListener('scroll', function () {
el.classList.toggle('is-scrolled', window.scrollY > 20);
}, { passive: true });import { useEffect, useRef } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./SearchBarMerge.module.scss";
export type SearchBarMergeProps = {
logo: string;
placeholder: string;
children?: ReactNode;
/** 검색 줄이 위로 합쳐지는 시간 (기본 300ms) */
duration?: number;
/** 관성 lerp 커브(p += (목표-p)*.32 를 20스텝 반복해 구운 linear()) — 기본값이 vanilla 와 같다 */
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const LERP_EASING = "linear(0, .32, .538, .686, .786, .855, .901, .933, .954, .969, .979, .986, .99, .993, .995, .997, .998, .999, .999, .999, 1)";
export default function SearchBarMerge({
logo,
placeholder,
children,
duration = 300,
easing = LERP_EASING,
color = "#2f6df6",
}: SearchBarMergeProps) {
const ref = useRef<HTMLDivElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
function onScroll() {
ref.current?.classList.toggle("is-scrolled", window.scrollY > 20);
}
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<div ref={ref} className={styles.slot} style={vars}>
<header className={styles.head}>
<span className={styles.logo}>{logo}</span>
<nav className={styles.nav}>{children}</nav>
</header>
<div className={styles.search}>
<span className={styles.searchBox}>
<span className={styles.searchIcon} />
<span className={styles.searchText}>{placeholder}</span>
</span>
</div>
</div>
);
}04 · 검색 바 흡수 — 측정 누적면적 15.596% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
01과 반대 방향의 층 겹치기
두 줄을 고정 높이 슬롯에 겹쳐 두고 검색 줄만 헤더 높이만큼 translateY로 끌어올리는 방식이라, 01번과 같은 층 겹치기 구조를 반대 방향으로 씁니다.
.sbm__search {
position: absolute; left: 0; right: 0; top: 56px;
height: 40px;
border-top: 1px solid rgba($stage-ink, .08);
transform: translateY(0);
transition: transform .3s ease-out;
}
// 실제 사용: 스크롤을 시작하면 검색 줄이 위로 미끄러져 헤더 줄 안으로 합쳐진다
.sbm.is-scrolled .sbm__search { transform: translateY(-56px); }
05검색창 확장
아이콘이 입력창으로 넓어진다
돋보기 아이콘 하나였던 자리가 클릭하면 폭이 넓어지며 실제 입력창으로 바뀝니다.

<main class="stage">
<header class="sse is-demo">
<span class="sse__logo">GODRICH</span>
<div class="sse__search">
<button class="sse__toggle" type="button" aria-label="검색 열기" aria-expanded="false" aria-controls="sseInput">
<svg class="sse__icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="6.5"/><line x1="20" y1="20" x2="15.7" y2="15.7"/></svg>
</button>
<input id="sseInput" class="sse__input" type="search" placeholder="검색" aria-label="검색어 입력" tabindex="-1">
</div>
</header>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 ──────────────────────────
$duration: $dur-base; // 폭이 넓어지는 시간
$easing: $ease-spring;
$color: $subject-blue; // 아이콘·포커스 색
@include stage($stage-ink);
// 320px 데모 스테이지: stage() 믹스인은 html,body 에만 overflow:hidden 을 준다 — html(root)은
// 자기 overflow:hidden 이 scrollWidth 를 뷰포트에 눌러주지만 body 는 보통 요소라 그대로 실제 콘텐츠
// 폭을 보고한다. 검색창이 156px 로 펼쳐질 때 로고+패딩과 합쳐 90vw 보다 실측 폭이 넓어 320px 에서
// 13px 넘친다 (128-05 실측, body.scrollWidth 333 vs innerWidth 320). .stage 를 직접 잘라 위로 전파를 막는다.
.stage { overflow: hidden; }
.sse {
width: min(380px, 90vw);
display: flex; align-items: center; justify-content: space-between;
padding: $sp-3 $sp-4;
border-radius: $r-control;
background: rgba($subject-cream, .06);
}
.sse__logo { color: $subject-cream; font-weight: 800; font-size: clamp(14px, 4vw, 18px); line-height: 1; font-family: inherit; letter-spacing: .04em; }
// 작은 요소 안에서만 폭이 바뀐다 — 아이콘→입력창은 본질적으로 width 변화라 이 항목만의 예외
.sse__search {
position: relative;
display: inline-flex;
align-items: center;
height: 36px;
width: 36px;
border-radius: $r-pill;
background: rgba($subject-cream, .12);
overflow: hidden;
transition: width $duration $easing, background $duration $easing;
&.is-open { width: 156px; background: $subject-cream; }
}
.sse__toggle {
flex: none;
width: 36px; height: 36px;
display: grid; place-items: center;
border: 0; padding: 0;
background: transparent;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.sse__icon {
width: 16px; height: 16px;
fill: none;
stroke: $subject-cream;
stroke-width: 2;
stroke-linecap: round;
}
.sse__search.is-open .sse__icon { stroke: $color; }
.sse__input {
flex: 1 1 auto;
min-width: 0;
border: 0; outline: 0;
background: transparent;
padding: 0 $sp-3 0 0;
font-weight: 500;
font-size: clamp(12px, 3.6vw, 13px);
line-height: 1;
font-family: inherit;
color: $stage-ink;
opacity: 0;
transition: opacity $duration $easing;
}
.sse__search.is-open .sse__input { opacity: 1; }
// ── 데모(.is-demo): 아이콘이 클릭 한 번을 시뮬레이션해 입력창으로 넓어지는 2초 루프 ──────────────
.sse.is-demo .sse__search {
animation: sse-expand-loop $dur-loop $easing infinite;
}
.sse.is-demo .sse__icon { animation: sse-icon-loop $dur-loop $easing infinite; }
.sse.is-demo .sse__input { animation: sse-input-loop $dur-loop $easing infinite; }
@keyframes sse-expand-loop {
0%, 20% { width: 36px; background: rgba($subject-cream, .12); }
45%, 70% { width: 156px; background: $subject-cream; }
95%, 100% { width: 36px; background: rgba($subject-cream, .12); }
}
@keyframes sse-icon-loop {
0%, 20% { stroke: $subject-cream; }
45%, 70% { stroke: $color; }
95%, 100% { stroke: $subject-cream; }
}
@keyframes sse-input-loop {
0%, 20% { opacity: 0; }
45%, 70% { opacity: 1; }
95%, 100% { opacity: 0; }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.sse__search, .sse__input { transition: none; }
.sse.is-demo {
.sse__search, .sse__icon, .sse__input { animation: none !important; }
.sse__search { width: 36px; background: rgba($subject-cream, .12); }
.sse__icon { stroke: $subject-cream; }
.sse__input { opacity: 0; }
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
overflow: hidden;
}
.sse {
width: min(380px, 90vw);
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-radius: 12px;
background: rgba(255, 247, 230, 0.06);
}
.sse__logo {
color: #fff7e6;
font-weight: 800;
font-size: clamp(14px, 4vw, 18px);
line-height: 1;
font-family: inherit;
letter-spacing: 0.04em;
}
.sse__search {
position: relative;
display: inline-flex;
align-items: center;
height: 36px;
width: 36px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.12);
overflow: hidden;
transition: width 300ms cubic-bezier(0.2, 0.8, 0.2, 1), background 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sse__search.is-open {
width: 156px;
background: #fff7e6;
}
.sse__toggle {
flex: none;
width: 36px;
height: 36px;
display: grid;
place-items: center;
border: 0;
padding: 0;
background: transparent;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.sse__icon {
width: 16px;
height: 16px;
fill: none;
stroke: #fff7e6;
stroke-width: 2;
stroke-linecap: round;
}
.sse__search.is-open .sse__icon {
stroke: #2f6df6;
}
.sse__input {
flex: 1 1 auto;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
padding: 0 12px 0 0;
font-weight: 500;
font-size: clamp(12px, 3.6vw, 13px);
line-height: 1;
font-family: inherit;
color: #17141a;
opacity: 0;
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sse__search.is-open .sse__input {
opacity: 1;
}
.sse.is-demo .sse__search {
animation: sse-expand-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.sse.is-demo .sse__icon {
animation: sse-icon-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.sse.is-demo .sse__input {
animation: sse-input-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes sse-expand-loop {
0%, 20% {
width: 36px;
background: rgba(255, 247, 230, 0.12);
}
45%, 70% {
width: 156px;
background: #fff7e6;
}
95%, 100% {
width: 36px;
background: rgba(255, 247, 230, 0.12);
}
}
@keyframes sse-icon-loop {
0%, 20% {
stroke: #fff7e6;
}
45%, 70% {
stroke: #2f6df6;
}
95%, 100% {
stroke: #fff7e6;
}
}
@keyframes sse-input-loop {
0%, 20% {
opacity: 0;
}
45%, 70% {
opacity: 1;
}
95%, 100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.sse__search, .sse__input {
transition: none;
}
.sse.is-demo .sse__search, .sse.is-demo .sse__icon, .sse.is-demo .sse__input {
animation: none !important;
}
.sse.is-demo .sse__search {
width: 36px;
background: rgba(255, 247, 230, 0.12);
}
.sse.is-demo .sse__icon {
stroke: #fff7e6;
}
.sse.is-demo .sse__input {
opacity: 0;
}
}// 실제 페이지: 아이콘을 클릭하면 입력창 폭이 넓어지며 실제 검색창이 된다 (9줄)
var box = document.querySelector('.sse__search');
var input = document.querySelector('#sseInput');
document.querySelector('.sse__toggle').addEventListener('click', function (e) {
var open = box.classList.toggle('is-open');
e.currentTarget.setAttribute('aria-expanded', String(open));
document.querySelector('.sse').classList.remove('is-demo');
input.tabIndex = open ? 0 : -1;
if (open) input.focus({ preventScroll: true }); else input.blur();
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SearchExpandHeader.module.scss";
export type SearchExpandHeaderProps = {
logo: string;
placeholder: string;
/** 폭이 넓어지는 시간 (기본 300ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SearchExpandHeader({
logo,
placeholder,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: SearchExpandHeaderProps) {
const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function toggle() {
const next = !open;
setOpen(next);
if (next) inputRef.current?.focus({ preventScroll: true });
else inputRef.current?.blur();
}
return (
<header className={styles.head} style={vars}>
<span className={styles.logo}>{logo}</span>
<div className={open ? `${styles.search} ${styles.isOpen}` : styles.search}>
<button type="button" className={styles.toggle} aria-label="검색 열기" aria-expanded={open} onClick={toggle}>
<svg className={styles.icon} viewBox="0 0 24 24" aria-hidden="true">
<circle cx="11" cy="11" r="6.5" />
<line x1="20" y1="20" x2="15.7" y2="15.7" />
</svg>
</button>
<input
ref={inputRef}
className={styles.input}
type="search"
placeholder={placeholder}
aria-label="검색어 입력"
tabIndex={open ? 0 : -1}
/>
</div>
</header>
);
}05 · 검색창 확장 — 측정 누적면적 3.736% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
유일하게 스크롤과 무관하다
아홉 개 중 유일하게 스크롤과 무관한 항목으로, 트리거는 스크롤 위치가 아니라 클릭(포커스)입니다.
.sse__search {
width: 36px;
border-radius: $r-pill;
background: rgba($subject-cream, .12);
overflow: hidden;
transition: width .3s ease-spring, background .3s ease-spring;
&.is-open { width: 156px; background: $subject-cream; }
}
06브레드크럼 등장
빈 자리에 경로가 뜬다
로고 옆 비어 있던 자리에, 스크롤하는 순간 지금 보고 있는 위치 경로가 페이드와 슬라이드로 나타납니다.

<main class="stage">
<header class="head is-demo">
<span class="head__logo">GODRICH</span>
<nav class="head__crumb" aria-hidden="true">
<span>Home</span><span class="head__sep">/</span><span>Category</span><span class="head__sep">/</span><span>Page</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; // 브레드크럼이 나타나는 시간
// 관성 lerp — p += (목표-p) * .32 를 20스텝 반복한 계산 결과를 커브로 굽는다 (형태 2차, 사용자 2026-09-14)
$easing: linear(0, .32, .538, .686, .786, .855, .901, .933, .954, .969, .979, .986, .99, .993, .995, .997, .998, .999, .999, .999, 1);
$easing-content: linear(0, .18, .328, .449, .548, .629, .696, .751, .796, .832, .863, .887, .908, .924, .938, .949, .958, .966, .972, .977, 1); // 알파 .18 — 헤더보다 느리게 따라잡아 시차가 생긴다
$dur-content: $dur-enter * 1.3;
$color: $stage-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;
}
.head {
position: sticky;
top: 0;
height: 56px;
display: flex;
align-items: center;
gap: $sp-4;
padding: 0 clamp(16px, 5vw, 24px);
background: $stage-yellow;
&__logo { color: $color; font-weight: 800; font-size: 18px; line-height: 1; font-family: inherit; letter-spacing: .04em; flex: 0 0 auto; }
&__crumb {
display: flex;
align-items: center;
gap: $sp-2;
font-weight: 600;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: $color;
opacity: 0;
transform: translateX(-16px);
transition: opacity $duration $easing, transform $duration $easing;
}
&__sep { opacity: .4; }
// 실제 사용: 스크롤하면 JS 가 붙인 클래스가 브레드크럼을 페이드+슬라이드로 보여준다
&.is-scrolled &__crumb { opacity: 1; transform: translateX(0); }
}
// ── 데모(.is-demo): 브레드크럼이 사라졌다 나타났다 2초 루프 ──────
.head.is-demo .head__crumb {
animation: crumb-reveal $dur-loop $easing infinite;
}
@keyframes crumb-reveal {
0%, 8% { opacity: 0; transform: translateX(-16px); }
38%, 62% { opacity: 1; transform: translateX(0); }
92%, 100% { opacity: 0; transform: translateX(-16px); }
}
// 콘텐츠는 브레드크럼 등장보다 늦게 따라온다 — 시차 스크롤
.head.is-scrolled ~ .page { transform: translateY(-8px); }
.head.is-demo ~ .page { animation: page-parallax $dur-loop $easing-content infinite; }
@keyframes page-parallax {
0%, 8% { transform: translateY(0); }
38%, 62% { transform: translateY(-8px); }
92%, 100% { transform: translateY(0); }
}
// ── 배경: 문서처럼 보이는 자리 ─────────────────────────
.page {
padding: $sp-6 $sp-6 0;
transform: translateY(0);
transition: transform $dur-content $easing-content;
}
.page__line {
height: 12px;
margin: 0 0 $sp-3;
border-radius: $r-xs;
background: rgba($stage-ink, .12);
&.short { width: 60%; }
}
// ── 접근성: 움직임 줄이기 설정이면 즉시 상태만 바뀐다 ────────
@media (prefers-reduced-motion: reduce) {
.head__crumb { transition: none; }
.head.is-demo .head__crumb { animation: none !important; opacity: 1; transform: translateX(0); }
.page { transition: none; }
.head.is-demo ~ .page { animation: none !important; transform: translateY(0); }
}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;
}
.head {
position: sticky;
top: 0;
height: 56px;
display: flex;
align-items: center;
gap: 16px;
padding: 0 clamp(16px, 5vw, 24px);
background: #ffd23f;
}
.head__logo {
color: #17141a;
font-weight: 800;
font-size: 18px;
line-height: 1;
font-family: inherit;
letter-spacing: 0.04em;
flex: 0 0 auto;
}
.head__crumb {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: #17141a;
opacity: 0;
transform: translateX(-16px);
transition: opacity 600ms linear(0, 0.32, 0.538, 0.686, 0.786, 0.855, 0.901, 0.933, 0.954, 0.969, 0.979, 0.986, 0.99, 0.993, 0.995, 0.997, 0.998, 0.999, 0.999, 0.999, 1), transform 600ms linear(0, 0.32, 0.538, 0.686, 0.786, 0.855, 0.901, 0.933, 0.954, 0.969, 0.979, 0.986, 0.99, 0.993, 0.995, 0.997, 0.998, 0.999, 0.999, 0.999, 1);
}
.head__sep {
opacity: 0.4;
}
.head.is-scrolled .head__crumb {
opacity: 1;
transform: translateX(0);
}
.head.is-demo .head__crumb {
animation: crumb-reveal 2s linear(0, 0.32, 0.538, 0.686, 0.786, 0.855, 0.901, 0.933, 0.954, 0.969, 0.979, 0.986, 0.99, 0.993, 0.995, 0.997, 0.998, 0.999, 0.999, 0.999, 1) infinite;
}
@keyframes crumb-reveal {
0%, 8% {
opacity: 0;
transform: translateX(-16px);
}
38%, 62% {
opacity: 1;
transform: translateX(0);
}
92%, 100% {
opacity: 0;
transform: translateX(-16px);
}
}
.head.is-scrolled ~ .page {
transform: translateY(-8px);
}
.head.is-demo ~ .page {
animation: page-parallax 2s linear(0, 0.18, 0.328, 0.449, 0.548, 0.629, 0.696, 0.751, 0.796, 0.832, 0.863, 0.887, 0.908, 0.924, 0.938, 0.949, 0.958, 0.966, 0.972, 0.977, 1) infinite;
}
@keyframes page-parallax {
0%, 8% {
transform: translateY(0);
}
38%, 62% {
transform: translateY(-8px);
}
92%, 100% {
transform: translateY(0);
}
}
.page {
padding: 24px 24px 0;
transform: translateY(0);
transition: transform 780ms linear(0, 0.18, 0.328, 0.449, 0.548, 0.629, 0.696, 0.751, 0.796, 0.832, 0.863, 0.887, 0.908, 0.924, 0.938, 0.949, 0.958, 0.966, 0.972, 0.977, 1);
}
.page__line {
height: 12px;
margin: 0 0 12px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.12);
}
.page__line.short {
width: 60%;
}
@media (prefers-reduced-motion: reduce) {
.head__crumb {
transition: none;
}
.head.is-demo .head__crumb {
animation: none !important;
opacity: 1;
transform: translateX(0);
}
.page {
transition: none;
}
.head.is-demo ~ .page {
animation: none !important;
transform: translateY(0);
}
}// 실제 페이지: 스크롤을 40px 넘게 내리면 로고 옆 빈자리에 브레드크럼이 나타난다 (7줄)
var head = document.querySelector('.head');
window.addEventListener('scroll', function () {
head.classList.toggle('is-scrolled', window.scrollY > 40);
}, { passive: true });import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./BreadcrumbHeader.module.scss";
export type BreadcrumbHeaderProps = {
logo: string;
crumbs: string[];
/** 브레드크럼이 나타나는 시간 (기본 600ms) */
duration?: number;
/** 관성 lerp 커브(p += (목표-p)*.32 를 20스텝 반복해 구운 linear()) — 기본값이 vanilla 와 같다 */
easing?: string;
color?: string;
};
// vanilla 128 과 같은 관성 lerp 커브 — p += (목표-p)*.32 를 20스텝 반복해 구운 linear()
const LERP_EASING = "linear(0, .32, .538, .686, .786, .855, .901, .933, .954, .969, .979, .986, .99, .993, .995, .997, .998, .999, .999, .999, 1)";
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function BreadcrumbHeader({
logo,
crumbs,
duration = 600,
easing = LERP_EASING,
color = "#17141a",
}: BreadcrumbHeaderProps) {
const [scrolled, setScrolled] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
function onScroll() {
setScrolled(window.scrollY > 40);
}
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<header className={styles.head} style={vars}>
<span className={styles.logo}>{logo}</span>
<nav
className={scrolled ? `${styles.crumb} ${styles.visible}` : styles.crumb}
aria-hidden={!scrolled}
>
{crumbs.map((c, i) => (
<span key={c}>
{i > 0 && <span className={styles.sep}>/</span>}
{c}
</span>
))}
</nav>
</header>
);
}06 · 브레드크럼 등장 — 측정 누적면적 13.451% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
움직임은 전부 CSS 가 맡는다
자바스크립트는 클래스 하나만 붙이고, 실제 움직임(opacity·translateX)은 전부 CSS transition이 맡습니다.
.head__crumb {
display: flex; align-items: center; gap: $sp-2;
opacity: 0;
transform: translateX(-16px);
transition: opacity .2s ease-spring, transform .2s ease-spring;
}
// 실제 사용: 스크롤하면 JS가 붙인 클래스가 브레드크럼을 페이드+슬라이드로 보여준다
.head.is-scrolled .head__crumb { opacity: 1; transform: translateX(0); }
07유저 메뉴 압축
이름이 접혀 아바타만 남는다
우측 아바타 옆에 붙어 있던 사용자 이름 글자가 스크롤하면 폭과 함께 접혀 아바타 아이콘만 남습니다.

<main class="stage">
<header class="head is-demo">
<span class="head__logo">GODRICH</span>
<div class="head__user">
<span class="head__name">Alex Kim</span>
<span class="head__avatar" aria-hidden="true"></span>
</div>
</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: $subject-cream; // 아바타 색
// ── 데모 무대 ──────────────────────────────────────────
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;
justify-content: space-between;
padding: 0 clamp(16px, 5vw, 24px);
background: $stage-orange;
&__logo { color: $subject-ink; font-weight: 800; font-size: 18px; line-height: 1; font-family: inherit; letter-spacing: .04em; } // 주황 헤더 위 글자 — AA 4.5 ($color 크림은 3.11:1)
&__user { display: flex; align-items: center; }
&__avatar { width: 32px; height: 32px; border-radius: 50%; background: $color; flex: 0 0 auto; }
// 폭까지 접어야 아바타가 실제로 왼쪽으로 붙는다 — 텍스트 하나에만 한정
&__name {
display: inline-block;
max-width: 104px;
margin-right: $sp-2;
overflow: hidden;
white-space: nowrap;
color: $subject-ink; // 주황 헤더 위 글자 — AA 4.5
font-weight: 600;
font-size: 13px;
line-height: 32px;
font-family: inherit;
opacity: 1;
transform: scaleX(1);
transform-origin: left; // 규칙8: 이름 텍스트 좌측 기준 접힘
transition: max-width $duration $easing, margin-right $duration $easing,
opacity $duration $easing, transform $duration $easing;
}
// 실제 사용: 스크롤하면 이름이 접히고 아바타만 남는다
&.is-scrolled &__name { max-width: 0; margin-right: 0; opacity: 0; transform: scaleX(0); }
}
// ── 데모(.is-demo): 이름이 접혔다 펼쳐졌다 2초 루프 ──────
.head.is-demo .head__name {
animation: name-collapse $dur-loop $easing infinite;
}
@keyframes name-collapse {
0%, 15% { max-width: 104px; margin-right: $sp-2; opacity: 1; transform: scaleX(1); }
45%, 70% { max-width: 0; margin-right: 0; opacity: 0; transform: scaleX(0); }
95%, 100% { max-width: 104px; margin-right: $sp-2; opacity: 1; transform: scaleX(1); }
}
// ── 배경: 문서처럼 보이는 자리 ─────────────────────────
.page { padding: $sp-6 $sp-6 0; }
.page__line {
height: 12px;
margin: 0 0 $sp-3;
border-radius: $r-xs;
background: rgba($stage-paper, .28);
&.short { width: 60%; }
}
// ── 접근성: 움직임 줄이기 설정이면 즉시 상태만 바뀐다 ────────
@media (prefers-reduced-motion: reduce) {
.head__name { transition: none; }
.head.is-demo .head__name { animation: none !important; max-width: 104px; margin-right: $sp-2; opacity: 1; transform: scaleX(1); }
}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;
justify-content: space-between;
padding: 0 clamp(16px, 5vw, 24px);
background: #ff4d1f;
}
.head__logo {
color: #17141a;
font-weight: 800;
font-size: 18px;
line-height: 1;
font-family: inherit;
letter-spacing: 0.04em;
}
.head__user {
display: flex;
align-items: center;
}
.head__avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: #fff7e6;
flex: 0 0 auto;
}
.head__name {
display: inline-block;
max-width: 104px;
margin-right: 8px;
overflow: hidden;
white-space: nowrap;
color: #17141a;
font-weight: 600;
font-size: 13px;
line-height: 32px;
font-family: inherit;
opacity: 1;
transform: scaleX(1);
transform-origin: left;
transition: max-width 300ms ease-out, margin-right 300ms ease-out, opacity 300ms ease-out, transform 300ms ease-out;
}
.head.is-scrolled .head__name {
max-width: 0;
margin-right: 0;
opacity: 0;
transform: scaleX(0);
}
.head.is-demo .head__name {
animation: name-collapse 2s ease-out infinite;
}
@keyframes name-collapse {
0%, 15% {
max-width: 104px;
margin-right: 8px;
opacity: 1;
transform: scaleX(1);
}
45%, 70% {
max-width: 0;
margin-right: 0;
opacity: 0;
transform: scaleX(0);
}
95%, 100% {
max-width: 104px;
margin-right: 8px;
opacity: 1;
transform: scaleX(1);
}
}
.page {
padding: 24px 24px 0;
}
.page__line {
height: 12px;
margin: 0 0 12px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.28);
}
.page__line.short {
width: 60%;
}
@media (prefers-reduced-motion: reduce) {
.head__name {
transition: none;
}
.head.is-demo .head__name {
animation: none !important;
max-width: 104px;
margin-right: 8px;
opacity: 1;
transform: scaleX(1);
}
}// 실제 페이지: 스크롤을 40px 넘게 내리면 사용자 이름이 접히고 아바타만 남는다 (7줄)
var head = document.querySelector('.head');
window.addEventListener('scroll', function () {
head.classList.toggle('is-scrolled', window.scrollY > 40);
}, { passive: true });import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./UserMenuHeader.module.scss";
export type UserMenuHeaderProps = {
logo: string;
name: string;
/** 이름이 접히는 시간 (기본 300ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function UserMenuHeader({
logo,
name,
duration = 300,
easing = "ease-out",
color = "#fff7e6",
}: UserMenuHeaderProps) {
const [scrolled, setScrolled] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
function onScroll() {
setScrolled(window.scrollY > 40);
}
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<header className={styles.head} style={vars}>
<span className={styles.logo}>{logo}</span>
<div className={styles.user}>
<span className={scrolled ? `${styles.name} ${styles.collapsed}` : styles.name}>
{name}
</span>
<span className={styles.avatar} aria-hidden="true" />
</div>
</header>
);
}07 · 유저 메뉴 압축 — 측정 누적면적 0.298% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
margin-right 도 함께 줄인다
02번(아이콘만 남는 내비)과 같은 max-width 접기 기법을 오른쪽 정렬 요소에 적용한 형태라, margin-right도 함께 0으로 줄여야 아바타가 실제로 오른쪽 끝에 붙습니다.
.head__name {
display: inline-block;
max-width: 104px;
margin-right: $sp-2;
overflow: hidden;
white-space: nowrap;
transform: scaleX(1);
transform-origin: left;
transition: max-width .3s ease-out, margin-right .3s ease-out,
opacity .3s ease-out, transform .3s ease-out;
}
.head.is-scrolled .head__name {
max-width: 0; margin-right: 0; opacity: 0; transform: scaleX(0);
}
08CTA 버튼 슬라이드인
한참 내려야 버튼이 들어온다
본문을 일정 구간 이상 내려야 헤더 오른쪽 끝에 구매·문의 버튼이 화면 밖에서 미끄러져 들어와 자리 잡습니다.

<main class="stage">
<header class="head is-demo">
<span class="head__logo">GODRICH</span>
<button class="head__cta" type="button" tabindex="-1">Contact</button>
</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; // 버튼이 들어오는 시간
// 관성 lerp — p += (목표-p) * .32 를 20스텝 반복한 계산 결과를 커브로 굽는다 (형태 2차, 사용자 2026-09-14)
$easing: linear(0, .32, .538, .686, .786, .855, .901, .933, .954, .969, .979, .986, .99, .993, .995, .997, .998, .999, .999, .999, 1);
$easing-content: linear(0, .18, .328, .449, .548, .629, .696, .751, .796, .832, .863, .887, .908, .924, .938, .949, .958, .966, .972, .977, 1); // 알파 .18 — CTA보다 느리게 따라잡아 시차가 생긴다
$dur-content: $dur-base * 1.4;
$color: $subject-blue; // CTA 배경
// ── 데모 무대 ──────────────────────────────────────────
html, body { margin: 0; overflow: hidden; }
.stage {
min-height: 100vh;
margin: 0;
overflow: hidden; // CTA 는 평상시 translateX(140%) 로 화면 밖에 쉰다 — html,body 의 overflow:hidden 은
// 자기 자신에게만 scrollWidth 를 clientWidth 로 눌러줄 뿐, body.scrollWidth 는 여전히
// 실제 콘텐츠 폭(581px@480 · 409px@320)을 그대로 보고한다 (실측, 128-08). .stage 자체를 잘라야 한다.
background: $stage-paper;
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;
gap: $sp-3;
padding: 0 clamp(12px, 4vw, 24px);
background: $stage-paper;
border-bottom: 1px solid rgba($stage-ink, .1);
&__logo { color: $stage-ink; font-weight: 800; font-size: 18px; line-height: 1; font-family: inherit; letter-spacing: .04em; }
&__cta {
flex: 0 0 auto;
padding: $sp-2 clamp($sp-3, 4vw, $sp-5);
border: 0;
border-radius: $r-control;
background: $color;
color: $stage-paper;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
cursor: pointer;
transform: translateX(140%);
transition: transform $duration $easing;
}
// 실제 사용: 120px 넘게 스크롤해야 버튼이 화면 밖에서 미끄러져 들어온다
&.is-scrolled &__cta { transform: translateX(0); }
}
// ── 데모(.is-demo): 버튼이 나갔다 들어왔다 2초 루프 ──────
.head.is-demo .head__cta {
animation: cta-slide $dur-loop $easing infinite;
}
@keyframes cta-slide {
0%, 10% { transform: translateX(140%); }
40%, 65% { transform: translateX(0); }
95%, 100% { transform: translateX(140%); }
}
// 콘텐츠는 CTA 들어옴보다 늦게 따라온다 — 시차 스크롤
.head.is-scrolled ~ .page { transform: translateY(-8px); }
.head.is-demo ~ .page { animation: page-parallax $dur-loop $easing-content infinite; }
@keyframes page-parallax {
0%, 10% { transform: translateY(0); }
40%, 65% { transform: translateY(-8px); }
95%, 100% { transform: translateY(0); }
}
// ── 배경: 문서처럼 보이는 자리 ─────────────────────────
.page {
padding: $sp-6 $sp-6 0;
transform: translateY(0);
transition: transform $dur-content $easing-content;
}
.page__line {
height: 12px;
margin: 0 0 $sp-3;
border-radius: $r-xs;
background: rgba($stage-ink, .1);
&.short { width: 60%; }
}
// ── 접근성: 움직임 줄이기 설정이면 즉시 상태만 바뀐다 ────────
@media (prefers-reduced-motion: reduce) {
.head__cta { transition: none; }
.head.is-demo .head__cta { animation: none !important; transform: translateX(0); }
.page { transition: none; }
.head.is-demo ~ .page { animation: none !important; transform: translateY(0); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
min-height: 100vh;
margin: 0;
overflow: hidden;
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;
gap: 12px;
padding: 0 clamp(12px, 4vw, 24px);
background: #ffffff;
border-bottom: 1px solid rgba(23, 20, 26, 0.1);
}
.head__logo {
color: #17141a;
font-weight: 800;
font-size: 18px;
line-height: 1;
font-family: inherit;
letter-spacing: 0.04em;
}
.head__cta {
flex: 0 0 auto;
padding: 8px clamp(12px, 4vw, 20px);
border: 0;
border-radius: 12px;
background: #2f6df6;
color: #ffffff;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
cursor: pointer;
transform: translateX(140%);
transition: transform 300ms linear(0, 0.32, 0.538, 0.686, 0.786, 0.855, 0.901, 0.933, 0.954, 0.969, 0.979, 0.986, 0.99, 0.993, 0.995, 0.997, 0.998, 0.999, 0.999, 0.999, 1);
}
.head.is-scrolled .head__cta {
transform: translateX(0);
}
.head.is-demo .head__cta {
animation: cta-slide 2s linear(0, 0.32, 0.538, 0.686, 0.786, 0.855, 0.901, 0.933, 0.954, 0.969, 0.979, 0.986, 0.99, 0.993, 0.995, 0.997, 0.998, 0.999, 0.999, 0.999, 1) infinite;
}
@keyframes cta-slide {
0%, 10% {
transform: translateX(140%);
}
40%, 65% {
transform: translateX(0);
}
95%, 100% {
transform: translateX(140%);
}
}
.head.is-scrolled ~ .page {
transform: translateY(-8px);
}
.head.is-demo ~ .page {
animation: page-parallax 2s linear(0, 0.18, 0.328, 0.449, 0.548, 0.629, 0.696, 0.751, 0.796, 0.832, 0.863, 0.887, 0.908, 0.924, 0.938, 0.949, 0.958, 0.966, 0.972, 0.977, 1) infinite;
}
@keyframes page-parallax {
0%, 10% {
transform: translateY(0);
}
40%, 65% {
transform: translateY(-8px);
}
95%, 100% {
transform: translateY(0);
}
}
.page {
padding: 24px 24px 0;
transform: translateY(0);
transition: transform 420ms linear(0, 0.18, 0.328, 0.449, 0.548, 0.629, 0.696, 0.751, 0.796, 0.832, 0.863, 0.887, 0.908, 0.924, 0.938, 0.949, 0.958, 0.966, 0.972, 0.977, 1);
}
.page__line {
height: 12px;
margin: 0 0 12px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.1);
}
.page__line.short {
width: 60%;
}
@media (prefers-reduced-motion: reduce) {
.head__cta {
transition: none;
}
.head.is-demo .head__cta {
animation: none !important;
transform: translateX(0);
}
.page {
transition: none;
}
.head.is-demo ~ .page {
animation: none !important;
transform: translateY(0);
}
}// 실제 페이지: 스크롤을 120px 넘게 내려야 구매·문의 버튼이 화면 밖에서 들어온다 (7줄)
var head = document.querySelector('.head');
window.addEventListener('scroll', function () {
head.classList.toggle('is-scrolled', window.scrollY > 120);
}, { passive: true });import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CtaHeader.module.scss";
export type CtaHeaderProps = {
logo: string;
label: string;
/** 이 값(px)을 넘게 스크롤해야 버튼이 들어온다 (기본 120) */
threshold?: number;
/** 버튼이 들어오는 시간 (기본 300ms) */
duration?: number;
/** 관성 lerp 커브(p += (목표-p)*.32 를 20스텝 반복해 구운 linear()) — 기본값이 vanilla 와 같다 */
easing?: string;
color?: string;
};
// vanilla 128 과 같은 관성 lerp 커브 — p += (목표-p)*.32 를 20스텝 반복해 구운 linear()
const LERP_EASING = "linear(0, .32, .538, .686, .786, .855, .901, .933, .954, .969, .979, .986, .99, .993, .995, .997, .998, .999, .999, .999, 1)";
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function CtaHeader({
logo,
label,
threshold = 120,
duration = 300,
easing = LERP_EASING,
color = "#2f6df6",
}: CtaHeaderProps) {
const [visible, setVisible] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
function onScroll() {
setVisible(window.scrollY > threshold);
}
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, [threshold]);
return (
<header className={styles.head} style={vars}>
<span className={styles.logo}>{logo}</span>
<button
type="button"
className={visible ? `${styles.cta} ${styles.visible}` : styles.cta}
tabIndex={visible ? 0 : -1}
>
{label}
</button>
</header>
);
}08 · CTA 버튼 슬라이드인 — 측정 누적면적 14.556% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
임계값이 가장 큰 이유
아홉 개 중 스크롤 임계값이 가장 커서(120px), 페이지에 발을 들이자마자 버튼부터 들이대지 않습니다.
.head__cta {
transform: translateX(140%);
transition: transform .3s ease-spring;
}
// 실제 사용: 120px 넘게 스크롤해야 버튼이 화면 밖에서 미끄러져 들어온다
.head.is-scrolled .head__cta { transform: translateX(0); }
09알림 뱃지 등장
점이 튀어나왔다 맥박친다
스크롤이 80px을 넘는 순간 헤더 아이콘 위 알림 점이 튀어나오듯 커졌다가, 그 뒤로는 은은하게 맥박치기를 반복합니다.

<main class="stage">
<header class="head is-demo">
<span class="head__logo">GODRICH</span>
<span class="head__icon" aria-hidden="true">
<span class="head__dot"></span>
</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-pop;
$color: $subject-blue; // 알림 점 색
// ── 데모 무대 ──────────────────────────────────────────
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: $stage-ink;
&__logo { color: $subject-cream; font-weight: 800; font-size: 18px; line-height: 1; font-family: inherit; letter-spacing: .04em; }
&__icon {
position: relative;
display: inline-flex;
width: 28px;
height: 28px;
border-radius: $r-sm;
border: 2px solid $subject-cream;
}
&__dot {
position: absolute;
top: -6px;
right: -6px;
width: 16px;
height: 16px;
border-radius: 50%;
background: $color;
transform: scale(0);
opacity: 0;
transition: transform $duration $easing, opacity $duration $easing;
}
// 실제 사용: 스크롤을 넘으면 점이 튀어나오고, 그 뒤로는 은은하게 계속 맥박친다
&.is-scrolled &__dot {
transform: scale(1);
opacity: 1;
animation: dot-pulse-real 1.6s ease-in-out infinite;
}
}
@keyframes dot-pulse-real {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.3); opacity: .7; }
}
// ── 데모(.is-demo): 팝인 + 맥박 + 사라짐 2초 루프 ──────
.head.is-demo .head__dot {
animation: dot-demo-loop $dur-loop $easing infinite;
}
@keyframes dot-demo-loop {
0% { transform: scale(0); opacity: 0; }
10% { transform: scale(1.6); opacity: 1; }
18% { transform: scale(1); opacity: 1; }
38% { transform: scale(1.3); opacity: .7; }
52% { transform: scale(1); opacity: 1; }
72% { transform: scale(1.3); opacity: .7; }
86% { transform: scale(1); opacity: 1; }
96% { transform: scale(.4); opacity: 0; }
100% { transform: scale(0); opacity: 0; }
}
// ── 배경: 문서처럼 보이는 자리 ─────────────────────────
.page { padding: $sp-6 $sp-6 0; }
.page__line {
height: 12px;
margin: 0 0 $sp-3;
border-radius: $r-xs;
background: rgba($stage-paper, .12);
&.short { width: 60%; }
}
// ── 접근성: 움직임 줄이기 설정이면 즉시 상태만 바뀐다 ────────
@media (prefers-reduced-motion: reduce) {
.head__dot { transition: none; animation: none !important; }
.head.is-scrolled .head__dot { transform: scale(1); opacity: 1; }
.head.is-demo .head__dot { animation: none !important; transform: scale(1); opacity: 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;
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__icon {
position: relative;
display: inline-flex;
width: 28px;
height: 28px;
border-radius: 8px;
border: 2px solid #fff7e6;
}
.head__dot {
position: absolute;
top: -6px;
right: -6px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #2f6df6;
transform: scale(0);
opacity: 0;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.head.is-scrolled .head__dot {
transform: scale(1);
opacity: 1;
animation: dot-pulse-real 1.6s ease-in-out infinite;
}
@keyframes dot-pulse-real {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.3);
opacity: 0.7;
}
}
.head.is-demo .head__dot {
animation: dot-demo-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes dot-demo-loop {
0% {
transform: scale(0);
opacity: 0;
}
10% {
transform: scale(1.6);
opacity: 1;
}
18% {
transform: scale(1);
opacity: 1;
}
38% {
transform: scale(1.3);
opacity: 0.7;
}
52% {
transform: scale(1);
opacity: 1;
}
72% {
transform: scale(1.3);
opacity: 0.7;
}
86% {
transform: scale(1);
opacity: 1;
}
96% {
transform: scale(0.4);
opacity: 0;
}
100% {
transform: scale(0);
opacity: 0;
}
}
.page {
padding: 24px 24px 0;
}
.page__line {
height: 12px;
margin: 0 0 12px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.12);
}
.page__line.short {
width: 60%;
}
@media (prefers-reduced-motion: reduce) {
.head__dot {
transition: none;
animation: none !important;
}
.head.is-scrolled .head__dot {
transform: scale(1);
opacity: 1;
}
.head.is-demo .head__dot {
animation: none !important;
transform: scale(1);
opacity: 1;
}
}// 실제 페이지: 스크롤을 80px 넘게 내리는 순간 알림 점이 튀어나오듯 나타난다 (7줄)
var head = document.querySelector('.head');
window.addEventListener('scroll', function () {
head.classList.toggle('is-scrolled', window.scrollY > 80);
}, { passive: true });import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./NoticeHeader.module.scss";
export type NoticeHeaderProps = {
logo: string;
/** 이 값(px)을 넘게 스크롤하면 점이 나타난다 (기본 80) */
threshold?: number;
/** 점이 튀어나오는 시간 (기본 220ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function NoticeHeader({
logo,
threshold = 80,
duration = 220,
easing = "cubic-bezier(.2, 1.4, .4, 1)",
color = "#2f6df6",
}: NoticeHeaderProps) {
const [active, setActive] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
function onScroll() {
setActive(window.scrollY > threshold);
}
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, [threshold]);
return (
<header className={styles.head} style={vars}>
<span className={styles.logo}>{logo}</span>
<span className={styles.icon} aria-hidden="true">
<span className={active ? `${styles.dot} ${styles.active}` : styles.dot} />
</span>
</header>
);
}09 · 알림 뱃지 등장 — 측정 누적면적 0.449% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
키프레임을 둘로 나눈 이유
점이 나타나는 순간용 키프레임과 나타난 뒤 계속 도는 맥박용 키프레임을 분리해 둔 게 특징입니다.
.head__dot {
transform: scale(0);
opacity: 0;
transition: transform .15s ease-pop, opacity .15s ease-pop;
}
.head.is-scrolled .head__dot {
transform: scale(1);
opacity: 1;
animation: dot-pulse-real 1.6s ease-in-out infinite;
}
@keyframes dot-pulse-real {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.3); opacity: .7; }
}
CSS 헤더 고정, 어디서 깨지는가
짐작한 translateX 는 늘 틀린다
가장 자주 걸려 넘어지는 건 03번 같은 슬라이딩 인디케이터입니다. 밑줄이나 필처럼 탭 사이를 미끄러져 다니는 요소는 탭 폭이 서로 다르면(이 데모에서 "개요" 61.45px·"리포트" 76.17px·"설정" 61.45px) translateX 값을 짐작으로 넣기 쉬운데, 균등폭이 아닌 이상 그 짐작은 항상 틀립니다. 이 항목은 flex: 0 0 auto로 탭 폭을 내용에 맞게 고정한 뒤 offsetLeft·offsetWidth를 실제로 읽어 밑줄 위치를 계산합니다(18.73px·87.54px·156.35px). 같은 실수를 CSS만으로 재현해보면, 탭에 flex: 1 1 0(균등분배)을 주고 고정폭 그대로 옮기면 밑줄이 짧은 탭에서는 못 미치고 긴 탭에서는 넘칩니다. 이 03번 폴더를 직접 열어 탭 글자 수를 바꿔보면 밑줄이 실측값을 따라 다시 계산되는지 바로 확인할 수 있습니다.
05번과 겹침 순서
두 번째로 주의할 점은 05번(검색창 확장)입니다. 이름만 보면 "스티키 헤더" 모음에 속하지만 실제로는 스크롤과 무관하게 클릭으로만 열려, 스크롤 이벤트 리스너를 기대하고 코드를 읽으면 없어서 당황하게 됩니다. 04번처럼 두 층을 겹쳐 쌓은 슬롯 구조도 순서를 하나만 틀리면(절대 위치 레이어의 z-index를 안 주면) 검색 줄이 로고 위로 올라와 버리니 겹침 순서를 확인해야 합니다 — 이 아홉 개 파일을 담은 zip의 비밀번호는 weqydbxa이고, 압축을 풀면 같은 vanilla/·react/ 구조로 직접 열어 비교해 볼 수 있습니다.
접근성 — 아홉 개가 reduced-motion에서 하는 일
전환 시간만 없앤다
prefers-reduced-motion: reduce가 켜진 사람에게 이 아홉 개는 서로 조금씩 다르게 반응합니다. 01·02·04·06·07·08번은 transition: none으로 전환 시간만 없애서, 실제 페이지에서 스크롤을 넘기면 배너가 접히고 라벨이 줄고 이름이 사라지는 결과 자체는 그대로 일어나되 순간적으로 바뀝니다 — 기능은 지키고 애니메이션만 뺀 것입니다.
09번만 다르게 처리했다
09번(알림 뱃지)만 다르게 처리했는데, 전환뿐 아니라 스크롤 이후에도 계속 반복되는 dot-pulse-real 맥박 자체를 꺼서 점이 한 번 나타난 뒤로는 완전히 정지합니다 — 끝없이 반복되는 애니메이션은 전환과 달리 그 자체가 움직임이라 따로 꺼야 합니다. 03번(탭 밑줄)과 05번(검색창)은 애초에 스크롤에 자동으로 반응하지 않으니 줄일 "전환"도 사용자가 클릭한 순간에만 있고, 그 전환의 시간만 없앱니다.
더 읽을 거리
스크롤에 반응해 내용물이 움직이는 헤더를 더 보고 싶다면 CSS 스티키 헤더 9종 글에서 헤더 자체가 접히고 그림자가 지는 기본형을 다룹니다. 스크롤 고정 css 는 헤더만의 주제가 아닙니다 — 스크롤에 반응해 움직이는 애니메이션 전반은 CSS 스크롤 애니메이션 9종에 모아 뒀습니다. 다른 주제 모음은 CSS 애니메이션 카테고리에서 둘러볼 수 있습니다. position: sticky의 스크롤 컨테이너 규칙은 MDN 레퍼런스에, 03번이 쓰는 뷰포트 감지는 MDN IntersectionObserver 문서에 정리돼 있습니다.
FAQ — 자주 묻는 질문
네비게이션 바 고정 효과는 아홉 개 전부 스크롤해야 나타나나요?
아니요. 7개(01·02·04·06·07·08·09)는 스크롤 위치(20~120px 임계값)에 반응하고, 03번은 어느 섹션이 보이는지에 반응하지만, 05번(검색창 확장)은 스크롤과 무관하게 클릭으로만 열립니다.
헤더가 여러 층(배너+헤더, 헤더+검색)으로 겹칠 때 왜 height 대신 transform을 쓰나요?
height나 margin을 애니메이션하면 브라우저가 매 프레임 레이아웃을 다시 계산해야 해서 버벅이기 쉽습니다. 대신 두 층을 절대 위치로 겹쳐 두고 transform: translateY()만 움직이면 레이아웃 계산 없이 합성 레이어만 바뀌어 더 부드럽습니다. 01·04번이 이 방식입니다.
vanilla와 react 코드가 다른가요?
움직임 자체는 같습니다. 차이는 트리거뿐인데, vanilla 데모는 is-demo 클래스로 끊임없이 반복 재생하지만 React 컴포넌트는 실제 scroll 이벤트(또는 03번은 IntersectionObserver)를 직접 듣고 있다가 조건이 맞을 때만 클래스를 바꿉니다.









