tool-ui
blockchartsource: ok · hit
Chart
Visualize data with interactive charts.
npx shadcn add "https://www.tool-ui.com/r/chart.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightformszod
Registry dependencies
cardchart
Files8 · source cached 10/9/2026, 3:51:40 AM
components/tool-ui/chart/_adapter.tsx
/**
* Adapter: UI and utility re-exports for copy-standalone portability.
*
* When copying this component to another project, update these imports
* to match your project's paths:
*
* cn → Your Tailwind merge utility (e.g., "@/lib/utils", "~/lib/cn")
* Chart → shadcn/ui Chart (recharts wrapper)
* Card → shadcn/ui Card
*/
export { cn } from "@/lib/utils";
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
type ChartConfig,
} from "@/components/ui/chart";
export {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
components/tool-ui/chart/chart.tsx
"use client";
import { useMemo, useCallback, memo } from "react";
import {
BarChart,
LineChart,
Bar,
Line,
XAxis,
YAxis,
CartesianGrid,
} from "recharts";
import {
cn,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
type ChartConfig,
} from "./_adapter";
import type { ChartProps } from "./schema";
const DEFAULT_COLORS = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
];
export const Chart = memo(function Chart({
id,
type,
title,
description,
data,
xKey,
series,
colors,
showLegend = false,
showGrid = true,
className,
onDataPointClick,
}: ChartProps) {
const palette = colors?.length ? colors : DEFAULT_COLORS;
const seriesColors = useMemo(
() =>
series.map(
(seriesItem, index) =>
seriesItem.color ?? palette[index % palette.length],
),
[series, palette],
);
const chartConfig: ChartConfig = useMemo(
() =>
Object.fromEntries(
series.map((seriesItem, index) => [
seriesItem.key,
{
label: seriesItem.label,
color: seriesColors[index],
},
]),
),
[series, seriesColors],
);
const handleDataPointClick = useCallback(
(
seriesKey: string,
seriesLabel: string,
payload: Record<string, unknown>,
index: number,
) => {
onDataPointClick?.({
seriesKey,
seriesLabel,
xValue: payload[xKey],
yValue: payload[seriesKey],
index,
payload,
});
},
[onDataPointClick, xKey],
);
const ChartComponent = type === "bar" ? BarChart : LineChart;
const chartContent = (
<ChartContainer
config={chartConfig}
className="min-h-[200px] w-full"
data-tool-ui-id={id}
>
<ChartComponent data={data} accessibilityLayer>
{showGrid && <CartesianGrid vertical={false} />}
<XAxis
dataKey={xKey}
tickLine={false}
tickMargin={10}
axisLine={false}
/>
<YAxis tickLine={false} axisLine={false} tickMargin={10} />
<ChartTooltip content={<ChartTooltipContent />} />
{showLegend && <ChartLegend content={<ChartLegendContent />} />}
{type === "bar" &&
series.map((s, i) => (
<Bar
key={s.key}
dataKey={s.key}
fill={seriesColors[i]}
radius={4}
onClick={(data) =>
handleDataPointClick(s.key, s.label, data.payload, data.index)
}
cursor={onDataPointClick ? "pointer" : undefined}
/>
))}
{type === "line" &&
series.map((s, i) => (
<Line
key={s.key}
dataKey={s.key}
type="monotone"
stroke={seriesColors[i]}
strokeWidth={2}
dot={{ r: 4, cursor: onDataPointClick ? "pointer" : undefined }}
activeDot={{
r: 6,
cursor: onDataPointClick ? "pointer" : undefined,
// Recharts types are incorrect - onClick receives (event, dotData) at runtime
onClick: ((
_: unknown,
dotData: { payload: Record<string, unknown>; index: number },
) => {
handleDataPointClick(
s.key,
s.label,
dotData.payload,
dotData.index,
);
}) as unknown as React.MouseEventHandler,
}}
/>
))}
</ChartComponent>
</ChartContainer>
);
return (
<Card
className={cn("w-full min-w-80", className)}
data-tool-ui-id={id}
data-slot="chart"
>
{(title || description) && (
<CardHeader>
{title && <CardTitle className="text-pretty">{title}</CardTitle>}
{description && (
<CardDescription className="text-pretty">
{description}
</CardDescription>
)}
</CardHeader>
)}
<CardContent>{chartContent}</CardContent>
</Card>
);
});
components/tool-ui/chart/index.tsx
export { Chart } from "./chart";
export {
type ChartProps,
type ChartSeries,
type ChartDataPoint,
type ChartClientProps,
type SerializableChart,
} from "./schema";
components/tool-ui/chart/README.md
# Chart
Implementation for the "chart" Tool UI surface.
## Files
- public exports: components/tool-ui/chart/index.tsx
- serializable schema + parse helpers: components/tool-ui/chart/schema.ts
## Companion assets
- Docs page: app/docs/chart/content.mdx
- Preset payload: lib/presets/chart.ts
## Quick check
Run this after edits:
pnpm test
components/tool-ui/chart/schema.ts
import { z } from "zod";
import { defineToolUiContract } from "../shared/contract";
import {
ToolUIIdSchema,
ToolUIReceiptSchema,
ToolUIRoleSchema,
} from "../shared/schema";
export const ChartSeriesSchema = z.object({
key: z.string().min(1),
label: z.string().min(1),
color: z.string().optional(),
});
export type ChartSeries = z.infer<typeof ChartSeriesSchema>;
export const ChartPropsSchema = z
.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
receipt: ToolUIReceiptSchema.optional(),
type: z.enum(["bar", "line"]),
title: z.string().optional(),
description: z.string().optional(),
data: z.array(z.record(z.string(), z.unknown())).min(1),
xKey: z.string().min(1),
series: z.array(ChartSeriesSchema).min(1),
/** Color palette applied to series in order. Individual series.color takes precedence. */
colors: z.array(z.string().min(1)).min(1).optional(),
showLegend: z.boolean().optional(),
showGrid: z.boolean().optional(),
})
.superRefine((value, ctx) => {
const seenSeriesKeys = new Set<string>();
value.series.forEach((series, index) => {
if (seenSeriesKeys.has(series.key)) {
ctx.addIssue({
code: "custom",
path: ["series", index, "key"],
message: `Duplicate series key "${series.key}".`,
});
return;
}
seenSeriesKeys.add(series.key);
});
value.data.forEach((row, rowIndex) => {
if (!(value.xKey in row)) {
ctx.addIssue({
code: "custom",
path: ["data", rowIndex, value.xKey],
message: `Missing xKey "${value.xKey}" in data row.`,
});
} else {
const xVal = row[value.xKey];
const isValidX = typeof xVal === "string" || typeof xVal === "number";
if (!isValidX) {
ctx.addIssue({
code: "custom",
path: ["data", rowIndex, value.xKey],
message: `Expected "${value.xKey}" to be a string or number.`,
});
}
}
value.series.forEach((series) => {
if (!(series.key in row)) {
ctx.addIssue({
code: "custom",
path: ["data", rowIndex, series.key],
message: `Missing series key "${series.key}" in data row.`,
});
return;
}
const yVal = row[series.key];
if (yVal === null) {
return;
}
if (typeof yVal !== "number" || !Number.isFinite(yVal)) {
ctx.addIssue({
code: "custom",
path: ["data", rowIndex, series.key],
message: `Expected "${series.key}" to be a finite number (or null).`,
});
}
});
});
});
export type ChartDataPoint = {
seriesKey: string;
seriesLabel: string;
xValue: unknown;
yValue: unknown;
index: number;
payload: Record<string, unknown>;
};
export type ChartClientProps = {
className?: string;
onDataPointClick?: (point: ChartDataPoint) => void;
};
export type ChartProps = z.infer<typeof ChartPropsSchema> & ChartClientProps;
export const SerializableChartSchema = ChartPropsSchema;
export type SerializableChart = z.infer<typeof SerializableChartSchema>;
const SerializableChartSchemaContract = defineToolUiContract(
"Chart",
SerializableChartSchema,
);
export const parseSerializableChart: (input: unknown) => SerializableChart =
SerializableChartSchemaContract.parse;
export const safeParseSerializableChart: (
input: unknown,
) => SerializableChart | null = SerializableChartSchemaContract.safeParse;
components/tool-ui/shared/contract.ts
import { z } from "zod";
import { parseWithSchema, safeParseWithSchema } from "./parse";
export interface ToolUiContract<T> {
schema: z.ZodType<T>;
parse: (input: unknown) => T;
safeParse: (input: unknown) => T | null;
}
export function defineToolUiContract<T>(
componentName: string,
schema: z.ZodType<T>,
): ToolUiContract<T> {
return {
schema,
parse: (input: unknown) => parseWithSchema(schema, input, componentName),
safeParse: (input: unknown) => safeParseWithSchema(schema, input),
};
}
components/tool-ui/shared/parse.ts
import { z } from "zod";
function formatZodPath(path: Array<string | number | symbol>): string {
if (path.length === 0) return "root";
return path
.map((segment) =>
typeof segment === "number" ? `[${segment}]` : String(segment),
)
.join(".");
}
/**
* Format Zod errors into a compact `path: message` string.
*/
export function formatZodError(error: z.ZodError): string {
const parts = error.issues.map((issue) => {
const path = formatZodPath(issue.path);
return `${path}: ${issue.message}`;
});
return Array.from(new Set(parts)).join("; ");
}
/**
* Parse unknown input and throw a readable error.
*/
export function parseWithSchema<T>(
schema: z.ZodType<T>,
input: unknown,
name: string,
): T {
const res = schema.safeParse(input);
if (!res.success) {
throw new Error(`Invalid ${name} payload: ${formatZodError(res.error)}`);
}
return res.data;
}
/**
* Parse unknown input, returning `null` instead of throwing on failure.
*
* Use this in assistant-ui `render` functions where `args` stream in
* incrementally and may be incomplete until the tool call finishes.
*/
export function safeParseWithSchema<T>(
schema: z.ZodType<T>,
input: unknown,
): T | null {
const res = schema.safeParse(input);
return res.success ? res.data : null;
}
components/tool-ui/shared/schema.ts
import { z } from "zod";
import type { ReactNode } from "react";
/**
* Tool UI conventions:
* - Serializable schemas are JSON-safe (no callbacks/ReactNode/`className`).
* - Schema: `SerializableXSchema`
* - Parser: `parseSerializableX(input: unknown)` (throws on invalid)
* - Safe parser: `safeParseSerializableX(input: unknown)` (returns `null` on invalid)
* - Actions: `LocalActions` for non-receipt actions and `DecisionActions` for consequential actions
* - Root attrs: `data-tool-ui-id` + `data-slot`
*/
/**
* Schema for tool UI identity.
*
* Every tool UI should have a unique identifier that:
* - Is stable across re-renders
* - Is meaningful (not auto-generated)
* - Is unique within the conversation
*
* Format recommendation: `{component-type}-{semantic-identifier}`
* Examples: "data-table-expenses-q3", "option-list-deploy-target"
*/
export const ToolUIIdSchema = z.string().min(1);
export type ToolUIId = z.infer<typeof ToolUIIdSchema>;
/**
* Primary role of a Tool UI surface in a chat context.
*/
export const ToolUIRoleSchema = z.enum([
"information",
"decision",
"control",
"state",
"composite",
]);
export type ToolUIRole = z.infer<typeof ToolUIRoleSchema>;
export const ToolUIReceiptOutcomeSchema = z.enum([
"success",
"partial",
"failed",
"cancelled",
]);
export type ToolUIReceiptOutcome = z.infer<typeof ToolUIReceiptOutcomeSchema>;
/**
* Optional receipt metadata: a durable summary of an outcome.
*/
export const ToolUIReceiptSchema = z.object({
outcome: ToolUIReceiptOutcomeSchema,
summary: z.string().min(1),
identifiers: z.record(z.string(), z.string()).optional(),
at: z.string().datetime(),
});
export type ToolUIReceipt = z.infer<typeof ToolUIReceiptSchema>;
/**
* Base schema for Tool UI payloads (id + optional role/receipt).
*/
export const ToolUISurfaceSchema = z.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
receipt: ToolUIReceiptSchema.optional(),
});
export type ToolUISurface = z.infer<typeof ToolUISurfaceSchema>;
export const ActionSchema = z.object({
id: z.string().min(1),
label: z.string().min(1),
/**
* Canonical narration the assistant can use after this action is taken.
*
* Example: "I exported the table as CSV." / "I opened the link in a new tab."
*/
sentence: z.string().optional(),
confirmLabel: z.string().optional(),
variant: z
.enum(["default", "destructive", "secondary", "ghost", "outline"])
.optional(),
icon: z.custom<ReactNode>().optional(),
loading: z.boolean().optional(),
disabled: z.boolean().optional(),
shortcut: z.string().optional(),
});
export type Action = z.infer<typeof ActionSchema>;
export type LocalAction = Action;
export type DecisionAction = Action;
export const DecisionResultSchema = z.object({
kind: z.literal("decision"),
version: z.literal(1),
decisionId: z.string().min(1),
actionId: z.string().min(1),
actionLabel: z.string().min(1),
at: z.string().datetime(),
payload: z.record(z.string(), z.unknown()).optional(),
});
export type DecisionResult<
TPayload extends Record<string, unknown> = Record<string, unknown>,
> = Omit<z.infer<typeof DecisionResultSchema>, "payload"> & {
payload?: TPayload;
};
export function createDecisionResult<
TPayload extends Record<string, unknown> = Record<string, unknown>,
>(args: {
decisionId: string;
action: { id: string; label: string };
payload?: TPayload;
}): DecisionResult<TPayload> {
return {
kind: "decision",
version: 1,
decisionId: args.decisionId,
actionId: args.action.id,
actionLabel: args.action.label,
at: new Date().toISOString(),
payload: args.payload,
};
}
export const ActionButtonsPropsSchema = z.object({
actions: z.array(ActionSchema).min(1),
align: z.enum(["left", "center", "right"]).optional(),
confirmTimeout: z.number().positive().optional(),
className: z.string().optional(),
});
export const SerializableActionSchema = ActionSchema.omit({ icon: true });
export const SerializableActionsSchema = ActionButtonsPropsSchema.extend({
actions: z.array(SerializableActionSchema),
}).omit({ className: true });
export interface ActionsConfig {
items: Action[];
align?: "left" | "center" | "right";
confirmTimeout?: number;
}
export const SerializableActionsConfigSchema = z.object({
items: z.array(SerializableActionSchema).min(1),
align: z.enum(["left", "center", "right"]).optional(),
confirmTimeout: z.number().positive().optional(),
});
export type SerializableActionsConfig = z.infer<
typeof SerializableActionsConfigSchema
>;
export type SerializableAction = z.infer<typeof SerializableActionSchema>;