UI Components Medium
Swipe Action
iOS-style swipe-to-reveal action buttons on list items. Swipe left to expose Archive and Delete actions. No libraries.
Open in Lab
MCP
vanilla-js css
Targets: JS HTML React Native
Expo Snack
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f5f5f7;
min-height: 100vh;
display: flex;
align-items: flex-start;
justify-content: center;
}
.page {
width: 100%;
max-width: 480px;
min-height: 100vh;
background: #fff;
}
header {
padding: 24px 20px 12px;
border-bottom: 1px solid #f0f0f0;
}
header h1 {
font-size: 22px;
font-weight: 700;
color: #111;
}
header p {
font-size: 13px;
color: #888;
margin-top: 4px;
}
/* List */
.message-list {
list-style: none;
}
/* Swipe item wrapper */
.swipe-item {
position: relative;
overflow: hidden;
border-bottom: 1px solid #f0f0f0;
height: 88px;
transition: height 0.3s ease, opacity 0.3s ease;
}
.swipe-item.removing {
height: 0;
opacity: 0;
}
/* Actions layer (behind content) */
.item-actions {
position: absolute;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: stretch;
}
.action-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
width: 80px;
border: none;
cursor: pointer;
font-size: 11px;
font-weight: 600;
color: #fff;
transition: opacity 0.15s;
}
.action-btn:active {
opacity: 0.85;
}
.action-btn.archive {
background: #3b82f6;
}
.action-btn.delete {
background: #ef4444;
}
.action-btn svg {
width: 20px;
height: 20px;
}
/* Content layer (swipeable) */
.item-content {
position: absolute;
inset: 0;
display: flex;
align-items: center;
gap: 14px;
padding: 0 16px;
background: #fff;
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
cursor: grab;
}
.item-content.dragging {
transition: none;
cursor: grabbing;
}
.item-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
flex-shrink: 0;
}
.av1 {
background: linear-gradient(135deg, #6366f1, #8b5cf6);
}
.av2 {
background: linear-gradient(135deg, #f59e0b, #ef4444);
}
.av3 {
background: linear-gradient(135deg, #10b981, #3b82f6);
}
.av4 {
background: linear-gradient(135deg, #ec4899, #f43f5e);
}
.item-body {
flex: 1;
overflow: hidden;
}
.item-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.item-header strong {
font-size: 14px;
color: #111;
}
.time {
font-size: 12px;
color: #aaa;
margin-left: auto;
}
.unread-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #6366f1;
flex-shrink: 0;
}
.item-body p {
font-size: 13px;
color: #555;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.swipe-item.unread .item-body strong {
font-weight: 700;
}
.swipe-item.unread .item-body p {
color: #333;
font-weight: 500;
}const SNAP_THRESHOLD = 80; // px to snap open
let openItem = null;
document.querySelectorAll(".swipe-item").forEach((item) => {
const content = item.querySelector(".item-content");
const archiveBtn = item.querySelector(".action-btn.archive");
const deleteBtn = item.querySelector(".action-btn.delete");
const actionsWidth = 160; // two 80px buttons
let startX = 0;
let currentX = 0;
let isDragging = false;
let isOpen = false;
function snapOpen() {
isOpen = true;
openItem = item;
content.style.transform = `translateX(-${actionsWidth}px)`;
}
function snapClose() {
isOpen = false;
if (openItem === item) openItem = null;
content.style.transform = "translateX(0)";
}
function closeOthers() {
if (openItem && openItem !== item) {
openItem.querySelector(".item-content").style.transform = "translateX(0)";
openItem = null;
}
}
// Touch events
content.addEventListener(
"touchstart",
(e) => {
startX = e.touches[0].clientX;
isDragging = true;
content.classList.add("dragging");
closeOthers();
},
{ passive: true }
);
content.addEventListener(
"touchmove",
(e) => {
if (!isDragging) return;
const delta = e.touches[0].clientX - startX;
currentX = isOpen ? delta - actionsWidth : delta;
currentX = Math.min(0, Math.max(-actionsWidth, currentX));
content.style.transform = `translateX(${currentX}px)`;
},
{ passive: true }
);
content.addEventListener("touchend", () => {
isDragging = false;
content.classList.remove("dragging");
const dragged = isOpen ? currentX + actionsWidth : currentX;
if (dragged < -SNAP_THRESHOLD) {
snapOpen();
} else {
snapClose();
}
});
// Pointer events (mouse/stylus)
content.addEventListener("pointerdown", (e) => {
if (e.pointerType === "touch") return;
startX = e.clientX;
isDragging = true;
content.setPointerCapture(e.pointerId);
content.classList.add("dragging");
closeOthers();
});
content.addEventListener("pointermove", (e) => {
if (!isDragging || e.pointerType === "touch") return;
const delta = e.clientX - startX;
currentX = isOpen ? delta - actionsWidth : delta;
currentX = Math.min(0, Math.max(-actionsWidth, currentX));
content.style.transform = `translateX(${currentX}px)`;
});
content.addEventListener("pointerup", (e) => {
if (e.pointerType === "touch") return;
isDragging = false;
content.classList.remove("dragging");
const dragged = isOpen ? currentX + actionsWidth : currentX;
if (dragged < -SNAP_THRESHOLD) {
snapOpen();
} else {
snapClose();
}
});
// Action buttons
archiveBtn.addEventListener("click", () => {
item.classList.add("removing");
setTimeout(() => item.remove(), 300);
});
deleteBtn.addEventListener("click", () => {
item.classList.add("removing");
setTimeout(() => item.remove(), 300);
});
});<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="style.css" />
<title>Swipe Action</title>
</head>
<body>
<div class="page">
<header>
<h1>Inbox</h1>
<p>Swipe left to reveal actions</p>
</header>
<ul class="message-list" id="messageList">
<li class="swipe-item" data-id="1">
<div class="item-actions">
<button class="action-btn archive" aria-label="Archive">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9"/><path d="M10 13h4"/></svg>
<span>Archive</span>
</button>
<button class="action-btn delete" aria-label="Delete">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
<span>Delete</span>
</button>
</div>
<div class="item-content">
<div class="item-avatar av1"></div>
<div class="item-body">
<div class="item-header"><strong>Alex Morgan</strong><span class="time">9:41 AM</span></div>
<p>Hey, are you free for a quick call tomorrow to discuss the mobile UX patterns?</p>
</div>
</div>
</li>
<li class="swipe-item" data-id="2">
<div class="item-actions">
<button class="action-btn archive" aria-label="Archive">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9"/><path d="M10 13h4"/></svg>
<span>Archive</span>
</button>
<button class="action-btn delete" aria-label="Delete">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
<span>Delete</span>
</button>
</div>
<div class="item-content">
<div class="item-avatar av2"></div>
<div class="item-body">
<div class="item-header"><strong>Sam Rivera</strong><span class="time">8:15 AM</span></div>
<p>The design system update is ready for review. Check the Figma link when you get a chance.</p>
</div>
</div>
</li>
<li class="swipe-item" data-id="3">
<div class="item-actions">
<button class="action-btn archive" aria-label="Archive">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9"/><path d="M10 13h4"/></svg>
<span>Archive</span>
</button>
<button class="action-btn delete" aria-label="Delete">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
<span>Delete</span>
</button>
</div>
<div class="item-content">
<div class="item-avatar av3"></div>
<div class="item-body">
<div class="item-header"><strong>Jordan Lee</strong><span class="time">Yesterday</span></div>
<p>Can you share the swipe gesture component once it's done? I'd love to use it in the project.</p>
</div>
</div>
</li>
<li class="swipe-item unread" data-id="4">
<div class="item-actions">
<button class="action-btn archive" aria-label="Archive">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9"/><path d="M10 13h4"/></svg>
<span>Archive</span>
</button>
<button class="action-btn delete" aria-label="Delete">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
<span>Delete</span>
</button>
</div>
<div class="item-content">
<div class="item-avatar av4"></div>
<div class="item-body">
<div class="item-header">
<strong>Taylor Kim</strong>
<span class="time">Monday</span>
<span class="unread-dot"></span>
</div>
<p>The sprint review is moved to 3 PM. Please update your calendar accordingly.</p>
</div>
</div>
</li>
</ul>
</div>
<script src="script.js"></script>
</body>
</html>import React, { useRef, useState } from "react";
import {
Animated,
PanResponder,
StyleSheet,
Text,
TouchableOpacity,
View,
type GestureResponderEvent,
type PanResponderGestureState,
} from "react-native";
const ACTION_WIDTH = 80;
const SWIPE_THRESHOLD = 40;
interface SwipeEmail {
id: string;
sender: string;
subject: string;
preview: string;
}
function SwipeableItem({
item,
onDelete,
onArchive,
onStar,
}: {
item: SwipeEmail;
onDelete: (id: string) => void;
onArchive: (id: string) => void;
onStar: (id: string) => void;
}) {
const translateX = useRef(new Animated.Value(0)).current;
const lastOffset = useRef(0);
const snapTo = (value: number) => {
lastOffset.current = value;
Animated.spring(translateX, {
toValue: value,
useNativeDriver: true,
tension: 100,
friction: 10,
}).start();
};
const panResponder = useRef(
PanResponder.create({
onMoveShouldSetPanResponder: (_: GestureResponderEvent, gs: PanResponderGestureState) =>
Math.abs(gs.dx) > 10 && Math.abs(gs.dx) > Math.abs(gs.dy),
onPanResponderMove: (_: GestureResponderEvent, gs: PanResponderGestureState) => {
const newX = lastOffset.current + gs.dx;
translateX.setValue(newX);
},
onPanResponderRelease: (_: GestureResponderEvent, gs: PanResponderGestureState) => {
const newX = lastOffset.current + gs.dx;
if (newX < -SWIPE_THRESHOLD) {
snapTo(-ACTION_WIDTH * 2);
} else if (newX > SWIPE_THRESHOLD) {
snapTo(ACTION_WIDTH);
} else {
snapTo(0);
}
},
})
).current;
return (
<View style={styles.itemContainer}>
{/* Left action (star) */}
<View style={[styles.actionBehind, styles.leftAction]}>
<TouchableOpacity
style={[styles.actionBtn, { backgroundColor: "#f59e0b" }]}
onPress={() => {
snapTo(0);
onStar(item.id);
}}
>
<Text style={styles.actionText}>★ Star</Text>
</TouchableOpacity>
</View>
{/* Right actions (archive + delete) */}
<View style={[styles.actionBehind, styles.rightActions]}>
<TouchableOpacity
style={[styles.actionBtn, { backgroundColor: "#3b82f6" }]}
onPress={() => {
snapTo(0);
onArchive(item.id);
}}
>
<Text style={styles.actionText}>Archive</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionBtn, { backgroundColor: "#ef4444" }]}
onPress={() => onDelete(item.id)}
>
<Text style={styles.actionText}>Delete</Text>
</TouchableOpacity>
</View>
{/* Swipeable content */}
<Animated.View
style={[styles.itemContent, { transform: [{ translateX }] }]}
{...panResponder.panHandlers}
>
<Text style={styles.sender}>{item.sender}</Text>
<Text style={styles.subject}>{item.subject}</Text>
<Text style={styles.preview} numberOfLines={1}>
{item.preview}
</Text>
</Animated.View>
</View>
);
}
const EMAILS: SwipeEmail[] = [
{
id: "1",
sender: "Sarah Chen",
subject: "Q2 design review",
preview: "Hey team, please review the attached mockups before Friday...",
},
{
id: "2",
sender: "GitHub",
subject: "[stealthis] PR #42 merged",
preview: "Your pull request has been merged into main.",
},
{
id: "3",
sender: "Alex Rivera",
subject: "Lunch tomorrow?",
preview: "Want to grab sushi at that new place downtown?",
},
{
id: "4",
sender: "Stripe",
subject: "Your invoice is ready",
preview: "Invoice #INV-2026-0315 for $49.00 is available.",
},
{
id: "5",
sender: "Jamie Park",
subject: "Re: conference talk",
preview: "I think we should focus on the animation section more...",
},
];
export default function App() {
const [emails, setEmails] = useState(EMAILS);
return (
<View style={styles.container}>
<Text style={styles.header}>Inbox</Text>
<Text style={styles.hint}>← Swipe left for actions · Swipe right →</Text>
{emails.map((email) => (
<SwipeableItem
key={email.id}
item={email}
onDelete={(id) => setEmails((prev) => prev.filter((e) => e.id !== id))}
onArchive={(id) => setEmails((prev) => prev.filter((e) => e.id !== id))}
onStar={() => {}}
/>
))}
{emails.length === 0 && <Text style={styles.empty}>All clear!</Text>}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#0f172a",
paddingTop: 60,
},
header: {
color: "#f8fafc",
fontSize: 28,
fontWeight: "700",
paddingHorizontal: 20,
marginBottom: 4,
},
hint: {
color: "#64748b",
fontSize: 13,
paddingHorizontal: 20,
marginBottom: 16,
},
empty: {
color: "#64748b",
fontSize: 16,
textAlign: "center",
marginTop: 40,
},
itemContainer: {
height: 88,
marginBottom: 1,
overflow: "hidden",
},
actionBehind: {
...StyleSheet.absoluteFillObject,
flexDirection: "row",
},
leftAction: {
justifyContent: "flex-start",
},
rightActions: {
justifyContent: "flex-end",
},
actionBtn: {
width: ACTION_WIDTH,
height: "100%",
alignItems: "center",
justifyContent: "center",
},
actionText: {
color: "#fff",
fontSize: 13,
fontWeight: "600",
},
itemContent: {
backgroundColor: "#1e293b",
height: "100%",
paddingHorizontal: 20,
justifyContent: "center",
},
sender: {
color: "#f8fafc",
fontSize: 16,
fontWeight: "600",
marginBottom: 2,
},
subject: {
color: "#cbd5e1",
fontSize: 14,
marginBottom: 2,
},
preview: {
color: "#64748b",
fontSize: 13,
},
});Swipe Action
A list where each item can be swiped left to reveal action buttons, just like iOS Mail. Uses touch events for mobile and pointer events for desktop support.
How it works
touchstart/pointermovetracks horizontal drag on each list item- The
.item-contentlayer translates left viatranslateXrevealing the action buttons behind - Dragging beyond a threshold snaps the item open; releasing early snaps it closed
- Tapping an action button (Archive / Delete) animates the item out of the list
Features
- Swipe one item at a time — opening a new item automatically closes the previous
- Archive button slides the item out upward; Delete removes it immediately
- Works with both touch and mouse/pointer events
When to use it
- Email or notification inboxes
- Todo lists with swipe-to-complete
- Any list where quick actions improve the flow