02Due-Date Grouped Sticky Header
Todos group by due date (today, this week, later), and the sticky header counts the remaining items in that group live as you scroll (different from 231's first-letter grouping — here it's due-date groups plus a remaining-count badge).
Best forDue-date todo lists, calendar-linked lists
currentGroupAndRemain() 스크롤 위치 판정overflow-y:scroll 분리 스크롤러그룹별 미완료 개수 매 프레임 재계산

<main class="stage">
<div class="wrap is-demo" id="wrap">
<div class="head" id="head">
<span class="headLabel" id="headLabel">Today</span>
<span class="headCount" id="headCount">3 left</span>
</div>
<div class="scroller" id="scroller">
<ul class="list" id="list">
<li class="group" data-group="Today">
<p class="groupTitle">Today</p>
<ul class="items">
<li data-done="0">Confirm dentist appointment</li>
<li data-done="0">Submit report draft</li>
<li data-done="1">Buy milk</li>
</ul>
</li>
<li class="group" data-group="This week">
<p class="groupTitle">This week</p>
<ul class="items">
<li data-done="0">Prepare tax filing documents</li>
<li data-done="0">Renew gym membership</li>
<li data-done="0">Return books</li>
<li data-done="1">Water the plant</li>
</ul>
</li>
<li class="group" data-group="Later">
<p class="groupTitle">Later</p>
<ul class="items">
<li data-done="0">Plan a trip</li>
<li data-done="0">Tidy up desk</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 + ' left';
}
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 Due-Date Grouped Sticky Header — Measured area 8.496% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · Todo List UI: 9 Patterns From Ring Gauge to Rewind
The post this demo lives in
Todo List UI: 9 Patterns From Ring Gauge to RewindAmong nine todo list ui patterns, one doubles how many load each tap, one rewinds the timeline dot by dot, one fills a r See all demos in this post


