Interaction blueprint
Drag handle pickup
A lifted row feels distinct from the list before the user has moved it far.
Build it
Choose a target and adapt the source to your interface.
<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.
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.