Nº 065 of 520
Staggered list reveal
Twelve transaction rows slide up 14px and fade in with a 30ms stagger and expo-out easing on load; a filter input animates leaving rows out (180ms) before re-staggering the survivors.
Preview
Use it
Free to use · credit appreciated1 Once, in your project
npx skills add SusanAcharya/Design-Lounge 2 Then ask
Using the Design Lounge skill, build "Staggered list reveal" (piece staggered-list-reveal) 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.
<!-- Design Lounge Nº 065 · "Staggered list reveal" · designlounge.vercel.app -->
# Staggered list reveal
> **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
A transactions list for a personal banking account ("Fjord Bank"): 12 rows of date, payee, category tag and a monospaced amount, under a heading and a filter input. On load the rows arrive one after another — each slides up 14px and fades in over 500ms with expo-out easing, 30ms apart, so the whole list lands in about 830ms. Typing in the filter is the interesting part: rows that no longer match fade out and lift 8px over 180ms, *then* the surviving rows re-run the staggered entrance with fresh indices, so the list always reads as one gesture rather than a jump cut. The detail worth copying is the two-phase filter: exit first, wait for it, then hide and re-stagger.
## Reference behaviour
1. Initial state: heading "Transactions" with a subtitle "12 of 12 · September 2026 · Fjord Bank current account", a 300px search input on the right with a `/` keycap hint, and a white card with a column header row (Date / Payee / Category / Amount) and 12 rows. On first paint all rows are at opacity 0, `translateY(14px)`.
2. t = 0: row *i* (0–11) starts its entrance at `i × 30ms`: opacity 0 → 1, translateY 14px → 0, 500ms, `cubic-bezier(.16,1,.3,1)`, `animation-fill-mode: forwards`. Row 11 completes at 330 + 500 = 830ms.
3. Hover a row: background changes to `--bg` (the page colour), no motion.
4. Type in the input (each `input` event): matching is case-insensitive substring over payee, category and the small memo line. Rows currently visible that no longer match get class `out` (opacity → 0, translateY → −8px, 180ms, `cubic-bezier(.2,.7,.2,1)`, forwards). The count in the subtitle updates immediately ("4 of 12").
5. 180ms later (or immediately if nothing is leaving): non-matching rows get `hidden`; every matching row is re-indexed from 0 in DOM order, its `in` class is removed and re-added after a forced reflow, so the survivors replay the staggered entrance from step 2.
6. If nothing matches: after the exits, an empty message shows inside the card ("No transactions match “xyz”. Try a payee name or a category like “Groceries”.") with the same 500ms entrance.
7. Press `/` anywhere (when the input isn't focused): focus the input. Press `Escape` in the input when it has a value: clear it and re-run the filter; all 12 rows re-enter with the stagger.
8. Fast typing: a pending 180ms timer is cleared on every input so exits never double-fire; rows mid-exit that match again are simply re-revealed.
9. The footer shows the computed net for the month ("Net +NOK 32,588.40 this month") and a caption; neither animates.
10. With `prefers-reduced-motion: reduce`: entrance and exit run in 1ms with 0 delay; the filter still goes through the same two phases, so behaviour is unchanged.
## Structure
```
1280 × 800 (content column 880px, centred; 32px top padding)
┌───────────────────────────────────────────────────────────────────────┐
│ Transactions [ o Filter by payee… / ]│ head
│ 12 of 12 · September 2026 · Fjord Bank current account │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ DATE PAYEE CATEGORY AMOUNT │ │ cols 40
│ │ 28 Sep [NP] Nord Post (Subscription) −NOK 49.00│ │ row 48
│ │ Newsletters │ │
│ │ 27 Sep [ME] Meny Grünerløkka (Groceries) −NOK 612.40 │ │
│ │ … ×12 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ Net +NOK 32,588.40 this month Rows animate in with a 30 ms stagger │ foot
└───────────────────────────────────────────────────────────────────────┘
grid columns: 110px | 1fr | 160px | 130px, gap 16px, padding 0 20px
… 202 more lines. Copy or download for the full brief. Loading the source…Brief
242 linesBuild 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
A transactions list for a personal banking account (“Fjord Bank”): 12 rows of date, payee, category tag and a monospaced amount, under a heading and a filter input. On load the rows arrive one after another — each slides up 14px and fades in over 500ms with expo-out easing, 30ms apart, so the whole list lands in about 830ms. Typing in the filter is the interesting part: rows that no longer match fade out and lift 8px over 180ms, then the surviving rows re-run the staggered entrance with fresh indices, so the list always reads as one gesture rather than a jump cut. The detail worth copying is the two-phase filter: exit first, wait for it, then hide and re-stagger.
Reference behaviour
- Initial state: heading “Transactions” with a subtitle “12 of 12 · September 2026 · Fjord Bank current account”, a 300px search input on the right with a
/keycap hint, and a white card with a column header row (Date / Payee / Category / Amount) and 12 rows. On first paint all rows are at opacity 0,translateY(14px). - t = 0: row i (0–11) starts its entrance at
i × 30ms: opacity 0 → 1, translateY 14px → 0, 500ms,cubic-bezier(.16,1,.3,1),animation-fill-mode: forwards. Row 11 completes at 330 + 500 = 830ms. - Hover a row: background changes to
--bg(the page colour), no motion. - Type in the input (each
inputevent): matching is case-insensitive substring over payee, category and the small memo line. Rows currently visible that no longer match get classout(opacity → 0, translateY → −8px, 180ms,cubic-bezier(.2,.7,.2,1), forwards). The count in the subtitle updates immediately (“4 of 12”). - 180ms later (or immediately if nothing is leaving): non-matching rows get
hidden; every matching row is re-indexed from 0 in DOM order, itsinclass is removed and re-added after a forced reflow, so the survivors replay the staggered entrance from step 2. - If nothing matches: after the exits, an empty message shows inside the card (“No transactions match “xyz”. Try a payee name or a category like “Groceries”.”) with the same 500ms entrance.
- Press
/anywhere (when the input isn’t focused): focus the input. PressEscapein the input when it has a value: clear it and re-run the filter; all 12 rows re-enter with the stagger. - Fast typing: a pending 180ms timer is cleared on every input so exits never double-fire; rows mid-exit that match again are simply re-revealed.
- The footer shows the computed net for the month (“Net +NOK 32,588.40 this month”) and a caption; neither animates.
- With
prefers-reduced-motion: reduce: entrance and exit run in 1ms with 0 delay; the filter still goes through the same two phases, so behaviour is unchanged.
Structure
1280 × 800 (content column 880px, centred; 32px top padding)
┌───────────────────────────────────────────────────────────────────────┐
│ Transactions [ o Filter by payee… / ]│ head
│ 12 of 12 · September 2026 · Fjord Bank current account │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ DATE PAYEE CATEGORY AMOUNT │ │ cols 40
│ │ 28 Sep [NP] Nord Post (Subscription) −NOK 49.00│ │ row 48
│ │ Newsletters │ │
│ │ 27 Sep [ME] Meny Grünerløkka (Groceries) −NOK 612.40 │ │
│ │ … ×12 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ Net +NOK 32,588.40 this month Rows animate in with a 30 ms stagger │ foot
└───────────────────────────────────────────────────────────────────────┘
grid columns: 110px | 1fr | 160px | 130px, gap 16px, padding 0 20px
.wrap— 880px wide, flex column,padding: 32px 0..head— flex,align-items: flex-end,space-between.<h1>+<p>with a<b id="count">.<label class="search">wrapping a visually hidden label text, a 16px SVG magnifier,<input type="search">, and a<kbd>/</kbd>..list— white card, 1px border, 12px radius,overflow: hidden..cols—aria-hiddenheader row, 40px tall, mono uppercase 11px.<ul id="rows" aria-live="polite" aria-label="Transactions">of<li class="row">each with:.date(mono),.payee(28px initials tile + name +<small>memo),.tagpill,.amt(mono,.posfor credits)..empty— hidden by default;display: blockwith classshow.
.foot— mono 12px, space-between.
Row data (date · payee · category · memo · amount NOK):
28 Sep · Nord Post · Subscription · Newsletters · −49.00
27 Sep · Meny Grünerløkka · Groceries · Card 4471 · −612.40
26 Sep · Halden Hotel · Travel · Two nights · −4800.00
25 Sep · Loam Studio AS · Salary · Sept payroll · +42350.00
24 Sep · Vy Tog · Travel · Oslo → Bergen · −899.00
23 Sep · Tessel Type · Software · Font licence · −540.00
22 Sep · Kaffebrenneriet · Groceries · Card 4471 · −58.00
21 Sep · Orbital Cloud · Software · eu-1 usage · −1240.20
20 Sep · Marrow · Software · Team plan · −480.00
19 Sep · Mira Refund · Refund · Order 8813 · +360.00
18 Sep · Rema 1000 · Groceries · Card 4471 · −1123.00
17 Sep · Fjord Bank · Fees · Card fee · −320.00
Amounts render as −NOK 1,240.20 / +NOK 360.00 (U+2212 minus, en locale grouping, two decimals). Initials tile = first two letters of the payee, uppercase.
Tokens
:root {
/* colour — warm off-white, white card, near-black ink, one blue accent, green credits */
--bg: #faf9f6; /* page + row hover + tag fill */
--surface: #ffffff; /* card, input */
--line: #e8e5de; /* borders */
--line-2: #f1efe9; /* row separators, initials tile */
--ink: #1a1917; /* primary text, debit amounts */
--ink-2: #6b675f; /* dates, tags, subtitle */
--ink-3: #9c978d; /* column headers, memo, placeholder, kbd */
--accent: #2a5bd7; /* focus border */
--accent-soft: #e8eefc; /* focus halo */
--pos: #1f7a4d; /* credit amounts */
/* type */
--sans: "Space Grotesk", system-ui, sans-serif;
--mono: "IBM Plex Mono", ui-monospace, monospace;
/* layout */
--wrap-w: 880px;
--row-h: 48px;
--cols-h: 40px;
--grid: 110px 1fr 160px 130px;
--r: 10px; /* input */
--r-card: 12px;
--r-tile: 7px;
/* motion */
--t-in: 500ms;
--t-out: 180ms;
--stagger: 30ms;
--t-micro: 140ms;
--rise: 14px; /* entrance offset */
--lift: 8px; /* exit offset */
--ease-out: cubic-bezier(.16, 1, .3, 1);
--ease: cubic-bezier(.2, .7, .2, 1);
}
Typography
| Role | Family | Size | Weight | Line-height | Tracking | Case |
|---|---|---|---|---|---|---|
| Heading | Space Grotesk | 24px | 600 | 1.2 | −0.02em | sentence |
| Subtitle | Space Grotesk | 13px | 400 | 1.45 | 0 | sentence |
| Count | IBM Plex Mono | 13px | 500 | 1.45 | 0 | numerals |
| Input | Space Grotesk | 14px | 400 | 40px box | 0 | sentence |
| Column header | IBM Plex Mono | 11px | 500 | 40px box | +0.08em | UPPERCASE |
| Date | IBM Plex Mono | 13px | 400 | 1.45 | 0 | sentence |
| Payee | Space Grotesk | 14px | 500 | 1.3 | 0 | sentence |
| Memo | Space Grotesk | 12px | 400 | 1.3 | 0 | sentence |
| Initials tile | IBM Plex Mono | 11px | 500 | 28px box | 0 | UPPERCASE |
| Tag | IBM Plex Mono | 11px | 500 | 1.4 | +0.04em | sentence |
| Amount | IBM Plex Mono | 15px | 500 | 1.45 | −0.01em | numerals |
| Footer | IBM Plex Mono | 12px | 400 | 1.45 | 0 | sentence |
Motion
| Element | Trigger | Property | From → To | Duration | Easing | Delay |
|---|---|---|---|---|---|---|
.row.in | load / filter settle | opacity, translateY | 0, 14px → 1, 0 | 500ms | --ease-out | i × 30ms (i = index among visible rows) |
.row.out | row stops matching | opacity, translateY | 1, 0 → 0, −8px | 180ms | --ease | 0 |
.empty.show | zero matches | opacity, translateY | 0, 14px → 1, 0 | 500ms | --ease-out | 0 |
input | focus | border-color, box-shadow | --line → --accent, 0 → 0 0 0 3px var(--accent-soft) | 140ms | linear | 0 |
.row | hover | background | transparent → --bg | 0 | — | instant |
Filter timeline for typing “gro” into a full list (3 matches, 9 leaving):
| t (ms) | Event |
|---|---|
| 0 | count reads “3 of 12”; 9 rows start the 180ms exit |
| 180 | 9 rows get hidden; 3 survivors re-indexed 0–2 and restarted |
| 180 / 210 / 240 | survivors start their 500ms entrance |
| 740 | last survivor lands |
Both row animations use animation-fill-mode: forwards, and the base .row style is the hidden state (opacity 0, translateY 14px) so a row without a class is invisible — never partially shown.
Reduced motion: animation-duration: 1ms; animation-delay: 0ms on .in, .out and .empty.show.
States
- Row hover: background
--bg. - Input focus-visible: border
--accent, 3px--accent-softhalo. Placeholder--ink-3. - Credit amount:
.pos, colour--pos, leading+. Debit:--ink, leading−. - Filtered: subtitle count reflects matches; hidden rows have the
hiddenattribute (display: none). - Empty:
.empty.showvisible inside the card, 48px vertical padding, centred, with the query in bold. - Loading / error: not part of this piece.
Accessibility
- The input has a visually hidden
<span>label inside its<label>(“Filter transactions”); the placeholder is not the label. <ul aria-live="polite" aria-label="Transactions">— screen readers announce changes as rows are hidden/shown. If this is too chatty in your stack, movearia-liveto the count element (“4 of 12”) instead.- The column header row is
aria-hidden="true"; if you need a real table, use<table>with<th scope="col">and apply the same animations to<tr>(usedisplay: gridon rows only if your table semantics survive it — otherwise animate<td>contents). - Keyboard: Tab → input.
/focuses the input from anywhere (guarde.preventDefault()so the browser’s quick-find doesn’t open).Escapeclears a non-empty input. Rows are not focusable (no actions on them here); if you add row actions, make each row a link or button with a 48px hit area. - Contrast:
--ink-2on white 5.6:1;--ink-33.1:1 (11–12px meta and column headers only).--poson white 5.1:1. - Amounts use tabular figures by default in IBM Plex Mono; set
font-variant-numeric: tabular-numsif you substitute a proportional font.
Responsive rules
- ≥ 1280: 880px column, centred, as specified.
- 1024–1279: column becomes
calc(100% − 96px); grid unchanged. - 768–1023: grid
90px 1fr 120px 120px; memo line hidden; search width 240px. - < 640: head stacks (search below heading, full width); grid
1fr autowith date and tag folded into the memo line as “28 Sep · Subscription”; row height 56px; stagger stays 30ms but entrance offset drops to 10px.
Acceptance checklist
- On load 12 rows enter with opacity 0→1 and translateY 14px→0 over 500ms,
cubic-bezier(.16,1,.3,1), row i delayedi × 30ms. - Rows have no hidden-state flash: before their animation starts they are already at opacity 0 (base style, not JS).
- Filtering removes non-matching rows with a 180ms fade + 8px lift before they are
display: none. - After exits complete, surviving rows re-run the entrance with indices re-based to 0 in DOM order (the first visible row has 0ms delay).
- Filtering with no leaving rows (e.g. deleting characters) re-staggers immediately, no 180ms wait.
- Rapid typing never leaves a row stuck at opacity 0 or half-exited (pending timer cleared per input).
- Zero matches shows the empty message with the query in bold inside the card.
- Count in the subtitle updates on every input event.
-
/focuses the input;Escapeclears it and restores all 12 rows with the stagger. - Input focus shows a
--accentborder and a 3px--accent-softhalo. - Amounts are monospaced, right-aligned, with U+2212 minus and thousands grouping; credits are green with
+. - Rows are 48px tall and the whole list fits in 800px with 32px top padding.
- Reduced motion: animations are 1ms with no delay; filtering still works identically.
Implementation notes
Two-phase filter. Exit first, then hide and re-reveal. Re-base the index each time so the stagger always starts at 0 for the first visible row:
function reveal(list) {
list.forEach((r, i) => {
r.hidden = false; r.classList.remove('out', 'in');
r.style.setProperty('--i', i);
void r.offsetWidth; // restart the CSS animation
r.classList.add('in');
});
}
let pending;
function filter() {
const s = q.value.trim().toLowerCase();
const keep = rows.filter((r, i) => !s || haystack[i].includes(s));
const leaving = rows.filter(r => !r.hidden && !keep.includes(r));
leaving.forEach(r => { r.classList.remove('in'); r.classList.add('out'); });
clearTimeout(pending);
pending = setTimeout(() => {
rows.forEach(r => { if (!keep.includes(r)) r.hidden = true; });
reveal(keep);
}, leaving.length ? 180 : 0);
}
Hidden is the default. Put the invisible state on the base selector and let the keyframe fill forwards; that way a row is never visible without having animated in:
.row { opacity: 0; transform: translateY(var(--rise)); }
.row.in { animation: in var(--t-in) var(--ease-out) forwards; animation-delay: calc(var(--i) * var(--stagger)); }
.row.out { animation: out var(--t-out) var(--ease) forwards; }
@keyframes in { to { opacity: 1; transform: none; } }
@keyframes out { to { opacity: 0; transform: translateY(calc(-1 * var(--lift))); } }
Slash shortcut — only when the input isn’t already focused, and stop the browser’s own quick-find:
addEventListener('keydown', e => {
if (e.key === '/' && document.activeElement !== q) { e.preventDefault(); q.focus(); }
});
Common mistakes: using transition instead of animation (you can’t re-trigger it on re-filter without toggling classes twice); forgetting the forced reflow so the animation doesn’t restart; setting display: none at the same time as the exit class (nothing animates); computing the stagger from the row’s original index (later rows wait for rows that are hidden).
Details
- Palette
- Type
- Space Grotesk · IBM Plex Mono
- Motion
- Subtle motion
- Build
- Quick build
- Tags
On the shelves