shadimage
tool-ui
blockimage-gallerysource: ok · hit

Image Gallery

Grid layout for browsing image collections.

npx shadcn add "https://www.tool-ui.com/r/image-gallery.json"
free

Where it goes

gallery

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react
formszod

Registry dependencies

button

Files18 · source cached 10/8/2026, 3:38:46 PM

components/tool-ui/image-gallery/_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")
 *   Button → shadcn/ui Button
 */

export { cn } from "@/lib/utils";
export { Button } from "@/components/ui/button";
export { ChevronLeft, ChevronRight, X, ImageOff } from "lucide-react";
components/tool-ui/image-gallery/context.tsx
"use client";

import {
  createContext,
  use,
  useState,
  useCallback,
  useMemo,
  useRef,
} from "react";
import { flushSync } from "react-dom";
import type { ImageGalleryItem } from "./schema";

const VIEW_TRANSITION_NAME = "active-gallery-image";

interface ImageGalleryContextValue {
  images: ImageGalleryItem[];
  activeIndex: number | null;
  openLightbox: (index: number) => void;
  closeLightbox: () => void;
  registerImage: (id: string, element: HTMLElement | null) => void;
  lightboxContentRef: React.RefObject<HTMLDivElement | null>;
  setDialogRef: (element: HTMLDialogElement | null) => void;
}

const ImageGalleryContext = createContext<ImageGalleryContextValue | null>(
  null,
);

export function useImageGallery(): ImageGalleryContextValue {
  const context = use(ImageGalleryContext);
  if (!context) {
    throw new Error("useImageGallery must be used within ImageGalleryProvider");
  }
  return context;
}

function supportsViewTransitions(): boolean {
  return (
    typeof document !== "undefined" &&
    "startViewTransition" in document &&
    typeof window !== "undefined" &&
    !window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches
  );
}

function withViewTransition(
  element: HTMLElement,
  domUpdate: () => void,
  onFinished?: () => void,
): void {
  if (!supportsViewTransitions()) {
    domUpdate();
    onFinished?.();
    return;
  }

  element.style.viewTransitionName = VIEW_TRANSITION_NAME;

  const transition = document.startViewTransition(() => domUpdate());

  transition.finished.finally(() => {
    element.style.removeProperty("view-transition-name");
    onFinished?.();
  });
}

interface ImageGalleryProviderProps {
  images: ImageGalleryItem[];
  children: React.ReactNode;
}

export function ImageGalleryProvider({
  images,
  children,
}: ImageGalleryProviderProps) {
  const [activeIndex, setActiveIndex] = useState<number | null>(null);

  const imageElementsRef = useRef<Map<string, HTMLElement>>(new Map());
  const lightboxContentRef = useRef<HTMLDivElement>(null);
  const dialogRef = useRef<HTMLDialogElement | null>(null);
  const originalParentRef = useRef<HTMLElement | null>(null);

  const registerImage = useCallback(
    (id: string, element: HTMLElement | null) => {
      if (element) {
        imageElementsRef.current.set(id, element);
      } else {
        imageElementsRef.current.delete(id);
      }
    },
    [],
  );

  const setDialogRef = useCallback((element: HTMLDialogElement | null) => {
    dialogRef.current = element;
  }, []);

  const openLightbox = useCallback(
    (index: number) => {
      const image = images[index];
      if (!image) return;

      const imageElement = imageElementsRef.current.get(image.id);
      const container = lightboxContentRef.current;
      const dialog = dialogRef.current;

      if (!imageElement || !container || !dialog) {
        setActiveIndex(index);
        dialog?.showModal();
        return;
      }

      originalParentRef.current = imageElement.parentElement;

      withViewTransition(imageElement, () => {
        container.appendChild(imageElement);
        flushSync(() => setActiveIndex(index));
        dialog.showModal();
      });
    },
    [images],
  );

  const closeLightbox = useCallback(() => {
    if (activeIndex === null) return;

    const image = images[activeIndex];
    const dialog = dialogRef.current;

    if (!image) {
      setActiveIndex(null);
      dialog?.close();
      return;
    }

    const imageElement = imageElementsRef.current.get(image.id);
    const originalParent = originalParentRef.current;

    if (!imageElement || !originalParent) {
      setActiveIndex(null);
      dialog?.close();
      return;
    }

    withViewTransition(
      imageElement,
      () => {
        originalParent.appendChild(imageElement);
        flushSync(() => setActiveIndex(null));
        dialog?.close();
      },
      () => {
        originalParentRef.current = null;
      },
    );
  }, [activeIndex, images]);

  const value = useMemo<ImageGalleryContextValue>(
    () => ({
      images,
      activeIndex,
      openLightbox,
      closeLightbox,
      registerImage,
      lightboxContentRef,
      setDialogRef,
    }),
    [
      images,
      activeIndex,
      openLightbox,
      closeLightbox,
      registerImage,
      setDialogRef,
    ],
  );

  return (
    <ImageGalleryContext.Provider value={value}>
      {children}
    </ImageGalleryContext.Provider>
  );
}
components/tool-ui/image-gallery/gallery-grid.tsx
"use client";

import { useState, useCallback, useEffect, useRef } from "react";
import { cn, ImageOff } from "./_adapter";
import { useImageGallery } from "./context";
import type { ImageGalleryItem } from "./schema";

type GridImage = Pick<
  ImageGalleryItem,
  "id" | "src" | "alt" | "width" | "height"
>;

interface GalleryGridProps {
  onImageClick?: (imageId: string) => void;
}

export function GalleryGrid({ onImageClick }: GalleryGridProps) {
  const { images, openLightbox } = useImageGallery();

  const handleOpen = useCallback(
    (index: number) => {
      const image = images[index];
      if (image && onImageClick) {
        onImageClick(image.id);
      }
      openLightbox(index);
    },
    [images, onImageClick, openLightbox],
  );

  return (
    <div
      className="grid grid-cols-2 gap-2 @md:grid-cols-3 @lg:grid-cols-4"
      role="list"
    >
      {images.map((image, index) => (
        <GridImageCard
          key={image.id}
          image={image}
          index={index}
          onClick={handleOpen}
        />
      ))}
    </div>
  );
}

interface GridImageCardProps {
  image: GridImage;
  index: number;
  onClick: (index: number) => void;
}

function GridImageCard({ image, index, onClick }: GridImageCardProps) {
  const [hasError, setHasError] = useState(false);
  const wrapperRef = useRef<HTMLDivElement>(null);

  const { registerImage } = useImageGallery();

  const shouldSpanTwoRows = isPortraitImage(image);

  useEffect(() => {
    const wrapper = wrapperRef.current;
    const img = wrapper?.querySelector("img");
    if (img) {
      registerImage(image.id, img);
    }
    return () => {
      registerImage(image.id, null);
    };
  }, [image.id, registerImage]);

  const handleClick = useCallback(() => {
    onClick(index);
  }, [onClick, index]);

  return (
    <div
      role="listitem"
      className={cn(
        "group relative cursor-pointer",
        shouldSpanTwoRows && "row-span-2",
      )}
      style={{ aspectRatio: shouldSpanTwoRows ? undefined : "1 / 1" }}
    >
      <button
        type="button"
        onClick={handleClick}
        className="absolute inset-0 z-20 h-full w-full rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
        aria-label={image.alt}
      />

      <div
        ref={wrapperRef}
        className="bg-muted relative h-full w-full overflow-hidden rounded-lg transition-transform duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] group-hover:scale-[1.02] group-active:scale-[0.98]"
      >
        {hasError ? (
          <ImageErrorState alt={image.alt} />
        ) : (
          <img
            src={image.src}
            alt={image.alt}
            width={image.width}
            height={image.height}
            loading="lazy"
            decoding="async"
            draggable={false}
            onError={() => setHasError(true)}
            className="h-full w-full object-cover"
          />
        )}
      </div>
    </div>
  );
}

function isPortraitImage(image: GridImage): boolean {
  const aspectRatio = image.width / image.height;
  const isPortrait = aspectRatio < 1;
  const isSquarish = aspectRatio >= 0.9 && aspectRatio <= 1.1;
  return isPortrait && !isSquarish;
}

function ImageErrorState({ alt }: { alt: string }) {
  return (
    <div className="absolute inset-0 flex flex-col items-center justify-center gap-2 p-4">
      <ImageOff className="text-muted-foreground h-8 w-8" />
      <span className="text-muted-foreground line-clamp-2 text-center text-xs">
        {alt}
      </span>
    </div>
  );
}
components/tool-ui/image-gallery/gallery-lightbox.tsx
"use client";

import { useRef, useCallback } from "react";
import { cn, Button, X } from "./_adapter";
import { useImageGallery } from "./context";
import type { ImageGalleryItem } from "./schema";
import { resolveSafeNavigationHref } from "../shared/media";

type LightboxImage = Pick<ImageGalleryItem, "title" | "caption" | "source">;

export function GalleryLightbox() {
  const dialogRef = useRef<HTMLDialogElement>(null);

  const {
    images,
    activeIndex,
    closeLightbox,
    lightboxContentRef,
    setDialogRef,
  } = useImageGallery();

  const isOpen = activeIndex !== null;
  const currentImage = isOpen ? images[activeIndex] : null;

  const handleDialogRef = useCallback(
    (element: HTMLDialogElement | null) => {
      dialogRef.current = element;
      setDialogRef(element);
    },
    [setDialogRef],
  );

  const handleBackdropClick = useCallback(
    (e: React.MouseEvent<HTMLDialogElement>) => {
      if (e.target === dialogRef.current) {
        closeLightbox();
      }
    },
    [closeLightbox],
  );

  const handleCancel = useCallback(
    (e: React.SyntheticEvent<HTMLDialogElement>) => {
      e.preventDefault();
      closeLightbox();
    },
    [closeLightbox],
  );

  return (
    <dialog
      ref={handleDialogRef}
      onClick={handleBackdropClick}
      onCancel={handleCancel}
      className={cn(
        "m-0 h-full max-h-full w-full max-w-full",
        "overflow-hidden p-0",
        "bg-transparent backdrop:bg-black/95 dark:backdrop:bg-black/90",
        "focus-visible:outline-none",
      )}
      aria-label="Image lightbox"
    >
      <div className="relative h-full w-full">
        {isOpen && <CloseButton onClose={closeLightbox} />}
        <div className="relative z-10 flex h-full w-full flex-col items-center justify-center gap-4 p-8">
          <div
            ref={lightboxContentRef}
            className={cn(
              "pointer-events-auto relative w-fit max-w-full overflow-hidden rounded-lg shadow-2xl",
              "[&>img]:block [&>img]:max-h-[80vh] [&>img]:max-w-full",
              "[&>img]:h-auto [&>img]:w-auto [&>img]:object-contain [&>img]:select-none",
            )}
          />
          {currentImage && <Metadata image={currentImage} />}
        </div>
      </div>
    </dialog>
  );
}

function CloseButton({ onClose }: { onClose: () => void }) {
  return (
    <div className="absolute top-4 right-4 z-20">
      <Button
        type="button"
        variant="ghost"
        size="icon"
        onClick={onClose}
        className="text-white/80 hover:bg-white/10 hover:text-white"
        aria-label="Close"
      >
        <X className="h-5 w-5" />
      </Button>
    </div>
  );
}

function Metadata({ image }: { image: LightboxImage }) {
  const { title, caption, source } = image;
  const hasTitle = Boolean(title);
  const hasCaption = Boolean(caption);
  const hasSource = Boolean(source?.label);

  if (!hasTitle && !hasCaption && !hasSource) {
    return null;
  }

  return (
    <div className="text-center">
      {hasTitle && (
        <h3 className="text-base font-medium tracking-tight text-white">
          {title}
        </h3>
      )}
      {(hasCaption || hasSource) && (
        <p className="mt-1 text-sm text-white/60">
          {caption}
          {hasCaption && hasSource && " · "}
          {hasSource && <SourceLink source={source!} />}
        </p>
      )}
    </div>
  );
}

function SourceLink({
  source,
}: {
  source: NonNullable<LightboxImage["source"]>;
}) {
  const href = resolveSafeNavigationHref(source.url);
  if (!href) {
    return <>{source.label}</>;
  }

  return (
    <a
      href={href}
      target="_blank"
      rel="noopener noreferrer"
      className="hover:text-white/80 hover:underline"
    >
      {source.label}
    </a>
  );
}
components/tool-ui/image-gallery/image-gallery.tsx
"use client";

import "./styles.css";
import { cn } from "./_adapter";
import { ImageGalleryProvider } from "./context";
import { GalleryGrid } from "./gallery-grid";
import { GalleryLightbox } from "./gallery-lightbox";
import type { ImageGalleryProps } from "./schema";

export function ImageGallery({
  id,
  images,
  title,
  description,
  className,
  onImageClick,
}: ImageGalleryProps) {
  const handleImageClick = (imageId: string) => {
    if (!onImageClick) return;

    const image = images.find((img) => img.id === imageId);
    if (image) {
      onImageClick(imageId, image);
    }
  };

  return (
    <article
      className={cn("relative w-full min-w-80 max-w-lg", className)}
      data-tool-ui-id={id}
      data-slot="image-gallery"
    >
      <div
        className={cn(
          "@container relative isolate flex w-full min-w-0 flex-col rounded-xl",
          "border border-border bg-card text-sm shadow-xs",
        )}
      >
        <ImageGalleryProvider images={images}>
          <Header title={title} description={description} />
          <div className="p-3">
            <GalleryGrid onImageClick={handleImageClick} />
          </div>
          <GalleryLightbox />
        </ImageGalleryProvider>
      </div>
    </article>
  );
}

interface HeaderProps {
  title?: string;
  description?: string;
}

function Header({ title, description }: HeaderProps) {
  if (!title && !description) {
    return null;
  }

  return (
    <div className="border-border/60 border-b px-4 pt-4 pb-3">
      {title && (
        <h3 className="text-[15px] leading-tight font-semibold tracking-tight">
          {title}
        </h3>
      )}
      {description && (
        <p className="text-muted-foreground mt-1 text-sm leading-snug">
          {description}
        </p>
      )}
    </div>
  );
}
components/tool-ui/image-gallery/index.tsx
export { ImageGallery } from "./image-gallery";
export type {
  ImageGalleryProps,
  ImageGalleryItem,
  SerializableImageGallery,
} from "./schema";
components/tool-ui/image-gallery/README.md
# Image Gallery

Implementation for the "image-gallery" Tool UI surface.

## Files

- public exports: components/tool-ui/image-gallery/index.tsx
- serializable schema + parse helpers: components/tool-ui/image-gallery/schema.ts

## Companion assets

- Docs page: app/docs/image-gallery/content.mdx
- Preset payload: lib/presets/image-gallery.ts

## Quick check

Run this after edits:

pnpm test
components/tool-ui/image-gallery/schema.ts
import { z } from "zod";
import { defineToolUiContract } from "../shared/contract";
import {
  ToolUIIdSchema,
  ToolUIReceiptSchema,
  ToolUIRoleSchema,
} from "../shared/schema";

export const ImageGallerySourceSchema = z.object({
  label: z.string(),
  url: z.string().url().optional(),
});

export type ImageGallerySource = z.infer<typeof ImageGallerySourceSchema>;

export const ImageGalleryItemSchema = z.object({
  id: z.string().min(1),
  src: z.string().url(),
  alt: z.string().min(1, "Images require alt text for accessibility"),
  width: z.number().positive(),
  height: z.number().positive(),
  title: z.string().optional(),
  caption: z.string().optional(),
  source: ImageGallerySourceSchema.optional(),
});

export type ImageGalleryItem = z.infer<typeof ImageGalleryItemSchema>;

export const SerializableImageGallerySchema = z.object({
  id: ToolUIIdSchema,
  role: ToolUIRoleSchema.optional(),
  receipt: ToolUIReceiptSchema.optional(),
  images: z.array(ImageGalleryItemSchema).min(1),
  title: z.string().optional(),
  description: z.string().optional(),
});

export type SerializableImageGallery = z.infer<
  typeof SerializableImageGallerySchema
>;

export interface ImageGalleryProps extends SerializableImageGallery {
  className?: string;
  onImageClick?: (imageId: string, image: ImageGalleryItem) => void;
}

const SerializableImageGallerySchemaContract = defineToolUiContract(
  "ImageGallery",
  SerializableImageGallerySchema,
);

export const parseSerializableImageGallery: (
  input: unknown,
) => SerializableImageGallery = SerializableImageGallerySchemaContract.parse;

export const safeParseSerializableImageGallery: (
  input: unknown,
) => SerializableImageGallery | null =
  SerializableImageGallerySchemaContract.safeParse;
components/tool-ui/image-gallery/styles.css
@supports (view-transition-name: none) {
  ::view-transition-group(active-gallery-image) {
    animation-duration: 300ms;
    animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
    overflow: clip;
    border-radius: 0.75rem;
  }

  ::view-transition-image-pair(active-gallery-image) {
    overflow: clip;
    border-radius: 0.75rem;
  }

  ::view-transition-old(active-gallery-image),
  ::view-transition-new(active-gallery-image) {
    border-radius: 0.75rem;
    mix-blend-mode: normal;
  }

  @media (prefers-reduced-motion: reduce) {
    ::view-transition-group(active-gallery-image) {
      animation-duration: 0ms;
    }
  }
}
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/media/aspect-ratio.ts
import { z } from "zod";

export const AspectRatioSchema = z
  .enum(["auto", "1:1", "4:3", "16:9", "9:16"])
  .default("auto");

export type AspectRatio = z.infer<typeof AspectRatioSchema>;

export const MediaFitSchema = z.enum(["cover", "contain"]).default("cover");

export type MediaFit = z.infer<typeof MediaFitSchema>;

export const RATIO_CLASS_MAP: Record<AspectRatio, string> = {
  auto: "",
  "1:1": "aspect-square",
  "4:3": "aspect-[4/3]",
  "16:9": "aspect-video",
  "9:16": "aspect-[9/16]",
};

export function getRatioClass(ratio: AspectRatio): string {
  return RATIO_CLASS_MAP[ratio];
}

export function getFitClass(fit: MediaFit): string {
  return fit === "cover" ? "object-cover" : "object-contain";
}
components/tool-ui/shared/media/format-utils.ts
/**
 * Format duration in milliseconds to human-readable string.
 * @example formatDuration(128000) => "2:08"
 * @example formatDuration(3661000) => "1:01:01"
 */
export function formatDuration(durationMs: number): string {
  const totalSeconds = Math.round(durationMs / 1000);
  const hours = Math.floor(totalSeconds / 3600);
  const minutes = Math.floor((totalSeconds % 3600) / 60);
  const seconds = totalSeconds % 60;

  if (hours > 0) {
    return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds
      .toString()
      .padStart(2, "0")}`;
  }
  return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}

/**
 * Format file size in bytes to human-readable string.
 * @example formatFileSize(1024) => "1 KB"
 * @example formatFileSize(1536000) => "1.5 MB"
 */
export function formatFileSize(bytes: number): string {
  if (bytes < 1024) return `${bytes} B`;
  const units = ["KB", "MB", "GB"];
  let size = bytes / 1024;
  let unit = 0;
  while (size >= 1024 && unit < units.length - 1) {
    size /= 1024;
    unit += 1;
  }
  return `${size.toFixed(size >= 10 ? 0 : 1)} ${units[unit]}`;
}
components/tool-ui/shared/media/index.ts
export {
  AspectRatioSchema,
  MediaFitSchema,
  RATIO_CLASS_MAP,
  getRatioClass,
  getFitClass,
  type AspectRatio,
  type MediaFit,
} from "./aspect-ratio";

export { OVERLAY_GRADIENT } from "./overlay-gradient";

export { formatDuration, formatFileSize } from "./format-utils";

export { sanitizeHref } from "./sanitize-href";
export {
  resolveSafeNavigationHref,
  openSafeNavigationHref,
} from "./safe-navigation";
components/tool-ui/shared/media/overlay-gradient.ts
/**
 * Eased gradient for hover overlays on media elements.
 * Creates a smooth fade from opaque black at top to transparent.
 *
 * @see https://larsenwork.com/easing-gradients/
 */
export const OVERLAY_GRADIENT = `linear-gradient(
  to bottom,
  hsl(0, 0%, 0%) 0%,
  hsla(0, 0%, 0%, 0.987) 8.3%,
  hsla(0, 0%, 0%, 0.951) 16.6%,
  hsla(0, 0%, 0%, 0.896) 24.6%,
  hsla(0, 0%, 0%, 0.825) 32.5%,
  hsla(0, 0%, 0%, 0.741) 40.1%,
  hsla(0, 0%, 0%, 0.648) 47.6%,
  hsla(0, 0%, 0%, 0.55) 54.8%,
  hsla(0, 0%, 0%, 0.45) 61.7%,
  hsla(0, 0%, 0%, 0.352) 68.3%,
  hsla(0, 0%, 0%, 0.259) 74.5%,
  hsla(0, 0%, 0%, 0.175) 80.4%,
  hsla(0, 0%, 0%, 0.104) 86%,
  hsla(0, 0%, 0%, 0.049) 91.1%,
  hsla(0, 0%, 0%, 0.013) 95.8%,
  hsla(0, 0%, 0%, 0) 100%
)` as const;
components/tool-ui/shared/media/safe-navigation.ts
import { sanitizeHref } from "./sanitize-href";

export function resolveSafeNavigationHref(
  ...candidates: Array<string | null | undefined>
): string | undefined {
  for (const candidate of candidates) {
    const safeHref = sanitizeHref(candidate ?? undefined);
    if (safeHref) {
      return safeHref;
    }
  }

  return undefined;
}

export function openSafeNavigationHref(href: string | undefined): boolean {
  if (!href || typeof window === "undefined") {
    return false;
  }

  window.open(href, "_blank", "noopener,noreferrer");
  return true;
}
components/tool-ui/shared/media/sanitize-href.ts
/**
 * Sanitize a URL to ensure it's safe for use in href attributes.
 * Allows:
 * - Absolute http(s) URLs
 * - Relative URLs (/path, ./path, ../path, ?query, #hash)
 *
 * @returns The sanitized URL string, or undefined if invalid/unsafe
 */
export function sanitizeHref(href?: string): string | undefined {
  if (!href) return undefined;
  const candidate = href.trim();
  if (!candidate) return undefined;

  if (
    candidate.startsWith("/") ||
    candidate.startsWith("./") ||
    candidate.startsWith("../") ||
    candidate.startsWith("?") ||
    candidate.startsWith("#")
  ) {
    if (candidate.startsWith("//")) return undefined;
    // eslint-disable-next-line no-control-regex -- intentionally matching control characters
    if (/[\u0000-\u001F\u007F]/.test(candidate)) return undefined;
    return candidate;
  }

  try {
    const url = new URL(candidate);
    if (url.protocol === "http:" || url.protocol === "https:") {
      return url.toString();
    }
  } catch {
    return undefined;
  }
  return undefined;
}
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>;

More from tool-ui

Browse all components in this library →