05그룹 고정 헤더 — 목록을 그룹으로 나누고 스크롤에 헤더가 붙어 내려오는 구조
제안을 카테고리(제품·브랜드·카테고리)로 그룹화하고 각 그룹 헤더를 position: sticky 로 두면, 긴 목록을 스크롤해도 지금 보는 그룹의 이름이 상단에 붙어 따라옵니다. 그룹 경계에 그림자·1px 구분선을 얹어 헤더가 겹치는 순간을 시각적으로 읽히게 합니다.
쓰임대형 쇼핑몰 검색
position: stickyscrollCSS custom properties

<main class="stage">
<div class="gh" id="gh">
<p class="gh__hint">스크롤해도 지금 보는 그룹 이름이 위에 붙어 옵니다</p>
<div class="gh__list" id="ghList" role="listbox" aria-label="그룹 목록" tabindex="0">
<div class="gh__group" role="group" aria-label="제품">
<div class="gh__head" id="ghHead0">제품</div>
<ul class="gh__rows">
<li class="gh__item" role="option">바나나</li>
<li class="gh__item" role="option">파인애플</li>
<li class="gh__item" role="option">수박</li>
<li class="gh__item" role="option">자몽</li>
</ul>
</div>
<div class="gh__group" role="group" aria-label="브랜드">
<div class="gh__head" id="ghHead1">브랜드</div>
<ul class="gh__rows">
<li class="gh__item" role="option">체리</li>
<li class="gh__item" role="option">복숭아</li>
<li class="gh__item" role="option">딸기</li>
</ul>
</div>
<div class="gh__group" role="group" aria-label="카테고리">
<div class="gh__head" id="ghHead2">카테고리</div>
<ul class="gh__rows">
<li class="gh__item" role="option">아보카도</li>
<li class="gh__item" role="option">망고</li>
<li class="gh__item" role="option">파파야</li>
</ul>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-ink);
.gh {
width: 100%;
max-width: 360px;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.gh__hint {
margin: 0;
padding: 0 $sp-1;
font-weight: 400;
font-size: 11px;
line-height: 1.4;
color: rgba($subject-cream, .72);
}
.gh__list {
height: 176px;
overflow-y: auto;
background: $subject-cream;
border-radius: $r-card;
box-shadow: $shadow-press;
position: relative;
}
.gh__group {
&:first-child .gh__head {
border-top-left-radius: $r-card;
border-top-right-radius: $r-card;
}
}
.gh__head {
position: sticky;
top: 0;
z-index: 1;
padding: $sp-1 $sp-3;
background: $subject-cream;
border-bottom: 1px solid rgba($subject-ink, .08);
font-weight: 600;
font-size: 11px;
color: $color;
transition: box-shadow $duration $easing, border-color $duration $easing;
}
.gh__rows {
margin: 0;
padding: 0 $sp-1 $sp-1;
list-style: none;
}
.gh__item {
padding: $sp-1 $sp-2;
border-radius: $r-sm;
font-weight: 500;
font-size: 12px;
line-height: 1.7;
color: $subject-ink;
}
@media (max-width: 340px) {
.gh { max-width: 240px; }
.gh__list { height: 124px; }
}
@media (prefers-reduced-motion: reduce) {
.gh__head {
transition: none;
}
}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);
}
.gh {
width: 100%;
max-width: 360px;
display: flex;
flex-direction: column;
gap: 8px;
}
.gh__hint {
margin: 0;
padding: 0 4px;
font-weight: 400;
font-size: 11px;
line-height: 1.4;
color: rgba(255, 247, 230, 0.72);
}
.gh__list {
height: 176px;
overflow-y: auto;
background: #fff7e6;
border-radius: 16px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
position: relative;
}
.gh__group:first-child .gh__head {
border-top-left-radius: 16px;
border-top-right-radius: 16px;
}
.gh__head {
position: sticky;
top: 0;
z-index: 1;
padding: 4px 12px;
background: #fff7e6;
border-bottom: 1px solid rgba(23, 20, 26, 0.08);
font-weight: 600;
font-size: 11px;
color: #0a4fe8;
transition: box-shadow 300ms ease-out, border-color 300ms ease-out;
}
.gh__rows {
margin: 0;
padding: 0 4px 4px;
list-style: none;
}
.gh__item {
padding: 4px 8px;
border-radius: 8px;
font-weight: 500;
font-size: 12px;
line-height: 1.7;
color: #17141a;
}
@media (max-width: 340px) {
.gh {
max-width: 240px;
}
.gh__list {
height: 124px;
}
}
@media (prefers-reduced-motion: reduce) {
.gh__head {
transition: none;
}
}// 그룹 고정 헤더(05) — 각 그룹 헤더는 CSS position: sticky 로 스크롤 컨테이너 상단에
// 붙는다. 계산 층은 그 위에 얹는다: 다음 헤더가 현재 헤더를 밀어내기 직전 구간을
// getBoundingClientRect 로 매 프레임 재서 overlapRatio(0~1) 를 구하고, 그 값을 그림자
// 진하기(box-shadow alpha)로 환산해 "곧 교체된다"를 시각적으로 앞당겨 보여준다.
(function () {
var list = document.getElementById('ghList');
if (!list) return;
var heads = Array.prototype.slice.call(list.querySelectorAll('.gh__head'));
function applyShadow(i, ratio) {
var head = heads[i];
if (!head) return;
var alpha = 0.10 + 0.28 * ratio;
head.style.boxShadow = '0 6px 10px rgba(23,20,26,' + alpha.toFixed(3) + ')';
head.style.borderBottomColor = 'rgba(23,20,26,' + (0.08 + 0.4 * ratio).toFixed(3) + ')';
}
function measureStuck() {
var listTop = list.getBoundingClientRect().top;
for (var i = 0; i < heads.length; i++) {
var next = heads[i + 1];
if (!next) { applyShadow(i, 0); continue; }
var headH = heads[i].offsetHeight;
var nextTop = next.getBoundingClientRect().top - listTop;
var ratio = 1 - Math.min(1, Math.max(0, (nextTop - headH) / headH));
ratio = Math.min(1, Math.max(0, ratio));
applyShadow(i, ratio);
}
}
measureStuck();
var playing = true;
function stop() { playing = false; }
list.addEventListener('wheel', stop, { passive: true });
list.addEventListener('pointerdown', stop);
list.addEventListener('scroll', measureStuck, { passive: true });
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) {
var period = 2000;
var start = performance.now();
(function tick(now) {
if (playing) {
var t = (now - start) % period;
var half = period / 2;
var p = t < half ? (t / half) : ((period - t) / half);
var max = list.scrollHeight - list.clientHeight;
list.scrollTop = p * max;
measureStuck();
}
requestAnimationFrame(tick);
})(start);
}
})();// SearchAutocompleteGroupedStickyHeader — vanilla 판(run/335/demo/05-search-autocomplete-grouped-sticky-header/index.html)
// 의 React 포트. 각 그룹 헤더는 CSS position: sticky 로 붙는다. 계산 층은 다음 헤더가
// 현재 헤더를 밀어내기 직전 구간을 getBoundingClientRect 로 매 프레임 재서
// overlapRatio(0~1) 를 구하고, 그 값을 그림자 진하기로 환산해 시각적으로 앞당겨 보여준다.
import { useEffect, useRef } from "react";
import styles from "./SearchAutocompleteGroupedStickyHeader.module.scss";
export type SearchAutocompleteGroupedStickyHeaderProps = {
duration?: string;
easing?: string;
color?: string;
};
const HINT = "스크롤해도 지금 보는 그룹 이름이 위에 붙어 옵니다";
const LIST_LABEL = "그룹 목록";
const GROUPS: { label: string; items: string[] }[] = [
{ label: "제품", items: ["바나나", "파인애플", "수박", "자몽"] },
{ label: "브랜드", items: ["체리", "복숭아", "딸기"] },
{ label: "카테고리", items: ["아보카도", "망고", "파파야"] }
];
export default function SearchAutocompleteGroupedStickyHeader({
duration = "300ms",
easing = "ease-out",
color = "#0a4fe8"
}: SearchAutocompleteGroupedStickyHeaderProps) {
const listRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const list = listRef.current;
if (!list) return;
const heads = Array.prototype.slice.call(list.querySelectorAll<HTMLElement>("." + styles.head)) as HTMLElement[];
function applyShadow(i: number, ratio: number): void {
const head = heads[i];
if (!head) return;
const alpha = 0.1 + 0.28 * ratio;
head.style.boxShadow = "0 6px 10px rgba(23,20,26," + alpha.toFixed(3) + ")";
head.style.borderBottomColor = "rgba(23,20,26," + (0.08 + 0.4 * ratio).toFixed(3) + ")";
}
function measureStuck(): void {
const listTop = list!.getBoundingClientRect().top;
for (let i = 0; i < heads.length; i++) {
const next = heads[i + 1];
if (!next) {
applyShadow(i, 0);
continue;
}
const headH = heads[i].offsetHeight;
const nextTop = next.getBoundingClientRect().top - listTop;
let ratio = 1 - Math.min(1, Math.max(0, (nextTop - headH) / headH));
ratio = Math.min(1, Math.max(0, ratio));
applyShadow(i, ratio);
}
}
measureStuck();
let playing = true;
function stop(): void {
playing = false;
}
list.addEventListener("wheel", stop, { passive: true });
list.addEventListener("pointerdown", stop);
list.addEventListener("scroll", measureStuck, { passive: true });
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let raf = 0;
if (!reduce) {
const period = 2000;
const start = performance.now();
const tick = (now: number): void => {
if (playing) {
const t = (now - start) % period;
const half = period / 2;
const p = t < half ? t / half : (period - t) / half;
const max = list!.scrollHeight - list!.clientHeight;
list!.scrollTop = p * max;
measureStuck();
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
}
return () => {
cancelAnimationFrame(raf);
list.removeEventListener("wheel", stop);
list.removeEventListener("pointerdown", stop);
list.removeEventListener("scroll", measureStuck);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div className={styles.gh}>
<p className={styles.hint}>{HINT}</p>
<div
className={styles.list}
ref={listRef}
role="listbox"
aria-label={LIST_LABEL}
tabIndex={0}
style={{ transitionDuration: duration, transitionTimingFunction: easing, caretColor: color }}
>
{GROUPS.map((g, gi) => (
<div className={styles.group} role="group" aria-label={g.label} key={g.label}>
<div className={styles.head} id={"gh-head-react-" + gi}>
{g.label}
</div>
<ul className={styles.rows}>
{g.items.map((it) => (
<li className={styles.item} role="option" key={it}>
{it}
</li>
))}
</ul>
</div>
))}
</div>
</div>
</main>
);
}05 그룹 고정 헤더 — 목록을 그룹으로 나누고 스크롤에 헤더가 붙어 내려오는 구조 — 측정 누적면적 34.696% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · input 자동완성 검색창 — 하이라이트·디바운스·편집거리 계산을 얹은 9종
이 데모가 속한 글
input 자동완성 검색창 — 하이라이트·디바운스·편집거리 계산을 얹은 9종입력한 글자가 제안 항목 안에서 실제로 어디에 있는지 찾아 그 부분만 굵게 칠하고, 300ms 디바운스로 요청을 모아 키보드로도 목록이 순환합니다. 최근 검색·오타 제안·인기 순위 막대까지 — input 자동완성 검 이 글의 데모 전부 보기


