Elements

45 / 122 · Knowledge

Map

A location answer: pins, a route between them, and the list they came from.

Installation

npx shadcn@latest add "@assistant-ui/elements-map-answer"

Usage

import { MapAnswer } from "@/components/elements/map-answer";

<MapAnswer pins={pins} activeId="b" route onSelect={setActive} />

Props

MapAnswer

pins*MapPin[]Places in the answer, positioned as percentages so the element needs no tiles.
activeId*stringWhich pin is selected, in both the plot and the list.
routebooleanDraws a dashed path through the pins in order.
onSelect(id: string) => voidCalled when a pin or its list row is chosen. Both surfaces report the same id.
classNamestringExtra classes merged onto the root.

MapPin

id*stringStable identity for the list key and for the callbacks that report a selection.
label*stringPlace name, shown on the pin and in the list below it.
x*numberHorizontal position as a percentage.
y*numberVertical position as a percentage.
detail*stringSecondary fact: a distance, a duration, a rating.

Source

map-answer.tsx
"use client";

import { cn } from "@/lib/utils";
import { field, mono, paper } from "./surfaces";

export interface MapPin {
  id: string;
  label: string;
  detail: string;
  x: number;
  y: number;
}

export function MapAnswer({
  pins,
  activeId,
  route,
  onSelect,
  className,
}: {
  pins: readonly MapPin[];
  activeId: string;
  route?: boolean;
  onSelect?: (id: string) => void;
  className?: string;
}) {
  const path = pins.map((pin) => `${pin.x},${pin.y}`).join(" ");

  return (
    <div
      className={cn(
        paper,
        "flex w-full max-w-sm flex-col overflow-hidden rounded-2xl",
        className,
      )}
    >
      <div className={cn(field, "relative h-40")}>
        <svg
          viewBox="0 0 100 100"
          preserveAspectRatio="none"
          aria-hidden
          className="absolute inset-0 size-full"
        >
          {[18, 42, 66, 88].map((y) => (
            <line
              key={`h${y}`}
              x1="0"
              x2="100"
              y1={y}
              y2={y}
              className="stroke-foreground/[0.06]"
              strokeWidth="0.6"
            />
          ))}
          {[22, 50, 74].map((x) => (
            <line
              key={`v${x}`}
              x1={x}
              x2={x}
              y1="0"
              y2="100"
              className="stroke-foreground/[0.06]"
              strokeWidth="0.6"
            />
          ))}
          {route && pins.length > 1 && (
            <polyline
              points={path}
              fill="none"
              strokeWidth="1.2"
              strokeDasharray="3 2"
              strokeLinecap="round"
              className="stroke-blue-500/60 dark:stroke-blue-400/60"
            />
          )}
        </svg>

        {pins.map((pin) => (
          <button
            key={pin.id}
            type="button"
            aria-label={pin.label}
            onClick={() => onSelect?.(pin.id)}
            className="absolute -translate-x-1/2 -translate-y-1/2"
            style={{ left: `${pin.x}%`, top: `${pin.y}%` }}
          >
            <span
              className={cn(
                "block rounded-full border-2 transition-all duration-200 motion-reduce:transition-none",
                pin.id === activeId
                  ? "border-background size-3.5 bg-blue-500 dark:bg-blue-400"
                  : "border-background bg-foreground/45 size-2.5",
              )}
            />
          </button>
        ))}
      </div>

      <div className="flex flex-col">
        {pins.map((pin) => (
          <button
            key={pin.id}
            type="button"
            onClick={() => onSelect?.(pin.id)}
            className={cn(
              "border-foreground/[0.06] flex items-baseline gap-2 border-t px-3.5 py-2 text-start transition-colors",
              pin.id === activeId
                ? "bg-foreground/[0.03]"
                : "hover:bg-foreground/[0.02]",
            )}
          >
            <span className="text-foreground/85 min-w-0 flex-1 truncate text-[13px]">
              {pin.label}
            </span>
            <span className={cn(mono, "text-foreground/30 shrink-0")}>
              {pin.detail}
            </span>
          </button>
        ))}
      </div>
    </div>
  );
}