Navigation blueprint

Swipe action reveal

A measured horizontal reveal gives frequent mobile actions a fast, reversible path.

Playground

Playhead 0ms / 240ms

Build it

Choose a target and adapt the source to your interface.

181 lines
index.html
<div class="swipe-demo">
  <div class="swipe-lane">
    <button
      class="swipe-action"
      id="remove-action"
      type="button"
      tabindex="-1"
      aria-hidden="true"
    >
      Remove
    </button>
    <div class="swipe-row">
      <span class="motion-row-copy"
        ><strong>Project notes</strong><small>Updated just now</small></span
      ><button
        type="button"
        aria-label="Show actions"
        aria-expanded="false"
        aria-controls="remove-action"
      >
        ···
      </button>
    </div>
  </div>
  <button class="swipe-restore" type="button" hidden>Restore sample row</button>
  <p class="swipe-status" role="status"></p>
</div>

<script>
  const lane = document.querySelector('.swipe-lane');
  const row = document.querySelector('.swipe-row');
  const toggle = row.querySelector('button');
  const action = document.querySelector('.swipe-action');
  const restore = document.querySelector('.swipe-restore');
  const status = document.querySelector('.swipe-status');
  let open = false;
  let start;
  let x = 0;
  function snap(next) {
    open = next;
    x = next ? -88 : 0;
    row.style.transition = 'transform 240ms cubic-bezier(.22,1,.36,1)';
    row.style.transform = 'translateX(' + x + 'px)';
    toggle.setAttribute('aria-expanded', String(open));
    toggle.setAttribute('aria-label', open ? 'Hide actions' : 'Show actions');
    action.tabIndex = open ? 0 : -1;
    action.setAttribute('aria-hidden', String(!open));
    status.textContent = open ? 'Remove action revealed.' : '';
  }
  function end(cancel = false) {
    if (!start) return;
    const dragged = start.dragged;
    start = null;
    if (dragged) snap(cancel ? open : x < -44);
  }
  row.addEventListener('pointerdown', (event) => {
    if (event.button !== 0 || event.target.closest('button')) return;
    row.setPointerCapture(event.pointerId);
    row.style.transition = 'none';
    start = { x: event.clientX, y: event.clientY, offset: x, dragged: false };
  });
  row.addEventListener('pointermove', (event) => {
    if (!start) return;
    const dx = event.clientX - start.x;
    if (!start.dragged && Math.abs(event.clientY - start.y) > Math.abs(dx)) {
      end(true);
      return;
    }
    if (Math.abs(dx) > 4) start.dragged = true;
    if (start.dragged) {
      x = Math.max(-88, Math.min(0, start.offset + dx));
      row.style.transform = 'translateX(' + x + 'px)';
    }
  });
  row.addEventListener('pointerup', () => end());
  row.addEventListener('pointercancel', () => end(true));
  row.addEventListener('lostpointercapture', () => end(true));
  toggle.addEventListener('click', () => snap(!open));
  action.addEventListener('click', () => {
    lane.hidden = true;
    restore.hidden = false;
    restore.focus();
    status.textContent = 'Sample row removed.';
  });
  restore.addEventListener('click', () => {
    lane.hidden = false;
    restore.hidden = true;
    snap(false);
    toggle.focus();
  });
</script>

<style>
  .swipe-demo {
    width: 300px;
    max-width: 100%;
    font: 13px/1.5 system-ui;
  }
  .swipe-lane {
    position: relative;
    overflow: hidden;
    width: 100%;
    border-radius: 10px;
    background: #b42318;
  }
  .swipe-action {
    position: absolute;
    right: 0;
    top: 0;
    bottom: 0;
    width: 88px;
    border: 0;
    background: #b42318;
    color: white;
    font: 500 12px system-ui;
  }
  .swipe-row {
    position: relative;
    display: flex;
    align-items: center;
    gap: 16px;
    min-height: 72px;
    padding: 12px 10px 12px 18px;
    background: var(--stage-bg, var(--bg, #f8f9fb));
    color: var(--stage-ink, var(--ink, #252b34));
    border: 1px solid var(--stage-grid, var(--line, #cbd0d7));
    border-radius: 10px;
    touch-action: pan-y;
    box-sizing: border-box;
  }
  .swipe-row button {
    display: grid;
    place-items: center;
    min-height: 44px;
    min-width: 44px;
    padding: 0;
    border: 0;
    border-radius: 6px;
    background: transparent;
    color: inherit;
    font-size: 18px;
    letter-spacing: 2px;
  }
  .swipe-row button:hover {
    background: color-mix(in srgb, currentColor 8%, transparent);
  }
  .swipe-restore {
    width: 100%;
    min-height: 72px;
    border: 1px dashed var(--stage-grid, #cbd0d7);
    border-radius: 10px;
    background: transparent;
    color: var(--stage-ink, #252b34);
  }
  .swipe-status {
    margin: 10px 0 0;
    min-height: 36px;
    text-align: center;
    font: 11px/18px system-ui;
    color: var(--stage-muted, var(--muted, #68778c));
  }
  .motion-row-copy {
    display: grid;
    gap: 3px;
    flex: 1;
    min-width: 0;
  }
  .motion-row-copy strong {
    font-size: 13px;
    font-weight: 550;
  }
  .motion-row-copy small {
    font-size: 11px;
    color: var(--stage-muted, var(--muted, #68778c));
  }
  @media (prefers-reduced-motion: reduce) {
    .swipe-row {
      transition: none !important;
    }
  }
</style>

Animation breakdown

Frame phases, motion intent, implementation constraints and accessible alternatives.

240ms
01

Rest: row fills its lane.

02

0–180ms: row tracks the finger to -88px, exposing action colour.

03

180–240ms: snap to the nearest state.

Motion intent

A measured horizontal reveal gives frequent mobile actions a fast, reversible path.

This duration leaves enough time to read the state change while keeping the interface responsive.

The fast acceleration and long ease-out make the state feel responsive while giving the final pixels time to settle.

Motion specification

Trigger
A row is dragged horizontally.
Timing
240ms
Easing
cubic-bezier(.22, 1, .36, 1)

Animated properties

translatebackground-colorshadow

Implementation notes

  • Keep closed and revealed aligned to the same visual anchor.
  • Drive the transition from semantic state, not a timer, when the trigger is interactive.
  • Use translate, background-color, shadow and avoid layout work inside the animation loop.

Reduced motion

Swap to the final state instantly, preserving colour, label, and accessible state.

  • Use a minimum 44 by 44 pixel interactive target.
  • Expose the closed and revealed states with aria-pressed, aria-expanded, or an updated accessible label.
  • Keep visible keyboard focus independent from the animation.

Avoid

  • Do not add bounce unless a real object pickup or spring settle is part of the interaction.
  • Do not hide the final state from assistive technology.
  • Do not make the user wait for animation before the underlying state updates.

Related blueprints

Browse all blueprints