9 CSS Sticky Header Extras — Search, CTA, Badge
A CSS sticky header keeps a page's menu bar pinned to the top while everything below scrolls past.
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Announcement bar collapse
- 02 Icon-only nav collapse
- 03 Tab underline synced to scroll
- 04 Search bar merges into header
- 05 Search icon expands to field
- 06 Breadcrumb reveal on scroll
- 07 User menu compacts to avatar
- 08 CTA button slides in
- 09 Notification dot pulse
The nine aren't ranked by popularity — they're grouped by what actually changes inside the header. Three fold something down to save space (01, 02, 07), two move or reveal position information as you scroll (03, 06), two widen or merge search UI into the header (04, 05), and the last two bring in an element that wasn't there before (08, 09).
01Announcement bar collapse
The bar disappears upward
The promo bar pinned above the header disappears upward the instant you start scrolling, without ever pushing the layout around, and the logo row rises to fill the space it leaves behind.

<main class="stage">
<div class="ahc is-demo">
<div class="ahc__slot">
<div class="ahc__bar">Free shipping · today only</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 · Announcement bar collapse — Measured area 23.972% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why nothing reflows
Both pieces sit in the same absolutely-positioned slot and only their transform changes, which is why nothing jumps or reflows while the bar is vanishing.
.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;
}
// in production: once scrolling starts, the bar slides up and the header rises into its place
.ahc.is-scrolled {
.ahc__bar { transform: translateY(-100%); opacity: 0; }
.ahc__head { transform: translateY(-32px); }
}
The collapse easing is not ease-out but an inertial lerp curve: repeated p += (target - p) * alpha steps baked into a CSS linear() function, so the header keeps drifting briefly after the scroll stops.
02Icon-only nav collapse
Labels shrink width-first
Nav link labels shrink away width-first as you scroll, leaving only their icons behind so a narrow header doesn't run out of room.
<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">Home</span></a>
<a class="ioc__item" href="#"><span class="ioc__icon ioc__icon--search" aria-hidden="true"></span><span class="ioc__label">Explore</span></a>
<a class="ioc__item" href="#"><span class="ioc__icon ioc__icon--save" aria-hidden="true"></span><span class="ioc__label">Save</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 · Icon-only nav collapse — Measured area 1.844% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why opacity alone is not enough
Fading the opacity alone would leave blank space where the text used to be, so max-width has to collapse to zero too before the icons actually slide left.
.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;
}
// in production: labels collapse width-first on scroll, leaving only icons
.ioc.is-scrolled .ioc__label { max-width: 0; transform: scaleX(0); opacity: 0; }
03Tab underline synced to scroll
An underline that follows the section
A single underline beneath a row of anchor tabs slides over to whichever tab matches the page section currently in view.

<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">Overview</a>
<a class="tus__tab" href="#report" data-i="1">Report</a>
<a class="tus__tab" href="#setting" data-i="2">Settings</a>
<span class="tus__underline" aria-hidden="true"></span>
</nav>
<div class="tus__panel">The underline slides to whichever tab matches the section scrolling past</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 · Tab underline synced to scroll — Measured area 0.308% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
Real numbers, not a guess
Because the tabs aren't the same width, the underline can't jump to a guessed pixel value — the real page reads each tab's offsetLeft and offsetWidth while IntersectionObserver decides which tab is active.
// tab widths differ, so the underline reads offsetLeft/offsetWidth at runtime — never a guessed 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)';
}
04Search bar merges into header
A search row merging upward
A search row that normally floats below the header slides upward and merges into the header's own row once you start scrolling.

<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">Enter a search term</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);
}
}// 실제 페이지: 스크롤하면 아래 Search 줄이 위로 미끄러져 헤더 한 줄 안으로 합쳐진다 (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 · Search bar merges into header — Measured area 15.596% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
Item 01 stacked the other way
The two rows sit in a fixed-height slot exactly like item 01, just stacked the opposite way, with the search row traveling the header's full height to land on top.
.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;
}
// in production: once scrolling starts, the search row slides up into the header row
.sbm.is-scrolled .sbm__search { transform: translateY(-56px); }
05Search icon expands to field
An icon widening into a field
A single magnifier icon widens out on click and turns into a real text field in the same spot.

<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="Open search" 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="Search" aria-label="Search input" 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 · Search icon expands to field — Measured area 3.736% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
The one item scroll never touches
It's the only one of the nine that has nothing to do with scrolling at all — the trigger is a click (and the focus that follows), not a scroll position.
.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; }
}
06Breadcrumb reveal on scroll
A breadcrumb fading in
The empty space beside the logo fades and slides in a breadcrumb trail the moment you scroll.

<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 · Breadcrumb reveal on scroll — Measured area 13.451% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
One class, then CSS takes over
The JavaScript only toggles a single class; the fade and the slide themselves are handled entirely by a 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;
}
// in production: the JS only toggles a class, the fade and slide are pure CSS transitions
.head.is-scrolled .head__crumb { opacity: 1; transform: translateX(0); }
07User menu compacts to avatar
A username folding away
The username sitting next to the avatar on the right collapses width-first as you scroll, leaving only the avatar icon behind.

<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 · User menu compacts to avatar — Measured area 0.298% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why margin-right shrinks too
It's the same max-width collapse technique as item 02 turned around for a right-aligned element, and margin-right has to shrink along with it or the avatar won't actually reach the edge.
.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 button slides in
A css sticky header on scroll threshold
Only after you scroll past a set point does a buy or contact button slide in from off-screen to the header's right edge.

<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 button slides in — Measured area 14.556% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why 120px is the highest
Its 120px threshold is the highest of the seven scroll-triggered items, so the button doesn't shove itself at someone who just landed on the page.
.head__cta {
transform: translateX(140%);
transition: transform .3s ease-spring;
}
// in production: only past 120px of scroll does the button slide in from off-screen
.head.is-scrolled .head__cta { transform: translateX(0); }
09Notification dot pulse
A dot popping in, then pulsing
The instant scroll passes a threshold, a notification dot pops onto a header icon and then keeps pulsing gently on a loop.

<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 · Notification dot pulse — Measured area 0.449% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
Two keyframes, on purpose
The pop-in runs on a plain CSS transition while the ongoing pulse is a separate @keyframes animation, which is what lets the pulse be switched off on its own later.
.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; }
}
Where CSS sticky headers break
A hardcoded translateX guess
The trap most people hit first is a sliding indicator like item 03. When tabs aren't the same width, it's tempting to hardcode a translateX guess for each one, but unless every tab happens to measure exactly the same, that guess is wrong the moment a label gets longer or shorter. This demo instead fixes each tab's width to its own content with flex: 0 0 auto, then reads offsetLeft and offsetWidth at runtime to compute where the underline should land — real numbers, not guesses, which is how it lands at 18.73px, 87.54px, and 156.35px for this particular row. Reproduce the mistake yourself in plain CSS by giving the tabs flex: 1 1 0 (an equal share of the row) while leaving the underline's positions fixed, and it falls short under the narrow tabs while overshooting the wide one; open the 03 folder and change a tab's label length to see the real version recalculate instead of drifting.
Scroll listeners and stacking order
The second thing worth watching is item 05 (search icon expands to field): despite sitting in a sticky-header roundup, it ignores scroll completely and opens only on a click, so anyone hunting through its code for a scroll listener will come up empty. Item 04's overlapping-layer trick needs one line most people skip — leave the z-index off the absolutely-positioned search row and you get the classic css sticky header overlapping content problem, with the search row stacking on top of the logo instead of sliding cleanly beneath it. The zip holding all nine files opens with weqydbxa, and once it's unzipped you'll find the same vanilla/ and react/ layout laid out side by side for comparison.
Accessibility — what reduced-motion does to each of the nine
Timing removed, behavior kept
Under prefers-reduced-motion: reduce, these nine don't all respond the same way. Items 01, 02, 04, 06, 07, and 08 lose only the transition's timing (transition: none) — scroll past the same trigger point in production and the bar still collapses, the label still shrinks, the name still disappears, just instantly instead of eased, so the behavior survives and only the animation is gone.
Why item 09 goes further
Item 09 (notification dot) goes further: on top of dropping the transition, it also kills the dot-pulse-real animation itself, so once the dot appears it stops there and never pulses again — an animation that loops forever is itself motion, unlike a one-off transition, and has to be switched off separately. Items 03 (tab underline) and 05 (search expand) never had a scroll trigger to reduce in the first place, so reduced-motion only shortens the transition that fires at the moment of a click or a focus, not anything tied to scrolling.
Where to go next
For headers that fold and shade on their own without any of this internal movement, 9 CSS Sticky Header Effects — No JS, Scroll-Driven covers the base set this one builds on. If scroll effects beyond headers are more what you're after, 9 CSS Animation on Scroll Effects, No JavaScript is a related roundup, and the CSS category hub has the rest. The scroll-container rules behind position: sticky are documented on MDN, and the viewport-detection API item 03 relies on is covered in the MDN IntersectionObserver docs.
FAQ — Frequently Asked Questions
Does every css sticky header example here need scrolling?
No. Seven of them (01, 02, 04, 06, 07, 08, 09) react to a scroll position — a threshold somewhere between 20px and 120px — and item 03 reacts to which section is currently visible, but item 05 (search icon expands to field) ignores scroll entirely and only opens on click.
Why transform instead of height when a banner and a header overlap?
Animating height or margin forces the browser to recompute layout on every frame, which is where the stutter comes from. Stacking the two layers with absolute positioning and moving only transform: translateY() changes a composited layer instead, so it stays smooth — items 01 and 04 both use this trick.
Does the vanilla code differ from the React version?
Vanilla and React render the same motion values. The difference lives only in the trigger — the vanilla demo loops nonstop under an is-demo class for the gallery, while the React component listens for a real scroll event (or, for item 03, an IntersectionObserver) and swaps the class only when the condition is actually met.









