Elements

Elements · Knowledge

Map

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

fig. 01

Installation

npx shadcn@latest add "@assistant-ui/elements-map-answer"
First time? Set up a runtime

Runtime components read their state from an assistant-ui runtime. Add one to an existing project:

npx assistant-ui@latest init

Then wrap your app in a runtime provider:

import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/ai-sdk";

export default function App() {
  const runtime = useChatRuntime({
    transport: new AssistantChatTransport({ api: "/api/chat" }),
  });

  return (
    <AssistantRuntimeProvider runtime={runtime}>
      {/* your components */}
    </AssistantRuntimeProvider>
  );
}

The installation guide covers new projects, templates, and API routes.

MapAnswer lays location results on a schematic grid: pins you can select, an optional route between them, and the list they came from underneath. With a runtime the pins come from a tool call that looked places up; standalone you already hold them.

Getting started

assistant-ui has no map or geocoding concept, and MapAnswer draws no real tiles or projection: x and y are plain 0 to 100 percentages that place a pin on the grid, not latitude and longitude. Whatever looks places up is responsible for turning real coordinates into that percentage layout before the result reaches this component.

Render the tool call

components/assistant-ui/elements/find-places-tool-ui.tsx
"use client";

import { useState } from "react";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import {
  MapAnswer,
  type MapPin,
} from "@/components/assistant-ui/elements/map-answer";

type FindPlacesResult = {
  pins: MapPin[];
  route: boolean;
};

export const FindPlacesToolUI: ToolCallMessagePartComponent<
  { query: string },
  FindPlacesResult
> = ({ result }) => {
  const [activeId, setActiveId] = useState("");

  if (!result) return null;
  const activePin = activeId || (result.pins[0]?.id ?? "");

  return (
    <MapAnswer
      pins={result.pins}
      activeId={activePin}
      route={result.route}
      onSelect={setActiveId}
    />
  );
};

Register the tool

app/toolkit.ts
import { defineToolkit } from "@assistant-ui/react";
import { z } from "zod";
import { FindPlacesToolUI } from "@/components/assistant-ui/elements/find-places-tool-ui";

export const toolkit = defineToolkit({
  find_places: {
    type: "frontend",
    description: "Find places for a query and lay them out on a schematic map.",
    parameters: z.object({ query: z.string() }),
    execute: async ({ query }) => lookupPlaces(query),
    render: FindPlacesToolUI,
  },
});

lookupPlaces owns the coordinate math: normalize each result's real latitude and longitude against the bounding box of the whole set, so the closest pins end up close together on the grid. Wire the toolkit in with Tools({ toolkit }); see Tool UI.

Anatomy

<div data-slot="map-answer">
  <div>
    <svg>{/* hairline grid, plus an optional dashed route through pins in array order */}</svg>
    {/* one marker button per pin, positioned by x/y percentage */}
  </div>
  <div>
    {/* one row per pin, top-bordered, including the first */}
  </div>
</div>

The route line draws only when route is true and there are at least two pins. It is a static dashed polyline connecting pins strictly in array order, not a computed path, so reordering pins changes the drawn route without adding or removing a single pin. x and y place a marker by percentage inside a 0 to 100 viewBox; nothing clamps an out-of-range value. The active pin's marker grows and switches to a blue fill; every other marker stays a smaller, neutral fill, both sharing the same background-colored ring so they read clearly against the grid lines. Clicking a marker and clicking its row in the list below both call onSelect with the same pin.id, so either one moves the active pin. Every list row carries a top divider, including the first, since the list sits directly under the grid rather than under another row. An empty pins array renders an empty grid, its hairlines still visible, with an empty list underneath.

Examples

Draw the route

The dashed line is decorative connectivity, not a routed path: it always runs in pins order and never adjusts itself to the active pin.

<MapAnswer pins={pins} activeId={activeId} route onSelect={onSelect} />

Restyle the map

Both lanes take className on the root. The grid background reads the shared field token, the card itself paper, and the list's detail column mono, all from surfaces.tsx.

<MapAnswer className="max-w-none" /* ... */ />

API reference

Tool-call render props

PropTypeDescription
argsTArgsParsed arguments. Partial while the model is still streaming them.
argsTextstringRaw JSON argument text streamed by the model.
resultTResult | undefinedThe tool's return value once it completes. undefined while running.
statusToolCallMessagePartStatusstatus.type is "running", "requires-action", "complete", or "incomplete".
toolNamestringName of the tool the model called.
toolCallIdstringStable id for this invocation.
isErrorboolean | undefinedWhether result represents a tool execution error.

Register the renderer on a toolkit entry's render field and attach the toolkit with Tools({ toolkit }). See Tool UI for the full render-prop surface, including addResult, human tools, and approval gates.