02그룹 리스트 고정 섹션 헤더
연락처를 첫 글자별로 묶고, 스크롤하는 동안 현재 그룹의 글자 헤더가 화면 위에 붙어 있다가 다음 그룹이 오면 밀려 올라간다.
쓰임연락처 앱 목록, 카테고리별 상품 리스트
position:sticky top:0그룹별 wrapper파스텔 톤

<main class="stage">
<div class="grp-wrap is-demo" tabindex="0" aria-label="연락처 목록. 화살표 키나 드래그로 스크롤">
<ul class="grp-list">
<li class="grp-group">
<div class="grp-group__header">A</div>
<ul class="grp-group__rows">
<li>Ari</li><li>Aiden</li><li>Ava</li>
</ul>
</li>
<li class="grp-group">
<div class="grp-group__header">B</div>
<ul class="grp-group__rows">
<li>Bella</li><li>Ben</li><li>Bora</li>
</ul>
</li>
<li class="grp-group">
<div class="grp-group__header">C</div>
<ul class="grp-group__rows">
<li>Chloe</li><li>Charlie</li><li>Cyan</li>
</ul>
</li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 스크롤 자동 시연 왕복 시간(스크립트 값과 맞춤)
$easing: $ease-out;
$color: $subject-pink; // 그룹 헤더 배경 (파스텔)
@include stage($stage-paper);
.grp-wrap {
width: 220px;
height: 168px;
overflow: auto;
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-raised;
}
.grp-wrap:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.grp-list { list-style: none; margin: 0; padding: 0; }
.grp-group { margin: 0; padding: 0; }
.grp-group__header {
position: sticky;
top: 0;
z-index: 1;
padding: $sp-2 $sp-4;
font-weight: 700;
font-size: 13px;
color: $stage-ink;
background: $color;
}
.grp-group:nth-of-type(2) .grp-group__header { background: $subject-mint; }
.grp-group:nth-of-type(3) .grp-group__header { background: $subject-lilac; }
.grp-group__rows {
list-style: none;
margin: 0;
padding: 0;
}
.grp-group__rows li {
padding: $sp-2 $sp-4;
font-size: 13px;
color: $stage-ink;
border-bottom: 1px solid rgba(23, 20, 26, .06);
}
@media (prefers-reduced-motion: reduce) {
.grp-wrap { scroll-behavior: auto; }
}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);
}
.grp-wrap {
width: 220px;
height: 168px;
overflow: auto;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.grp-wrap:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.grp-list {
list-style: none;
margin: 0;
padding: 0;
}
.grp-group {
margin: 0;
padding: 0;
}
.grp-group__header {
position: sticky;
top: 0;
z-index: 1;
padding: 8px 16px;
font-weight: 700;
font-size: 13px;
color: #17141a;
background: #ff9ebb;
}
.grp-group:nth-of-type(2) .grp-group__header {
background: #4cd4a6;
}
.grp-group:nth-of-type(3) .grp-group__header {
background: #b9a0ff;
}
.grp-group__rows {
list-style: none;
margin: 0;
padding: 0;
}
.grp-group__rows li {
padding: 8px 16px;
font-size: 13px;
color: #17141a;
border-bottom: 1px solid rgba(23, 20, 26, 0.06);
}
@media (prefers-reduced-motion: reduce) {
.grp-wrap {
scroll-behavior: auto;
}
}(function () {
var wrap = document.querySelector('.grp-wrap');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = null, t0 = null;
function maxY() { return wrap.scrollHeight - wrap.clientHeight; }
function loop(ts) {
if (!wrap.classList.contains('is-demo')) return;
if (t0 === null) t0 = ts;
var p = ((ts - t0) / 2000) % 1;
var s = p < .5 ? p * 2 : 2 - p * 2;
wrap.scrollTop = s * maxY();
raf = requestAnimationFrame(loop);
}
function stop() {
wrap.classList.remove('is-demo');
if (raf) cancelAnimationFrame(raf);
}
wrap.addEventListener('pointerdown', stop);
wrap.addEventListener('wheel', stop, { passive: true });
wrap.addEventListener('keydown', function (e) {
var step = 24;
if (e.key === 'ArrowDown') { stop(); wrap.scrollTop += step; }
else if (e.key === 'ArrowUp') { stop(); wrap.scrollTop -= step; }
else return;
e.preventDefault();
});
if (!reduce) requestAnimationFrame(loop);
})();import type { CSSProperties } from "react";
import styles from "./GroupedHeaderStickySection.module.scss";
export type Group = { letter: string; names: string[] };
export type GroupedHeaderStickySectionProps = {
groups: Group[];
/** 스크롤 이징 (기본 300ms) */
duration?: number;
easing?: string;
/** 첫 그룹 헤더 배경(파스텔) */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** 그룹 헤더가 position:sticky 로 스크롤 중 위에 붙어 있다가 다음 그룹이 밀어 올린다 */
export default function GroupedHeaderStickySection({
groups,
duration = 300,
easing = "ease-out",
color = "#ff9ebb",
}: GroupedHeaderStickySectionProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.wrap} style={vars} tabIndex={0} aria-label="연락처 목록">
<ul className={styles.list}>
{groups.map((g, i) => (
<li key={g.letter} className={styles.group}>
<div className={styles.header} data-i={i % 3}>{g.letter}</div>
<ul className={styles.rows}>
{g.names.map((n) => <li key={n}>{n}</li>)}
</ul>
</li>
))}
</ul>
</div>
);
}02 그룹 리스트 고정 섹션 헤더 — 측정 누적면적 25.533% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 아코디언 리스트 UI 9가지 — 체크리스트까지
이 데모가 속한 글
아코디언 리스트 UI 9가지 — 체크리스트까지아코디언 리스트는 목록 행을 클릭했을 때 그 자리에서 높이가 부드럽게 늘어나며 상세 내용이 펼쳐지는 UI로, 별도 페이지 이동 없이 리스트 화면 안에서 정보를 더 보여주고 싶을 때 씁니다. 리스트 한 줄을 눌렀을 때 이 글의 데모 전부 보기


