Nº 434 of 520
Swipe deck with undo
A stacked deck of trip cards: drag right to save, left to pass, arrow keys for both, undo flies the card back from the side it left.
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 "Swipe deck with undo" (piece card-stacked-deck) 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º 434 · "Swipe deck with undo" · designlounge.vercel.app -->
# Swipe deck with undo
> **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 decision deck for content, not dating: "Farfield · Weekend deck", seven short trips from Lisbon. Three cards are visible in a stack. Drag the top card and it follows the pointer, tilting with the drag, while a SAVE or PASS stamp fades in. Past 110px (or a quick flick) it flies off and the stack steps up. Arrow keys do the same, three round buttons mirror them, and Undo brings the last card back in from the side it left, removing it from the shortlist if it was saved. Saved trips gather as chips in the left column. Every card has a flat illustrated scene made from CSS gradients and clip-paths: no images.
The detail worth copying is the undo: the card is placed off-screen on the side it left, with transitions off, then released so it travels back onto the stack. Not `spring-deck`, which only springs back and never decides.
## Reference behaviour
1. First frame: left column with brand line, "Where next, Inês?" (64px serif), a lede, a key legend, and "Saved trips 0" with an empty message. Right column: the deck with Sintra on top, Comporta and Douro Valley behind it, then Pass, Undo (disabled), Save buttons and "1 of 7".
2. Stack: depth 0 at identity; depth 1 at `translateY(18px) scale(.95)` with an 18% page-colour veil; depth 2 at `translateY(36px) scale(.90)` with a 34% veil; depth 3 is parked at depth 2's transform with opacity 0, so it can fade in when the stack steps. Transform origin is bottom centre. Cards behind the top hide their facts row so only the clean edge peeks.
3. Drag: pointer down on the top card captures the pointer and turns transitions off. The card moves `translate(dx, dy × .4)` and rotates `dx / 18` degrees. The stamp on the side of travel fades to full opacity at 110px: SAVE (persimmon, top right, rotated 12°) for right, PASS (dark green, top left, rotated −12°) for left. While dragging, the card behind shows its facts.
4. Release: if `|dx| > 110px`, or `|vx| > .6px/ms` with `|dx| > 30px`, the card decides in that direction. Otherwise it springs back to identity over 420ms on expo out and the stamps reset.
5. Decide: the card flies to `translate(±(deckWidth + 260px), dy + 40px) rotate(±24°)` and opacity 0 over 380ms on `cubic-bezier(.3,.6,.4,1)`. At the same moment the stack steps: every remaining card moves up one depth over 420ms expo. The fly-out card is set to `display: none` after 380ms. Input is locked during the fly.
6. Save adds the trip as a chip to "Saved trips" (pops in from 70% scale over 400ms) and updates the count. Pass adds nothing.
7. Keyboard on the focused deck: Right Arrow saves, Left Arrow passes, U, Backspace, or Cmd/Ctrl+Z undoes. The page does not scroll.
8. Undo: pops the last decision. That card is placed on its exit side at `translate(±(deckWidth + 260px), 40px) rotate(±24°)`, opacity 0, with no transition. After a forced reflow, transitions come back on and it returns to depth 0 over 420ms; the other cards step back down. A saved trip's chip is removed. Undo is disabled when there is nothing to undo.
9. End: after the seventh card, a dashed empty slot reads "That's the deck" and "You saved N of 7 trips. Undo to look again." with a "Deal again" button. Pass and Save disable; the counter reads "7 of 7 seen". Deal again clears history and restacks instantly with Sintra on top.
10. Live region announces each step: "Saved Sintra. Next: Comporta.", "Back to Douro Valley. Removed from saved.", "That was the last card."
11. Reduced motion: no drag tilt animation on release (cards jump), no fly-out (cards vanish), no stack tween, no chip pop. Dragging still moves the card under the finger because that is direct manipulation.
## Structure
```
1280 × 800, page #22302a with a lighter ellipse behind the deck
┌───────────────────────────────────────────────────────────────────────────────┐
│ ⊘ FARFIELD · WEEKEND DECK ┌───────────────────┐ │
│ Where next, │ [HILLS] ( SAVE )│ │
│ Inês? (64px Gloock) │ scene 56% │ │
│ Seven short trips from Lisbon… (38ch) │ sun, far, near │ │
│ [←] pass [→] save [U] undo ├───────────────────┤ │
│ ───────────────────────────── │ Sintra (34px) │ │
│ SAVED TRIPS 2 │ Lisbon district… │ │
│ (● Sintra) (● Douro Valley) │ pitch 14.5px │ │
│ │ 40 min│May–Oct│€60│ │
… 213 more lines. Copy or download for the full brief. Loading the source…Brief
253 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 decision deck for content, not dating: “Farfield · Weekend deck”, seven short trips from Lisbon. Three cards are visible in a stack. Drag the top card and it follows the pointer, tilting with the drag, while a SAVE or PASS stamp fades in. Past 110px (or a quick flick) it flies off and the stack steps up. Arrow keys do the same, three round buttons mirror them, and Undo brings the last card back in from the side it left, removing it from the shortlist if it was saved. Saved trips gather as chips in the left column. Every card has a flat illustrated scene made from CSS gradients and clip-paths: no images.
The detail worth copying is the undo: the card is placed off-screen on the side it left, with transitions off, then released so it travels back onto the stack. Not spring-deck, which only springs back and never decides.
Reference behaviour
- First frame: left column with brand line, “Where next, Inês?” (64px serif), a lede, a key legend, and “Saved trips 0” with an empty message. Right column: the deck with Sintra on top, Comporta and Douro Valley behind it, then Pass, Undo (disabled), Save buttons and “1 of 7”.
- Stack: depth 0 at identity; depth 1 at
translateY(18px) scale(.95)with an 18% page-colour veil; depth 2 attranslateY(36px) scale(.90)with a 34% veil; depth 3 is parked at depth 2’s transform with opacity 0, so it can fade in when the stack steps. Transform origin is bottom centre. Cards behind the top hide their facts row so only the clean edge peeks. - Drag: pointer down on the top card captures the pointer and turns transitions off. The card moves
translate(dx, dy × .4)and rotatesdx / 18degrees. The stamp on the side of travel fades to full opacity at 110px: SAVE (persimmon, top right, rotated 12°) for right, PASS (dark green, top left, rotated −12°) for left. While dragging, the card behind shows its facts. - Release: if
|dx| > 110px, or|vx| > .6px/mswith|dx| > 30px, the card decides in that direction. Otherwise it springs back to identity over 420ms on expo out and the stamps reset. - Decide: the card flies to
translate(±(deckWidth + 260px), dy + 40px) rotate(±24°)and opacity 0 over 380ms oncubic-bezier(.3,.6,.4,1). At the same moment the stack steps: every remaining card moves up one depth over 420ms expo. The fly-out card is set todisplay: noneafter 380ms. Input is locked during the fly. - Save adds the trip as a chip to “Saved trips” (pops in from 70% scale over 400ms) and updates the count. Pass adds nothing.
- Keyboard on the focused deck: Right Arrow saves, Left Arrow passes, U, Backspace, or Cmd/Ctrl+Z undoes. The page does not scroll.
- Undo: pops the last decision. That card is placed on its exit side at
translate(±(deckWidth + 260px), 40px) rotate(±24°), opacity 0, with no transition. After a forced reflow, transitions come back on and it returns to depth 0 over 420ms; the other cards step back down. A saved trip’s chip is removed. Undo is disabled when there is nothing to undo. - End: after the seventh card, a dashed empty slot reads “That’s the deck” and “You saved N of 7 trips. Undo to look again.” with a “Deal again” button. Pass and Save disable; the counter reads “7 of 7 seen”. Deal again clears history and restacks instantly with Sintra on top.
- Live region announces each step: “Saved Sintra. Next: Comporta.”, “Back to Douro Valley. Removed from saved.”, “That was the last card.”
- Reduced motion: no drag tilt animation on release (cards jump), no fly-out (cards vanish), no stack tween, no chip pop. Dragging still moves the card under the finger because that is direct manipulation.
Structure
1280 × 800, page #22302a with a lighter ellipse behind the deck
┌───────────────────────────────────────────────────────────────────────────────┐
│ ⊘ FARFIELD · WEEKEND DECK ┌───────────────────┐ │
│ Where next, │ [HILLS] ( SAVE )│ │
│ Inês? (64px Gloock) │ scene 56% │ │
│ Seven short trips from Lisbon… (38ch) │ sun, far, near │ │
│ [←] pass [→] save [U] undo ├───────────────────┤ │
│ ───────────────────────────── │ Sintra (34px) │ │
│ SAVED TRIPS 2 │ Lisbon district… │ │
│ (● Sintra) (● Douro Valley) │ pitch 14.5px │ │
│ │ 40 min│May–Oct│€60│ │
│ column 1fr gap 72px └───────────────────┘ │
│ ▔▔▔▔▔ two cards peek │
│ ( × ) (↶) ( ♥ ) │
│ 1 of 7 │
└───────────────────────────────────────────────────────────────────────────────┘
card 360 × 500, r 20px; deck box 360 × 536; buttons 64 / 44 / 64, gap 18
main.wrap: gridminmax(0,1fr) auto, gap 72px, max 1040px.- Left:
header(brandpwith compass SVG,h1, ledep, keyspwithkbd), thensection.savedlabelled by itsh2with aulof chips. - Right:
.deck[role=group][aria-roledescription="card deck"][tabindex=0],touch-action: none, containing sevenarticle.cardand the.endslot. Then.controlswith three buttons and the counterp. - Card: grid rows
56% minmax(0,1fr)..scene(aria-hidden) holds.sun,.far,.sea,.near, the.tagpill and two.stamps..infoholdsh3, the regionp, the pitchp, and a three-cellul.factspushed to the bottom withmargin-top: auto.
Deck content (seven cards):
| Trip | Region | Tag | Facts | Scene |
|---|---|---|---|---|
| Sintra | Lisbon district · Portugal | Hills | 40 min by train · May–Oct · €60 | sage sky, pale sun right, peaks + hills |
| Comporta | Alentejo coast · Portugal | Dunes | 1 h 15 by car · Sep–Oct · €95 | apricot sky, persimmon sun left, rolling + flat |
| Douro Valley | Vila Real · Portugal | Vineyards | 3 h 30 by train · Oct harvest · €80 | sand sky, hills + stepped terraces |
| Berlengas | Off Peniche · Portugal | Island | 2 h car + boat · Jun–Sep · €45 | sea-glass sky, cliff left, striped sea 30% |
| Monsaraz | Évora district · Portugal | Village | 2 h by car · Oct–Apr · €70 | night navy sky, moon, dark hills |
| Serra da Estrela | Guarda · Portugal | Peaks | 3 h 30 by car · Oct–Nov · €65 | cold grey sky, peaks + rolling |
| Tavira | Algarve · Portugal | Coast | 2 h 45 by train · Sep–Nov · €75 | blush sky, low sun, flat shore, sea 36% |
Tokens
:root {
--bg: #22302a; /* deep olive page */
--bg-2: #2b3a33; /* ellipse behind the deck */
--paper: #f6eedf; /* card */
--paper-rule: #dccfb6;/* facts rule */
--ink: #1f2a24; /* card text */
--ink-2: #4d564f; /* region, fact labels */
--on-bg: #efe7d6; /* headline on page */
--on-bg-2: #b9bfae; /* lede, labels, counter */
--line: rgba(239, 231, 214, .14);
--save: #e2643a; /* save button, SAVE stamp, chip dots, brand icon */
--pass-stamp: #3d4d45;
--focus: #f2b38f;
--serif: "Gloock", Georgia, serif;
--sans: "Instrument Sans", system-ui, sans-serif;
--cw: 360px; --ch: 500px;
--r-card: 20px; --r-pill: 99px;
--depth-1: translateY(18px) scale(.95);
--depth-2: translateY(36px) scale(.90);
--threshold: 110px; --flick: .6; /* px per ms */
--expo: cubic-bezier(.16, 1, .3, 1);
--fly: cubic-bezier(.3, .6, .4, 1);
--ease: cubic-bezier(.2, .7, .2, 1);
--t-step: 420ms; --t-fly: 380ms; --t-chip: 400ms; --t-micro: 160ms;
--shadow-card: inset 0 1px 0 rgba(255,255,255,.6), 0 24px 50px -24px rgba(0,0,0,.55);
}
Scene recipe per card, as custom properties on .scene: --sky1, --sky2 (vertical gradient), --sun and --sx/--sy (76px disc), --far + --farp (62% tall band, clip-path polygon), --near + --nearp (42% band), --sea + --seah (striped band: 7px colour, 1px 20% lighter). The polygons are shared presets: PEAKS, HILLS, ROLL, FLAT, CLIFF, TERRACES.
Typography
| Role | Family | Size | Weight | Line-height | Tracking | Case |
|---|---|---|---|---|---|---|
| Brand line | Instrument Sans | 12px | 600 | 1 | 0.16em | UPPER |
| Headline | Gloock | 64px | 400 | 1 | -0.01em | Sentence, max 9ch |
| Lede | Instrument Sans | 16px | 400 | 1.5 | 0 | sentence, 38ch |
| Section label | Instrument Sans | 12px | 600 | 1 | 0.14em | UPPER |
| Chip | Instrument Sans | 14px | 500 | 1 | 0 | Title |
| Card title | Gloock | 34px | 400 | 1.02 | -0.01em | Title |
| Region | Instrument Sans | 14px | 400 | 1.5 | 0 | — |
| Pitch | Instrument Sans | 14.5px | 400 | 1.5 | 0 | sentence |
| Fact value / label | Instrument Sans | 14px 600 / 12px 400 | 1.3 | 0 | — | |
| Scene tag | Instrument Sans | 11px | 600 | 1 | 0.1em | UPPER |
| Stamp | Gloock | 30px | 400 | 1 | 0.04em | UPPER, 3px border |
Motion
| Element | Trigger | Property | From → To | Duration | Easing | Reduced motion |
|---|---|---|---|---|---|---|
| Top card | drag | translate, rotate | follows pointer, rotate dx/18° | live | none | same |
| Stamp | drag | opacity | 0 → 1 at 110px | live | none | same |
| Top card | release under threshold | transform | current → identity | 420ms | --expo | instant |
| Top card | decide | translate, rotate, opacity | → ±(w+260), +40, ±24°, 0 | 380ms | --fly | instant hide |
| Stack | decide / undo | transform, opacity | depth n → n∓1 | 420ms | --expo | instant |
| Returning card | undo | transform, opacity | exit side → identity | 420ms | --expo | instant |
| Facts row | depth change | opacity | 0 ↔ 1 | 300ms | --ease | instant |
| Saved chip | added | scale, opacity | .7, 0 → 1, 1 | 400ms | --expo | none |
| Round buttons | active | scale | 1 → .92 | 160ms | --ease | none |
States
- Top card:
cursor: grab;grabbingwhile dragging; no veil. - Behind cards: page-colour veil (18% / 34%), facts hidden,
aria-hidden="true". - Dragging: transitions off on the top card; card behind shows its facts.
- Busy: during the 380ms fly or 420ms return, new decisions and undo are ignored.
- Undo disabled: opacity .35,
not-allowed, when history is empty. - End: dashed slot with title, summary, Deal again; Pass/Save disabled.
- Empty shortlist: “Nothing yet. Save a card to start a shortlist.” in
--on-bg-2, no dot. - Hover: Pass gets a 6% light fill and stronger border; Save lightens to
#ec7650; Undo text brightens. - Focus-visible: 2px
#f2b38foutline, 4px offset (8px on the deck).
Accessibility
- The deck is one tab stop:
role="group",aria-roledescription="card deck", label “Weekend trips. Left arrow passes, right arrow saves, U undoes.”, andaria-describedbypointing at the “1 of 7” counter. - Only the top card is exposed; cards behind are
aria-hidden. Each card is anarticlelabelled “Sintra, Lisbon district · Portugal”. - Buttons are labelled Pass, Undo last choice, Save; their icons are
aria-hidden. - Every decision and undo is announced in a polite live region with the next card’s name.
- Dragging is never the only way: buttons and keys do everything.
- Contrast:
--on-bgon page 11.2:1,--on-bg-27.3:1, card ink 12.9:1,--ink-26.6:1, Save icon#2a140bon persimmon 5.1:1. - Hit targets: 64px Pass and Save, 44px Undo, 44px Deal again.
Responsive rules
- ≥ 1280: two columns, card 360 × 500.
- 981–1279: same, gap 72px.
- 761–980: gap 40px, headline 48px.
- < 760: one column. Header first, deck second, saved list third (
display: contentson the left column andorder). Lede and key legend hide. Headline 38px. Cardmin(330px, 100vw − 32px)× 440px, title 28px, pitch 13.5px. Page padding 24/16px. - Fly-out distance is computed from the deck width, so it always clears the viewport. The body has
overflow-x: hiddenso the off-screen card never adds scroll.
Acceptance checklist
Always
- Three cards visible in a stack with depth transforms from the bottom-centre origin; a fourth is parked invisible.
- Drag follows the pointer with rotation proportional to dx and a directional stamp that reaches full opacity at the threshold.
- Decides on distance (110px) or flick (> .6px/ms past 30px); otherwise springs back.
- Left/Right arrows and two buttons decide; U, Backspace, Cmd/Ctrl+Z and a button undo.
- Undo returns the card from the side it left and reverses its effect on the shortlist.
- Input is ignored while a card is flying or returning.
- End state with a summary and a way to deal again.
- Only the top card is in the accessibility tree; decisions are announced.
- Reduced motion removes fly, step and pop tweens but keeps direct drag.
- No horizontal scroll at 375px, including mid-fly.
This demo
- Farfield · Weekend deck, “Where next, Inês?”, seven Portuguese trips in the order above.
- Page
#22302a, card#f6eedf, save#e2643a. - Card 360 × 500, radius 20px, scene 56% of height.
- Gloock for headline, titles and stamps; Instrument Sans for everything else.
- Scenes are CSS gradients and clip-path polygons only.
Implementation notes
Depth is a data attribute; CSS owns the stack. JS only writes data-depth, and inline transforms only while dragging or flying:
.card { transition: transform .42s var(--expo), opacity .42s var(--expo); transform-origin: 50% 100%; }
.card[data-depth="0"] { z-index: 4; }
.card[data-depth="1"] { z-index: 3; transform: translateY(18px) scale(.95); }
.card[data-depth="2"] { z-index: 2; transform: translateY(36px) scale(.90); }
.card[data-depth="3"] { z-index: 1; transform: translateY(36px) scale(.90); opacity: 0; }
.card[data-depth="gone"] { display: none; }
.deck.dragging .card[data-depth="0"] { transition: none; }
Undo: teleport, reflow, release.
function undo() {
if (busy || !history.length) return;
busy = true;
const h = history.pop(), el = cards[h.i], w = deck.offsetWidth;
el.style.transition = 'none';
el.dataset.depth = '0';
el.style.opacity = '0';
el.style.transform = `translate(${h.dir * (w + 260)}px, 40px) rotate(${h.dir * 24}deg)`;
el.getBoundingClientRect(); // commit the off-screen pose
el.style.transition = reduce ? 'none' : '';
el.style.opacity = ''; el.style.transform = '';
top = h.i; layout(); // others step back down
setTimeout(() => { busy = false; }, reduce ? 0 : 420);
}
Flick velocity from the last move, not the whole drag. Average velocity over a slow drag is near zero even when the user flicks at the end:
deck.addEventListener('pointermove', e => {
if (!drag) return;
const now = performance.now();
drag.vx = (e.clientX - drag.lx) / Math.max(1, now - drag.lt);
drag.lx = e.clientX; drag.lt = now;
drag.x = e.clientX - drag.x0; drag.y = (e.clientY - drag.y0) * .4;
drag.el.style.transform = `translate(${drag.x}px, ${drag.y}px) rotate(${drag.x / 18}deg)`;
});
Common mistakes:
- Re-creating card nodes on every decision. Transitions have nothing to run from.
- Forgetting
setPointerCapture; the drag drops when the pointer leaves the card. touch-action: autoon the deck; the page scrolls instead of the card moving.- Undo that fades the card in at the top instead of returning it from its exit side. The direction is the feedback.
- Letting a second arrow press start before the first fly ends; two cards fly and history goes out of step.
- Facts rows peeking out under the top card. Hide them on depth ≥ 1 and show depth 1’s while dragging.
- A heart-and-cross dating look with gradients. This is a shortlist tool: paper cards, a serif, one accent.
Rebuild order:
- Two-column layout and one static card with its scene.
- Seven cards,
data-depthstack andlayout(). - Buttons and keys calling
decide(dir)with the fly-out and busy lock. - History, undo, shortlist chips, counter.
- Pointer drag with stamps, threshold and flick.
- End state and Deal again, live region, reduced motion, < 760 layout.
Details
- Palette
- Type
- Gloock · Instrument Sans
- Motion
- Rich motion
- Build
- Multi-step
- Tags
Studied
The F-ListMake the reveal the message. Bars lift off the text, and badges explain the list.
All sourcesOn the shelves