Skip to content
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.