Itinerary
An interactive map component that streams travel itineraries from AI with animated markers and routes.
A trip planning component that progressively reveals stops on an interactive map as they stream from AI, with animated markers, route lines, and a timeline sidebar.
Installation
npm install @stream.ui/react maplibre-gl lucide-react motionnpx shadcn@latest add card skeletonCopy the Map component from mapcn to your project.
"use client";
import type { DeepPartial } from "@stream.ui/react";
import { Stream } from "@stream.ui/react";
import {
Camera,
Clock,
Footprints,
Hotel,
MapPin,
ShoppingBag,
Train,
Utensils,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
// biome-ignore lint/suspicious/noShadowRestrictedNames: <explanation shadowing>
Map,
MapControls,
MapMarker,
MapRoute,
MarkerContent,
MarkerPopup,
useMap,
} from "@/components/ui/map";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import type {
ItineraryStop,
StreamingItineraryData,
} from "./streaming-itinerary-schema";
const stopTypeIcons: Record<
string,
React.ComponentType<{ className?: string }>
> = {
food: Utensils,
attraction: Camera,
hotel: Hotel,
transport: Train,
activity: Footprints,
shopping: ShoppingBag,
};
const stopTypeColors: Record<string, string> = {
food: "bg-orange-500",
attraction: "bg-blue-500",
hotel: "bg-purple-500",
transport: "bg-green-500",
activity: "bg-pink-500",
shopping: "bg-amber-500",
};
function getStopIcon(type: string | undefined) {
if (type && stopTypeIcons[type]) {
return stopTypeIcons[type];
}
return MapPin;
}
function getStopColor(type: string | undefined) {
if (type && stopTypeColors[type]) {
return stopTypeColors[type];
}
return "bg-gray-500";
}
interface StopMarkerProps {
stop: DeepPartial<ItineraryStop>;
index: number;
}
function StopMarker({ stop, index }: StopMarkerProps) {
const Icon = getStopIcon(stop.type);
const color = getStopColor(stop.type);
const prefersReducedMotion = useReducedMotion();
if (
stop.longitude === undefined ||
stop.latitude === undefined ||
!stop.name
) {
return null;
}
return (
<MapMarker longitude={stop.longitude} latitude={stop.latitude}>
<MarkerContent>
<motion.div
initial={
prefersReducedMotion ? { opacity: 0 } : { scale: 0, opacity: 0 }
}
animate={{ scale: 1, opacity: 1 }}
transition={
prefersReducedMotion
? { duration: 0 }
: {
type: "spring",
stiffness: 400,
damping: 20,
delay: index * 0.1,
}
}
className="relative"
>
<div
className={cn(
"flex h-8 w-8 items-center justify-center rounded-full border-2 border-white shadow-lg",
color,
)}
>
<Icon className="h-4 w-4 text-white" aria-hidden="true" />
</div>
<div className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-foreground text-[10px] font-bold text-background">
{index + 1}
</div>
</motion.div>
</MarkerContent>
<MarkerPopup className="min-w-[200px]">
<div className="space-y-1">
<div className="flex items-center gap-2">
<div
className={cn(
"flex h-5 w-5 items-center justify-center rounded-full",
color,
)}
>
<Icon className="h-3 w-3 text-white" aria-hidden="true" />
</div>
<span className="font-medium">{stop.name}</span>
</div>
{stop.description && (
<p className="text-sm text-muted-foreground">{stop.description}</p>
)}
{stop.duration && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" aria-hidden="true" />
{stop.duration}
</div>
)}
</div>
</MarkerPopup>
</MapMarker>
);
}
interface StopListItemProps {
stop: DeepPartial<ItineraryStop>;
index: number;
isLast: boolean;
}
function StopListItem({ stop, index, isLast }: StopListItemProps) {
const Icon = getStopIcon(stop.type);
const color = getStopColor(stop.type);
const prefersReducedMotion = useReducedMotion();
return (
<motion.div
initial={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, x: -12 }}
animate={{ opacity: 1, x: 0 }}
transition={
prefersReducedMotion
? { duration: 0 }
: { duration: 0.2, delay: index * 0.05 }
}
className="flex gap-3"
>
<div className="flex flex-col items-center">
<div
className={cn(
"flex h-6 w-6 shrink-0 items-center justify-center rounded-full",
color,
)}
>
<Icon className="h-3 w-3 text-white" aria-hidden="true" />
</div>
{!isLast && <div className="w-px flex-1 bg-border" />}
</div>
<div className={cn("pb-4", isLast && "pb-0")}>
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-muted-foreground">
#{index + 1}
</span>
<span className="font-medium text-sm">{stop.name}</span>
</div>
{stop.description && (
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-2">
{stop.description}
</p>
)}
{stop.duration && (
<div className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" aria-hidden="true" />
{stop.duration}
</div>
)}
</div>
</motion.div>
);
}
function StopListSkeleton() {
return (
<div className="flex gap-3">
<div className="flex flex-col items-center">
<Skeleton className="h-6 w-6 rounded-full" />
<div className="w-px flex-1 bg-border" />
</div>
<div className="flex-1 space-y-1.5 pb-4">
<div className="flex items-center gap-2">
<Skeleton className="h-3 w-5" />
<Skeleton className="h-4 w-28" />
</div>
<Skeleton className="h-3 w-full max-w-[200px]" />
<div className="flex items-center gap-1">
<Skeleton className="h-3 w-3" />
<Skeleton className="h-3 w-14" />
</div>
</div>
</div>
);
}
interface CameraControllerProps {
stops: DeepPartial<ItineraryStop>[];
isStreaming: boolean;
}
function CameraController({ stops, isStreaming }: CameraControllerProps) {
const { map, isLoaded } = useMap();
const prevStopsCountRef = React.useRef(0);
// Adjust camera as stops are added
React.useEffect(() => {
if (!map || !isLoaded || stops.length === 0) return;
const lngs = stops
.filter((s) => typeof s.longitude === "number")
.map((s) => s.longitude as number);
const lats = stops
.filter((s) => typeof s.latitude === "number")
.map((s) => s.latitude as number);
if (lngs.length === 0 || lats.length === 0) return;
// Only animate if stops count changed (new stop added)
const stopsCountChanged = stops.length !== prevStopsCountRef.current;
prevStopsCountRef.current = stops.length;
if (!stopsCountChanged) return;
if (stops.length === 1) {
// First stop: fly to it
map.flyTo({
center: [lngs[0], lats[0]],
zoom: 14,
duration: 800,
});
} else {
// Multiple stops: fit bounds with smooth animation
map.fitBounds(
[
[Math.min(...lngs), Math.min(...lats)],
[Math.max(...lngs), Math.max(...lats)],
],
{
padding: 60,
duration: isStreaming ? 600 : 1000,
},
);
}
}, [map, isLoaded, stops, isStreaming]);
// Reset when stops are cleared (new request)
React.useEffect(() => {
if (stops.length === 0) {
prevStopsCountRef.current = 0;
}
}, [stops.length]);
return null;
}
interface StreamingItineraryProps {
data: DeepPartial<StreamingItineraryData> | undefined;
isLoading: boolean;
error?: Error;
className?: string;
}
export function StreamingItinerary({
data,
isLoading,
error,
className,
}: StreamingItineraryProps) {
const isStreaming = isLoading && data !== undefined;
const isComplete = !isLoading && data !== undefined;
const isIdle = !isLoading && data === undefined;
const currentState = isComplete
? "complete"
: isStreaming
? "streaming"
: isLoading
? "loading"
: "idle";
const borderColors = {
idle: "",
loading: "border-yellow-500/50",
streaming: "border-blue-500/50",
complete: "border-green-500/50",
};
const validStops = React.useMemo(() => {
if (!data?.stops) return [];
return data.stops.filter(
(stop): stop is DeepPartial<ItineraryStop> =>
stop !== null &&
stop !== undefined &&
typeof stop.name === "string" &&
typeof stop.longitude === "number" &&
typeof stop.latitude === "number" &&
Number.isFinite(stop.longitude) &&
Number.isFinite(stop.latitude) &&
typeof stop.type === "string" &&
stop.type.length > 0,
);
}, [data?.stops]);
// Calculate map bounds to fit all markers
const mapBounds = React.useMemo(() => {
if (validStops.length === 0) return null;
const lngs = validStops.map((s) => s.longitude as number);
const lats = validStops.map((s) => s.latitude as number);
return {
center: [
(Math.min(...lngs) + Math.max(...lngs)) / 2,
(Math.min(...lats) + Math.max(...lats)) / 2,
] as [number, number],
// Simple zoom calculation based on spread
zoom: Math.max(
2,
12 -
Math.log2(
Math.max(
Math.max(...lngs) - Math.min(...lngs),
Math.max(...lats) - Math.min(...lats),
) + 0.01,
),
),
};
}, [validStops]);
// Route coordinates for the line
const routeCoordinates = React.useMemo(() => {
return validStops.map(
(stop) =>
[stop.longitude as number, stop.latitude as number] as [number, number],
);
}, [validStops]);
return (
<Stream.Root data={data} isLoading={isLoading} error={error}>
<Card
className={cn(
"w-full max-w-2xl overflow-hidden transition-color",
isIdle ? "py-0" : "py-6",
borderColors[currentState],
className,
)}
>
<AnimatePresence mode="popLayout">
{!isIdle && (
<motion.div
initial={{
opacity: 0,
clipPath: "inset(0 0 100% 0)",
y: -8,
}}
animate={{
opacity: 1,
clipPath: "inset(0 0 0 0)",
y: 0,
}}
exit={{
opacity: 0,
clipPath: "inset(0 0 100% 0)",
y: -8,
}}
transition={{
duration: 0.25,
ease: [0.4, 0, 0.2, 1],
}}
style={{ willChange: "clip-path, opacity, transform" }}
>
<CardHeader className="min-h-[100px] pb-2">
<CardTitle>
<Stream.Field fallback={<Skeleton className="h-6 w-3/4" />}>
{data?.title}
</Stream.Field>
</CardTitle>
<Stream.Field
fallback={
<div className="space-y-1.5">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
</div>
}
>
{data?.description && (
<p className="text-sm text-muted-foreground">
{data.description}
</p>
)}
</Stream.Field>
</CardHeader>
</motion.div>
)}
</AnimatePresence>
<CardContent className="p-0">
<motion.div
layout
className="relative w-full"
initial={false}
animate={{ height: isIdle ? 400 : 280 }}
transition={{
height: { duration: 0.35, ease: [0.4, 0, 0.2, 1] },
layout: { duration: 0.35, ease: [0.4, 0, 0.2, 1] },
}}
style={{ willChange: "height" }}
>
<Map
center={mapBounds?.center ?? [0, 20]}
zoom={mapBounds?.zoom ?? 2}
>
<CameraController stops={validStops} isStreaming={isStreaming} />
<MapControls showZoom showLocate={false} />
{routeCoordinates.length >= 2 && (
<MapRoute
coordinates={routeCoordinates}
color="#3b82f6"
width={3}
opacity={0.7}
/>
)}
<AnimatePresence>
{validStops.map((stop, index) => (
<StopMarker
key={stop.id ?? index}
stop={stop}
index={index}
/>
))}
</AnimatePresence>
</Map>
</motion.div>
<AnimatePresence mode="popLayout">
{!isIdle && (
<motion.div
initial={{
opacity: 0,
clipPath: "inset(100% 0 0 0)",
y: 16,
}}
animate={{
opacity: 1,
clipPath: "inset(0 0 0 0)",
y: 0,
}}
exit={{
opacity: 0,
clipPath: "inset(100% 0 0 0)",
y: 16,
}}
transition={{
duration: 0.3,
ease: [0.4, 0, 0.2, 1],
delay: 0.05,
}}
style={{ willChange: "clip-path, opacity, transform" }}
className="max-h-[200px] overflow-y-auto px-6 py-4"
>
{validStops.length > 0 ? (
<div>
{validStops.map((stop, index) => (
<StopListItem
key={stop.id ?? index}
stop={stop}
index={index}
isLast={index === validStops.length - 1 && !isLoading}
/>
))}
{isLoading && <StopListSkeleton />}
</div>
) : (
<div className="space-y-3">
<StopListSkeleton />
<StopListSkeleton />
<StopListSkeleton />
</div>
)}
</motion.div>
)}
</AnimatePresence>
</CardContent>
</Card>
</Stream.Root>
);
}import { z } from "zod";
export const itineraryStopSchema = z.object({
id: z.string().describe("Unique identifier for the stop"),
name: z.string().describe("Name of the place or attraction"),
description: z.string().describe("Brief description of what to do here"),
duration: z.string().describe("Suggested time to spend"),
longitude: z.number().describe("Longitude coordinate"),
latitude: z.number().describe("Latitude coordinate"),
type: z
.enum(["food", "attraction", "hotel", "transport", "activity", "shopping"])
.describe("Type of stop"),
});
export const streamingItinerarySchema = z.object({
title: z.string().describe("Trip title"),
description: z.string().describe("Brief overview of the trip"),
stops: z.array(itineraryStopSchema).describe("Ordered list of stops"),
});
export type ItineraryStop = z.infer<typeof itineraryStopSchema>;
export type StreamingItineraryData = z.infer<typeof streamingItinerarySchema>;import { gateway, Output, streamText } from "ai";
import { streamingItinerarySchema } from "@/components/ui/streaming-itinerary-schema";
export async function POST(request: Request) {
const { prompt } = await request.json();
const result = streamText({
model: gateway("openai/gpt-4.1-mini"),
output: Output.object({ schema: streamingItinerarySchema }),
system: \`You are a travel planning assistant. Generate detailed itineraries with real, accurate locations.
For each stop, provide:
- id: A unique identifier (e.g., "stop-1", "shibuya-crossing")
- name: The actual name of the place
- description: What to do or see there (1-2 sentences)
- duration: Realistic time to spend (e.g., "1 hour", "30 min", "2 hours")
- longitude: Accurate longitude coordinate
- latitude: Accurate latitude coordinate
- type: One of "food", "attraction", "hotel", "transport", "activity", "shopping"
Guidelines:
- Use REAL places with accurate coordinates
- Order stops logically for efficient travel
- Include a mix of stop types when appropriate
- Keep descriptions concise but informative
- Generate 5-7 stops for a day trip\`,
prompt: \`Create an itinerary for: \${prompt}\`,
providerOptions: {
openai: {
strictJsonSchema: false,
},
},
});
return result.toTextStreamResponse();
}Features
- Interactive Map: Built on MapLibre GL with theme support (light/dark)
- Animated Markers: Stops appear with spring animations as they stream
- Route Visualization: Automatic route line connecting all stops
- Timeline Sidebar: Scrollable list of stops with icons and duration
- Type-based Icons: Different icons for food, attractions, hotels, etc.
- Popup Details: Click markers for more information
API Reference
StreamingItinerary
| Prop | Type | Default |
|---|---|---|
data | DeepPartial<StreamingItineraryData> | undefined | — |
isLoading | boolean | — |
error | Error | undefined | — |
className | string | undefined | — |
StreamingItineraryData Schema
| Field | Type |
|---|---|
title | string |
description | string |
stops | ItineraryStop[] |
ItineraryStop Schema
| Field | Type |
|---|---|
id | string |
name | string |
description | string |
duration | string |
longitude | number |
latitude | number |
type | "food" | "attraction" | "hotel" | "transport" | "activity" | "shopping" |