Interaction blueprint

Drag handle pickup

A lifted row feels distinct from the list before the user has moved it far.

Playground

Playhead 0ms / 180ms

Build it

Choose a target and adapt the source to your interface.

200 lines
index.html
<div class="pickup-lane">
  <div class="pickup-context">
    <span aria-hidden="true">⠿</span
    ><span class="motion-row-copy"
      ><strong>Project brief</strong><small>Outline the direction</small></span
    >
  </div>
  <div class="pickup-row" data-lifted="false">
    <button
      class="pickup-handle"
      type="button"
      aria-label="Pick up sample row"
      aria-pressed="false"
      aria-describedby="pickup-help"
    >
      ⠿</button
    ><span class="motion-row-copy"
      ><strong>Design review</strong><small>Ready to move</small></span
    >
  </div>
  <div class="pickup-context">
    <span aria-hidden="true">⠿</span
    ><span class="motion-row-copy"
      ><strong>Final handoff</strong><small>Share with the team</small></span
    >
  </div>
  <p id="pickup-help">
    Drag the handle to lift. Or press Space, use the arrows, then Escape to
    release.
  </p>
  <p class="pickup-status" role="status">Row at rest.</p>
</div>

<script>
  const row = document.querySelector('.pickup-row');
  const handle = document.querySelector('.pickup-handle');
  let start;
  let lifted = false;
  let x = 0;
  let y = 0;
  function update() {
    row.dataset.lifted = String(lifted);
    handle.setAttribute('aria-pressed', String(lifted));
    row.style.transform = 'translate(' + x + 'px,' + y + 'px)';
    document.querySelector('.pickup-status').textContent = lifted
      ? 'Row picked up.'
      : 'Row at rest.';
  }
  function release() {
    start = null;
    lifted = false;
    x = y = 0;
    update();
  }
  handle.addEventListener('pointerdown', (event) => {
    if (event.button !== 0) return;
    handle.setPointerCapture(event.pointerId);
    start = { x: event.clientX, y: event.clientY };
    lifted = true;
    update();
  });
  handle.addEventListener('pointermove', (event) => {
    if (!start) return;
    x = Math.max(-40, Math.min(40, event.clientX - start.x));
    y = Math.max(-24, Math.min(24, event.clientY - start.y));
    row.style.transform = 'translate(' + x + 'px,' + y + 'px)';
  });
  ['pointerup', 'pointercancel', 'lostpointercapture', 'blur'].forEach((type) =>
    handle.addEventListener(type, release),
  );
  handle.addEventListener('keydown', (event) => {
    if (event.key === ' ' || event.key === 'Enter') {
      event.preventDefault();
      if (!event.repeat) {
        if (lifted) release();
        else {
          lifted = true;
          update();
        }
      }
    } else if (event.key === 'Escape') release();
    else if (lifted && event.key.startsWith('Arrow')) {
      event.preventDefault();
      x = Math.max(
        -40,
        Math.min(
          40,
          x +
            (event.key === 'ArrowRight'
              ? 8
              : event.key === 'ArrowLeft'
                ? -8
                : 0),
        ),
      );
      y = Math.max(
        -24,
        Math.min(
          24,
          y +
            (event.key === 'ArrowDown' ? 8 : event.key === 'ArrowUp' ? -8 : 0),
        ),
      );
      update();
    }
  });
</script>

<style>
  .pickup-lane {
    width: 300px;
    max-width: 100%;
    font: 13px/1.5 system-ui;
    color: var(--stage-ink, var(--ink, #252b34));
  }
  .pickup-context {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 12px 18px;
    margin: 8px 0;
    border: 1px solid var(--stage-grid, var(--line, #cbd0d7));
    border-radius: 10px;
    color: var(--stage-muted, var(--muted, #68778c));
    background: var(--stage-bg, var(--bg, #f8f9fb));
  }
  .pickup-context > span:first-child {
    font-size: 20px;
  }
  .pickup-row {
    z-index: 1;
    position: relative;
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 10px 14px 10px 6px;
    background: var(--stage-bg, var(--bg, #f8f9fb));
    border: 1px solid var(--stage-grid, var(--line, #cbd0d7));
    border-radius: 10px;
    transition:
      box-shadow 180ms ease-out,
      scale 180ms ease-out;
  }
  .pickup-row[data-lifted='true'] {
    scale: 1.02;
    box-shadow: 0 4px 8px #0002;
  }
  .pickup-handle {
    display: grid;
    place-items: center;
    width: 44px;
    height: 44px;
    padding: 0;
    border: 0;
    border-radius: 6px;
    background: transparent;
    color: var(--stage-muted, var(--muted, #68778c));
    touch-action: none;
    cursor: grab;
    font-size: 22px;
  }
  .pickup-handle:hover {
    background: color-mix(in srgb, currentColor 8%, transparent);
  }
  .pickup-handle[aria-pressed='true'] {
    cursor: grabbing;
  }
  #pickup-help {
    margin: 14px 4px 0;
    font-size: 11px;
    line-height: 1.6;
    color: var(--stage-muted, var(--muted, #68778c));
  }
  .pickup-status {
    min-height: 18px;
    margin: 6px 4px 0;
    font-size: 11px;
    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) {
    .pickup-row {
      transition: none;
      scale: 1;
    }
  }
</style>

Animation breakdown

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

180ms
01

Rest: row is flat.

02

0–100ms: scale reaches 1.02 and shadow appears.

03

100–180ms: handle darkens; destination space remains.

Motion intent

A lifted row feels distinct from the list before the user has moved it far.

This is direct manipulation feedback, so it completes within the immediate-response window.

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

Motion specification

Trigger
A draggable row begins moving.
Timing
180ms
Easing
cubic-bezier(.22, 1, .36, 1)

Animated properties

translatebackground-colorshadow

Implementation notes

  • Keep resting and picked up 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 resting and picked up 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