# Image

> Display images with metadata and attribution.

A shadcn/ui block from the [@tool-ui](https://www.tool-ui.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/tool-ui/image).

- Page, with a live preview: https://shadcn.exchange/r/tool-ui/image
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/tool-ui/image-light-1569261ffae8c76c.png
- Registry item: https://www.tool-ui.com/r/image.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://www.tool-ui.com/r/image.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `zod`

## Files

### `components/tool-ui/image/_adapter.tsx`

```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")
 */
"use client";

export { cn } from "@/lib/utils";
```

### `components/tool-ui/image/image.tsx`

```tsx
"use client";

import * as React from "react";
import { cn } from "./_adapter";

import {
  RATIO_CLASS_MAP,
  getFitClass,
  openSafeNavigationHref,
  resolveSafeNavigationHref,
  sanitizeHref,
} from "../shared/media";
import type { SerializableImage, Source } from "./schema";

const FALLBACK_LOCALE = "en-US";

export interface ImageProps extends SerializableImage {
  className?: string;
  onNavigate?: (href: string, image: SerializableImage) => void;
}

export function Image(props: ImageProps) {
  const { className, onNavigate, ...serializable } = props;

  const {
    id,
    src,
    alt,
    title,
    href: rawHref,
    domain,
    ratio = "auto",
    fit = "cover",
    source,
    locale: providedLocale,
  } = serializable;

  const locale = providedLocale ?? FALLBACK_LOCALE;
  const sanitizedHref = sanitizeHref(rawHref);
  const resolvedSourceUrl = sanitizeHref(source?.url);

  const imageData: SerializableImage = {
    ...serializable,
    href: sanitizedHref,
    source: source ? { ...source, url: resolvedSourceUrl } : undefined,
    locale,
  };

  const sourceLabel = source?.label ?? domain;
  const fallbackInitial = (sourceLabel ?? "").trim().charAt(0).toUpperCase();
  const hasSource = Boolean(sourceLabel || source?.iconUrl);

  const handleSourceClick = (event: React.MouseEvent<HTMLButtonElement>) => {
    event.preventDefault();
    event.stopPropagation();
    const targetUrl = resolveSafeNavigationHref(
      resolvedSourceUrl,
      source?.url,
      sanitizedHref,
      src,
    );
    if (!targetUrl) return;
    if (onNavigate) {
      onNavigate(targetUrl, imageData);
    } else {
      openSafeNavigationHref(targetUrl);
    }
  };

  const handleImageClick = () => {
    if (!sanitizedHref) return;
    if (onNavigate) {
      onNavigate(sanitizedHref, imageData);
    } else {
      openSafeNavigationHref(sanitizedHref);
    }
  };

  const hasMetadata = title || hasSource;

  return (
    <article
      className={cn("relative w-full max-w-md min-w-80", className)}
      lang={locale}
      data-tool-ui-id={id}
      data-slot="image"
    >
      <div
        className={cn(
          "group @container relative isolate flex w-full min-w-0 flex-col overflow-hidden rounded-xl",
          "border-border bg-card border text-sm shadow-xs",
        )}
      >
        <>
          <div
            className={cn(
              "bg-muted group relative w-full overflow-hidden",
              ratio !== "auto" ? RATIO_CLASS_MAP[ratio] : "min-h-[160px]",
              sanitizedHref && "cursor-pointer",
            )}
            onClick={sanitizedHref ? handleImageClick : undefined}
            role={sanitizedHref ? "link" : undefined}
            tabIndex={sanitizedHref ? 0 : undefined}
            onKeyDown={
              sanitizedHref
                ? (e) => {
                    if (e.key === "Enter" || e.key === " ") {
                      e.preventDefault();
                      handleImageClick();
                    }
                  }
                : undefined
            }
          >
            <img
              src={src}
              alt={alt}
              loading="lazy"
              decoding="async"
              className={cn("absolute inset-0 h-full w-full", getFitClass(fit))}
            />
          </div>
          {hasMetadata && (
            <div className="flex items-center gap-3 px-4 py-3">
              <SourceAttribution
                source={source}
                sourceLabel={sourceLabel}
                fallbackInitial={fallbackInitial}
                hasClickableUrl={Boolean(resolvedSourceUrl)}
                onSourceClick={handleSourceClick}
                title={title}
              />
            </div>
          )}
        </>
      </div>
    </article>
  );
}

interface SourceAttributionProps {
  source?: Source;
  sourceLabel?: string;
  fallbackInitial: string;
  hasClickableUrl: boolean;
  onSourceClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
  title?: string;
}

function SourceAttribution({
  source,
  sourceLabel,
  fallbackInitial,
  hasClickableUrl,
  onSourceClick,
  title,
}: SourceAttributionProps) {
  const hasSource = Boolean(sourceLabel || source?.iconUrl);

  const content = (
    <div className="flex min-w-0 flex-1 items-center gap-3">
      {source?.iconUrl ? (
        <img
          src={source.iconUrl}
          alt=""
          aria-hidden="true"
          width={32}
          height={32}
          className="size-8 shrink-0 rounded-full object-cover"
          loading="lazy"
          decoding="async"
        />
      ) : fallbackInitial ? (
        <div className="bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold uppercase">
          {fallbackInitial}
        </div>
      ) : null}
      <div className="min-w-0 flex-1">
        {title && (
          <div className="text-foreground line-clamp-1 text-sm font-medium">
            {title}
          </div>
        )}
        {sourceLabel && (
          <div className="text-muted-foreground line-clamp-1 text-xs">
            {sourceLabel}
          </div>
        )}
      </div>
    </div>
  );

  if (hasClickableUrl && hasSource) {
    return (
      <button
        type="button"
        onClick={onSourceClick}
        className="focus-visible:ring-ring flex w-full items-center gap-3 text-left hover:opacity-80 focus-visible:ring-2 focus-visible:outline-none"
      >
        {content}
      </button>
    );
  }

  return <div className="flex w-full items-center gap-3">{content}</div>;
}
```

### `components/tool-ui/image/index.ts`

```ts
export { Image } from "./image";
export type { ImageProps } from "./image";
export type { SerializableImage, Source } from "./schema";
```

### `components/tool-ui/image/README.md`

```md
# Image

Implementation for the "image" Tool UI surface.

## Files

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

## Companion assets

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

## Quick check

Run this after edits:

pnpm test
```

### `components/tool-ui/image/schema.ts`

```ts
import { z } from "zod";
import { defineToolUiContract } from "../shared/contract";
import {
  ToolUIIdSchema,
  ToolUIReceiptSchema,
  ToolUIRoleSchema,
} from "../shared/schema";

import { AspectRatioSchema, MediaFitSchema } from "../shared/media";

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

export type Source = z.infer<typeof SourceSchema>;

export const SerializableImageSchema = z.object({
  id: ToolUIIdSchema,
  role: ToolUIRoleSchema.optional(),
  receipt: ToolUIReceiptSchema.optional(),
  assetId: z.string(),
  src: z.url(),
  alt: z.string().min(1, "Images require alt text for accessibility"),
  title: z.string().optional(),
  description: z.string().optional(),
  href: z.url().optional(),
  domain: z.string().optional(),
  ratio: AspectRatioSchema.optional(),
  fit: MediaFitSchema.optional(),
  fileSizeBytes: z.number().int().positive().optional(),
  createdAt: z.string().datetime().optional(),
  locale: z.string().optional(),
  source: SourceSchema.optional(),
});

export type SerializableImage = z.infer<typeof SerializableImageSchema>;

const SerializableImageSchemaContract = defineToolUiContract(
  "Image",
  SerializableImageSchema,
);

export const parseSerializableImage: (input: unknown) => SerializableImage =
  SerializableImageSchemaContract.parse;

export const safeParseSerializableImage: (
  input: unknown,
) => SerializableImage | null = SerializableImageSchemaContract.safeParse;
```

### `components/tool-ui/shared/contract.ts`

```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`

```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`

```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`

```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`

```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`

```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`

```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`

```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`

```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>;
```
