Interaction
Frozen-batch deck
A one-card-at-a-time review queue whose batch is snapshotted at mount, because the list it was handed shrinks underneath it as each card is acted on.
- queue
- review-flow
- animation
- keyboard
- reduced-motion
<div class="fbd-panel">
<div class="fbd-controls">
<button type="button" class="fbd-btn" id="fbd-reset">Reset</button>
<span class="fbd-hint">Keys: <kbd>1</kbd> approve · <kbd>2</kbd> skip · <kbd>3</kbd> fail</span>
</div>
<div class="fbd-outer" id="fbd-deck"></div>
</div>Colors come from shared theme tokens — --surface, --ink, --border, --accent and friends — so this CSS carries no palette
of its own. Use Runnable file to copy the tokens along with it.
.fbd-panel { display: flex; flex-direction: column; gap: 0.75rem; }
.fbd-controls { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; }
.fbd-btn {
font: inherit;
font-size: 0.9375rem;
padding: 0.4rem 0.8rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
color: var(--ink);
cursor: pointer;
}
.fbd-hint { color: var(--dim); font-size: 0.9375rem; }
.fbd-hint kbd {
font-family: var(--mono);
font-size: 0.8125rem;
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.05rem 0.35rem;
background: var(--surface-2);
}
.fbd-outer { width: 100%; max-width: 440px; }
/* The stack is its own positioning context, so the absolutely positioned peek
cards and the outgoing ghost can never spill over the progress bar or footer. */
.fbd-stack { position: relative; }
.fbd-card {
position: relative;
z-index: 1;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
min-height: 128px;
}
.fbd-peek {
position: absolute;
inset: 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
z-index: 0;
pointer-events: none;
}
/* The card that just left, cloned and animated out over the card that replaced it. */
.fbd-ghost {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
animation: fbd-out 300ms cubic-bezier(0.4, 0, 0.8, 0.4) forwards;
}
@keyframes fbd-out {
to { transform: translateX(-64px) rotate(-4deg); opacity: 0; }
}
.fbd-card.fbd-in { animation: fbd-in 300ms cubic-bezier(0.2, 0.7, 0.3, 1); }
@keyframes fbd-in {
from { transform: translateY(10px) scale(0.96); opacity: 0.5; }
}
.fbd-peek.fbd-in { animation: fbd-shift 300ms cubic-bezier(0.2, 0.7, 0.3, 1); }
@keyframes fbd-shift {
from { transform: translateY(20px) scale(0.92); opacity: 0.32; }
}
@media (prefers-reduced-motion: reduce) {
.fbd-ghost { display: none; }
.fbd-card.fbd-in, .fbd-peek.fbd-in { animation: none; }
}
.fbd-eyebrow {
margin: 0;
font-family: var(--mono);
font-size: 0.8125rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--dim);
}
.fbd-note { margin: 0; color: var(--dim); }
.fbd-error { margin: 0; color: var(--bad); }
.fbd-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1rem; }
/* flex: 1 0 auto with no forced basis, not flex: 1 -- see the prompt. */
.fbd-actions .fbd-btn { flex: 1 0 auto; min-width: fit-content; }
.fbd-foot {
display: flex;
justify-content: space-between;
gap: 1rem;
margin-top: 0.9rem;
font-family: var(--mono);
font-size: 0.9375rem;
color: var(--dim);
font-variant-numeric: tabular-nums;
}
.fbd-progress {
height: 5px;
border-radius: 3px;
background: var(--surface-2);
overflow: hidden;
margin-top: 0.5rem;
border: 1px solid var(--border);
}
.fbd-progress i { display: block; height: 100%; background: var(--accent); }(function () {
var reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var sourceRecords = [
{ id: 1, title: 'Review deployment configs', note: 'No update in 21 days' },
{ id: 2, title: 'Patch database server', note: 'No update in 18 days' },
{ id: 3, title: 'Verify backup restore', note: 'No update in 30 days' },
{ id: 4, title: 'Update documentation', note: 'No update in 16 days' },
{ id: 5, title: 'Check rate limits', note: 'No update in 42 days' },
{ id: 6, title: 'Approve schema change', note: 'No update in 15 days' }
];
// Snapshotted once, here, at mount. A real deck's actions each mutate a
// server record and revalidate, which hands the component back a SHORTER
// list on every action. A deck that addresses that live list by position
// skips every other card -- it looks like records went missing.
var frozenBatch = sourceRecords.slice();
var currentIndex = 0;
var currentError = '';
var deckNode = document.getElementById('fbd-deck');
function renderDeck(animate) {
var total = frozenBatch.length;
var current = frozenBatch[currentIndex];
var enterClass = animate ? ' fbd-in' : '';
// An index equal to the total is a legal position meaning "done", not an
// overflow -- which is what lets the position live in a URL query param
// in a real app without a bounds check on every read.
if (!current) {
deckNode.innerHTML =
'<div class="fbd-card"><p class="fbd-eyebrow">Done</p><h4>Queue cleared</h4>' +
'<p class="fbd-note">Nothing left needing a decision.</p></div>' +
'<div class="fbd-foot"><span>' +
currentIndex +
' of ' +
total +
'</span><span>0 left</span></div>';
return;
}
var peekMarkup = frozenBatch
.slice(currentIndex + 1, currentIndex + 3)
.map(function (record, depth) {
var offset = (depth + 1) * 10;
var scale = 1 - (depth + 1) * 0.04;
var opacity = 0.5 - depth * 0.18;
return (
'<div class="fbd-peek' +
enterClass +
'" aria-hidden="true" style="transform:translateY(' +
offset +
'px) scale(' +
scale +
');opacity:' +
opacity +
'"></div>'
);
})
.join('');
deckNode.innerHTML =
'<div class="fbd-stack">' +
peekMarkup +
'<div class="fbd-card' +
enterClass +
'">' +
'<p class="fbd-eyebrow">Needs a decision</p>' +
'<h4>' +
current.title +
'</h4>' +
'<p class="fbd-note">' +
current.note +
'</p>' +
(currentError ? '<p class="fbd-error">' + currentError + '</p>' : '') +
'<div class="fbd-actions">' +
'<button class="fbd-btn" data-fbd-act="keep" type="button">Approve</button>' +
'<button class="fbd-btn" data-fbd-act="close" type="button">Skip</button>' +
'<button class="fbd-btn" data-fbd-act="fail" type="button">Action that fails</button>' +
'</div></div></div>' +
'<div class="fbd-progress"><i style="width:' +
(currentIndex / total) * 100 +
'%"></i></div>' +
'<div class="fbd-foot"><span>' +
(currentIndex + 1) +
' of ' +
total +
'</span><span>' +
(total - currentIndex) +
' left</span></div>';
Array.prototype.forEach.call(deckNode.querySelectorAll('[data-fbd-act]'), function (button) {
button.addEventListener('click', function () {
act(button.getAttribute('data-fbd-act'));
});
});
}
function act(kind) {
var current = frozenBatch[currentIndex];
if (!current) return;
if (kind === 'fail') {
// Advance ONLY on success. A rejected write leaves the card in place
// so it can be retried -- an earlier version of this queue swallowed
// the failure and advanced anyway, so a failed save looked identical
// to a successful one.
currentError = 'That save was rejected. The card stays put so it can be retried.';
renderDeck(false);
return;
}
currentError = '';
var leavingCard = deckNode.querySelector('.fbd-card');
var ghost = reducedMotion || leavingCard === null ? null : leavingCard.cloneNode(true);
currentIndex += 1;
renderDeck(!reducedMotion);
var stack = deckNode.querySelector('.fbd-stack');
if (ghost !== null && stack !== null) {
ghost.className = 'fbd-card fbd-ghost';
stack.appendChild(ghost);
window.setTimeout(function () {
ghost.remove();
}, 320);
}
}
// Attached once, outside of any render cycle. It dispatches through act(),
// which always reads the current currentIndex and frozenBatch off the
// enclosing scope -- so there is no stale closure to worry about, and
// nothing to re-subscribe when either of those changes.
document.addEventListener('keydown', function (event) {
var targetTag = event.target && event.target.tagName;
if (targetTag === 'INPUT' || targetTag === 'TEXTAREA' || targetTag === 'SELECT') return;
if (event.key === '1') act('keep');
if (event.key === '2') act('close');
if (event.key === '3') act('fail');
});
document.getElementById('fbd-reset').addEventListener('click', function () {
frozenBatch = sourceRecords.slice();
currentIndex = 0;
currentError = '';
renderDeck(false);
});
renderDeck(false);
})();Paste this into an agent to rebuild the pattern from scratch.
Build a one-card-at-a-time review queue: a stack of peek cards behind the current one, action buttons that advance to the next card, a progress bar, and an "N of total" footer.
Reach for this whenever a person has to make a small decision on each item in a batch — triage, approvals, moderation, a stack of records to confirm or dismiss — and showing the whole list at once would either overwhelm them or invite them to skip around out of order. Skip it when the list needs to stay visible for comparison between items, or when items can be acted on in any order; a deck forces linear, one-at-a-time review, and that constraint is the point, not a limitation to work around.
The load-bearing decision is that the batch is snapshotted once, at mount, into its own array, and every read and write in the component addresses that frozen copy by position, never a live list fetched from a server. The reason is almost always true of a real review queue: each action typically mutates a record and revalidates the underlying list, which comes back shorter. A deck that indexes the live list directly skips every other card after the first action, because position 2 in a six-item list becomes a different record once the list is five items long. Freezing the batch at mount decouples "which card is on screen" from "how many records are still unresolved."
An index equal to the batch length is a legal, meaningful position -- it means "done" -- not an out-of-bounds error to guard against. Treating it as ordinary is what lets that index live in a URL query parameter and survive a page reload without a bounds check on every read.
Advance the index only when an action actually succeeds. Wire in a deliberately-failing action to prove it: triggering it should show an inline error and leave the current card exactly where it was, ready to retry. A version that advances regardless of outcome makes a rejected write look indistinguishable from a successful one, and that is the harder bug to catch in review, because everything still looks like it's working.
Two smaller details worth copying exactly. First, size the action buttons with `flex: 1 0 auto; min-width: fit-content`, not `flex: 1`. A bare `flex: 1` sets each button's flex-basis to 0, so all buttons size identically regardless of label length and the longest one clips; an auto basis with no shrink sizes each button to its content and wraps the row onto a second line when there isn't room, instead of clipping. Second, attach the keyboard shortcut listener exactly once, outside any render function, and let it call into a handler that reads the current index and batch from the enclosing scope rather than from values captured in a closure at attach time -- re-subscribing the listener on every state change is the version of this bug that shows up in every independent rewrite of this component.
Animate the outgoing card as a cloned ghost that slides out over the card that replaced it, with the stack established as its own positioning context so nothing spills over the progress bar or footer, and disable the whole animation set -- ghost, card-in, peek-shift -- under `prefers-reduced-motion`.