Navigation blueprint
Swipe action reveal
A measured horizontal reveal gives frequent mobile actions a fast, reversible path.
Build it
Choose a target and adapt the source to your interface.
<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.
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.