02마감일별 그룹 고정 헤더
할일을 마감일(오늘·이번주·나중)로 묶고, 스크롤하는 동안 헤더가 그 그룹에 남은 개수를 실시간으로 세며 화면 위에 붙는다 (231 의 이름 첫 글자 그룹과 다른 시연 — 여긴 마감일 그룹 + 잔여 개수 배지).
쓰임마감일 기준 할일 목록, 캘린더 연동 리스트
currentGroupAndRemain() 스크롤 위치 판정overflow-y:scroll 분리 스크롤러그룹별 미완료 개수 매 프레임 재계산

<main class="stage">
<div class="wrap is-demo" id="wrap">
<div class="head" id="head">
<span class="headLabel" id="headLabel">오늘</span>
<span class="headCount" id="headCount">3건 남음</span>
</div>
<div class="scroller" id="scroller">
<ul class="list" id="list">
<li class="group" data-group="오늘">
<p class="groupTitle">오늘</p>
<ul class="items">
<li data-done="0">치과 예약 확인</li>
<li data-done="0">보고서 초안 제출</li>
<li data-done="1">우유 사기</li>
</ul>
</li>
<li class="group" data-group="이번주">
<p class="groupTitle">이번주</p>
<ul class="items">
<li data-done="0">세금 신고 서류 준비</li>
<li data-done="0">헬스장 등록 연장</li>
<li data-done="0">책 반납</li>
<li data-done="1">화분 물 주기</li>
</ul>
</li>
<li class="group" data-group="나중">
<p class="groupTitle">나중</p>
<ul class="items">
<li data-done="0">여행 계획 세우기</li>
<li data-done="0">책상 정리</li>
</ul>
</li>
</ul>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html 의 currentGroupAndRemain() 이 스크롤 위치로 현재 그룹을 찾고
// 그 그룹의 미완료 개수를 매 스크롤 프레임마다 다시 센다 — 캐시된 표가 아니다.
@include stage($stage-yellow);
.wrap {
width: 280px;
border-radius: $r-card;
background: $subject-ink;
overflow: hidden;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-3;
padding: $sp-3 $sp-4;
background: $subject-blue-deep;
}
.headLabel {
color: $subject-cream;
font-size: 13px;
font-weight: 700;
}
.headCount {
color: rgba($subject-cream, .85);
font-size: 11px;
font-weight: 600;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: rgba($subject-cream, .16);
}
.scroller {
height: 168px;
overflow-y: scroll;
}
.list {
list-style: none;
margin: 0;
padding: 0;
}
.groupTitle {
margin: 0;
padding: $sp-2 $sp-4;
color: rgba($subject-cream, .55);
font-size: 10px;
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
}
.items {
list-style: none;
margin: 0;
padding: 0 $sp-4 $sp-2;
}
.items li {
padding: $sp-1 0;
color: $subject-cream;
font-size: 12px;
border-bottom: 1px solid rgba($subject-cream, .08);
&:last-child { border-bottom: none; }
&[data-done="1"] { color: rgba($subject-cream, .4); text-decoration: line-through; }
}
@media (max-width: 340px) {
.wrap { width: 220px; }
.head { padding: $sp-2 $sp-3; }
.scroller { height: 120px; }
}
@media (prefers-reduced-motion: reduce) {
// scss 만 — 이 항목은 트랜지션이 없다(값이 즉시 바뀐다). reduce 는 JS 쪽 자동 스크롤 루프를 멈춘다
}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);
}
.wrap {
width: 280px;
border-radius: 16px;
background: #17141a;
overflow: hidden;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
background: #0a4fe8;
}
.headLabel {
color: #fff7e6;
font-size: 13px;
font-weight: 700;
}
.headCount {
color: rgba(255, 247, 230, 0.85);
font-size: 11px;
font-weight: 600;
padding: 4px 8px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.16);
}
.scroller {
height: 168px;
overflow-y: scroll;
}
.list {
list-style: none;
margin: 0;
padding: 0;
}
.groupTitle {
margin: 0;
padding: 8px 16px;
color: rgba(255, 247, 230, 0.55);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.items {
list-style: none;
margin: 0;
padding: 0 16px 8px;
}
.items li {
padding: 4px 0;
color: #fff7e6;
font-size: 12px;
border-bottom: 1px solid rgba(255, 247, 230, 0.08);
}
.items li:last-child {
border-bottom: none;
}
.items li[data-done="1"] {
color: rgba(255, 247, 230, 0.4);
text-decoration: line-through;
}
@media (max-width: 340px) {
.wrap {
width: 220px;
}
.head {
padding: 8px 12px;
}
.scroller {
height: 120px;
}
}(function () {
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var wrap = document.getElementById('wrap');
var scroller = document.getElementById('scroller');
var headLabel = document.getElementById('headLabel');
var headCount = document.getElementById('headCount');
var groups = Array.prototype.slice.call(document.querySelectorAll('.group'));
// 계산 겹: 스크롤 위치로 "현재 그룹"을 찾고, 그 그룹 안의 미완료 개수를 실시간으로 다시 센다.
// 미리 계산해 둔 표가 아니라 매 스크롤 프레임마다 DOM 을 다시 읽어 센다.
function currentGroupAndRemain() {
var top = scroller.scrollTop;
var current = groups[0];
for (var i = 0; i < groups.length; i++) {
if (groups[i].offsetTop - scroller.offsetTop <= top + 4) current = groups[i];
}
var items = current.querySelectorAll('.items li');
var remain = 0;
for (var j = 0; j < items.length; j++) {
if (items[j].getAttribute('data-done') === '0') remain++;
}
return { name: current.getAttribute('data-group'), remain: remain };
}
function paint() {
var r = currentGroupAndRemain();
headLabel.textContent = r.name;
headCount.textContent = r.remain + '건 남음';
}
function human() {
if (wrap.classList.contains('is-demo')) { wrap.classList.remove('is-demo'); window.clearInterval(autoTimer); }
}
scroller.addEventListener('scroll', paint);
scroller.addEventListener('pointerdown', human);
scroller.addEventListener('keydown', human);
scroller.addEventListener('wheel', human, { passive: true });
var max = 0, dir = 1;
function autoAdvance() {
max = scroller.scrollHeight - scroller.clientHeight;
var next = scroller.scrollTop + dir * (max * 0.34);
if (next >= max - 1) { next = max; dir = -1; }
else if (next <= 1) { next = 0; dir = 1; }
scroller.scrollTop = next;
paint();
}
paint();
var autoTimer;
// 2초 측정 창 안에서 전환이 5~6회는 나야 "움직인 프레임" 하한을 안정적으로 넘긴다 —
// 인터벌만 걸면 첫 전환이 480ms 뒤에나 나와 여유가 border 에 걸린다(실측 09-22 5~6 왕복)
if (!reduce) { autoAdvance(); autoTimer = window.setInterval(autoAdvance, 350); }
})();// GroupedHeader — vanilla 판(run/377/demo/02-grouped-header/index.html) 의 React 포트.
// 스크롤 위치로 현재 그룹을 찾고 그 그룹의 미완료 개수를 매 스크롤 프레임마다 다시 센다.
import { useEffect, useRef, useState } from "react";
import styles from "./GroupedHeader.module.scss";
type Box<T> = { current: T };
type Item = { text: string; done: boolean };
type Group = { name: string; items: Item[] };
const GROUPS: Group[] = [
{ name: "오늘", items: [{ text: "치과 예약 확인", done: false }, { text: "보고서 초안 제출", done: false }, { text: "우유 사기", done: true }] },
{ name: "이번주", items: [{ text: "세금 신고 서류 준비", done: false }, { text: "헬스장 등록 연장", done: false }, { text: "책 반납", done: false }, { text: "화분 물 주기", done: true }] },
{ name: "나중", items: [{ text: "여행 계획 세우기", done: false }, { text: "책상 정리", done: false }] },
];
export default function GroupedHeader() {
const scrollerRef = useRef(null) as unknown as Box<HTMLDivElement>;
const groupRefs = useRef([]) as unknown as Box<(HTMLLIElement | null)[]>;
const [isDemo, setIsDemo] = useState(true);
const [head, setHead] = useState({ name: GROUPS[0].name, remain: GROUPS[0].items.filter(function (i) { return !i.done; }).length });
const timerRef = useRef(0) as unknown as Box<number>;
function paint() {
const scroller = scrollerRef.current;
if (!scroller) return;
const top = scroller.scrollTop;
let idx = 0;
for (let i = 0; i < groupRefs.current.length; i++) {
const el = groupRefs.current[i];
if (el && el.offsetTop - scroller.offsetTop <= top + 4) idx = i;
}
const remain = GROUPS[idx].items.filter(function (it) { return !it.done; }).length;
setHead({ name: GROUPS[idx].name, remain: remain });
}
function human() {
if (isDemo) { setIsDemo(false); window.clearInterval(timerRef.current); }
}
useEffect(function () {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const scroller = scrollerRef.current;
if (!scroller || reduce) return;
let dir = 1;
function autoAdvance() {
const max = scroller!.scrollHeight - scroller!.clientHeight;
let next = scroller!.scrollTop + dir * (max * 0.34);
if (next >= max - 1) { next = max; dir = -1; }
else if (next <= 1) { next = 0; dir = 1; }
scroller!.scrollTop = next;
paint();
}
// vanilla 와 동일 — 첫 전환을 즉시 내고 간격을 좁혀 측정 창 안에서 안정적으로 여러 번 전환한다
autoAdvance();
timerRef.current = window.setInterval(autoAdvance, 350);
return function () { window.clearInterval(timerRef.current); };
// eslint-disable-next-line
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + (isDemo ? " " + styles.isDemo : "")}>
<div className={styles.head}>
<span className={styles.headLabel}>{head.name}</span>
<span className={styles.headCount}>{head.remain}건 남음</span>
</div>
<div
className={styles.scroller}
ref={scrollerRef}
onScroll={paint}
onPointerDown={human}
onKeyDown={human}
onWheel={human}
>
<ul className={styles.list}>
{GROUPS.map(function (g, i) {
return (
<li className={styles.group} key={g.name} ref={function (el: HTMLLIElement | null) { groupRefs.current[i] = el; }}>
<p className={styles.groupTitle}>{g.name}</p>
<ul className={styles.items}>
{g.items.map(function (it) {
return <li key={it.text} data-done={it.done ? "1" : "0"}>{it.text}</li>;
})}
</ul>
</li>
);
})}
</ul>
</div>
</div>
</main>
);
}02 마감일별 그룹 고정 헤더 — 측정 누적면적 8.496% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 투두 리스트 ui 패턴 9종 — 진행률 게이지부터 되감기까지 한 번에 정리
이 데모가 속한 글
투두 리스트 ui 패턴 9종 — 진행률 게이지부터 되감기까지 한 번에 정리투두 리스트 ui 아홉 패턴 중 하나는 더보기를 누를 때마다 불러오는 개수가 두 배로 늘고, 하나는 되감기를 누르면 완료 타임라인이 방금 순서의 역순으로 사라지며, 하나는 체크박스를 누를 때마다 원형 게이지가 그 비 이 글의 데모 전부 보기


