All Taste components
Animated screens
Bottom sheet
A list of nearby places in a sheet over a map that rises to half, then full height, and settles back.
peek · half · full detents · spring settle · rows reveal
Search cafés, parks…PR
Cafés nearby
12 open now · Indiranagar
- Third Wave Roastery
4.7 · 350 m · Open till 11 pm
- Glasshouse Café
4.6 · 600 m · Wi-Fi · Garden
- Kettle & Crumb
4.5 · 850 m · Bakery
- Blue Tokai Indiranagar
4.4 · 1.1 km · Open till 10 pm
- Dose of Dosa
4.3 · 1.4 km · Breakfast
- Matcha Lane
4.6 · 1.6 km · Quiet
"use client";
import { useRef, useState } from "react";
import { ChevronRight, Search, SlidersHorizontal, Star, Wifi } from "lucide-react";
import { cn } from "@/lib/utils";
// How much of the sheet shows at each detent (px from the bottom).
const DETENTS = { peek: 124, half: 262, full: 540 } as const;
type Detent = keyof typeof DETENTS;
const cafes = [
{ name: "Third Wave Roastery", meta: "4.7 · 350 m · Open till 11 pm" },
{ name: "Glasshouse Café", meta: "4.6 · 600 m · Wi-Fi · Garden" },
{ name: "Kettle & Crumb", meta: "4.5 · 850 m · Bakery" },
{ name: "Blue Tokai Indiranagar", meta: "4.4 · 1.1 km · Open till 10 pm" },
{ name: "Dose of Dosa", meta: "4.3 · 1.4 km · Breakfast" },
{ name: "Matcha Lane", meta: "4.6 · 1.6 km · Quiet" },
];
const filters = ["Open now", "Wi-Fi", "Outdoor", "4.5+"];
export default function NearbySheetScreen() {
const [detent, setDetent] = useState<Detent>("half");
const [drag, setDrag] = useState<number | null>(null); // live offset while dragging
const start = useRef(0);
const shown = DETENTS[detent] - (drag ?? 0);
function release() {
if (drag === null) return;
// Snap to the nearest detent.
const target = (Object.keys(DETENTS) as Detent[]).reduce((a, b) => (Math.abs(DETENTS[b] - shown) < Math.abs(DETENTS[a] - shown) ? b : a));
setDetent(target);
setDrag(null);
}
return (
<div className="relative mx-auto h-dvh max-w-md overflow-hidden bg-surface-2 font-sans text-fg">
{/* Your map SDK goes here. */}
<div aria-hidden className="absolute inset-0 bg-[linear-gradient(var(--tst-bg)_14px,transparent_14px),linear-gradient(90deg,var(--tst-bg)_14px,transparent_14px)] bg-[length:126px_144px]" />
<div className="absolute inset-x-3 top-[50px] z-10 flex h-11 items-center gap-2 rounded-full bg-bg pl-3.5 pr-1.5 text-sm text-fg-subtle shadow-lg">
<Search className="size-[18px]" aria-hidden /><span className="flex-1">Search cafés, parks…</span>
<span className="grid size-7 place-items-center rounded-full bg-primary-soft text-[11px] font-medium text-primary-soft-fg">PR</span>
</div>
<div aria-hidden className={cn("pointer-events-none absolute inset-0 bg-neutral-950 transition-opacity duration-500", detent === "full" ? "opacity-30" : "opacity-0")} />
<section aria-label="Cafés nearby"
className={cn("absolute inset-x-0 -bottom-10 z-20 h-[580px] rounded-t-[28px] bg-bg px-4 shadow-[0_-10px_30px_-12px_rgb(0_0_0/.3)]",
drag === null && "transition-transform duration-500 ease-spring motion-reduce:transition-none")}
style={{ transform: "translateY(" + (540 - shown) + "px)" }}>
<button type="button" aria-label={"Sheet " + detent + " — drag or press to resize"}
onClick={() => drag === null && setDetent(detent === "full" ? "half" : detent === "half" ? "full" : "half")}
onPointerDown={(e) => { e.currentTarget.setPointerCapture(e.pointerId); start.current = e.clientY; setDrag(0); }}
onPointerMove={(e) => drag !== null && setDrag(e.clientY - start.current)}
onPointerUp={release} onPointerCancel={release}
className="block w-full touch-none py-2"><span className="mx-auto block h-[5px] w-9 rounded-full bg-border-strong" /></button>
<div className="flex items-start justify-between">
<div><h2 className="text-base font-semibold">Cafés nearby</h2><p className="text-xs text-fg-muted">12 open now · Indiranagar</p></div>
<button type="button" aria-label="Filters" className="grid size-11 place-items-center rounded-full bg-surface-2"><SlidersHorizontal className="size-[18px]" /></button>
</div>
<div className="-mx-4 mt-2.5 flex gap-2 overflow-x-auto px-4 [scrollbar-width:none]">
{filters.map((f, i) => (
<button key={f} type="button" aria-pressed={i === 0}
className="inline-flex h-8 shrink-0 items-center gap-1 rounded-full border border-border-strong px-3 text-[12.5px] font-medium aria-pressed:border-primary aria-pressed:bg-primary-soft aria-pressed:text-primary-soft-fg">
{f === "Wi-Fi" && <Wifi className="size-3.5" />}{f}
</button>
))}
</div>
<ul className="mt-2">
{cafes.map((c, i) => (
<li key={c.name} style={{ transitionDelay: (i % 3) * 80 + "ms" }}
className={cn("flex min-h-16 items-center gap-3 border-b border-border transition duration-500 ease-taste motion-reduce:transition-none",
(i < 3 ? detent !== "peek" : detent === "full") ? "translate-y-0 opacity-100" : "translate-y-2.5 opacity-0")}>
<span aria-hidden className="size-[52px] shrink-0 rounded bg-[linear-gradient(135deg,var(--tst-warning),var(--tst-primary))]" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{c.name}</p>
<p className="flex items-center gap-1 truncate text-xs text-fg-muted"><Star className="size-3 fill-warning text-warning" />{c.meta}</p>
</div>
<ChevronRight className="size-[18px] text-fg-subtle" aria-hidden />
</li>
))}
</ul>
</section>
</div>
);
}
The code uses the Taste Tailwind preset. Make yours on the Taste page with Export my system.