05Grouped sticky headers — splitting the list into groups whose headers pin on scroll
Grouping suggestions by category and making each header position: sticky keeps the current group's name pinned at the top through long scrolls. A shadow and hairline at the boundary make the moment a header stacks visually legible.
Best forLarge storefront search
position: stickyscrollCSS custom properties

<main class="stage">
<div class="gh" id="gh">
<p class="gh__hint">The name of the group you're viewing sticks to the top as you scroll.</p>
<div class="gh__list" id="ghList" role="listbox" aria-label="Grouped list" tabindex="0">
<div class="gh__group" role="group" aria-label="Products">
<div class="gh__head" id="ghHead0">Products</div>
<ul class="gh__rows">
<li class="gh__item" role="option">banana</li>
<li class="gh__item" role="option">pineapple</li>
<li class="gh__item" role="option">watermelon</li>
<li class="gh__item" role="option">grapefruit</li>
</ul>
</div>
<div class="gh__group" role="group" aria-label="Brands">
<div class="gh__head" id="ghHead1">Brands</div>
<ul class="gh__rows">
<li class="gh__item" role="option">cherry</li>
<li class="gh__item" role="option">peach</li>
<li class="gh__item" role="option">strawberry</li>
</ul>
</div>
<div class="gh__group" role="group" aria-label="Categories">
<div class="gh__head" id="ghHead2">Categories</div>
<ul class="gh__rows">
<li class="gh__item" role="option">avocado</li>
<li class="gh__item" role="option">mango</li>
<li class="gh__item" role="option">papaya</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 Grouped sticky headers — splitting the list into groups whose headers pin on scroll — Measured area 34.696% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Autocomplete Input: 9 Search Dropdowns
The post this demo lives in
Autocomplete Input: 9 Search DropdownsAutocomplete input, explained: Bolds where letters sit in each suggestion, debounces requests, cycles the list by keyboa See all demos in this post
If you like this effect - similar demos
Inputs04 Wheel delta — a stepper whose value changes by the amount and direction the wheel moves over itNumber Stepper UI: 9 Interaction Patterns
Inputs09 Focus ring that walks you to the first error9 Form Validation UI Patterns — As You Type
Inputs02 Line item that slides down into the saved-for-later groupCheckout Page Design — 9 Cart Patterns, No Library