Design Lounge
Install

Nº 068 of 520

Swipe-to-reveal row actions

Mail rows that swipe left to reveal 80px Archive and Delete buttons with rubber-band overshoot, commit on a 60% full swipe with a nudge, and swipe right to pin.

Preview

Open full size
390 × 844 · 10.6 KB · 123 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 "Swipe-to-reveal row actions" (piece ios-swipe-row-actions) 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

214 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

The inbox of “Nord Post”, a mail client, demonstrating the iOS swipe-actions pattern with pointer events. Dragging a row left uncovers two 80px buttons behind it (green Archive, red Delete); dragging right uncovers an orange Pin. Beyond the revealed width the row rubber-bands at 45% travel. Once the drag passes 60% of the row width the outermost action grows to fill the whole reveal and the row gives a 3px nudge, a visual stand-in for the haptic; releasing there commits (delete removes the row with a height collapse, pin moves it to the top). Only one row is open at a time; Reset rebuilds the list so the piece is replayable.

Reference behaviour

  1. Initial state: eight rows, the first two unread (9px blue dot at the left). All rows are closed; nothing is revealed.
  2. Drag a row left by less than 40px and release: it springs back to 0 over 360ms cubic-bezier(.32,.72,0,1).
  3. Drag left more than 40px and release: it snaps open to −160px, showing Archive (80px) then Delete (80px) at the right. The action buttons become focusable (tabindex −1 → 0).
  4. Keep dragging past −160px: travel is damped, x = −160 − (|dx| − 160) × .45.
  5. Cross 60% of the row width (234px at 390 wide): the row gets class commit; Delete’s width transitions to 100% over 200ms and Archive collapses to 0 and fades; the icon scales to 1.15; the row plays a 120ms nudge (3px further in the drag direction and back). Crossing back removes commit and nudges again.
  6. Release past 60%: the row translates fully off-screen (−390px) over 360ms, then after 200ms its height collapses to 0 over 320ms while fading, and the element is removed.
  7. Release between −40px and 60%: snaps to −160px open. Tapping Archive or Delete removes the row the same way; Archive’s exit is green.
  8. Drag right: Pin (80px) is revealed at the left; past 60% it fills the row. Release past 60% or tap Pin: the row closes, gets a pin glyph before the sender name and moves to the top of the list. Pinning a pinned row unpins it.
  9. Starting a drag on another row closes the currently open row.
  10. Tap (no movement) on a row focuses it. ArrowLeft opens Archive/Delete, ArrowRight opens Pin, Escape closes. Tab then reaches the revealed buttons.
  11. When the list is empty, “Inbox zero. Nothing to swipe.” shows; Reset restores all eight rows.

Structure

390 × 844
┌────────────────────────────────────────┐
│ (54 status)                            │
│ 58 Nord Post (13/500)          [Reset] │
│    Inbox (30/700)                      │
│    Swipe left for Archive…  (hint 12)  │
│────────────────────────────────────────│ list top hairline
│ ● Elin Aasen                    09:41  │  row ≈ 92px
│   Re: Cabin weekend, final headcount   │
│   We are twelve now. Jonas is bringing │
│   the boat, so bring layers and…       │
│────────────────────────────────────────│
│ ● Halden Kaffebar               08:55  │
│ ...                                    │
│                                        │
│  ← dragged −160:                       │
│ ┌──────────────────────┬───────┬──────┐│
│ │ Tessel Studio  …     │Archive│Delete││
│ └──────────────────────┴───────┴──────┘│
│  → dragged +80:              80   80   │
│ ┌────┬─────────────────────────────────┐│
│ │Pin │ Orbital Cloud  …               ││
│ └────┴─────────────────────────────────┘│
└────────────────────────────────────────┘
  • <ul class="list"> of <li class="row">. Each row:
    • .wrap (grid, grid-template-rows:1fr) → .inner (min-height:0; overflow:hidden; position:relative) — the collapse mechanism.
    • .acts.l and .acts.r: absolute, full-bleed flex rows, visibility:hidden unless the row’s data-dir matches; .l aligns start, .r aligns end.
    • .act buttons: 80px wide, icon over an 11px label.
    • .front[tabindex=0]: the visible content, position:relative, grid 1fr auto with sender, time, subject, 2-line preview; translated by the gesture.
  • <template> holds one row; JS clones it from a data array so Reset can rebuild.

Tokens

:root {
  /* colour — warm paper neutrals; semantic action colours follow platform convention */
  --bg: #fbfaf7;            /* page */
  --surface: #ffffff;       /* rows */
  --ink: #1a1916;
  --ink-2: #66625a;         /* preview text */
  --ink-3: #9b968c;         /* time, hint */
  --line: #ebe8e1;          /* hairlines */
  --unread: #2b6be4;        /* dot, focus ring */
  --archive: #2f8f5b;
  --delete: #d93b2b;
  --pin: #e0862b;
  --on-action: #ffffff;

  /* type */
  --font: "Schibsted Grotesk", system-ui, -apple-system, sans-serif;

  /* geometry */
  --act-w: 80px;            /* one action */
  --open-l: -160px;         /* two actions */
  --open-r: 80px;
  --commit: .6;             /* fraction of row width */
  --rubber: .45;            /* overshoot damping */
  --snap-min: 40px;         /* below this, close */
  --dot: 9px;
  --row-pad: 12px 20px 12px 30px;

  /* motion */
  --t-micro: 140ms;
  --t-nudge: 120ms;
  --t-fill: 200ms;
  --t-snap: 360ms;
  --t-collapse: 320ms;
  --spring: cubic-bezier(.32, .72, 0, 1);
  --ease: cubic-bezier(.2, .7, .2, 1);
}

Typography

RoleFamilySizeWeightLine-heightTrackingCase
Page titleSchibsted Grotesk30px7001−0.025emsentence
App nameSchibsted Grotesk13px5001.20sentence
HintSchibsted Grotesk12px4001.40sentence
SenderSchibsted Grotesk15px6001.350sentence, ellipsis
TimeSchibsted Grotesk12px4001.350tabular numerals
SubjectSchibsted Grotesk14px5001.350sentence, ellipsis
PreviewSchibsted Grotesk13px4001.40sentence, 2-line clamp
ActionSchibsted Grotesk11px6001+0.02emsentence
ResetSchibsted Grotesk13px50010sentence

Motion

ElementTriggerPropertyFrom → ToDurationEasingNotes
.frontpointer dragtransformtracks translateX(x)0—.snap class absent
.frontreleasetransformx → 0 / −160 / 80 / ±width360ms--spring.snap present
.frontcommit crossingtransform keyframex → x−3px → x120ms--easeboth directions; class re-added with a reflow
.delete/.pin.commitwidth80px → 100%200ms--ease
.archive.commitwidth, opacity80px,1 → 0,0200 / 160ms--ease
.act svg.committransform1 → scale(1.15)140ms--ease
.wrap.gonegrid-template-rows, opacity1fr,1 → 0fr,0320ms--easestarts 200ms after the off-screen slide
.resethoverbackgroundtransparent → white140ms--ease

Reduced motion: snaps, fills and collapses become 1ms; the nudge animation is removed; dragging still tracks the finger.

States

  • Closed: --x: 0, data-dir="", action buttons tabindex="-1", both action groups hidden.
  • Open left / right: data-dir="l" / "r", matching group visible, buttons tabindex="0".
  • Commit: .row.commit, outer action filled, inner collapsed.
  • Gone: .row.gone, collapsing; removed on transitionend.
  • Pinned: .row.pinned, 14px pin glyph before the sender; row is first in the list.
  • Unread: .row.unread, 9px --unread dot at left:12px; top:19px.
  • Front focus-visible: inset 2px --unread box-shadow (an outline would be clipped by the row).
  • Action focus-visible: 2px white outline, outline-offset:-4px.
  • Empty: .list:empty + .empty shows the message.

Accessibility

  • Every action is a real <button> behind the row. They are not in the tab order while hidden (tabindex="-1") and become reachable once the row is open, so keyboard users can operate them.
  • Rows’ .front is tabindex="0"; ArrowLeft / ArrowRight / Escape mirror the gesture. Document this in the hint text.
  • Pointer capture (setPointerCapture) keeps the drag alive when the finger leaves the row.
  • touch-action: pan-y on rows keeps vertical scrolling native while horizontal drags belong to the gesture.
  • Contrast: white on --delete 4.9:1, on --archive 4.6:1, on --pin 3.4:1 (icon + 11px bold label; increase to #c9731c if AA on text is required). --ink-2 on white 5.9:1.
  • Hit targets: rows ≈ 92px tall; action buttons 80px wide × row height.
  • After removal, move focus to the next row’s .front in a product build.

Responsive rules

  • Commit threshold is relative (60% of the list width), reveal widths are absolute (80px each).
  • 360 wide: same widths; the preview clamps to 2 lines as before; the commit point is 216px.
  • ≥ 430 wide: constrain the list to 430px centred; rows keep full-bleed hairlines within the column.
  • Tablet / pointer-only: keep the gesture but also show a hover-revealed inline action group at the right (Archive, Delete, Pin as 32px icon buttons) so mouse users are not forced to drag.

Acceptance checklist

  • Dragging a row left reveals Archive then Delete, each exactly 80px wide; right reveals Pin at 80px.
  • Release under 40px of travel closes; over 40px snaps open to −160px / +80px in 360ms cubic-bezier(.32,.72,0,1).
  • Past the reveal width, movement is damped to 45%.
  • Crossing 60% of the row width toggles .commit: outer action fills to 100% in 200ms, inner action collapses, icon scales 1.15, and a 3px nudge plays; crossing back reverses it and nudges again.
  • Release past 60% commits: left removes the row (slide off, then 320ms height collapse via grid-template-rows: 0fr); right pins and moves it to the top.
  • Tapping a revealed button performs its action; only one row can be open at a time.
  • Vertical scrolling still works while rows are closed (touch-action: pan-y).
  • ArrowLeft / ArrowRight / Escape work on a focused row; revealed buttons are reachable by Tab only when revealed.
  • Unread rows show a 9px #2b6be4 dot; pinned rows show the pin glyph and sit first.
  • Empty list shows “Inbox zero.” and Reset restores all eight rows.
  • No transition is applied to transform during a drag (the row tracks the pointer without lag).
  • Reduced motion removes the nudge and shortens snaps to 1ms.

Implementation notes

Track raw dx for thresholds, damp only what you draw. The commit test uses the undamped distance; the rubber band applies only beyond the revealed width:

const move = ev => {
  const dx = ev.clientX - startX + base;                 // base = current open offset
  const lim = dx < 0 ? 160 : 80, over = Math.abs(dx) - lim;
  const x = over > 0 ? Math.sign(dx) * (lim + over * .45) : dx;
  const commit = Math.abs(dx) > list.clientWidth * .6;
  if (commit !== row.classList.contains('commit')) {
    row.classList.toggle('commit', commit);
    row.classList.remove('nudge'); void row.offsetWidth; row.classList.add('nudge');
  }
  setX(row, x, false);                                   // no transition while dragging
};

Nudge relative to the current offset by animating from a custom property, so the keyframe works at any x:

.row.snap .front { transition: transform 360ms cubic-bezier(.32,.72,0,1); }
.row.nudge .front { animation: nudge 120ms cubic-bezier(.2,.7,.2,1); }
@keyframes nudge { 40% { transform: translateX(calc(var(--x) - 3px)); } 100% { transform: translateX(var(--x)); } }

Collapse without knowing the height using the 0fr grid trick, and remove the element only after the transition:

.wrap { display: grid; grid-template-rows: 1fr; transition: grid-template-rows 320ms var(--ease), opacity 320ms var(--ease); }
.row.gone .wrap { grid-template-rows: 0fr; opacity: 0; }
.inner { min-height: 0; overflow: hidden; }

Common mistakes: using touch-action: none on rows (kills list scrolling); testing commit against the damped x (the threshold becomes unreachable); leaving the buttons focusable while hidden; animating left or margin-left instead of transform; forgetting to close the previously open row on a new pointerdown.

Details

Palette
Type
Schibsted Grotesk
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