Design Lounge
Install

Nº 388 of 520

Rolling total with a jelly period switch

A spending screen where a lime pill squashes across Day to Year, the total and count roll digit by digit, bars spring, and the top-places list grows or shrinks.

Preview

Open full size
390 × 844 · 12.9 KB · 176 lines

Use it

Free to use · credit appreciated

1 Once, in your project

npx skills add SusanAcharya/Design-Lounge

2 Then ask

Using the Design Lounge skill, build "Rolling total with a jelly period switch" (piece rolling-total-period-switch) in my stack. Match its brief; restyle it onto my product's theme.

No skill? Paste the brief instead. It carries the numbers, tokens and behaviour.

Brief

265 lines

Build brief for a coding agent. Rebuild this piece in the reader’s stack. If they haven’t said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don’t “improve” them.

What it is

Studied from 60fps.design, a gallery of mobile micro-interaction clips: this piece takes the “number of transactions” idea from its finance shots (a numeric segmented control whose pill springs between options while the account preview reveals or hides rows) and the “ticker” effect tag, and rebuilds them as one working screen. It is the spending view of a fictional money app called Lekha. A dark five-option switch (Day, Week, Month, Quarter, Year) sits above the home clearance. Choosing a period slides a lime pill with a jelly squash proportional to the distance travelled; the big total rolls every digit in its own column, right to left; digit columns open or collapse when the number gets longer or shorter (8,915 → 1,36,480); the transaction count rolls the same way in mono; seven bars spring to new heights with a stagger; and the “Top places” list grows from two to five rows. The detail worth copying is the roller: it keys columns from the right, so the ones digit is always the same element and only the leading columns are added or removed.

The language is calm finance: cool grey page, one deep teal card, ink switch, lime as the single accent, Indian digit grouping.

Reference behaviour

  1. First frame: “Lekha” and an “SA” avatar; “Spent this week”; “Rs 8,915” at 56px; a green chip ”↓ 12% less” and “23 transactions”; a teal card “Mon to Sun / avg Rs 1,274” with seven bars M–S, Friday lime; “TOP PLACES” with three rows; the switch with Week selected.
  2. Nothing animates on load. Transitions are disabled until 120ms after first paint.
  3. Tapping an option scales it to 0.94 while pressed and navigator.vibrate(6) fires where available.
  4. Selecting moves the lime pill with translateX over 500ms on cubic-bezier(.34,1.56,.64,1). At the same time its inner layer plays a 500ms jelly: at 30% it is scaleX(1 + 0.12 × distance) (capped at 1.45) and scaleY(.84); at 65% scaleX(.96) scaleY(1.04); then rest.
  5. The selected label turns ink on lime; others are #aeb8c2 on ink.
  6. The period label changes immediately: today, this week, in October, Aug to Oct, in 2026.
  7. The total rolls: each digit column translates to its new digit over 700ms on cubic-bezier(.3,1.3,.6,1), with a 35ms delay per column counted from the right. New leading columns grow from width 0 and fade in over 450ms; removed columns collapse to 0 and are deleted after 460ms. Commas are fixed-width separators that also grow in or collapse.
  8. The transaction count rolls with the same component at 13px mono: 4, 23, 126, 371, 1,488.
  9. The delta chip flips between green ”↓ 12% less” and rust ”↑ 6% more”; its arrow rotates 180° on a 400ms spring.
  10. Bars spring to new heights over 600ms, staggered 30ms left to right. The highest-spend bar is lime. Periods with fewer buckets hide unused bars (Quarter shows Aug, Sep, Oct; Month shows W1–W5).
  11. The list’s visible height animates over 500ms (expo-out) to show 2, 3, 4, 5, 5 rows for Day → Year. Rows beyond the count fade out and lift 6px; rows coming in fade down into place, staggered 40ms.
  12. A polite live region announces “Quarter: Rs 1,36,480 spent Aug to Oct, 371 transactions”.
  13. Keyboard: the switch is a radiogroup with roving tabindex; arrows move and select (wrapping), Home/End jump.

Structure

390 × 844, padding 54 / 20 / 0
┌──────────────────────────────────────┐
│ Lekha                          (SA)  │ 44px
│ Spent this week                      │ 13px, 18px top
│ Rs 8,915                             │ 26px + 56px/800 roller, 60px tall
│ (↓ 12% less)  23 transactions        │ 26px chip, mono count
│ ╭──────────────────────────────────╮ │ teal card r24, padding 18
│ │ Mon to Sun          avg Rs 1,274 │ │
│ │ ▅  ▃  ▆  ▃  █  ▇  ▄             │ │ bars 96px, gap 8
│ │ M  T  W  T  F  S  S              │ │ 10px mono
│ ╰──────────────────────────────────╯ │
│ TOP PLACES                           │ 12px caps
│ [BK] Bhojan Kitchen    34×  Rs 2,140 │ rows 52px, hairline
│ [TM] Thamel Mart       12×  Rs 1,860 │
│ [SR] Sajha Rides       19×  Rs 1,320 │ (2–5 rows visible)
│                                      │
│ ╭──────────────────────────────────╮ │ switch 52px, padding 4
│ │ Day (Week) Month Quarter  Year   │ │ lime pill = 1/5 width
│ ╰──────────────────────────────────╯ │
│ 34px home clearance                  │
└──────────────────────────────────────┘
  • main.app is a flex column; the switch wrapper uses margin-top: auto so it sits at the bottom with a fade-to-page gradient above it.
  • The total is a div role="img" with aria-label="Rs 8,915"; the roller inside is not read.
  • Each roller slot is a span.slot (data-t="d" digit or "s" separator) with a span.strip inside. Digit strips hold ten spans 0–9, each 1.08em tall.
  • The count roller is aria-hidden with a visually hidden text twin.
  • The chart is a section labelled “Spending by day” with seven div.bar (an i for the bar, a small for the label).
  • The list is a section labelled “Top places”, a one-row CSS grid whose row height is set in px, with an inner div (overflow hidden) holding h3 and ul.
  • The switch is a div role="radiogroup" labelled “Period” with five button role="radio" and an aria-hidden pill span holding an inner i for the jelly.

Tokens

:root {
  --bg: #eef1f4;      /* page */
  --card: #0f3b3a;    /* chart card */
  --card-2: #175150;  /* resting bars */
  --ink: #0e1a26;     /* text, switch track */
  --ink-2: #3d4a57;
  --ink-3: #5f6b77;   /* labels, units */
  --line: #d6dce2;    /* row hairlines */
  --white: #fbfcfd;   /* row badges */
  --lime: #c8f25a;    /* accent: pill, top bar, avatar initials */
  --teal-ink: #d7ece9;
  --teal-3: #8fb8b4;  /* card labels */
  --down: #1f7a52;    /* spend fell: chip text on #dcefe4 */
  --up: #9a3d16;      /* spend rose: chip text on #f6e3d8 */
  --sans: "Schibsted Grotesk", system-ui, sans-serif;
  --mono: "DM Mono", ui-monospace, monospace;
  --total: 56px; --cell: 1.08em; --digit-w: .57em; --sep-w: .31em;
  --seg-h: 52px; --row-h: 52px; --bars-h: 96px;
  --r-card: 24px; --r-bar: 8px 8px 4px 4px; --r-pill: 999px;
  --s-2: 8px; --s-3: 12px; --s-4: 14px; --s-5: 18px; --s-6: 20px;
  --spring: cubic-bezier(.34, 1.56, .64, 1);  /* pill slide, arrow */
  --soft: cubic-bezier(.3, 1.3, .6, 1);       /* digit roll, bars */
  --out: cubic-bezier(.16, 1, .3, 1);         /* list height, slot width */
  --std: cubic-bezier(.2, .7, .2, 1);
  --t-pill: 500ms; --t-roll: 700ms; --t-col-stagger: 35ms;
  --t-bars: 600ms; --t-bar-stagger: 30ms; --t-list: 500ms;
}

Typography

RoleFamilySizeWeightLine-heightTracking
Total digitsSchibsted Grotesk56px8001.08em cells0 in roller, tabular
CurrencySchibsted Grotesk26px7001-0.02em
BrandSchibsted Grotesk17px8001-0.02em
Period labelSchibsted Grotesk13px400 / 5001.40
Delta chipSchibsted Grotesk13px50010
CountDM Mono13px500 number / 400 word10
Card labelsSchibsted Grotesk13px5001.40
Bar labels, badges, row countsDM Mono10–12px400–50010
Row nameSchibsted Grotesk15px5001.40
Row amountSchibsted Grotesk15px7001.4tabular
SwitchSchibsted Grotesk13px50010

Set letter-spacing to 0 inside the roller. Negative tracking on fixed-width cells pushes glyphs left and makes commas look misplaced.

Motion

ThingTriggerPropertyFrom → toDurationEasingDelayReduced motion
Option press:activetransform1 → 0.94150ms--std0none
Pill slideselecttranslateXold slot → new slot500ms--spring0instant
Pill jellyselectscaleX / scaleY keyframes1 → 1+0.12d, .84 → .96, 1.04 → 1500ms--std0none
Digit rollvalue changetranslateY-old×1.08em → -new×1.08em700ms--soft35ms × column from rightinstant
Column inlonger numberwidth, opacity0 → .57em, 0 → 1450ms / 300ms--out0instant
Column outshorter numberwidth, opacity→ 0, then removed450ms--out0removed at once
Delta arrowsign changerotate0 ↔ 180°400ms--spring0instant
Barsvalue changeheightold% → new%600ms--soft30ms × indexinstant
List heightrow count changegrid-template-rows (px)old → new500ms--out0instant
Row faderow shown/hiddenopacity, translateY0,-6px ↔ 1,0300ms / 400ms--std / --out40ms × (index-1)instant

States

  • Option resting: #aeb8c2 on ink. Selected: ink on lime, aria-checked="true". Pressed: 0.94.
  • Focus-visible on options: 2px lime outline inset 2px (an ink outline would vanish on the ink track). Elsewhere a 2px ink outline, 3px offset.
  • Delta down: #1f7a52 on #dcefe4, arrow down. Delta up: #9a3d16 on #f6e3d8, arrow up.
  • Bar resting --card-2; highest bar lime.
  • Hidden rows: opacity 0, aria-hidden="true".
  • Loading (not in demo): keep the previous numbers, dim the roller to 40% and pulse the lime pill opacity 1 → .6; roll when data lands.
  • Empty period (not in demo): total “Rs 0”, bars at 8% height, list hidden, line “No spending today” in --ink-3.

Accessibility

  • role="radiogroup" labelled “Period”; each option role="radio" with aria-checked; roving tabindex; arrows wrap; Home/End jump.
  • The total is announced through role="img" + aria-label, updated on every change; the count has a hidden text twin. Do not expose the digit strips: a reader would say “zero one two three…”.
  • A polite live region summarises each change in one sentence.
  • Row badges (“BK”) are aria-hidden; the name is the label.
  • Contrast: ink on lime ≈ 14:1; #aeb8c2 on #0e1a26 ≈ 9:1; #d7ece9 on #0f3b3a ≈ 9.5:1; #5f6b77 on #eef1f4 ≈ 5:1; #1f7a52 on #dcefe4 ≈ 4.8:1.
  • Hit targets: options 44px tall (52 minus padding) × 70px; rows are not interactive in this demo.

Responsive rules

  • At 360 wide the switch keeps five equal options (about 64px each; labels at 13px still fit, “Quarter” is the longest).
  • The total at 56px fits “Rs 5,62,905” in 320px. If a product can exceed seven digits, drop to 44px above 9,99,999.
  • Below 740px tall, bars drop to 70px and rows to 46px so five rows still clear the switch.
  • At tablet width, centre a 420px column; do not stretch the switch.
  • Do not draw a status bar.

Acceptance checklist

Always

  • Each digit is its own rolling column; columns are keyed from the right.
  • Longer and shorter numbers add or remove leading columns by animating width, never by re-rendering the whole number.
  • Separators follow the locale (Indian grouping here) and animate in or out like digits.
  • The pill slides on a spring and squashes more for longer jumps.
  • Bars and list height change together with the number, staggered, in one gesture.
  • Nothing animates on first paint.
  • Radiogroup semantics, roving tabindex, arrows wrap.
  • Screen readers hear the formatted total, not the digit strips.
  • Reduced motion: every value updates instantly.
  • No horizontal scroll at 375px.

This demo

  • Periods Day, Week, Month, Quarter, Year with totals 1,240 / 8,915 / 48,260 / 1,36,480 / 5,62,905.
  • Counts 4 / 23 / 126 / 371 / 1,488 transactions.
  • Places Bhojan Kitchen, Thamel Mart, Sajha Rides, Nanglo Bakery, Kapan Chiya; 2/3/4/5/5 rows shown.
  • Lime #c8f25a pill on an ink #0e1a26 track; teal card #0f3b3a.
  • Week selected on load with Friday’s bar lime.

Implementation notes

1. The roller, keyed from the right. Reverse the characters and the existing slots and walk them together. Reuse a slot when its type matches; otherwise replace it. Extra slots on the left collapse and are removed.

function roller(el) {
  return str => {
    const chars = [...str].reverse(); const slots = [...el.children].reverse();
    chars.forEach((ch, i) => {
      const isD = /\d/.test(ch); let s = slots[i];
      if (!s || s.dataset.t !== (isD ? 'd' : 's')) {
        const n = makeSlot(isD, ch);           // width 0 via .in
        s ? s.replaceWith(n) : el.prepend(n); s = n;
        requestAnimationFrame(() => requestAnimationFrame(() => n.classList.remove('in')));
      }
      if (isD) { const strip = s.firstElementChild;
        strip.style.transitionDelay = i * 35 + 'ms';
        strip.style.transform = `translateY(${-ch * 1.08}em)`; }
    });
    slots.slice(chars.length).forEach(s => { s.classList.add('in'); setTimeout(() => s.remove(), 460); });
  };
}
.roll  { display: flex; overflow: hidden; height: 1.08em; line-height: 1.08em; letter-spacing: 0;
         font-variant-numeric: tabular-nums; }
.slot  { width: .57em; overflow: hidden; transition: width .45s var(--out), opacity .3s var(--std); }
.slot.sep { width: .31em; overflow: visible; }
.slot.in  { width: 0; opacity: 0; }
.strip { display: flex; flex-direction: column; transition: transform .7s var(--soft); }

Common mistakes: using em cell heights but a px translate (they drift at other sizes); keying from the left (every digit rolls when the length changes); forgetting the double rAF, so new columns appear at full width without growing.

2. Jelly pill. Slide the outer element; squash the inner one with a keyframe whose stretch depends on distance.

.pill   { position: absolute; top: 4px; left: 4px; bottom: 4px; width: calc((100% - 8px) / 5);
          transition: transform .5s var(--spring); }
.pill i { display: block; height: 100%; border-radius: 999px; background: var(--lime); }
.pill i.jelly { animation: jelly .5s var(--std); }
@keyframes jelly { 30% { transform: scaleX(var(--sx, 1.25)) scaleY(.84); }
                   65% { transform: scaleX(.96) scaleY(1.04); } }
const w = (seg.clientWidth - 8) / 5;
pill.style.transform = `translateX(${i * w}px)`;
jelly.style.setProperty('--sx', (1 + Math.min(.45, .12 * Math.abs(i - cur))).toFixed(2));
jelly.classList.remove('jelly'); void jelly.offsetWidth; jelly.classList.add('jelly');

3. List height without height: auto. Use a one-row grid and set the row in px from measured row height × count. Rows past the count fade but stay in the DOM so they can come back.

const h = rows[0].offsetHeight, head = heading.offsetHeight + 8;
list.style.gridTemplateRows = (head + count * h) + 'px';
rows.forEach((li, j) => { li.classList.toggle('off', j >= count); li.setAttribute('aria-hidden', j >= count); });

Common mistakes overall:

  • Counting up through every intermediate number (1,240 → 1,241 → …). Each column rolls straight to its digit.
  • Animating the total with a JS tween and toLocaleString every frame; separators jump around.
  • A second accent colour for the selected bar or the pill. Lime only.
  • Letting the switch float over the list without the fade; rows read under the track.
  • Putting the switch at the top; this piece is thumb-reach at the bottom.

Where it sits: the summary tab of a money or analytics app. The roller alone also suits balances, follower counts and order totals.

Rebuild order:

  1. Lay out header, period label, total row, meta row, chart card, list and the bottom switch.
  2. Build the roller component and test it alone: 8,915 → 1,36,480 → 1,240 → 5,62,905.
  3. Tune digit and separator widths at the real font weight until commas sit tight to the left digit.
  4. Reuse the roller for the transaction count at 13px.
  5. Build the switch with equal options and the sliding pill; add the jelly keyframe.
  6. Wire the radiogroup keys and the live-region summary.
  7. Drive the bars from data with the stagger; hide unused buckets.
  8. Add the list height grid and row fades.
  9. Disable transitions until after first paint.
  10. Add the reduced-motion override and test at 360 wide and below 740 tall.

Details

Palette
Type
Schibsted Grotesk · DM Mono
Motion
Rich motion
Build
Multi-step
Designed bySusan AcharyaKathmandu

Studied

60fps

Show one small moment at a time, and let the tags do the sorting.

All sources