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

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.