Edge Swipe Back Navigation — Free HTML CSS JS Snippet
Edge Swipe Back Navigation · Navigation · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="es-phone">
<div class="es-statusbar"></div>
<div class="es-stack" id="esStack"></div>
<div class="es-edge-hint" aria-hidden="true"></div>
</div>* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f8fafc; display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 24px; }
.es-phone {
position: relative; width: 300px; height: 540px; background: #0f172a;
border-radius: 32px; padding: 10px; box-shadow: 0 24px 60px rgba(15,23,42,0.35);
overflow: hidden; touch-action: pan-y;
}
.es-statusbar { position: absolute; top: 10px; left: 10px; right: 10px; height: 20px; z-index: 50; }
.es-stack { position: relative; width: 100%; height: 100%; border-radius: 22px; overflow: hidden; background: #fff; }
.es-screen {
position: absolute; inset: 0; display: flex; flex-direction: column;
will-change: transform;
}
.es-screen-header {
display: flex; align-items: center; gap: 10px; padding: 16px 16px 12px;
border-bottom: 1px solid #f1f5f9; flex-shrink: 0;
}
.es-back-btn { border: none; background: none; padding: 4px; cursor: pointer; color: #6366f1; display: flex; }
.es-screen-title { font-size: 15px; font-weight: 800; color: #1e293b; }
.es-screen-body { flex: 1; padding: 18px 16px; overflow-y: auto; }
.es-row {
display: flex; align-items: center; justify-content: space-between;
padding: 13px 14px; background: #f8fafc; border-radius: 10px; margin-bottom: 8px;
font-size: 13px; font-weight: 600; color: #334155; cursor: pointer;
}
.es-row:active { background: #eef2ff; }
.es-hint-text { font-size: 11.5px; color: #94a3b8; line-height: 1.6; margin-top: 6px; }
/* A visible shadow-scrim on the layer beneath the active screen, like iOS,
so the peeking previous screen visually recedes rather than sitting flush. */
.es-screen.es-below::after {
content: ''; position: absolute; inset: 0; background: rgba(0,0,0,0.18);
transition: opacity 0.05s;
}
.es-edge-hint {
position: absolute; left: 10px; top: 50%; width: 4px; height: 46px; margin-top: -23px;
background: rgba(255,255,255,0.28); border-radius: 3px; pointer-events: none; z-index: 60;
}const stackEl = document.getElementById('esStack');
const SCREENS = [
{ id: 'home', title: 'Inbox', rows: ['Design review — Priya', 'Q3 roadmap draft', 'Standup notes', 'Client feedback thread'] },
{ id: 'thread', title: 'Design review', rows: ['Priya: Left comments on frame 4', 'You: Fixing the spacing now', 'Priya: Looks great, ship it'] },
{ id: 'detail', title: 'Frame 4 comments', rows: ['Line-height too tight on mobile', 'Button contrast fails AA', 'Resolved by Priya'] },
];
let stack = [0]; // indices into SCREENS currently pushed
let screenEls = [];
function iconSvg(name) {
if (name === 'back') return '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg>';
return '';
}
function buildScreen(screenIdx) {
const data = SCREENS[screenIdx];
const el = document.createElement('div');
el.className = 'es-screen';
el.dataset.idx = String(screenIdx);
const canGoBack = stack.length > 1 || stack.indexOf(screenIdx) > 0;
el.innerHTML = `
<div class="es-screen-header">
${screenIdx !== 0 ? '<button class="es-back-btn" aria-label="Back">' + iconSvg('back') + '</button>' : '<span style="width:20px"></span>'}
<span class="es-screen-title">${data.title}</span>
</div>
<div class="es-screen-body">
${data.rows.map((r, i) => '<div class="es-row" data-row="' + i + '">' + r + '</div>').join('')}
${screenIdx < SCREENS.length - 1 ? '<p class="es-hint-text">Tap a row to push the next screen. Swipe right from the left edge, or tap the back arrow, to pop back.</p>' : '<p class="es-hint-text">This is the deepest screen in the stack — swipe from the left edge or tap back.</p>'}
</div>`;
const backBtn = el.querySelector('.es-back-btn');
if (backBtn) backBtn.addEventListener('click', () => pop());
el.querySelectorAll('.es-row').forEach((row, i) => {
row.addEventListener('click', () => {
const nextIdx = Math.min(SCREENS.length - 1, screenIdx + 1);
if (nextIdx !== screenIdx) push(nextIdx);
});
});
return el;
}
function renderStack() {
stackEl.innerHTML = '';
screenEls = stack.map(idx => buildScreen(idx));
screenEls.forEach((el, i) => {
el.style.transform = i === screenEls.length - 1 ? 'translateX(0)' : 'translateX(-30%)';
el.style.transition = 'transform 0.28s cubic-bezier(0.32,0.72,0,1)';
if (i < screenEls.length - 1) el.classList.add('es-below');
stackEl.appendChild(el);
});
}
function push(screenIdx) {
stack.push(screenIdx);
renderStack();
}
function pop() {
if (stack.length <= 1) return;
const topEl = screenEls[screenEls.length - 1];
const belowEl = screenEls[screenEls.length - 2];
if (topEl) {
topEl.style.transition = 'transform 0.26s cubic-bezier(0.32,0.72,0,1)';
topEl.style.transform = 'translateX(100%)';
}
if (belowEl) {
belowEl.style.transition = 'transform 0.26s cubic-bezier(0.32,0.72,0,1)';
belowEl.style.transform = 'translateX(0)';
belowEl.classList.remove('es-below');
}
setTimeout(() => {
stack.pop();
renderStack();
}, 260);
}
// --- Edge-swipe-to-go-back gesture handling ---
const EDGE_ZONE = 24; // px from the left edge that starts tracking a swipe
let dragging = false;
let dragStartX = 0;
let dragCurrentX = 0;
let dragWidth = 300;
stackEl.addEventListener('pointerdown', (e) => {
const rect = stackEl.getBoundingClientRect();
const localX = e.clientX - rect.left;
if (localX > EDGE_ZONE || stack.length <= 1) return;
dragging = true;
dragStartX = e.clientX;
dragWidth = rect.width;
screenEls.forEach(el => { el.style.transition = 'none'; });
stackEl.setPointerCapture(e.pointerId);
});
stackEl.addEventListener('pointermove', (e) => {
if (!dragging) return;
dragCurrentX = Math.max(0, e.clientX - dragStartX);
const progress = Math.min(1, dragCurrentX / dragWidth);
const topEl = screenEls[screenEls.length - 1];
const belowEl = screenEls[screenEls.length - 2];
if (topEl) topEl.style.transform = `translateX(${progress * 100}%)`;
if (belowEl) belowEl.style.transform = `translateX(${-30 * (1 - progress)}%)`;
});
function endDrag(e) {
if (!dragging) return;
dragging = false;
const progress = Math.min(1, dragCurrentX / dragWidth);
screenEls.forEach(el => { el.style.transition = 'transform 0.22s cubic-bezier(0.32,0.72,0,1)'; });
if (progress > 0.35) {
pop();
} else {
const topEl = screenEls[screenEls.length - 1];
const belowEl = screenEls[screenEls.length - 2];
if (topEl) topEl.style.transform = 'translateX(0)';
if (belowEl) belowEl.style.transform = 'translateX(-30%)';
}
dragCurrentX = 0;
}
stackEl.addEventListener('pointerup', endDrag);
stackEl.addEventListener('pointercancel', endDrag);
renderStack();Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Edge Swipe Back Navigation — iOS-Style Drag-From-Edge Screen Stack in Vanilla JS

Mobile apps built with a push/pop navigation stack almost universally support swiping in from the very left edge of the screen to go back, dragging the current screen aside in real time to reveal the previous one peeking in underneath — a much more direct, cancelable interaction than only offering a small back-arrow tap target. This snippet implements the real gesture physics with the Pointer Events API: the drag distance is tracked live, both screens move together in proportion to the drag, and lifting your finger either commits the pop or snaps back based on how far you dragged.
A real navigation stack, not two hardcoded screens
stack is a plain array of indices into a SCREENS list. push(screenIdx) and pop() mutate that array and call renderStack(), which rebuilds only the top two visible layers with CSS transforms — the top screen at translateX(0) and the one beneath it partially shifted left at translateX(-30%), exactly matching how iOS renders a peeking previous screen with parallax rather than a hard cut. Any number of screens can be pushed; this is a genuine stack, not a fixed two-screen demo.
Restricting the gesture to the edge zone
The pointerdown handler checks localX > EDGE_ZONE and bails immediately if the touch didn't start within the leftmost 24px of the screen — this is the defining constraint of an *edge* swipe gesture as opposed to a general swipe-anywhere gesture, and it's essential so that normal scrolling or tapping rows elsewhere on the screen is never mistaken for a back-navigation attempt.
Live 1:1 drag tracking, not a fixed animation
While dragging, pointermove computes progress as the raw drag distance divided by the screen width (clamped to 0–1) and applies it directly as a percentage transform to both the top screen (sliding right) and the screen beneath it (sliding the rest of the way in from its -30% offset toward 0) — every pixel of finger movement maps directly to a pixel of screen movement, with transition: none set during the drag so there is zero animation lag between touch and visual response.
Commit-or-cancel on release
endDrag() reads the final progress value against a 0.35 threshold: past it, pop() runs and completes the transition off-screen; below it, both screens animate back to their resting positions instead. This threshold-based commit/cancel behavior — rather than always completing or always reverting — is what makes the gesture feel physically responsive instead of all-or-nothing, and matches the same threshold pattern iOS itself uses for its edge-swipe-back gesture.
A visible scrim on the receding screen
The .es-below::after pseudo-element darkens whichever screen sits beneath the active one with a semi-transparent overlay, reinforcing the sense that it is "behind" the active screen in a real depth stack rather than simply positioned off to the side.
Step by step
How to Use
- 1Tap a row to push a new screenTapping any row slides in the next screen from the right, adding it to the top of the navigation stack.
- 2Swipe from the very left edge to go backPress down within the leftmost ~24px of the phone frame and drag right — the current screen follows your finger in real time.
- 3Release past the halfway point to commitDragging more than about 35% of the screen width and releasing completes the pop; releasing before that snaps both screens back to their resting position.
- 4Tap the back arrow as an alternativeThe header back button calls the same pop() function as a completed swipe, for users who prefer tapping over gesturing.
- 5Adjust the edge zone widthChange the EDGE_ZONE constant in the JS panel to widen or narrow how close to the left edge a touch must start to begin tracking.
- 6Add more screens to the stackExtend the SCREENS array — push() and pop() work with any stack depth without additional changes.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
The pointerdown handler measures the touch's x-position relative to the container and immediately returns without starting a drag if that position is greater than the EDGE_ZONE constant (24px by default). Only a touch starting within that narrow left-edge band begins tracking a back-swipe.
pointermove computes progress as the raw horizontal drag distance divided by the container's width, clamped between 0 and 1, and applies it directly as a percentage transform to both the top screen (translateX(progress * 100%)) and the screen beneath it (translating the remaining distance from its -30% resting offset toward 0%) — a direct, un-eased mapping so there is no perceptible lag between finger and screen movement.
endDrag() compares the final progress value against a 0.35 threshold. If the drag passed that threshold, pop() runs and the screen finishes animating off-screen; if not, both screens animate back to their original resting transforms instead of completing the navigation.
A CSS transition adds a delay between a style change and its visual result. During an active drag, every pointermove event must move the screen instantly to match the finger exactly — any transition lag would make the drag feel disconnected from the touch. Transitions are re-enabled only once the drag ends, so the snap-back or completing animation is smooth.
Yes — stack is a plain array and can hold any number of pushed screen indices. renderStack() only ever positions the top two entries visually (the active screen and the one directly beneath it), which is both how the real interaction should look and keeps rendering cheap regardless of stack depth.
Keep the stack array in component state (e.g. useState<number[]>), and drive the pointerdown/pointermove/pointerup handlers with refs to the current and previous screen elements rather than direct DOM queries, applying the same progress-based transform math inside the move handler.
Yes. While the Edge Swipe Back Navigation snippet gives you one pattern to copy, the free Responsive Navbar Builder at webdevpuneet.com/navbar-builder/ designs a whole navigation bar: logo, links and dropdown menus, a mobile hamburger menu (dropdown panel, slide-in drawer or full screen) and a scroll effect (sticky, shrink, hide on scroll or transparent). It has six presets, a live preview that runs the real code, and exports HTML + CSS + vanilla JS, React, Tailwind, Vue 3 or Angular — or you can click Fork & Edit to keep editing it in My Code.