Tree
An animated tree component that streams hierarchical data from AI with progressive node expansion.
A tree view component that progressively reveals nodes as they stream from AI, with collapsible folders, animated transitions, and tree connector lines.
Click a button to generate a tree
Installation
npm install @stream.ui/react lucide-react motionnpx shadcn@latest add card skeletonimport { z } from "zod";
const baseTreeNode = z.object({
id: z.string().describe("Unique identifier for the node"),
label: z.string().describe("Display label for the node"),
icon: z.string().optional().describe("Optional icon identifier"),
description: z.string().optional().describe("Optional description"),
});
export type StreamingTreeNode = z.infer<typeof baseTreeNode> & {
children?: StreamingTreeNode[];
};
export const streamingTreeNodeSchema: z.ZodType<StreamingTreeNode> = baseTreeNode.extend({
children: z.lazy(() => z.array(streamingTreeNodeSchema)).optional(),
});
export const streamingTreeSchema = z.object({
title: z.string().optional().describe("Optional tree title"),
nodes: z.array(streamingTreeNodeSchema).describe("Root level tree nodes"),
});
export type StreamingTreeData = z.infer<typeof streamingTreeSchema>;"use client";
import type { DeepPartial } from "@stream.ui/react";
import { Stream } from "@stream.ui/react";
import {
ChevronRight,
File,
FileCode,
FileText,
Folder,
FolderOpen,
Image,
Settings,
User,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import * as React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import type { StreamingTreeData, StreamingTreeNode } from "./streaming-tree-schema";
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
folder: Folder,
"folder-open": FolderOpen,
file: File,
"file-code": FileCode,
"file-text": FileText,
image: Image,
user: User,
settings: Settings,
};
function getIcon(iconName: string | undefined, isOpen: boolean, hasChildren: boolean) {
if (iconName && iconMap[iconName]) {
return iconMap[iconName];
}
if (hasChildren) {
return isOpen ? FolderOpen : Folder;
}
return File;
}
interface TreeNodeProps {
node: DeepPartial<StreamingTreeNode>;
depth: number;
isLast: boolean;
parentPath: boolean[];
}
function TreeNodeSkeleton({ depth }: { depth: number }) {
return (
<div
className="flex items-center gap-2 py-1.5"
style={{ paddingLeft: depth * 20 + 8 }}
>
<Skeleton className="h-4 w-4 rounded" />
<Skeleton className="h-4 w-24" />
</div>
);
}
function TreeNode({ node, depth, isLast, parentPath }: TreeNodeProps) {
const [isOpen, setIsOpen] = React.useState(true);
const hasChildren = node.children && node.children.length > 0;
const validChildren = React.useMemo(() => {
if (!node.children) return [];
return node.children.filter(
(child): child is DeepPartial<StreamingTreeNode> =>
child !== null && child !== undefined && typeof child.label === "string"
);
}, [node.children]);
const Icon = getIcon(node.icon, isOpen, hasChildren);
const isComplete = node.id !== undefined && node.label !== undefined;
if (!isComplete) {
return <TreeNodeSkeleton depth={depth} />;
}
return (
<motion.div
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
>
<div
className={cn(
"group relative flex items-center gap-1 rounded-md py-1 pr-2 transition-colors",
hasChildren && "cursor-pointer hover:bg-muted/50",
)}
style={{ paddingLeft: depth * 20 + 8 }}
onClick={() => hasChildren && setIsOpen(!isOpen)}
role={hasChildren ? "button" : undefined}
tabIndex={hasChildren ? 0 : undefined}
>
{hasChildren ? (
<motion.div
animate={{ rotate: isOpen ? 90 : 0 }}
className="flex h-4 w-4 shrink-0 items-center justify-center"
>
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
</motion.div>
) : (
<div className="h-4 w-4 shrink-0" />
)}
<Icon
className={cn(
"h-4 w-4 shrink-0",
hasChildren
? "text-amber-500 dark:text-amber-400"
: "text-muted-foreground",
)}
/>
<span className="truncate text-sm">{node.label}</span>
{node.description && (
<span className="ml-2 truncate text-xs text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100">
{node.description}
</span>
)}
</div>
<AnimatePresence initial={false}>
{isOpen && validChildren.length > 0 && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
style={{ overflow: "hidden" }}
>
{validChildren.map((child, index) => (
<TreeNode
key={child.id ?? index}
node={child}
depth={depth + 1}
isLast={index === validChildren.length - 1}
parentPath={[...parentPath, !isLast]}
/>
))}
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}
interface StreamingTreeProps {
data: DeepPartial<StreamingTreeData> | undefined;
isLoading: boolean;
error?: Error;
className?: string;
}
export function StreamingTree({ data, isLoading, error, className }: StreamingTreeProps) {
const isStreaming = isLoading && data !== undefined;
const isComplete = !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 validNodes = React.useMemo(() => {
if (!data?.nodes) return [];
return data.nodes.filter(
(node): node is DeepPartial<StreamingTreeNode> =>
node !== null && node !== undefined && typeof node.label === "string"
);
}, [data?.nodes]);
return (
<Stream.Root data={data} isLoading={isLoading} error={error}>
<Card className={cn("w-full max-w-md transition-colors", borderColors[currentState], className)}>
{data?.title && (
<CardHeader className="pb-2">
<CardTitle>
<Stream.Field fallback={<Skeleton className="h-6 w-32" />}>
{data.title}
</Stream.Field>
</CardTitle>
</CardHeader>
)}
<CardContent className={cn(!data?.title && "pt-4")}>
<div className="min-h-[120px]">
{validNodes.map((node, index) => (
<TreeNode
key={node.id ?? index}
node={node}
depth={0}
isLast={index === validNodes.length - 1}
parentPath={[]}
/>
))}
{isLoading && <TreeNodeSkeleton depth={0} />}
</div>
</CardContent>
</Card>
</Stream.Root>
);
}import { openai } from "@ai-sdk/openai";
import { streamObject } from "ai";
import { streamingTreeSchema } from "@/components/ui/streaming-tree-schema";
export async function POST(request: Request) {
const { prompt } = await request.json();
const result = streamObject({
model: openai("gpt-4o-mini"),
schema: streamingTreeSchema,
system: `You are a file structure generator. Generate realistic file/folder tree structures.
For each node include:
- id: Unique identifier (e.g., "src/components")
- label: File or folder name
- icon: One of "folder", "file", "file-code", "file-text", "image"
- description: Brief description (optional)
- children: Array of child nodes (for folders)
Keep the tree focused (10-25 nodes total).`,
prompt: `Generate a file structure for: ${prompt}`,
});
return result.toTextStreamResponse();
}API Reference
StreamingTree
| Prop | Type | Default |
|---|---|---|
data | DeepPartial<StreamingTreeData> | undefined | — |
isLoading | boolean | — |
error | Error | undefined | — |
className | string | undefined | — |
StreamingTreeData Schema
| Field | Type |
|---|---|
title | string | undefined |
nodes | StreamingTreeNode[] |
StreamingTreeNode Schema
| Field | Type |
|---|---|
id | string |
label | string |
icon | string | undefined |
description | string | undefined |
children | StreamingTreeNode[] | undefined |