shadimage
Mapcn
blockstore-locatorsource: ok · hit

Store Locator

Searchable store list synced with map markers.

npx shadcn add "https://mapcn.dev/r/store-locator.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react

Registry dependencies

@mapcn/mapinputsidebar

Files4 · source cached 10/8/2026, 1:43:42 PM

src/registry/blocks/store-locator/page.tsx
"use client";

import { useMemo, useState, type CSSProperties } from "react";

import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { StoreList } from "./components/store-list";
import { LocatorMap } from "./components/locator-map";
import { MAP_CENTER, stores } from "./data";

export default function Page() {
  const [query, setQuery] = useState("");
  const [selectedId, setSelectedId] = useState<string | null>(stores[0].id);

  const filtered = useMemo(() => {
    const q = query.trim().toLowerCase();
    if (!q) return stores;
    return stores.filter(
      (store) =>
        store.name.toLowerCase().includes(q) ||
        store.address.toLowerCase().includes(q) ||
        store.neighborhood.toLowerCase().includes(q),
    );
  }, [query]);

  return (
    <SidebarProvider style={{ "--sidebar-width": "20rem" } as CSSProperties}>
      <StoreList
        stores={filtered}
        query={query}
        onQueryChange={setQuery}
        selectedId={selectedId}
        onSelect={setSelectedId}
      />

      <SidebarInset>
        <LocatorMap
          stores={filtered}
          selectedId={selectedId}
          onSelect={setSelectedId}
          onClearSelection={() => setSelectedId(null)}
          center={MAP_CENTER}
        />
      </SidebarInset>
    </SidebarProvider>
  );
}
src/registry/blocks/store-locator/data.ts
export interface Store {
  id: string;
  name: string;
  address: string;
  neighborhood: string;
  phone: string;
  hours: string;
  openNow: boolean;
  lat: number;
  lng: number;
}

export const MAP_CENTER: [number, number] = [-122.4194, 37.7749];

export const stores: Store[] = [
  {
    id: "ferry-building",
    name: "Ferry Building",
    address: "1 Ferry Building",
    neighborhood: "Embarcadero",
    phone: "(415) 555-0142",
    hours: "7:00 AM – 8:00 PM",
    openNow: true,
    lat: 37.7955,
    lng: -122.3933,
  },
  {
    id: "union-square",
    name: "Union Square",
    address: "333 Post St",
    neighborhood: "Downtown",
    phone: "(415) 555-0188",
    hours: "6:30 AM – 9:00 PM",
    openNow: true,
    lat: 37.788,
    lng: -122.4075,
  },
  {
    id: "north-beach",
    name: "North Beach",
    address: "601 Columbus Ave",
    neighborhood: "North Beach",
    phone: "(415) 555-0119",
    hours: "7:00 AM – 7:00 PM",
    openNow: true,
    lat: 37.8003,
    lng: -122.4097,
  },
  {
    id: "hayes-valley",
    name: "Hayes Valley",
    address: "450 Hayes St",
    neighborhood: "Hayes Valley",
    phone: "(415) 555-0163",
    hours: "7:30 AM – 6:00 PM",
    openNow: false,
    lat: 37.7765,
    lng: -122.4256,
  },
  {
    id: "mission",
    name: "Mission District",
    address: "2901 Mission St",
    neighborhood: "Mission",
    phone: "(415) 555-0177",
    hours: "6:00 AM – 8:00 PM",
    openNow: true,
    lat: 37.7509,
    lng: -122.4181,
  },
  {
    id: "marina",
    name: "Marina",
    address: "2156 Chestnut St",
    neighborhood: "Marina",
    phone: "(415) 555-0125",
    hours: "7:00 AM – 7:00 PM",
    openNow: false,
    lat: 37.8003,
    lng: -122.4366,
  },
  {
    id: "castro",
    name: "Castro",
    address: "489 Castro St",
    neighborhood: "Castro",
    phone: "(415) 555-0151",
    hours: "7:00 AM – 8:00 PM",
    openNow: true,
    lat: 37.7609,
    lng: -122.435,
  },
  {
    id: "soma",
    name: "SoMa",
    address: "680 Folsom St",
    neighborhood: "SoMa",
    phone: "(415) 555-0134",
    hours: "6:30 AM – 6:30 PM",
    openNow: true,
    lat: 37.7852,
    lng: -122.3982,
  },
];
src/registry/blocks/store-locator/components/store-list.tsx
"use client";

import { useEffect, useRef } from "react";
import { Clock, MapPin, Phone, Search } from "lucide-react";

import {
  Sidebar,
  SidebarContent,
  SidebarGroup,
  SidebarHeader,
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarSeparator,
} from "@/components/ui/sidebar";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import type { Store } from "../data";

interface StoreListProps {
  stores: Store[];
  query: string;
  onQueryChange: (value: string) => void;
  selectedId: string | null;
  onSelect: (id: string) => void;
}

export function StoreList({
  stores,
  query,
  onQueryChange,
  selectedId,
  onSelect,
}: StoreListProps) {
  const itemRefs = useRef(new Map<string, HTMLButtonElement>());

  useEffect(() => {
    if (!selectedId) return;
    itemRefs.current
      .get(selectedId)
      ?.scrollIntoView({ block: "nearest", behavior: "smooth" });
  }, [selectedId]);

  return (
    <Sidebar collapsible="offcanvas">
      <SidebarHeader className="gap-3 p-4">
        <div>
          <h2 className="text-foreground text-lg font-semibold tracking-tight">
            Find a store
          </h2>
          <p className="text-muted-foreground text-sm">
            {stores.length} {stores.length === 1 ? "location" : "locations"}{" "}
            near you
          </p>
        </div>
        <div className="relative">
          <Search className="text-muted-foreground absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
          <Input
            value={query}
            onChange={(e) => onQueryChange(e.target.value)}
            placeholder="Search name or address"
            className="bg-background pl-8"
            aria-label="Search stores"
          />
        </div>
      </SidebarHeader>

      <SidebarSeparator className="mx-0" />

      <SidebarContent>
        {stores.length === 0 ? (
          <div className="text-muted-foreground p-6 text-center text-sm">
            No stores match your search.
          </div>
        ) : (
          <SidebarGroup className="p-2">
            <SidebarMenu className="gap-1">
              {stores.map((store) => {
                const active = store.id === selectedId;
                return (
                  <SidebarMenuItem key={store.id}>
                    <SidebarMenuButton
                      asChild
                      isActive={active}
                      className="h-auto flex-col items-stretch gap-0 p-3"
                    >
                      <button
                        type="button"
                        ref={(el) => {
                          if (el) itemRefs.current.set(store.id, el);
                          else itemRefs.current.delete(store.id);
                        }}
                        onClick={() => onSelect(store.id)}
                        aria-current={active}
                      >
                        <div className="flex items-center justify-between gap-2">
                          <span className="text-foreground font-medium">
                            {store.name}
                          </span>
                          <span
                            className={cn(
                              "flex items-center gap-1.5 text-xs font-medium",
                              store.openNow
                                ? "text-foreground"
                                : "text-muted-foreground",
                            )}
                          >
                            <span
                              className={cn(
                                "size-1.5 rounded-full",
                                store.openNow
                                  ? "bg-emerald-500"
                                  : "bg-neutral-500",
                              )}
                            />
                            {store.openNow ? "Open" : "Closed"}
                          </span>
                        </div>

                        <div className="text-muted-foreground mt-2 space-y-1.5 text-xs font-normal tabular-nums">
                          <p className="flex items-center gap-1.5">
                            <MapPin className="size-3.5 shrink-0" />
                            {store.address}, {store.neighborhood}
                          </p>
                          <p className="flex items-center gap-1.5">
                            <Clock className="size-3.5 shrink-0" />
                            {store.hours}
                          </p>
                          <p className="flex items-center gap-1.5">
                            <Phone className="size-3.5 shrink-0" />
                            {store.phone}
                          </p>
                        </div>
                      </button>
                    </SidebarMenuButton>
                  </SidebarMenuItem>
                );
              })}
            </SidebarMenu>
          </SidebarGroup>
        )}
      </SidebarContent>
    </Sidebar>
  );
}
src/registry/blocks/store-locator/components/locator-map.tsx
"use client";

import { useEffect } from "react";
import { Clock, MapPin, Phone } from "lucide-react";

import {
  Map,
  MapControls,
  MapMarker,
  MapPopup,
  MarkerContent,
  MarkerTooltip,
  useMap,
} from "@/components/ui/map";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { cn } from "@/lib/utils";
import type { Store } from "../data";

interface LocatorMapProps {
  stores: Store[];
  selectedId: string | null;
  onSelect: (id: string) => void;
  onClearSelection: () => void;
  center: [number, number];
}

function FlyToSelected({ store }: { store?: Store }) {
  const { map } = useMap();

  useEffect(() => {
    if (!map || !store) return;
    map.flyTo({
      center: [store.lng, store.lat],
      zoom: 14,
      duration: 800,
      essential: true,
    });
  }, [map, store]);

  return null;
}

function StorePin({ active }: { active: boolean }) {
  return (
    <div
      className={cn(
        "text-background flex items-center justify-center rounded-full shadow-md transition-all",
        active
          ? "bg-foreground/80 size-9 scale-110"
          : "bg-foreground hover:bg-muted-foreground size-7",
      )}
    >
      <MapPin className={cn(active ? "size-4.5" : "size-3.5")} />
    </div>
  );
}

export function LocatorMap({
  stores,
  selectedId,
  onSelect,
  onClearSelection,
  center,
}: LocatorMapProps) {
  const selected = stores.find((store) => store.id === selectedId);

  return (
    <div className="relative h-full">
      <SidebarTrigger className="bg-background absolute top-3 left-3 z-10 border shadow-sm md:hidden" />

      <Map center={center} zoom={12} minZoom={10} maxZoom={17}>
        <MapControls showFullscreen showCompass />
        <FlyToSelected store={selected} />

        {stores.map((store) => (
          <MapMarker
            key={store.id}
            longitude={store.lng}
            latitude={store.lat}
            onClick={() => onSelect(store.id)}
          >
            <MarkerContent>
              <StorePin active={store.id === selectedId} />
            </MarkerContent>
            <MarkerTooltip
              offset={24}
              className="bg-foreground text-background"
            >
              {store.name}
            </MarkerTooltip>
          </MapMarker>
        ))}

        {selected && (
          <MapPopup
            longitude={selected.lng}
            latitude={selected.lat}
            offset={26}
            closeButton
            closeOnClick={false}
            onClose={onClearSelection}
            className="min-w-56"
            focusAfterOpen={false}
          >
            <p className="text-popover-foreground pr-5 font-medium">
              {selected.name}
            </p>
            <span
              className={cn(
                "mt-1 flex items-center gap-1.5 text-xs font-medium",
                selected.openNow ? "text-foreground" : "text-muted-foreground",
              )}
            >
              <span
                className={cn(
                  "size-1.5 rounded-full",
                  selected.openNow ? "bg-emerald-500" : "bg-neutral-500",
                )}
              />
              {selected.openNow ? "Open now" : "Closed"}
            </span>

            <div className="text-muted-foreground mt-2.5 space-y-1.5 text-xs tabular-nums">
              <p className="flex items-center gap-1.5">
                <MapPin className="size-3.5 shrink-0" />
                {selected.address}, {selected.neighborhood}
              </p>
              <p className="flex items-center gap-1.5">
                <Clock className="size-3.5 shrink-0" />
                {selected.hours}
              </p>
              <p className="flex items-center gap-1.5">
                <Phone className="size-3.5 shrink-0" />
                {selected.phone}
              </p>
            </div>
          </MapPopup>
        )}
      </Map>
    </div>
  );
}

More from Mapcn

Browse all components in this library →