A mail inbox inside a phone frame that shows the two touch gestures most list screens need. Pulling the list down reveals a chip whose ring fills as you drag; past the mark the arrow flips, and letting go holds the list open while a spinner runs, then a new message drops in at the top. Dragging a row left uncovers an Archive action that turns green once you have gone far enough, and releasing sends the row off screen, closes the gap and raises an Undo toast. A translucent fingertip performs both gestures on a loop until a visitor touches the phone. Beside it, two numbered cards light up to show which gesture is playing.
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, animate, motion, useInView, useMotionValue, useMotionValueEvent, useReducedMotion, useTransform } from "framer-motion";
import { Archive, ArrowDown, Check, RotateCw, Undo2 } from "lucide-react";
type Mail = { id: string; from: string; initials: string; tone: string; subject: string; preview: string; time: string; unread?: boolean };
// The inbox at load, newest first.
const inbox: Mail[] = [
{ id: "m1", from: "Priya Ramanathan", initials: "PR", tone: "#6d4aff", subject: "Quote for the kitchen refit", preview: "Revised figure attached, with the oak worktop.", time: "9:12", unread: true },
{ id: "m2", from: "Halden Print Co.", initials: "HP", tone: "#e0703a", subject: "Your proofs are ready", preview: "Two colourways, both on the heavier stock.", time: "8:47", unread: true },
{ id: "m3", from: "Tobias Lindqvist", initials: "TL", tone: "#1f9a8a", subject: "Thursday still good?", preview: "I can do any time after four if that helps.", time: "Tue" },
{ id: "m4", from: "Greenway Allotments", initials: "GA", tone: "#5d8a2e", subject: "Plot 14 renewal", preview: "Fees are due by the end of the month.", time: "Tue" },
{ id: "m5", from: "Mireille Okafor", initials: "MO", tone: "#c0408a", subject: "Photos from the launch", preview: "Forty good ones, six great ones. Link inside.", time: "Mon" },
{ id: "m6", from: "Corbin Vale Dental", initials: "CV", tone: "#3a6ea5", subject: "Appointment reminder", preview: "See you on the 14th at half past ten.", time: "Mon" },
];
// Messages that arrive on refresh, used in turn.
const arriving: Omit<Mail, "id">[] = [
{ from: "Juno Castellane", initials: "JC", tone: "#2f7fc0", subject: "Re: train times", preview: "The 7:42 gets us there with ten minutes spare.", time: "now", unread: true },
{ from: "Fieldhouse Cycles", initials: "FC", tone: "#b5362e", subject: "Your service is booked", preview: "Drop the bike off any time before ten.", time: "now", unread: true },
{ from: "Anselm Reyes", initials: "AR", tone: "#8a6a1c", subject: "Minutes from Tuesday", preview: "Three actions, two of them are yours.", time: "now", unread: true },
];
const ROW_H = 78; // row height in pixels
const PULL_AT = 72; // pull distance that triggers a refresh
const HOLD = 60; // where the list rests while loading
const LOAD_MS = 1100; // pretend network time
const SWIPE_AT = -96; // swipe distance that archives a row
const SPRING = { type: "spring", stiffness: 380, damping: 32 } as const;
function Row({ mail, auto, fly, still, onArchive, onSwipeStart }: { mail: Mail; auto: boolean; fly: boolean; still: boolean; onArchive: () => void; onSwipeStart: () => void }) {
const x = useMotionValue(0);
const [armed, setArmed] = useState(false);
const iconScale = useTransform(x, [0, SWIPE_AT], [0.5, 1], { clamp: true });
useMotionValueEvent(x, "change", (v) => setArmed(v < SWIPE_AT));
// `auto` is the demo dragging this row; `fly` sends it off screen just before it is removed.
useEffect(() => {
if (!auto && !fly) return;
const a = fly ? animate(x, -420, { duration: still ? 0 : 0.2, ease: "easeIn" }) : animate(x, -168, { duration: 0.8, ease: [0.3, 0, 0.2, 1] });
return () => a.stop();
}, [auto, fly, still, x]);
return (
<motion.li initial={still ? false : { height: 0, opacity: 0 }} animate={{ height: ROW_H, opacity: 1 }} exit={still ? { opacity: 0 } : { height: 0, opacity: 0, transition: { height: { duration: 0.28, delay: 0.1 }, opacity: { duration: 0.15 } } }} transition={still ? { duration: 0 } : SPRING} className="relative overflow-hidden">
<div aria-hidden="true" className={`absolute inset-0 flex items-center justify-end gap-2 pr-6 text-sm font-semibold text-white transition-colors duration-200 ${armed ? "bg-emerald-600" : "bg-slate-400"}`}>
<motion.span className="flex items-center gap-2" style={{ scale: iconScale }} animate={{ rotate: armed ? [0, -12, 8, 0] : 0 }} transition={{ duration: 0.35 }}>
<Archive className="h-5 w-5" />
Archive
</motion.span>
</div>
<motion.div
drag={still ? false : "x"}
dragDirectionLock
dragConstraints={{ left: 0, right: 0 }}
dragElastic={{ left: 0.85, right: 0 }}
onDragStart={onSwipeStart}
onDragEnd={() => x.get() < SWIPE_AT && onArchive()}
style={{ x }}
className="relative flex h-full touch-pan-y items-center gap-3 bg-white pl-4 pr-1"
>
<motion.span aria-hidden="true" className="pointer-events-none absolute inset-0 bg-[#ece8ff]" initial={{ opacity: mail.time === "now" ? 1 : 0 }} animate={{ opacity: 0 }} transition={{ duration: 1.4, delay: 0.3 }} />
<span aria-hidden="true" className="relative grid h-10 w-10 shrink-0 place-items-center rounded-full text-xs font-bold text-white" style={{ backgroundColor: mail.tone }}>
{mail.initials}
</span>
<span className="relative min-w-0 flex-1 border-b border-slate-100 py-[13px]">
<span className="flex items-baseline justify-between gap-2">
<span className={`truncate text-sm ${mail.unread ? "font-bold text-slate-950" : "font-medium text-slate-800"}`}>{mail.from}</span>
<span className={`shrink-0 text-[11px] ${mail.unread ? "font-semibold text-[#5a37f0]" : "text-slate-600"}`}>{mail.time}</span>
</span>
<span className="block truncate text-[13px] text-slate-800">{mail.subject}</span>
<span className="block truncate text-xs text-slate-600">{mail.preview}</span>
</span>
<button type="button" aria-label={`Archive message from ${mail.from}`} onClick={onArchive} onPointerDown={(e) => e.stopPropagation()} className="relative grid h-11 w-10 shrink-0 place-items-center rounded-xl text-slate-500 hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#6d4aff]">
<Archive aria-hidden="true" className="h-[18px] w-[18px]" />
</button>
</motion.div>
</motion.li>
);
}
export default function PullToRefreshAndSwipeToArchivePhoneList() {
const reduce = useReducedMotion();
const still = !!reduce;
const root = useRef<HTMLElement>(null);
const inView = useInView(root, { amount: 0.3 });
const [items, setItems] = useState(inbox);
const [phase, setPhase] = useState<"idle" | "pulling" | "loading" | "done">("idle");
const [armed, setArmed] = useState(false);
const [stage, setStage] = useState<"pull" | "swipe" | null>(null);
const [demo, setDemo] = useState(true);
const [autoId, setAutoId] = useState<string | null>(null);
const [flyId, setFlyId] = useState<string | null>(null);
const [toast, setToast] = useState<{ mail: Mail; index: number } | null>(null);
const pull = useMotionValue(0);
const progress = useTransform(pull, [0, PULL_AT], [0, 1], { clamp: true });
const chipY = useTransform(pull, (p) => p / 2 - 18);
const chipScale = useTransform(pull, [0, 44], [0.3, 1], { clamp: true });
const chipOpacity = useTransform(pull, [4, 30], [0, 1], { clamp: true });
const fingerX = useMotionValue(0);
const fingerY = useMotionValue(0);
const fingerOpacity = useMotionValue(0);
useMotionValueEvent(pull, "change", (v) => setArmed(v >= PULL_AT));
const timers = useRef<number[]>([]);
const count = useRef(0);
const live = useRef({ items, phase });
live.current = { items, phase };
const later = (fn: () => void, ms: number) => timers.current.push(window.setTimeout(fn, ms));
useEffect(() => () => timers.current.forEach((t) => window.clearTimeout(t)), []);
const refresh = () => {
if (live.current.phase === "loading") return;
setStage("pull");
setPhase("loading");
animate(pull, HOLD, still ? { duration: 0 } : SPRING);
later(() => {
const next = arriving[count.current % arriving.length];
count.current += 1;
setItems((list) => [{ ...next, id: `new-${count.current}` }, ...list].slice(0, 7));
setPhase("done");
animate(pull, 0, still ? { duration: 0 } : SPRING);
later(() => setPhase("idle"), 900);
}, LOAD_MS);
};
const archive = (id: string) => {
const index = live.current.items.findIndex((m) => m.id === id);
if (index < 0) return;
const mail = live.current.items[index];
setStage("swipe");
setFlyId(id);
later(() => {
setItems((list) => list.filter((m) => m.id !== id));
setFlyId(null);
setAutoId(null);
setToast({ mail, index });
later(() => setToast((t) => (t && t.mail.id === id ? null : t)), 2600);
}, still ? 0 : 170);
};
const undo = () => {
if (!toast) return;
setItems((list) => [...list.slice(0, toast.index), toast.mail, ...list.slice(toast.index)]);
setToast(null);
};
// Self-playing demo: pull, refresh, then swipe the second row away. Stops when someone touches the phone.
useEffect(() => {
if (!demo || still || !inView) return;
let dead = false;
const mine: number[] = [];
const wait = (ms: number) => new Promise<void>((done) => mine.push(window.setTimeout(done, ms)));
const run = async () => {
await wait(100);
while (!dead) {
setStage("pull");
setPhase("pulling");
fingerX.set(133);
fingerY.set(46);
animate(fingerOpacity, 1, { duration: 0.15 });
animate(fingerY, 200, { duration: 0.8, ease: [0.3, 0, 0.2, 1] });
animate(pull, 94, { duration: 0.8, ease: [0.3, 0, 0.2, 1] });
await wait(900);
if (dead) return;
animate(fingerOpacity, 0, { duration: 0.2 });
refresh();
await wait(LOAD_MS + 650);
if (dead) return;
const target = live.current.items[1];
setStage("swipe");
fingerX.set(236);
fingerY.set(ROW_H * 1.5 - 22);
animate(fingerOpacity, 1, { duration: 0.15 });
await wait(180);
if (dead) return;
setAutoId(target.id);
animate(fingerX, 60, { duration: 0.8, ease: [0.3, 0, 0.2, 1] });
await wait(900);
if (dead) return;
animate(fingerOpacity, 0, { duration: 0.2 });
archive(target.id);
await wait(2900);
setStage(null);
}
};
run();
return () => {
dead = true;
mine.forEach((t) => window.clearTimeout(t));
fingerOpacity.set(0);
setAutoId(null);
if (live.current.phase === "pulling") {
pull.set(0);
setPhase("idle");
}
};
}, [demo, still, inView]);
const takeOver = () => demo && setDemo(false);
const unread = items.filter((m) => m.unread).length;
const status = phase === "loading" ? "Checking for new mail" : phase === "done" ? "1 new message" : armed ? "Release to refresh" : "Pull to refresh";
const steps = [
{ id: "pull", title: "Pull to refresh", text: "Drag the list down past the mark and let go. It holds while it loads." },
{ id: "swipe", title: "Swipe to archive", text: "Drag a row left until it turns green. Undo puts it back." },
];
return (
<section ref={root} className="overflow-hidden bg-gradient-to-br from-[#120f24] via-[#1b1538] to-[#2c1f5e] px-6 py-14 text-white sm:py-20">
<div className="mx-auto grid max-w-5xl items-center gap-12 lg:grid-cols-[1fr_auto] lg:gap-20">
<div className="max-w-md">
<p className="font-mono text-xs uppercase tracking-[0.25em] text-[#b9a8ff]">Touch gestures</p>
<h2 className="mt-4 text-4xl font-semibold tracking-tight sm:text-5xl">Two gestures every list needs.</h2>
<p className="mt-5 text-base text-[#cfc8ee] sm:text-lg">Both are built on one draggable value each, with a threshold that decides what happens on release. Try them on the phone.</p>
<ol className="mt-8 space-y-3">
{steps.map((s, i) => {
const on = stage === s.id;
return (
<li key={s.id} className={`relative flex gap-4 overflow-hidden rounded-2xl border p-4 transition-colors duration-300 ${on ? "border-[#8f76ff] bg-white/10" : "border-white/10 bg-white/5"}`}>
<span className={`grid h-8 w-8 shrink-0 place-items-center rounded-full font-mono text-xs transition-colors duration-300 ${on ? "bg-[#8f76ff] text-white" : "bg-white/10 text-[#cfc8ee]"}`}>{i + 1}</span>
<span>
<span className="block text-sm font-semibold">{s.title}</span>
<span className="mt-0.5 block text-sm text-[#cfc8ee]">{s.text}</span>
</span>
</li>
);
})}
</ol>
</div>
<div className="relative mx-auto h-[660px] w-[330px] max-w-full rounded-[52px] bg-[#07060d] p-2.5 shadow-2xl shadow-black/60 ring-1 ring-white/15" onPointerDownCapture={takeOver} onKeyDownCapture={takeOver}>
<div className="relative flex h-full flex-col overflow-hidden rounded-[42px] bg-white text-slate-900">
<div aria-hidden="true" className="absolute left-1/2 top-2.5 z-20 h-6 w-24 -translate-x-1/2 rounded-full bg-[#07060d]" />
<div className="relative z-10 flex shrink-0 items-end justify-between bg-white px-5 pb-3 pt-12 shadow-sm shadow-slate-900/5">
<div>
<h3 className="text-2xl font-bold tracking-tight">Inbox</h3>
<p className="text-xs text-slate-600">{unread} unread</p>
</div>
<button type="button" aria-label="Refresh inbox" aria-busy={phase === "loading"} onClick={refresh} className="grid h-11 w-11 place-items-center rounded-full bg-[#f1eeff] text-[#5a37f0] hover:bg-[#e4deff] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#6d4aff] focus-visible:ring-offset-2">
<RotateCw aria-hidden="true" className={`h-5 w-5 ${phase === "loading" ? "animate-spin motion-reduce:animate-none" : ""}`} />
</button>
</div>
<div className="relative min-h-0 flex-1 touch-none overflow-hidden bg-[#f1eeff]">
{/* What the pull reveals: a progress chip and a status line, centred in the gap. */}
<motion.div className="absolute inset-x-0 top-0 flex items-center justify-center gap-2.5" style={{ y: chipY, opacity: chipOpacity }}>
<motion.span className="relative grid h-9 w-9 place-items-center rounded-full bg-white text-[#5a37f0] shadow-md shadow-violet-900/15" style={{ scale: chipScale }}>
<svg viewBox="0 0 36 36" aria-hidden="true" className={`absolute inset-0 -rotate-90 ${phase === "loading" ? "animate-[pull-refresh-spin_0.8s_linear_infinite] motion-reduce:animate-none" : ""}`}>
<motion.circle cx="18" cy="18" r="15" fill="none" stroke="#6d4aff" strokeWidth="2.5" strokeLinecap="round" style={{ pathLength: phase === "loading" ? 0.7 : phase === "done" ? 1 : progress }} />
</svg>
{phase === "done" ? <Check aria-hidden="true" className="h-4 w-4" strokeWidth={3} /> : phase === "loading" ? null : <ArrowDown aria-hidden="true" className={`h-4 w-4 transition-transform duration-200 ${armed ? "rotate-180" : ""}`} strokeWidth={2.5} />}
</motion.span>
<span role="status" className="text-xs font-semibold text-[#4a2fd0]">
{phase === "idle" && !armed ? "" : status}
</span>
</motion.div>
<motion.div
drag={still || phase === "loading" ? false : "y"}
dragDirectionLock
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: 0.5 }}
onDragStart={() => {
setStage("pull");
setPhase("pulling");
}}
onDragEnd={() => (pull.get() >= PULL_AT ? refresh() : setPhase("idle"))}
style={{ y: pull }}
className="relative h-full bg-white"
>
<ul>
<AnimatePresence initial={false}>
{items.map((m) => (
<Row key={m.id} mail={m} auto={autoId === m.id} fly={flyId === m.id} still={still} onArchive={() => archive(m.id)} onSwipeStart={() => setStage("swipe")} />
))}
</AnimatePresence>
</ul>
</motion.div>
{/* The demo's fingertip. */}
<motion.span aria-hidden="true" className="pointer-events-none absolute left-0 top-0 z-10 h-11 w-11 rounded-full border-2 border-[#6d4aff]/60 bg-[#6d4aff]/25 shadow-lg shadow-violet-900/30" style={{ x: fingerX, y: fingerY, opacity: fingerOpacity }} />
<AnimatePresence>
{toast && (
<motion.div key={toast.mail.id} role="status" className="absolute inset-x-3 bottom-3 z-10 flex items-center justify-between gap-3 rounded-2xl bg-[#17132b] py-1.5 pl-4 pr-1.5 text-sm text-white shadow-xl shadow-slate-900/30" initial={still ? false : { y: 70, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={still ? { opacity: 0 } : { y: 70, opacity: 0 }} transition={still ? { duration: 0 } : SPRING}>
<span className="truncate">Archived “{toast.mail.subject}”</span>
<button type="button" onClick={undo} className="inline-flex h-10 shrink-0 items-center gap-1.5 rounded-xl bg-white/10 px-3 text-sm font-semibold text-[#cdbfff] hover:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#b9a8ff]">
<Undo2 aria-hidden="true" className="h-4 w-4" />
Undo
</button>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
</div>
<style>{`@keyframes pull-refresh-spin { to { transform: rotate(270deg); } }`}</style>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/pull-to-refresh-and-swipe-to-archive-phone-list.json
The list is a framer-motion element with `drag="y"`, constraints of zero and some elasticity downwards, so it can be pulled but wants to return. Its offset is a motion value. The chip's ring length, scale and position are all derived from that value with `useTransform`. On release, if the offset has passed `PULL_AT`, the list animates to `HOLD` and the refresh runs; otherwise it springs back.
Each row is draggable on the x axis over a coloured strip that holds the action. When the drag ends past `SWIPE_AT` the row animates off the left edge and is removed from state. `AnimatePresence` then animates its height to zero so the rows below slide up.
Both use `dragDirectionLock`. Framer-motion waits for the first few pixels of movement, decides whether the gesture is horizontal or vertical and lets only the matching element move.
Inside the list area, yes: it uses `touch-action: none` so the browser hands vertical drags to the component. That is right when the list fills the screen. If it sits inside a longer page, start the pull only when your scroll container is at the top.
It animates the same motion values a real drag would change: the list offset for the pull and one row's x for the swipe. Everything derived from them reacts exactly as it does to a touch, and a circle follows along to show where the finger would be.
Related: Mobile Bottom Tab Bar with Liquid Drop Indicator, Habit Tracker Phone Screen with Tab Bar, Animated Notification Feed with Looping Arrivals.
More app and game ui or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.