All Taste components
Animated screens
Pull to refresh
An inbox that pulls down, shows a springy spinner, then drops a new message in at the top.
pull · spinner · new item · updated time
InboxUpdated just now
MI
Meera Iyer9:41
Goa tickets are booked!PNR attached. We land at 11:20, cab is sorted…RK
Ravi Kulkarni9:12
Site visit moved to ThursdayThe contractor can only do mornings, so let’s…BB
Bluefin Bank8:40
Your October statement is readyTotal due ₹14,218 by 2 Nov. View it in the app…PM
Priya MenonYesterday
Photos from the weekendFinally sorted them! Shared album has 86…AS
Aditya ShahYesterday
Re: flat depositSent it over UPI just now, please confirm…SC
SwiftcartMon
Delivered: running shoesYour order 48213 was left at the door…NI
Neha IyerMon
Book club — next pick?I’m voting for the Murakami, it’s short…// Pull to refresh: drag the list down, a springy spinner follows your finger, new mail drops in on top.
// Mobile-first page on the Taste preset; maps to React Native via NativeWind (use RefreshControl there).
"use client";
import { useRef, useState } from "react";
import { Loader2, PenSquare, Search } from "lucide-react";
import { cn } from "@/lib/utils";
type Mail = { id: string; from: string; initials: string; time: string; subject: string; preview: string; unread?: boolean };
const THRESHOLD = 64; // px of pull that triggers a refresh
const HOLD = 52; // where the list waits while loading
interface InboxProps {
initial: Mail[];
onRefresh: () => Promise<Mail[]>; // returns the new messages
}
export default function Inbox({ initial, onRefresh }: InboxProps) {
const [mail, setMail] = useState(initial);
const [pull, setPull] = useState(0);
const [dragging, setDragging] = useState(false);
const [busy, setBusy] = useState(false);
const [updated, setUpdated] = useState("Updated 5 min ago");
const scroller = useRef<HTMLDivElement>(null);
const startY = useRef<number | null>(null);
function down(e: React.PointerEvent) {
if (busy || (scroller.current?.scrollTop ?? 0) > 0) return;
startY.current = e.clientY;
setDragging(true);
}
function move(e: React.PointerEvent) {
if (startY.current === null) return;
const dy = e.clientY - startY.current;
setPull(dy > 0 ? Math.min(96, dy * 0.5) : 0); // rubber band
}
async function up() {
if (startY.current === null) return;
startY.current = null;
setDragging(false);
if (pull < THRESHOLD) return setPull(0);
setBusy(true);
setPull(HOLD);
try {
const fresh = await onRefresh();
setMail((m) => [...fresh.map((x) => ({ ...x, unread: true })), ...m]);
setUpdated("Updated just now");
} finally {
setBusy(false);
setPull(0);
}
}
const progress = Math.min(1, pull / THRESHOLD);
return (
<main className="mx-auto flex h-dvh max-w-md flex-col bg-bg font-sans text-fg">
<header className="flex h-[60px] shrink-0 items-end gap-1 border-b border-border pb-2 pl-4 pr-1.5">
<div className="min-w-0 flex-1">
<h1 className="text-2xl font-semibold leading-7 tracking-tight">Inbox</h1>
<p className="text-xs text-fg-muted" aria-live="polite">{updated}</p>
</div>
<button type="button" aria-label="Search" className="grid size-10 place-items-center rounded-full hover:bg-surface-2"><Search className="size-5" /></button>
<button type="button" aria-label="Compose" className="grid size-10 place-items-center rounded-full hover:bg-surface-2"><PenSquare className="size-5" /></button>
</header>
<div className="relative min-h-0 flex-1 overflow-hidden bg-surface">
<span
aria-hidden
className={cn("absolute left-1/2 top-0 z-10 -ml-4 grid size-8 place-items-center rounded-full bg-bg text-primary shadow-md ring-1 ring-border", !dragging && "transition-all duration-500 ease-spring motion-reduce:transition-none")}
style={{ opacity: busy ? 1 : progress, transform: "translateY(" + (pull / 2 - 16) + "px) scale(" + (0.5 + progress * 0.5) + ") rotate(" + pull * 4 + "deg)" }}
>
<Loader2 className={cn("size-[18px]", busy && "animate-spin motion-reduce:animate-none")} />
</span>
<div
ref={scroller}
onPointerDown={down}
onPointerMove={move}
onPointerUp={up}
onPointerCancel={up}
className={cn("h-full touch-pan-x overflow-y-auto bg-bg", !dragging && "transition-transform duration-500 ease-spring motion-reduce:transition-none")}
style={{ transform: "translateY(" + pull + "px)" }}
>
<ul>
{mail.map((m) => (
<li key={m.id} className={cn("relative flex h-[72px] gap-3 border-b border-border px-4 py-3", m.unread && "animate-[drop-in_.5s_cubic-bezier(.34,1.56,.64,1)_both] bg-primary-soft/60 motion-reduce:animate-none")}>
<span className="grid size-10 shrink-0 place-items-center rounded-full bg-primary-soft text-[13px] font-semibold text-primary-soft-fg">{m.initials}</span>
<div className="grid min-w-0 flex-1 content-start">
<p className="flex items-baseline gap-2">
<b className="truncate text-[13.5px] font-semibold">{m.from}</b>
{m.unread && <span className="size-[7px] shrink-0 rounded-full bg-primary" aria-label="Unread" />}
<span className="ml-auto shrink-0 text-[11.5px] tabular-nums text-fg-subtle">{m.time}</span>
</p>
<p className="truncate text-[13px]">{m.subject}</p>
<p className="truncate text-[12.5px] text-fg-muted">{m.preview}</p>
</div>
</li>
))}
</ul>
</div>
</div>
</main>
);
}
/* globals.css
@keyframes drop-in { from { opacity: 0; transform: translateY(-16px) scale(.97); } }
*/
The code uses the Taste Tailwind preset. Make yours on the Taste page with Export my system.