shadimage
tool-ui
blockpreferences-panelsource: ok · hit

Preferences Panel

Compact settings panel for user preferences.

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

Where it goes

settings-form

Use cases

dashboard

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

buttonlabelselectseparatorswitchtoggle-group

Files16 · source cached 10/14/2026, 3:42:28 AM

components/tool-ui/preferences-panel/_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
 *   Switch       → shadcn/ui Switch
 *   ToggleGroup  → shadcn/ui ToggleGroup
 *   Select       → shadcn/ui Select
 *   Separator    → shadcn/ui Separator
 *   Label        → shadcn/ui Label
 */

export { cn } from "@/lib/utils";
export { Button } from "@/components/ui/button";
export { Switch } from "@/components/ui/switch";
export { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
export {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
export { Separator } from "@/components/ui/separator";
export { Label } from "@/components/ui/label";
components/tool-ui/preferences-panel/index.tsx
export { PreferencesPanel, PreferencesPanelReceipt } from "./preferences-panel";
export {
  type SerializablePreferencesPanel,
  type SerializablePreferencesPanelReceipt,
  type PreferencesPanelProps,
  type PreferencesPanelReceiptProps,
  type PreferencesValue,
  type PreferenceItem,
  type PreferenceSection,
} from "./schema";
components/tool-ui/preferences-panel/preferences-panel.tsx
"use client";

import { useCallback, useMemo } from "react";
import type {
  PreferencesPanelProps,
  PreferencesPanelReceiptProps,
  PreferencesValue,
  PreferenceItem,
  PreferenceSection,
} from "./schema";
import { ActionButtons } from "../shared/action-buttons";
import { normalizeActionsConfig } from "../shared/actions-config";
import { type Action } from "../shared/schema";
import { useControllableState } from "../shared/use-controllable-state";
import { useSignatureReset } from "../shared/use-signature-reset";

import {
  cn,
  Switch,
  ToggleGroup,
  ToggleGroupItem,
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
  Separator,
  Label,
} from "./_adapter";
import { Check, AlertCircle } from "lucide-react";
import { createPreferencesSectionSignature } from "./signature";

function getInitialValue(item: PreferenceItem): string | boolean {
  switch (item.type) {
    case "switch":
      return item.defaultChecked ?? false;
    case "toggle":
      return item.defaultValue ?? item.options?.[0]?.value ?? "";
    case "select":
      return item.defaultSelected ?? item.selectOptions?.[0]?.value ?? "";
  }
}

function formatDisplayValue(
  item: PreferenceItem,
  value: string | boolean,
): string {
  if (item.type === "switch") {
    return typeof value === "boolean" && value ? "On" : "Off";
  }

  const stringValue = typeof value === "string" ? value : "";
  const options = item.type === "toggle" ? item.options : item.selectOptions;
  const option = options?.find((opt) => opt.value === stringValue);

  return option?.label ?? stringValue;
}

function computeInitialValues(sections: PreferenceSection[]): PreferencesValue {
  return sections.reduce<PreferencesValue>((acc, section) => {
    section.items.forEach((item) => {
      acc[item.id] = getInitialValue(item);
    });
    return acc;
  }, {});
}

interface PreferenceControlProps {
  item: PreferenceItem;
  value: string | boolean;
  onChange: (value: string | boolean) => void;
  disabled?: boolean;
}

function SwitchControl({
  id,
  checked,
  onChange,
  disabled,
  label,
}: {
  id: string;
  checked: boolean;
  onChange: (value: boolean) => void;
  disabled?: boolean;
  label: string;
}) {
  return (
    <Switch
      id={id}
      checked={checked}
      onCheckedChange={onChange}
      disabled={disabled}
      aria-label={label}
    />
  );
}

function ToggleControl({
  value,
  options,
  onChange,
  disabled,
  label,
}: {
  value: string;
  options: Array<{ value: string; label: string }>;
  onChange: (value: string) => void;
  disabled?: boolean;
  label: string;
}) {
  return (
    <ToggleGroup
      type="single"
      value={value}
      onValueChange={(v) => v && onChange(v)}
      disabled={disabled}
      aria-label={label}
      className="gap-1"
    >
      {options.map((opt) => (
        <ToggleGroupItem
          key={opt.value}
          value={opt.value}
          aria-label={opt.label}
          className="!rounded-full px-3 py-1.5 text-sm"
        >
          {opt.label}
        </ToggleGroupItem>
      ))}
    </ToggleGroup>
  );
}

function SelectControl({
  id,
  value,
  options,
  onChange,
  disabled,
  label,
}: {
  id: string;
  value: string;
  options: Array<{ value: string; label: string }>;
  onChange: (value: string) => void;
  disabled?: boolean;
  label: string;
}) {
  return (
    <Select value={value} onValueChange={onChange} disabled={disabled}>
      <SelectTrigger id={id} className="w-[180px]" aria-label={label}>
        <SelectValue placeholder="Select..." />
      </SelectTrigger>
      <SelectContent>
        {options.map((opt) => (
          <SelectItem key={opt.value} value={opt.value}>
            {opt.label}
          </SelectItem>
        ))}
      </SelectContent>
    </Select>
  );
}

function PreferenceControl({
  item,
  value,
  onChange,
  disabled,
}: PreferenceControlProps) {
  const id = `preference-${item.id}`;

  if (item.type === "switch") {
    return (
      <SwitchControl
        id={id}
        checked={typeof value === "boolean" ? value : false}
        onChange={onChange}
        disabled={disabled}
        label={item.label}
      />
    );
  }

  const stringValue = typeof value === "string" ? value : "";

  if (item.type === "toggle" && item.options) {
    return (
      <ToggleControl
        value={stringValue}
        options={item.options}
        onChange={onChange}
        disabled={disabled}
        label={item.label}
      />
    );
  }

  if (item.type === "select" && item.selectOptions) {
    return (
      <SelectControl
        id={id}
        value={stringValue}
        options={item.selectOptions}
        onChange={onChange}
        disabled={disabled}
        label={item.label}
      />
    );
  }

  return null;
}

interface PreferenceItemRowProps {
  item: PreferenceItem;
  value: string | boolean;
  onChange?: (value: string | boolean) => void;
  disabled?: boolean;
  isReceipt?: boolean;
  error?: string;
  showSuccessIndicators?: boolean;
  isFirstInSectionWithoutHeading?: boolean;
}

function ItemLabel({
  item,
  error,
  isReceipt,
}: {
  item: PreferenceItem;
  error?: string;
  isReceipt: boolean;
}) {
  const htmlFor = `preference-${item.id}`;

  if (isReceipt) {
    return (
      <>
        <span className="text-sm leading-6 font-medium text-pretty">
          {item.label}
        </span>
        {error ? (
          <span className="text-destructive text-sm font-normal text-pretty">
            {error}
          </span>
        ) : item.description ? (
          <span className="text-muted-foreground text-sm font-normal text-pretty">
            {item.description}
          </span>
        ) : null}
      </>
    );
  }

  return (
    <>
      <Label htmlFor={htmlFor} className="leading-6 font-medium text-pretty">
        {item.label}
      </Label>
      {item.description && (
        <p className="text-muted-foreground text-sm font-normal text-pretty">
          {item.description}
        </p>
      )}
    </>
  );
}

function ItemValue({
  item,
  value,
  error,
  showSuccessIndicators,
}: {
  item: PreferenceItem;
  value: string | boolean;
  error?: string;
  showSuccessIndicators: boolean;
}) {
  const displayValue = formatDisplayValue(item, value);

  return (
    <div className="flex shrink-0 items-center gap-2">
      <span className="text-muted-foreground text-sm font-medium">
        {displayValue}
      </span>
      {error ? (
        <AlertCircle className="text-destructive size-3.5" />
      ) : showSuccessIndicators ? (
        <Check className="size-3.5 text-emerald-600 dark:text-emerald-500" />
      ) : null}
    </div>
  );
}

function PreferenceItemRow({
  item,
  value,
  onChange,
  disabled,
  isReceipt = false,
  error,
  showSuccessIndicators = false,
  isFirstInSectionWithoutHeading = false,
}: PreferenceItemRowProps) {
  const shouldStack = item.type !== "switch" && !isReceipt;

  return (
    <div
      className={cn(
        "flex items-start justify-between gap-4",
        isFirstInSectionWithoutHeading ? "pt-0 pb-3" : "py-3",
        shouldStack &&
          "flex-col gap-3 @sm/preferences-panel:flex-row @sm/preferences-panel:gap-4",
      )}
    >
      <div className="flex flex-col gap-1">
        <ItemLabel item={item} error={error} isReceipt={isReceipt} />
      </div>

      {isReceipt ? (
        <ItemValue
          item={item}
          value={value}
          error={error}
          showSuccessIndicators={showSuccessIndicators}
        />
      ) : (
        <div className="flex shrink-0">
          <PreferenceControl
            item={item}
            value={value}
            onChange={onChange!}
            disabled={disabled}
          />
        </div>
      )}
    </div>
  );
}

interface ItemListProps {
  items: PreferenceItem[];
  values: PreferencesValue;
  onChangeValue?: (itemId: string, value: string | boolean) => void;
  disabled?: boolean;
  isReceipt?: boolean;
  errors?: Record<string, string>;
  showSuccessIndicators?: boolean;
  hasHeading?: boolean;
  hasTitle?: boolean;
}

function ItemList({
  items,
  values,
  onChangeValue,
  disabled,
  isReceipt,
  errors,
  showSuccessIndicators,
  hasHeading = false,
  hasTitle = false,
}: ItemListProps) {
  const shouldRemoveFirstPadding = !hasHeading && hasTitle;

  return (
    <div className="flex flex-col">
      {items.map((item, index) => {
        const isFirst = index === 0;
        const itemValue = values[item.id] ?? getInitialValue(item);
        const handleChange = onChangeValue
          ? (v: string | boolean) => onChangeValue(item.id, v)
          : undefined;

        return (
          <div key={item.id}>
            {!isFirst && <Separator className="my-1" />}
            <PreferenceItemRow
              item={item}
              value={itemValue}
              onChange={handleChange}
              disabled={disabled}
              isReceipt={isReceipt}
              error={errors?.[item.id]}
              showSuccessIndicators={showSuccessIndicators}
              isFirstInSectionWithoutHeading={
                isFirst && shouldRemoveFirstPadding
              }
            />
          </div>
        );
      })}
    </div>
  );
}

interface PreferencesSectionProps {
  section: PreferenceSection;
  values: PreferencesValue;
  onChangeValue?: (itemId: string, value: string | boolean) => void;
  disabled?: boolean;
  isReceipt?: boolean;
  errors?: Record<string, string>;
  hasTitle?: boolean;
}

function PreferencesSection({
  section,
  values,
  onChangeValue,
  disabled,
  isReceipt = false,
  errors,
  hasTitle = false,
}: PreferencesSectionProps) {
  const hasErrors = !!(errors && Object.keys(errors).length > 0);

  const content = (
    <ItemList
      items={section.items}
      values={values}
      onChangeValue={onChangeValue}
      disabled={disabled}
      isReceipt={isReceipt}
      errors={errors}
      showSuccessIndicators={hasErrors}
      hasHeading={!!section.heading}
      hasTitle={hasTitle}
    />
  );

  if (section.heading) {
    return (
      <fieldset className="flex flex-col">
        <legend className="text-muted-foreground pb-1 text-xs tracking-widest uppercase">
          {section.heading}
        </legend>
        {content}
      </fieldset>
    );
  }

  return content;
}

interface ReceiptHeaderProps {
  title: string;
  hasErrors: boolean;
}

function ReceiptHeader({ title, hasErrors }: ReceiptHeaderProps) {
  return (
    <>
      <div className="flex items-center justify-between gap-3 px-5 py-4">
        <h2 className="text-base leading-none font-semibold">{title}</h2>
        {hasErrors === true ? (
          <span className="text-destructive flex items-center gap-1.5 text-xs font-medium">
            <AlertCircle className="size-3.5" />
            Error
          </span>
        ) : (
          <span className="flex items-center gap-1.5 text-xs font-medium text-emerald-600 dark:text-emerald-500">
            <Check className="size-3.5" />
            Saved
          </span>
        )}
      </div>
      <Separator />
    </>
  );
}

export function PreferencesPanelReceipt({
  id,
  title,
  sections,
  choice,
  error,
  className,
}: PreferencesPanelReceiptProps) {
  const hasErrors = error && Object.keys(error).length > 0;

  return (
    <article
      data-slot="preferences-panel"
      data-tool-ui-id={id}
      data-receipt="true"
      role="status"
      aria-label={
        hasErrors ? "Preferences with errors" : "Confirmed preferences"
      }
      className={cn(
        "@container/preferences-panel flex w-full max-w-md min-w-80 flex-col",
        className,
      )}
    >
      <div className="bg-card/60 flex w-full flex-col overflow-hidden rounded-2xl border opacity-95 shadow-xs">
        {title && <ReceiptHeader title={title} hasErrors={!!hasErrors} />}
        <div
          className={cn("flex flex-col gap-4 px-5", title ? "py-6" : "py-2")}
        >
          {sections.map((section, index) => (
            <div key={index}>
              <PreferencesSection
                section={section}
                values={choice}
                errors={error}
                isReceipt={true}
                hasTitle={!!title}
              />
            </div>
          ))}
        </div>
      </div>
    </article>
  );
}

function PreferencesPanelRoot({
  id,
  title,
  sections,
  value: controlledValue,
  onChange,
  actions,
  onAction,
  onBeforeAction,
  className,
}: PreferencesPanelProps) {
  const initialValues = useMemo(
    () => computeInitialValues(sections),
    [sections],
  );
  const sectionsSignature = useMemo(
    () => createPreferencesSectionSignature(sections),
    [sections],
  );
  const {
    value: currentValue,
    isControlled,
    setValue,
    setUncontrolledValue,
  } = useControllableState<PreferencesValue>({
    value: controlledValue,
    defaultValue: initialValues,
    onChange,
  });

  useSignatureReset(sectionsSignature, () => {
    if (!isControlled) {
      setUncontrolledValue(initialValues);
    }
  });

  const updateValue = useCallback(
    (itemId: string, newValue: string | boolean) => {
      setValue((prev) => ({ ...prev, [itemId]: newValue }));
    },
    [setValue],
  );

  const isDirty = useMemo(() => {
    return Object.keys(currentValue).some(
      (key) => currentValue[key] !== initialValues[key],
    );
  }, [currentValue, initialValues]);

  const handleCancel = useCallback((): PreferencesValue => {
    setValue(initialValues);
    return initialValues;
  }, [initialValues, setValue]);

  const handleAction = useCallback(
    async (actionId: string) => {
      let nextValue = currentValue;

      if (actionId === "cancel") {
        nextValue = handleCancel();
      }

      await onAction?.(actionId, nextValue);
    },
    [currentValue, handleCancel, onAction],
  );

  const normalizedActions = useMemo(() => {
    const normalized = normalizeActionsConfig(actions);
    if (normalized) {
      return {
        ...normalized,
        align: normalized.align ?? ("right" as const),
      };
    }

    const defaultActions: Action[] = [
      { id: "cancel", label: "Cancel", variant: "ghost" },
      { id: "save", label: "Save Changes", variant: "default" },
    ];

    return {
      items: defaultActions,
      align: "right" as const,
    };
  }, [actions]);

  const actionsWithState = useMemo((): Action[] => {
    return normalizedActions.items.map((action) => {
      const isSaveAction = action.id === "save";
      const baseDisabled = "disabled" in action ? action.disabled : false;
      const shouldDisable = baseDisabled || (isSaveAction && !isDirty);

      return {
        ...action,
        disabled: shouldDisable,
      };
    });
  }, [normalizedActions.items, isDirty]);

  return (
    <article
      data-slot="preferences-panel"
      data-tool-ui-id={id}
      role="form"
      className={cn(
        "text-foreground @container/preferences-panel flex w-full max-w-md min-w-80 flex-col gap-3",
        className,
      )}
    >
      <div className="bg-card flex w-full flex-col overflow-hidden rounded-2xl border shadow-xs">
        {title && (
          <>
            <div className="px-5 py-4">
              <h2 className="text-base leading-none font-semibold">{title}</h2>
            </div>
            <Separator />
          </>
        )}
        <div
          className={cn("flex flex-col gap-4 px-5", title ? "py-6" : "py-2")}
        >
          {sections.map((section, sectionIndex) => (
            <div key={sectionIndex}>
              <PreferencesSection
                section={section}
                values={currentValue}
                onChangeValue={updateValue}
                isReceipt={false}
                hasTitle={!!title}
              />
            </div>
          ))}
        </div>
      </div>

      <div className="@container/actions">
        <ActionButtons
          actions={actionsWithState}
          align={normalizedActions.align}
          confirmTimeout={normalizedActions.confirmTimeout}
          onAction={handleAction}
          onBeforeAction={
            onBeforeAction
              ? (actionId) => onBeforeAction(actionId, currentValue)
              : undefined
          }
        />
      </div>
    </article>
  );
}

type PreferencesPanelComponent = typeof PreferencesPanelRoot & {
  Receipt: typeof PreferencesPanelReceipt;
};

export const PreferencesPanel = Object.assign(PreferencesPanelRoot, {
  Receipt: PreferencesPanelReceipt,
}) as PreferencesPanelComponent;
components/tool-ui/preferences-panel/README.md
# Preferences Panel

Implementation for the "preferences-panel" Tool UI surface.

## Files

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

## Companion assets

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

## Quick check

Run this after edits:

pnpm test
components/tool-ui/preferences-panel/schema.ts
import { z } from "zod";
import { type ActionsProp } from "../shared/actions-config";
import type { EmbeddedActionsProps } from "../shared/embedded-actions";
import { defineToolUiContract } from "../shared/contract";
import {
  SerializableActionSchema,
  SerializableActionsConfigSchema,
  ToolUIIdSchema,
  ToolUIReceiptSchema,
  ToolUIRoleSchema,
} from "../shared/schema";

const PreferenceItemBaseSchema = z.object({
  id: z.string().min(1),
  label: z.string().min(1),
  description: z.string().optional(),
});

const PreferenceSwitchSchema = PreferenceItemBaseSchema.extend({
  type: z.literal("switch"),
  defaultChecked: z.boolean().optional(),
});

const PreferenceToggleSchema = PreferenceItemBaseSchema.extend({
  type: z.literal("toggle"),
  options: z
    .array(
      z.object({
        value: z.string().min(1),
        label: z.string().min(1),
      }),
    )
    .min(2),
  defaultValue: z.string().optional(),
});

const PreferenceSelectSchema = PreferenceItemBaseSchema.extend({
  type: z.literal("select"),
  selectOptions: z
    .array(
      z.object({
        value: z.string().min(1),
        label: z.string().min(1),
      }),
    )
    .min(5),
  defaultSelected: z.string().optional(),
});

const PreferenceItemSchema = z.discriminatedUnion("type", [
  PreferenceSwitchSchema,
  PreferenceToggleSchema,
  PreferenceSelectSchema,
]);

const PreferenceSectionSchema = z.object({
  heading: z.string().min(1).optional(),
  items: z.array(PreferenceItemSchema).min(1),
});

const PreferencesPanelBaseSchema = z.object({
  id: ToolUIIdSchema,
  role: ToolUIRoleSchema.optional(),
  receipt: ToolUIReceiptSchema.optional(),
  title: z.string().min(1).optional(),
  sections: z.array(PreferenceSectionSchema).min(1),
});

export const SerializablePreferencesPanelSchema =
  PreferencesPanelBaseSchema.extend({
    actions: z
      .union([
        z.array(SerializableActionSchema),
        SerializableActionsConfigSchema,
      ])
      .optional(),
  }).strict();

export const SerializablePreferencesPanelReceiptSchema =
  PreferencesPanelBaseSchema.extend({
    choice: z.record(z.string(), z.union([z.string(), z.boolean()])),
    error: z.record(z.string(), z.string()).optional(),
  }).strict();

export type SerializablePreferencesPanel = z.infer<
  typeof SerializablePreferencesPanelSchema
>;

export type SerializablePreferencesPanelReceipt = z.infer<
  typeof SerializablePreferencesPanelReceiptSchema
>;

const SerializablePreferencesPanelSchemaContract = defineToolUiContract(
  "PreferencesPanel",
  SerializablePreferencesPanelSchema,
);

const SerializablePreferencesPanelReceiptSchemaContract = defineToolUiContract(
  "PreferencesPanelReceipt",
  SerializablePreferencesPanelReceiptSchema,
);

export const parseSerializablePreferencesPanel: (
  input: unknown,
) => SerializablePreferencesPanel =
  SerializablePreferencesPanelSchemaContract.parse;

export const safeParseSerializablePreferencesPanel: (
  input: unknown,
) => SerializablePreferencesPanel | null =
  SerializablePreferencesPanelSchemaContract.safeParse;

export const parseSerializablePreferencesPanelReceipt: (
  input: unknown,
) => SerializablePreferencesPanelReceipt =
  SerializablePreferencesPanelReceiptSchemaContract.parse;

export const safeParseSerializablePreferencesPanelReceipt: (
  input: unknown,
) => SerializablePreferencesPanelReceipt | null =
  SerializablePreferencesPanelReceiptSchemaContract.safeParse;

export interface PreferencesValue {
  [itemId: string]: string | boolean;
}

export interface PreferencesPanelProps extends Omit<
  SerializablePreferencesPanel,
  "actions"
> {
  className?: string;
  value?: PreferencesValue;
  onChange?: (value: PreferencesValue) => void;
  actions?: ActionsProp;
  onAction?: EmbeddedActionsProps<PreferencesValue>["onAction"];
  onBeforeAction?: EmbeddedActionsProps<PreferencesValue>["onBeforeAction"];
}

export interface PreferencesPanelReceiptProps extends SerializablePreferencesPanelReceipt {
  className?: string;
}

export type PreferenceItem = z.infer<typeof PreferenceItemSchema>;
export type PreferenceSection = z.infer<typeof PreferenceSectionSchema>;
components/tool-ui/preferences-panel/signature.ts
import type { PreferenceSection } from "./schema";

export function createPreferencesSectionSignature(
  sections: PreferenceSection[],
): string {
  return JSON.stringify(
    sections.map((section) => ({
      heading: section.heading ?? "",
      items: section.items.map((item) => {
        if (item.type === "switch") {
          return {
            id: item.id,
            type: item.type,
            defaultChecked: item.defaultChecked ?? false,
          };
        }

        if (item.type === "toggle") {
          return {
            id: item.id,
            type: item.type,
            defaultValue: item.defaultValue ?? item.options[0]?.value ?? "",
            options: item.options.map((option) => option.value),
          };
        }

        return {
          id: item.id,
          type: item.type,
          defaultSelected:
            item.defaultSelected ?? item.selectOptions[0]?.value ?? "",
          options: item.selectOptions.map((option) => option.value),
        };
      }),
    })),
  );
}
components/tool-ui/shared/_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";
components/tool-ui/shared/action-buttons.tsx
"use client";

import type { Action } from "./schema";
import { cn, Button } from "./_adapter";
import { useActionButtons } from "./use-action-buttons";

export interface ActionButtonsProps {
  actions: Action[];
  onAction: (actionId: string) => void | Promise<void>;
  onBeforeAction?: (actionId: string) => boolean | Promise<boolean>;
  confirmTimeout?: number;
  align?: "left" | "center" | "right";
  className?: string;
}

export function ActionButtons({
  actions,
  onAction,
  onBeforeAction,
  confirmTimeout = 3000,
  align = "right",
  className,
}: ActionButtonsProps) {
  const { actions: resolvedActions, runAction } = useActionButtons({
    actions,
    onAction,
    onBeforeAction,
    confirmTimeout,
  });

  return (
    <div
      className={cn(
        "flex flex-col gap-3",
        "@sm/actions:flex-row @sm/actions:flex-wrap @sm/actions:items-center @sm/actions:gap-2",
        align === "left" && "@sm/actions:justify-start",
        align === "center" && "@sm/actions:justify-center",
        align === "right" && "@sm/actions:justify-end",
        className,
      )}
    >
      {resolvedActions.map((action) => {
        const label = action.currentLabel;
        const variant = action.variant || "default";

        return (
          <Button
            key={action.id}
            variant={variant}
            onClick={() => runAction(action.id)}
            disabled={action.isDisabled}
            className={cn(
              "rounded-full px-4!",
              "justify-center",
              "min-h-11 w-full text-base",
              "@sm/actions:min-h-0 @sm/actions:w-auto @sm/actions:px-3 @sm/actions:py-2 @sm/actions:text-sm",
              action.isConfirming &&
                "ring-destructive ring-2 ring-offset-2 motion-safe:animate-pulse",
            )}
            aria-label={
              action.shortcut ? `${label} (${action.shortcut})` : label
            }
          >
            {action.isLoading && (
              <svg
                className="mr-2 h-4 w-4 motion-safe:animate-spin"
                xmlns="http://www.w3.org/2000/svg"
                fill="none"
                viewBox="0 0 24 24"
              >
                <circle
                  className="opacity-25"
                  cx="12"
                  cy="12"
                  r="10"
                  stroke="currentColor"
                  strokeWidth="4"
                />
                <path
                  className="opacity-75"
                  fill="currentColor"
                  d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
                />
              </svg>
            )}
            {action.icon && !action.isLoading && (
              <span className="mr-2">{action.icon}</span>
            )}
            {label}
            {action.shortcut && !action.isLoading && (
              <kbd className="border-border bg-muted ml-2.5 hidden rounded-lg border px-2 py-0.5 font-mono text-xs font-medium sm:inline-block">
                {action.shortcut}
              </kbd>
            )}
          </Button>
        );
      })}
    </div>
  );
}
components/tool-ui/shared/actions-config.ts
import type { Action, ActionsConfig } from "./schema";

export type ActionsProp = ActionsConfig | Action[];

const NEGATORY_ACTION_IDS = new Set([
  "cancel",
  "dismiss",
  "skip",
  "no",
  "reset",
  "close",
  "decline",
  "reject",
  "back",
  "later",
  "not-now",
  "maybe-later",
]);

function inferVariant(action: Action): Action {
  if (action.variant) return action;
  if (NEGATORY_ACTION_IDS.has(action.id)) {
    return { ...action, variant: "ghost" };
  }
  return action;
}

export function normalizeActionsConfig(
  actions?: ActionsProp,
): ActionsConfig | null {
  if (!actions) return null;

  const rawItems = Array.isArray(actions) ? actions : (actions.items ?? []);

  if (rawItems.length === 0) {
    return null;
  }

  const items = rawItems.map(inferVariant);

  return Array.isArray(actions)
    ? { items }
    : {
        items,
        align: actions.align,
        confirmTimeout: actions.confirmTimeout,
      };
}
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/embedded-actions.ts
import type { ActionsProp } from "./actions-config";

export type EmbeddedActionHandler<TState> = (
  actionId: string,
  state: TState,
) => void | Promise<void>;

export type EmbeddedBeforeActionHandler<TState> = (
  actionId: string,
  state: TState,
) => boolean | Promise<boolean>;

export interface EmbeddedActionsProps<TState> {
  actions?: ActionsProp;
  onAction?: EmbeddedActionHandler<TState>;
  onBeforeAction?: EmbeddedBeforeActionHandler<TState>;
}
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>;
components/tool-ui/shared/use-action-buttons.tsx
"use client";

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Action } from "./schema";

export type UseActionButtonsOptions = {
  actions: Action[];
  onAction: (actionId: string) => void | Promise<void>;
  onBeforeAction?: (actionId: string) => boolean | Promise<boolean>;
  confirmTimeout?: number;
};

export type UseActionButtonsResult = {
  actions: Array<
    Action & {
      currentLabel: string;
      isConfirming: boolean;
      isExecuting: boolean;
      isDisabled: boolean;
      isLoading: boolean;
    }
  >;
  runAction: (actionId: string) => Promise<void>;
  confirmingActionId: string | null;
  executingActionId: string | null;
};

type ActionExecutionLock = {
  tryAcquire: () => boolean;
  release: () => void;
};

export function createActionExecutionLock(): ActionExecutionLock {
  let locked = false;

  return {
    tryAcquire: () => {
      if (locked) return false;
      locked = true;
      return true;
    },
    release: () => {
      locked = false;
    },
  };
}

export function useActionButtons(
  options: UseActionButtonsOptions,
): UseActionButtonsResult {
  const { actions, onAction, onBeforeAction, confirmTimeout = 3000 } = options;

  const [confirmingActionId, setConfirmingActionId] = useState<string | null>(
    null,
  );
  const [executingActionId, setExecutingActionId] = useState<string | null>(
    null,
  );
  const executionLockRef = useRef<ActionExecutionLock>(
    createActionExecutionLock(),
  );

  useEffect(() => {
    if (!confirmingActionId) return;
    const id = setTimeout(() => setConfirmingActionId(null), confirmTimeout);
    return () => clearTimeout(id);
  }, [confirmingActionId, confirmTimeout]);

  useEffect(() => {
    if (!confirmingActionId) return;
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === "Escape") {
        setConfirmingActionId(null);
      }
    };

    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
  }, [confirmingActionId]);

  const runAction = useCallback(
    async (actionId: string) => {
      const action = actions.find((a) => a.id === actionId);
      if (!action) return;

      const isAnyActionExecuting = executingActionId !== null;
      if (action.disabled || action.loading || isAnyActionExecuting) {
        return;
      }

      if (action.confirmLabel && confirmingActionId !== action.id) {
        setConfirmingActionId(action.id);
        return;
      }

      if (!executionLockRef.current.tryAcquire()) {
        return;
      }

      if (onBeforeAction) {
        const shouldProceed = await onBeforeAction(action.id);
        if (!shouldProceed) {
          setConfirmingActionId(null);
          executionLockRef.current.release();
          return;
        }
      }

      try {
        setExecutingActionId(action.id);
        await onAction(action.id);
      } finally {
        executionLockRef.current.release();
        setExecutingActionId(null);
        setConfirmingActionId(null);
      }
    },
    [actions, confirmingActionId, executingActionId, onAction, onBeforeAction],
  );

  const resolvedActions = useMemo(
    () =>
      actions.map((action) => {
        const isConfirming = confirmingActionId === action.id;
        const isThisActionExecuting = executingActionId === action.id;
        const isLoading = action.loading || isThisActionExecuting;
        const isDisabled =
          action.disabled ||
          (executingActionId !== null && !isThisActionExecuting);
        const currentLabel =
          isConfirming && action.confirmLabel
            ? action.confirmLabel
            : action.label;

        return {
          ...action,
          currentLabel,
          isConfirming,
          isExecuting: isThisActionExecuting,
          isDisabled,
          isLoading,
        };
      }),
    [actions, confirmingActionId, executingActionId],
  );

  return {
    actions: resolvedActions,
    runAction,
    confirmingActionId,
    executingActionId,
  };
}
components/tool-ui/shared/use-controllable-state.ts
"use client";

import { useCallback, useMemo, useRef, useState } from "react";

export type UseControllableStateOptions<T> = {
  value?: T;
  defaultValue: T;
  onChange?: (next: T) => void;
};

export function useControllableState<T>({
  value,
  defaultValue,
  onChange,
}: UseControllableStateOptions<T>) {
  const [uncontrolled, setUncontrolled] = useState<T>(defaultValue);
  const isControlled = value !== undefined;

  const currentValue = useMemo(
    () => (isControlled ? (value as T) : uncontrolled),
    [isControlled, value, uncontrolled],
  );
  const currentValueRef = useRef(currentValue);
  currentValueRef.current = currentValue;

  const setValue = useCallback(
    (next: T | ((prev: T) => T)) => {
      const resolved =
        typeof next === "function"
          ? (next as (prev: T) => T)(currentValueRef.current)
          : next;

      currentValueRef.current = resolved;
      if (!isControlled) {
        setUncontrolled(resolved);
      }

      onChange?.(resolved);
      return resolved;
    },
    [isControlled, onChange],
  );

  const setUncontrolledValue = useCallback((next: T) => {
    setUncontrolled(next);
  }, []);

  return {
    value: currentValue,
    isControlled,
    setValue,
    setUncontrolledValue,
  };
}
components/tool-ui/shared/use-signature-reset.ts
"use client";

import { useEffect, useRef } from "react";

export function useSignatureReset(
  signature: string,
  onSignatureChange: () => void,
) {
  const previousSignature = useRef(signature);

  useEffect(() => {
    if (previousSignature.current === signature) return;
    previousSignature.current = signature;
    onSignatureChange();
  }, [signature, onSignatureChange]);
}

More from tool-ui

Browse all components in this library →