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>
);
}