Aarav, 29Architect · 3 km away
All Taste components
Animated screens
Swipe cards
A stack of profile cards where the top one swipes off with a tilt and a like or nope stamp.
like · nope · next card scales up · action buttons
// Swipe cards: drag the top card, it tilts and shows LIKE / NOPE, and flies off past the threshold;
// the next card scales up into place. Buttons do the same. Mobile-first on the Taste preset.
"use client";
import { useRef, useState } from "react";
import { Heart, MapPin, SlidersHorizontal, Star, X } from "lucide-react";
import { cn } from "@/lib/utils";
type Person = { id: string; name: string; meta: string; tags: string[]; tone: string };
const FLING = 110; // px to count as a swipe
export default function SwipeDeck({ people, onSwipe }: { people: Person[]; onSwipe?: (p: Person, liked: boolean) => void }) {
const [deck, setDeck] = useState(people);
const [dx, setDx] = useState(0);
const [leaving, setLeaving] = useState<0 | 1 | -1>(0);
const start = useRef<number | null>(null);
function fling(dir: 1 | -1) {
const top = deck[0];
if (!top || leaving) return;
setLeaving(dir);
onSwipe?.(top, dir === 1);
setTimeout(() => {
setDeck((d) => d.slice(1));
setLeaving(0);
setDx(0);
}, 320);
}
const offset = leaving ? leaving * 480 : dx;
const tilt = offset / 18;
return (
<main className="mx-auto flex h-dvh max-w-md flex-col bg-bg font-sans text-fg">
<header className="flex h-12 shrink-0 items-center justify-between pl-4 pr-1.5">
<h1 className="flex items-center gap-2 text-lg font-semibold tracking-tight"><Heart className="size-5 text-primary" />Kindred</h1>
<button type="button" aria-label="Filters" className="grid size-10 place-items-center rounded-full hover:bg-surface-2"><SlidersHorizontal className="size-5" /></button>
</header>
<div className="relative mx-4 mt-1 min-h-0 flex-1">
{deck.slice(0, 3).map((p, i) => {
const top = i === 0;
const style = top
? { transform: "translateX(" + offset + "px) rotate(" + tilt + "deg)" }
: { transform: "translateY(" + i * 13 + "px) scale(" + (1 - i * 0.05) + ")" };
return (
<article
key={p.id}
aria-hidden={!top}
className={cn(
"absolute inset-x-0 top-0 bottom-[26px] origin-bottom touch-none select-none overflow-hidden rounded-xl text-neutral-50 shadow-xl ring-1 ring-fg/10",
(!top || start.current === null) && "transition-transform duration-300 ease-taste motion-reduce:transition-none",
"bg-gradient-to-br", p.tone,
)}
style={{ ...style, zIndex: 3 - i }}
onPointerDown={top ? (e) => { start.current = e.clientX; e.currentTarget.setPointerCapture(e.pointerId); } : undefined}
onPointerMove={top ? (e) => start.current !== null && setDx(e.clientX - start.current) : undefined}
onPointerUp={top ? () => { start.current = null; if (Math.abs(dx) > FLING) fling(dx > 0 ? 1 : -1); else setDx(0); } : undefined}
>
{top && (
<>
<span aria-hidden className="absolute left-[18px] top-[22px] -rotate-[14deg] rounded border-[3px] border-current bg-neutral-50/90 px-2.5 text-[22px] font-extrabold tracking-[.08em] text-[var(--tst-success-700)]" style={{ opacity: Math.max(0, offset / FLING) }}>LIKE</span>
<span aria-hidden className="absolute right-[18px] top-[22px] rotate-[14deg] rounded border-[3px] border-current bg-neutral-50/90 px-2.5 text-[22px] font-extrabold tracking-[.08em] text-[var(--tst-danger-700)]" style={{ opacity: Math.max(0, -offset / FLING) }}>NOPE</span>
</>
)}
<div className="absolute inset-x-0 bottom-0 grid gap-1 bg-gradient-to-t from-neutral-950/80 to-transparent px-4 pb-4 pt-16">
<h2 className="text-[22px] font-semibold leading-[26px] tracking-tight">{p.name}</h2>
<p className="flex items-center gap-1.5 text-[12.5px] text-neutral-50/80"><MapPin className="size-3.5" />{p.meta}</p>
<p className="mt-1.5 flex flex-wrap gap-1.5">
{p.tags.map((t) => <span key={t} className="h-6 rounded-full bg-neutral-50/20 px-2.5 text-[11.5px] font-medium leading-6">{t}</span>)}
</p>
</div>
</article>
);
})}
{deck.length === 0 && <p className="grid h-full place-items-center text-sm text-fg-muted">That’s everyone nearby for today.</p>}
</div>
<div className="flex h-[92px] shrink-0 items-center justify-center gap-[18px] pb-[18px]">
<button type="button" aria-label="Nope" onClick={() => fling(-1)} className="grid size-[54px] place-items-center rounded-full border border-border bg-bg text-danger shadow-md transition active:scale-90 active:bg-danger-soft motion-reduce:transition-none"><X className="size-6" /></button>
<button type="button" aria-label="Super like" className="grid size-11 place-items-center rounded-full border border-border bg-bg text-info shadow-md transition active:scale-90 motion-reduce:transition-none"><Star className="size-5" /></button>
<button type="button" aria-label="Like" onClick={() => fling(1)} className="grid size-[54px] place-items-center rounded-full border border-border bg-bg text-success shadow-md transition active:scale-90 active:bg-success-soft motion-reduce:transition-none"><Heart className="size-6" /></button>
</div>
</main>
);
}
// Example data: tone is the photo stand-in, a two-colour gradient.
export const people: Person[] = [
{ id: "1", name: "Aarav, 29", meta: "Architect · 3 km away", tags: ["Bouldering", "Filter coffee", "Jazz"], tone: "from-brand-300 to-brand-700" },
{ id: "2", name: "Meera, 27", meta: "Product designer · 5 km away", tags: ["Film photos", "Running"], tone: "from-[var(--tst-warning-300)] to-[var(--tst-danger-600)]" },
{ id: "3", name: "Ishaan, 31", meta: "Chef · 2 km away", tags: ["Vinyl", "Trekking", "Board games"], tone: "from-[var(--tst-success-300)] to-[var(--tst-info-700)]" },
];
The code uses the Taste Tailwind preset. Make yours on the Taste page with Export my system.