GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Todo List UI: 9 Patterns From Ring Gauge to Rewind

Among nine todo list ui patterns, one doubles how many load each tap, one rewinds the timeline dot by dot, one fills a ring gauge to the checked ratio

Auto-plays · click a tile to jump to its section · all nine in one zip

Nine patterns, one archive: every demo above is a plain HTML/CSS/JS file, not a component library, and each one earns its place with one small calculation — a ratio, a coordinate diff, an index — computed right as you interact with it. Whether you're wiring this into a todo list ui mobile screen or a wide desktop dashboard, the same nine computed layers hold up because none of them depend on a specific screen size to work.

Demo

Every grid above is fully interactive, not a looping preview — click a row, hold a button, drag a handle, or scroll the header exactly as you would once the files are unzipped, and each pattern responds with its own real computation instead of a canned animation.

Anatomy of nine patterns — where the numbers come from

Every row below comes from the same 24 sampled frames per pattern, measured after render, not estimated. If you're pulling these straight from a todo list ui figma handoff, the shapes translate directly: cumulative area is how much of the frame the moving parts covered added up across those frames, average intensity is how strong the pixel-level change was per moved frame, and frames moved (of 23) is how many of the 23 frame-to-frame comparisons actually differed.

# Pattern Cumulative area Avg. intensity Frames moved (of 23)
01 Todo Row Expand for Subtasks 43.151% 82.7 7
02 Due-Date Grouped Sticky Header 8.496% 108.5 8
03 List-to-Detail Card Zoom Transition 26.048% 149.9 7
04 Load More With Doubling Batch Size 6.275% 88.4 6
05 Long-Press Ring to Schedule Undo 18.42% 148.1 16
06 Reorder Insertion-Line Preview 10.539% 176.9 12
07 Inline Edit With Character Countdown 7.756% 94.8 11
08 Completed Timeline Rewind 9.168% 79.8 22
09 Checklist Ring Gauge Sync 25.237% 70.4 22

Pattern 01 covers the most cumulative area of the nine at 43.151%, and pattern 04 covers the least at 6.275% — the row that expands a whole subtask panel simply displaces more pixels than a button that just grows a list downward. Pattern 06 has the highest average intensity at 176.9, since a dashed insertion line snapping between two rows changes more pixels per frame than a slow fade ever would. Patterns 08 and 09 both move across 22 of the 23 frames, tied for the most, while pattern 04 moves in only 6, the fewest of the nine.

Cluster A — geometry gets measured before it animates (01, 03, 07)

Patterns 01, 03, and 07 all read a real DOM measurement — scrollHeight, a pair of getBoundingClientRect() calls, or a hidden mirror element's width — before they touch a single style property. None of them animate toward a guessed or fixed value.

Cluster B — one ratio scales the magnitude (04, 05, 09)

Patterns 04, 05, and 09 each take a single number — a click count, an elapsed-time fraction, a checked ratio — and feed it straight into an exponent, a stroke offset, or a rotation angle. The visual scale is a direct function of that one ratio, not a set of fixed keyframes.

Cluster C — position or order recomputes live (02, 06, 08)

Patterns 02, 06, and 08 recalculate a position or an order on nearly every interaction frame: which group is under the header, where the pointer sits relative to every row, or which reverse index a removed dot gets. Nothing about their layout is precomputed once and left alone.

Code per pattern

Nine short, framework-free snippets — only the computed layer that decides what actually moves, trimmed out of each pattern's full source.

01Todo Row Expand for Subtasks

Tapping a todo row shrinks its progress badge first, then subtasks unfold below to their real content height (a different demo from 231's arrow-rotate expand — here the progress badge is the trigger). Across the 24 sampled frames (23 frame-to-frame comparisons) the badge shrink and panel expand move together for 7 of them, covering 43.151% of the frame at peak, the largest cumulative area of the nine.

Todo Row Expand for Subtasks
Open in a new tab

01 · Todo Row Expand for Subtasks — Measured area 43.151% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password

ratio() 배지 축소 계산--h scrollHeight 실측 max-heightaria-expanded
  function ratio(i) {
    var text = rows[i].badge.textContent; // like "1/3" — parses done/total to compute the shrink ratio
    var parts = text.split('/');
    var done = parseInt(parts[0], 10);
    var total = parseInt(parts[1], 10);
    return total > 0 ? done / total : 0;
  }

  function measure(sub) {
    // measures the real content height and passes it as a CSS variable — grows with the subtask count, not a fixed px
    sub.style.setProperty('--h', sub.firstElementChild.scrollHeight + 'px');
  }

  function setOpen(i, open) {
    var r = rows[i];
    if (r.open === open) return;
    r.open = open;
    r.btn.setAttribute('aria-expanded', String(open));
    // the badge shrinks first — the shrink ratio comes from the completion ratio (more left to do shrinks it more, so it stands out)
    var shrink = 1 - (1 - ratio(i)) * BADGE_SHRINK_MAX;
    r.badge.style.transform = open ? 'scale(' + shrink + ')' : 'scale(1)';
    r.caret.style.transform = open ? 'rotate(180deg)' : 'rotate(0deg)';
    if (open) measure(r.sub);
    r.sub.classList.toggle('open', open);
  }

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). The header only reacts to scroll, pointer, and keyboard events — there's no separate click handler, since the group name comes from currentGroupAndRemain() re-reading the DOM on every scroll frame.

Due-Date Grouped Sticky Header
Open in a new tab

02 · Due-Date Grouped Sticky Header — Measured area 8.496% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

currentGroupAndRemain() 스크롤 위치 판정overflow-y:scroll 분리 스크롤러그룹별 미완료 개수 매 프레임 재계산
  // computed layer: finds the "current group" from scroll position, then recounts unfinished items in that group live.
  // not a precomputed table — it re-reads the DOM and recounts on every scroll frame.
  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';
  }

03List-to-Detail Card Zoom Transition

Tapping a todo card enlarges that very card into the full detail screen, and going back shrinks it right back to its original spot (different from 231's left-right slide — here the card itself scales up in place). The card computes its own start position with getBoundingClientRect(), inverts into that position instantly, then a forced reflow lets the browser animate the invert away — the same first-last-invert-play sequence runs in reverse when the detail view closes.

List-to-Detail Card Zoom Transition
Open in a new tab

03 · List-to-Detail Card Zoom Transition — Measured area 26.048% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password

flipFrom() getBoundingClientRect 좌표차translate()·scale() 좌표 역전overflow:hidden 컨테이너
  function openDetail(i) {
    var item = data[i];
    detailTitle.textContent = item.title;
    document.getElementById('detailList').innerHTML = item.items.map(function (t) { return '<li>' + t + '</li>'; }).join('');
    detail.setAttribute('aria-hidden', 'false');
    detail.style.transition = 'none';
    detail.style.display = 'flex';
    back = flipFrom(cards[i]);
    detail.style.transform = 'translate(' + back.dx + 'px,' + back.dy + 'px) scale(' + back.sx + ',' + back.sy + ')';
    detail.style.opacity = '1';
    // forced reflow — the Invert state has to actually paint before the Play (undo-the-invert) animation can run
    detail.getBoundingClientRect();
    detail.style.transition = reduce ? 'none' : 'transform ' + DUR_MS + 'ms var(--flip-ease)';
    detail.style.transform = 'translate(0,0) scale(1,1)';
  }

04Load More With Doubling Batch Size

Tapping load more doubles the batch size each time, and new items bounce gently into place from the bottom in order (different from 231's fixed three-at-a-time slide — here the batch size grows every tap). LOAD_MORE_MULTIPLIER doubles the batch on every tap, and because the trigger is a native <button type="button">, Enter and Space already work without any extra keydown code.

Load More With Doubling Batch Size
Open in a new tab

04 · Load More With Doubling Batch Size — Measured area 6.275% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password

LOAD_MORE_MULTIPLIER 배치 배율 증가nextBatchSize() 지수 계산aria-live 항목 추가 알림
  // computed layer: the next batch size isn't fixed — it's an exponential function of the click count.
  function nextBatchSize() {
    var size = Math.round(BASE_BATCH * Math.pow(LOAD_MORE_MULTIPLIER, clicks));
    var remain = ALL.length - shown;
    return Math.min(size, remain);
  }

  function renderBatch(n) {
    var frag = document.createDocumentFragment();
    for (var i = 0; i < n; i++) {
      var idx = shown + i;
      var li = document.createElement('li');
      li.className = 'row';
      li.textContent = ALL[idx];
      // computed layer: the entry delay comes from the in-batch order (i) — not a hardcoded list of delays.
      li.style.transitionDelay = reduce ? '0ms' : (i * STAGGER_MS) + 'ms';
      list.appendChild(li);
      frag.appendChild(li);
    }
    shown += n;
    // class added after a forced reflow — only the just-added items transition from their start state to their arrived state
    list.getBoundingClientRect();
    var added = list.querySelectorAll('.row:not(.in)');
    for (var j = 0; j < added.length; j++) added[j].classList.add('in');
  }

05Long-Press Ring to Schedule Undo

Long-pressing a todo fills a circular ring that schedules an undo, and lifting your finger before the ring completes cancels it (different from 231's instantly popping icon menu — here it's a timed confirm ring). The ring is a plain SVG circle whose strokeDashoffset tracks elapsed / LONGPRESS_HOLD_MS on every animation frame, not a conic-gradient, and releasing the pointer before the hold completes cancels the countdown outright.

Long-Press Ring to Schedule Undo
Open in a new tab

05 · Long-Press Ring to Schedule Undo — Measured area 18.42% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

LONGPRESS_HOLD_MS 경과비율 → strokeDashoffsetpointerdown/pointerup 조기 해제 취소elapsed/LONGPRESS_HOLD_MS 계산
  // computed layer: multiplies the circle's circumference by the actual elapsed-time ratio (elapsed / LONGPRESS_HOLD_MS)
  // so the ring exactly reflects how many seconds it's actually been held.
  function paintRing(i, ratio) {
    var offset = CIRC * (1 - Math.min(1, Math.max(0, ratio)));
    rings[i].style.strokeDashoffset = offset.toFixed(2);
  }

  function startPress(i, startTime) {
    rows[i].classList.add('pressing');
    var raf;
    function tick(now) {
      var elapsed = now - startTime;
      var ratio = elapsed / LONGPRESS_HOLD_MS;
      paintRing(i, ratio);
      if (ratio >= 1) {
        openActions(i);
        cancelPress(i);
        return;
      }
      raf = requestAnimationFrame(tick);
    }
    raf = requestAnimationFrame(tick);
    rows[i]._raf = raf;
  }

06Reorder Insertion-Line Preview

Holding the handle and moving up or down doesn't move items live — a dashed insertion line just marks where it would land, and the swap happens all at once on release (different from 231's blue empty row and 336's live drag — here it's a lightweight preview with no live movement). The handle is a native button with its own arrow-key keydown handling and an explicit .focus() call after each move, since arrow-key reordering isn't something a plain button gets for free.

Reorder Insertion-Line Preview
Open in a new tab

06 · Reorder Insertion-Line Preview — Measured area 10.539% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

gapIndexFor() 포인터Y 위치 비교점선 삽입선 — 드롭 전 실이동 없음keydown 방향키 재정렬
  // computed layer: compares the pointer's Y coordinate against each row's measured bounds (top/bottom) to find the insertion point.
  // doesn't move the element live — only the single insertion line moves to that point.
  function gapIndexFor(pointerY, excludeIndex) {
    var rows = Array.prototype.slice.call(list.querySelectorAll('.row'));
    var visible = rows.filter(function (r) { return Number(r.dataset.index) !== excludeIndex; });
    for (var i = 0; i < visible.length; i++) {
      var rect = visible[i].getBoundingClientRect();
      var mid = rect.top + rect.height / 2;
      if (pointerY < mid) return i;
    }
    return visible.length;
  }

07Inline Edit With Character Countdown

Clicking a todo's text turns it into an input while a character countdown ticks down on the right, shaking red as it nears the limit (different from 231's pencil-to-check icon swap — here the character counter is the point). A hidden mirror <span> measures the exact pixel width the typed text would take, and the input's width is set to match it on every keystroke, while the badge only shakes once the remaining count drops to 8 or fewer.

Inline Edit With Character Countdown
Open in a new tab

07 · Inline Edit With Character Countdown — Measured area 7.756% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

measureWidth() 미러 span 폭 측정input width 실시간 재계산잔여 글자수 badge shake
    function paint() {
      var remain = MAX_LEN - input.value.length;
      badge.textContent = String(remain);
      input.style.width = measureWidth(input.value) + 'px';
      var warn = remain <= WARN_AT;
      badge.classList.toggle('warn', warn);
      input.classList.toggle('warn', warn);
      if (warn && !reduce) {
        badge.classList.remove('shake');
        void badge.offsetWidth;
        badge.classList.add('shake');
      }
    }

08Completed Timeline Rewind

As completed todos pile up, a vertical timeline grows downward, and pressing rewind removes the dots one by one in reverse of their original order (different from 231's top-down sequential appear — here reverse rewind is the point). Rewinding computes each dot's delay from its reverse index, so the most recently completed entry disappears first and the oldest one disappears last, and it's triggered only by a click on a native <button type="button">.

Completed Timeline Rewind
Open in a new tab

08 · Completed Timeline Rewind — Measured area 9.168% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

STAGGER_MS 순차 transitionDelayrewind() 역순 제거 — reverseIndex 지연aria-live 기록/되감기 알림
  // computed layer: rewind removes the most recent entries first, deriving each item's delay from its
  // "reverse index from the end" — the older (inner) an item is, the later it disappears.
  function rewind() {
    var total = shown.length;
    if (total === 0) return;
    var toRemove = shown.slice().reverse();
    toRemove.forEach(function (li, i) {
      var reverseIndex = total - 1 - i; // grows larger the further back you count
      var delay = reduce ? 0 : reverseIndex * STAGGER_MS * 0.4;
      window.setTimeout(function () {
        li.classList.remove('in');
        li.classList.add('out');
        window.setTimeout(function () {
          if (li.parentNode) li.parentNode.removeChild(li);
          paintLine();
        }, reduce ? 0 : 220);
      }, delay);
    });
    shown = [];
    liveNote.textContent = total + ' entries rewound.';
  }

09Checklist Ring Gauge Sync

Checking each box instantly fills a ring gauge at the top by that exact ratio, and when everything's checked a shine sweeps once around the ring (different from 231's strikethrough animation — here the overall progress gauge is the point). The ring reads its fill from a --fill-deg custom property driven by a conic-gradient, and the one-time shine sweep only plays once the ratio actually reaches 100%, never on repeat.

Checklist Ring Gauge Sync
Open in a new tab

09 · Checklist Ring Gauge Sync — Measured area 25.237% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

paintGauge() 체크비율→각도 conic-gradientGAUGE_RING_WIDTH 링 두께100% shine 스윕 1회(반복 없음)
  // computed layer: converts the checked/total ratio into an angle (0–360deg) and paints it with conic-gradient.
  function paintGauge() {
    var total = rows.length;
    var done = rows.filter(function (r) { return r.dataset.done === '1'; }).length;
    var ratio = done / total;
    var deg = ratio * 360;
    gauge.style.setProperty('--fill-deg', deg + 'deg');
    gaugePct.textContent = Math.round(ratio * 100) + '%';
    if (ratio >= 1 && !wasFull) {
      wasFull = true;
      if (!reduce) {
        shine.classList.remove('play');
        void shine.offsetWidth;
        shine.classList.add('play');
      }
    } else if (ratio < 1) {
      wasFull = false;
    }
  }

Where the computed value drives the pattern

Every pattern above hangs off one variable that does the actual work — pull on that variable and you can see exactly why the pattern looks the way it does.

01 — the shrink ratio

The badge's scale comes from 1 - (1 - done/total) * BADGE_SHRINK_MAX, so a barely-started list shrinks the badge less and a nearly finished one shrinks it more.

02 — the scroll-derived group

currentGroupAndRemain() walks each group's offsetTop against scroller.scrollTop to decide which label and remaining count to paint, so the header is only ever as fresh as the last scroll frame.

03 — the coordinate diff

flipFrom() diffs the card's and the detail panel's getBoundingClientRect() values into a translate()/scale() pair, so the zoom always starts from wherever that specific card sits in the grid, never a fixed corner.

04 — the batch exponent

nextBatchSize() raises LOAD_MORE_MULTIPLIER to the power of the click count, so the fourth tap loads a visibly larger batch than the first one did.

05 — the hold ratio

paintRing() multiplies the ring's circumference by elapsed / LONGPRESS_HOLD_MS on every frame, so the ring's fill is a direct read of how long the pointer has actually been down.

06 — the pointer-to-row comparison

gapIndexFor() compares the pointer's Y position against every row's live getBoundingClientRect() midpoint, so the insertion line tracks the nearest gap instead of a fixed grid slot.

07 — the mirrored width

A hidden mirror element measures the exact pixel width the typed text would take, and the input's width is set to match it on every keystroke.

08 — the reverse index

rewind() turns each dot's position in the list into a reverse index, and that index — not the original insertion order — decides how long the removal is delayed.

09 — the angle from a ratio

paintGauge() turns done / total straight into a 0–360deg angle for the ring's conic-gradient, so the gauge's arc lands on the exact ratio instead of rounding to a step.

Where does it break?

Pattern 02's scrollable container has no tabindex and defines no arrow-key scrolling of its own — its keydown listener only flags that a human touched it, so a keyboard user who tabs past the container with no focusable child inside it has no way to move between due-date groups except an actual scroll gesture. That's a real gap worth fixing before shipping the pattern as-is, not a demo shortcut.

All nine sources above sit behind one shared password, jq5k5x9y, so unzip the archive once and every pattern in this article resolves to matching HTML, SCSS, and JS files on disk, ready to open directly in a browser.

What reduced motion leaves behind

  • 01 row expand — the click path itself is unaffected; only the badge, caret, and panel transition are removed, so subtasks open and close instantly instead of unfolding.
  • 02 grouped header — there was no transition to remove here; the label and count already swapped instantly, and reduced motion simply stops the demo's own auto-scroll loop.
  • 03 card zoom — the transitionend wait is skipped entirely, so the detail screen appears and disappears in its final position with no zoom at all.
  • 04 load more — new rows lose their staggered entry delay and their transition, so a whole batch appears at once instead of bouncing in one by one.
  • 05 long-press ring — the ring and the action panel both drop their transitions; the ring still fills as a direct read of hold time, it just no longer eases between frames.
  • 06 insertion line — the dashed line jumps straight to the next gap with no glide, since only its transition is removed.
  • 07 character countdown — the input width still updates instantly, but the warning badge no longer shakes once the count nears the limit.
  • 08 timeline rewind — dots still disappear in the same reverse order, but instantly and together instead of staggered one by one.
  • 09 ring gauge — the gauge still jumps straight to the correct percentage, but the completion shine never plays at all.

Three variants and accessibility

Variant Value Feel
Thicken the checklist gauge ring 09 GAUGE_RING_WIDTH 6px → 12px Widening GAUGE_RING_WIDTH to 12px makes progress changes more visible
Lengthen the long-press confirm ring 05 LONGPRESS_HOLD_MS 500 → 900 Raising LONGPRESS_HOLD_MS to 900 reduces accidental undo but feels slower to respond
Lower the load-more batch multiplier 04 LOAD_MORE_MULTIPLIER 2.0 → 1.5 Lowering LOAD_MORE_MULTIPLIER to 1.5 grows the list more gradually so scroll length doesn't spike

Pattern 01's aria-expanded/aria-controls pair announces the open state on the row button itself, pattern 09's checkboxes carry role="checkbox" with aria-checked, and pattern 03's detail panel toggles aria-hidden so screen readers skip it while closed. Patterns 04, 06, and 08 each push their own aria-live="polite" region so a batch load, a reorder, or a rewind gets announced without stealing focus, and because 04's load-more button and 08's rewind button are native <button type="button"> elements, Enter and Space already trigger them with no extra keydown handler required.

FAQ

Do these nine todo list ui patterns need a framework or build step?

No — each pattern is a single self-contained HTML/CSS/JS file with no build step and no framework, so you can open its index.html directly or drop the markup straight into any todo list ui template and it behaves the same way.

How do I check whether prefers-reduced-motion is actually respected in this todo list ui design?

Turn on "reduce motion" in your OS accessibility settings, then reload any of the nine demos above — each one reads window.matchMedia('(prefers-reduced-motion: reduce)') on load and removes its own transition or animation, which you can confirm directly against that pattern's CSS or JS.

Why does pattern 08's rewind remove items in reverse order instead of completion order?

rewind() deliberately computes each dot's delay from total - 1 - i, so the most recently completed entry disappears first and the oldest one disappears last — that reverse order is the entire point of a rewind, not an accident of the code.

Sources: MDN — conic-gradient(), MDN — prefers-reduced-motion, MDN — aria-live, MDN — CSS transitions, MDN — Element.scrollHeight.

Related reading: 9 Accordion List UI Patterns (Copy-Paste) · Drag to Reorder UI: 9 Lists, One Drop Index · 9 Data Table UI Patterns — Drag, Resize, Select

Enter the archive password

The password is inside this article. You will find it as you read.