S

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 motion
npx shadcn@latest add card skeleton

Copy the Map component from mapcn to your project.

streaming-itinerary.tsx
"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>
  );
}
streaming-itinerary-schema.ts
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>;
app/api/stream/itinerary/route.ts
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

PropTypeDefault
dataDeepPartial<StreamingItineraryData> | undefined
isLoadingboolean
errorError | undefined
classNamestring | undefined

StreamingItineraryData Schema

FieldType
titlestring
descriptionstring
stopsItineraryStop[]

ItineraryStop Schema

FieldType
idstring
namestring
descriptionstring
durationstring
longitudenumber
latitudenumber
type"food" | "attraction" | "hotel" | "transport" | "activity" | "shopping"

On this page