An adapter can also be set once for the whole app instead of on every FileUploader: <SlotsmithProvider components={{ fileUploader: shadcnComponents }}>. See how the provider's slots resolve.

shadcn/ui

This adapter renders FileUploader with shadcn/ui parts. Copy the file into your app and pass the map to components; every slot you leave out keeps its fallback. It imports these shadcn/ui components; add them with npx shadcn@latest add alert avatar button progress. Or install it in one step from slotsmith's shadcn/ui registry: add "@slotsmith": "https://slotsmith.dev/r/{name}.json" to the registries in your components.json, then run npx shadcn@latest add @slotsmith/file-uploader. It writes components/slotsmith/file-uploader.tsx, with these parts and a <FileUploader /> that already renders with them, and adds the shadcn/ui components it needs. npx shadcn@latest add @slotsmith/provider applies every adapter to the whole app through one SlotsmithProvider.

The demo sits inside shadcn/ui's provider, as any app built on it already does.

shadcn.tsx

/**
 * FileUploader for shadcn/ui
 *
 * A `components` map that renders the slotsmith file uploader with shadcn/ui
 * primitives, written for shadcn/ui on Tailwind CSS v4. Copy the file, keep
 * the parts you want, and pass the map as `components={shadcnComponents}`;
 * every slot left out keeps its fallback.
 *
 * The `@/components/ui/*` and `@/lib/utils` imports are the app's own
 * shadcn/ui files.
 */
import { useState } from "react";
import type {
  DropzoneSlotProps,
  FileUploaderComponents,
  ItemMetaSlotProps,
  RejectionsSlotProps,
  ThumbnailSlotProps,
  UploaderActionSlotProps,
  UploaderEmptySlotProps,
  UploaderIconSlotProps,
  UploaderItemSlotProps,
  UploaderListSlotProps,
  UploaderProgressSlotProps,
  UploaderRootSlotProps,
  UploaderTriggerSlotProps,
} from "slotsmith/file-uploader";
import { Alert } from "@/components/ui/alert";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";

/**
 * Shadcn file uploader parts
 *
 * Built from shadcn/ui primitives — `Button`, `Avatar`, `Progress`, `Alert` —
 * not from a shadcn upload block, which is a copy-in recipe rather than a
 * shared primitive.
 *
 * shadcn's `Button` sets no `type`, so inside a form it would submit it;
 * every `Button` here passes `type="button"`.
 */

/** Stands in for `lucide-react`'s Upload icon. */
const UploadIcon = ({ className }: { className?: string }) => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className={className} aria-hidden="true">
    <path d="M12 16V4" />
    <path d="m7 9 5-5 5 5" />
    <path d="M4 16v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" />
  </svg>
);

/** Stands in for `lucide-react`'s File icon. */
const FileIcon = ({ className }: { className?: string }) => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className={className} aria-hidden="true">
    <path d="M14 3v4a1 1 0 0 0 1 1h4" />
    <path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2Z" />
  </svg>
);

/** Stands in for `lucide-react`'s X icon. */
const X = ({ className = "size-4" }: { className?: string }) => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className={className} aria-hidden="true">
    <path d="M18 6 6 18M6 6l12 12" />
  </svg>
);

/** Stands in for `lucide-react`'s CircleAlert icon. */
const CircleAlert = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
    <circle cx="12" cy="12" r="10" />
    <path d="M12 8v4" />
    <path d="M12 16h.01" />
  </svg>
);

/** Stands in for `lucide-react`'s RotateCw icon. */
const RotateCw = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="size-4" aria-hidden="true">
    <path d="M21 12a9 9 0 1 1-3-6.7M21 4v5h-5" />
  </svg>
);

/** Stands in for `lucide-react`'s Square icon (used for cancel). */
const Square = () => (
  <svg viewBox="0 0 24 24" fill="currentColor" className="size-3" aria-hidden="true">
    <rect x="6" y="6" width="12" height="12" rx="1" />
  </svg>
);

/**
 * Shadcn dropzone
 *
 * The drop target, styled off the `data-*` attributes the part already
 * carries — no state of its own.
 */
const ShadcnDropzone = ({ className, ...props }: DropzoneSlotProps) => (
  <div
    data-slot="dropzone"
    className={cn(
      "flex flex-col items-center gap-2 rounded-lg border border-dashed p-8 text-center transition-colors",
      "hover:border-primary hover:bg-accent/50",
      "data-dragging:border-primary data-dragging:bg-accent",
      "data-disabled:cursor-not-allowed data-disabled:opacity-55",
      className,
    )}
    {...props}
  />
);

/**
 * Shadcn item
 *
 * The `<li>` for one file, laid out as a grid so the progress bar can span it.
 */
const ShadcnItem = ({ className, ...props }: UploaderItemSlotProps) => (
  <li
    data-slot="file-item"
    className={cn(
      "grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 border-b py-2 last:border-b-0",
      "data-[status=error]:border-destructive/50",
      className,
    )}
    {...props}
  />
);

/**
 * Shadcn thumbnail
 *
 * An `Avatar` holding the preview, or a file glyph in its fallback.
 */
const ShadcnThumbnail = ({ src, isImage, alt }: ThumbnailSlotProps) => {
  const [broken, setBroken] = useState(false);
  const showImage = isImage && !!src && !broken;
  return (
    <Avatar className="rounded-md">
      {showImage && <AvatarImage src={src} alt={alt} onError={() => setBroken(true)} />}
      {!showImage && (
        <AvatarFallback>
          <FileIcon className="size-4" />
        </AvatarFallback>
      )}
    </Avatar>
  );
};

/**
 * Shadcn item meta
 *
 * Name over a secondary line, truncated instead of wrapped.
 */
const ShadcnItemMeta = ({ item, size, status }: ItemMetaSlotProps) => (
  <div className="min-w-0">
    <p className="truncate text-sm">{item.name}</p>
    <p className={cn("truncate text-xs", item.status === "error" ? "text-destructive" : "text-muted-foreground")}>
      {item.status === "error" ? item.error : [size, status].filter(Boolean).join(" · ")}
    </p>
  </div>
);

/**
 * Shadcn progress
 *
 * The `Progress` primitive, spanning the row. shadcn's `Progress` keeps
 * `value` for its indicator and never hands it to Radix's root, so the bar
 * sets its own `aria-valuenow` for assistive technology.
 */
const ShadcnProgress = ({ value, active, ...aria }: UploaderProgressSlotProps) => (
  <Progress
    value={value}
    aria-valuenow={Math.round(value)}
    aria-valuemin={0}
    aria-valuemax={100}
    data-active={active || undefined}
    className="col-span-full"
    {...aria}
  />
);

/**
 * Shadcn action
 *
 * One ghost icon `Button` for remove, retry and cancel.
 */
const ShadcnAction = ({ action, onClick, disabled, ...aria }: UploaderActionSlotProps) => (
  <Button
    type="button"
    variant="ghost"
    size="icon"
    disabled={disabled}
    onClick={(event) => {
      event.stopPropagation();
      onClick();
    }}
    {...aria}
  >
    {action === "remove" ? <X /> : action === "retry" ? <RotateCw /> : <Square />}
  </Button>
);

/**
 * Shadcn rejections
 *
 * A destructive `Alert` led by an icon, which gives its grid two real columns;
 * the messages and the Dismiss button share the second, with Dismiss at the
 * inline end. The role is `status` with `aria-live="polite"` rather than
 * shadcn's `alert`, to match the uploader's contract: refusals are announced
 * politely, never assertively.
 */
const ShadcnRejections = ({ rejections, onDismiss, dismissLabel }: RejectionsSlotProps) => (
  <Alert variant="destructive" role="status" aria-live="polite">
    <CircleAlert />
    <div className="flex items-start gap-2">
      <ul className="min-w-0 flex-1 space-y-0.5">
        {rejections.map((rejection, index) => (
          <li key={`${rejection.file.name}-${index}`}>{rejection.message}</li>
        ))}
      </ul>
      <Button
        type="button"
        variant="ghost"
        size="icon"
        className="-my-1.5 -me-2 size-7 shrink-0"
        aria-label={dismissLabel}
        onClick={onDismiss}
      >
        <X />
      </Button>
    </div>
  </Alert>
);

/**
 * Shadcn components
 *
 * The slot map a shadcn/ui project would pass as `components`.
 */
export const shadcnComponents: Partial<FileUploaderComponents> = {
  Root: ({ className, ...props }: UploaderRootSlotProps) => (
    <div data-slot="file-uploader" className={cn("flex flex-col gap-3", className)} {...props} />
  ),
  Dropzone: ShadcnDropzone,
  Icon: ({ dragging }: UploaderIconSlotProps) => (
    <UploadIcon className={cn("size-6", dragging ? "text-primary" : "text-muted-foreground")} />
  ),
  Empty: ({ title, hint }: UploaderEmptySlotProps) => (
    <div>
      <p className="text-sm font-medium">{title}</p>
      <p className="text-xs text-muted-foreground">{hint}</p>
    </div>
  ),
  Trigger: ({ onClick, disabled, children }: UploaderTriggerSlotProps) => (
    <Button
      type="button"
      variant="outline"
      size="sm"
      disabled={disabled}
      onClick={(event) => {
        event.stopPropagation();
        onClick();
      }}
    >
      {children}
    </Button>
  ),
  List: ({ className, ...props }: UploaderListSlotProps) => (
    <ul data-slot="file-list" className={cn("flex flex-col", className)} {...props} />
  ),
  Item: ShadcnItem,
  Thumbnail: ShadcnThumbnail,
  ItemMeta: ShadcnItemMeta,
  Progress: ShadcnProgress,
  Action: ShadcnAction,
  Rejections: ShadcnRejections,
};

MUI

This adapter renders FileUploader with MUI parts. Copy the file into your app and pass the map to components; every slot you leave out keeps its fallback. It imports @mui/material.

The demo sits inside MUI's provider, as any app built on it already does.

mui.tsx

/**
 * FileUploader for MUI
 *
 * A `components` map that renders the slotsmith file uploader with MUI
 * primitives, tested against MUI v7. Copy the file, keep the parts you want,
 * and pass the map as `components={muiComponents}`; every slot left out
 * keeps its fallback.
 */
import Alert from "@mui/material/Alert";
import Avatar from "@mui/material/Avatar";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import IconButton from "@mui/material/IconButton";
import LinearProgress from "@mui/material/LinearProgress";
import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import Paper from "@mui/material/Paper";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import type { ReactNode } from "react";
import type {
  DropzoneSlotProps,
  FileUploaderComponents,
  ItemMetaSlotProps,
  RejectionsSlotProps,
  ThumbnailSlotProps,
  UploaderActionSlotProps,
  UploaderEmptySlotProps,
  UploaderIconSlotProps,
  UploaderItemSlotProps,
  UploaderListSlotProps,
  UploaderProgressSlotProps,
  UploaderRootSlotProps,
  UploaderTriggerSlotProps,
} from "slotsmith/file-uploader";

/**
 * MUI file uploader parts
 *
 * Built from MUI v7 primitives — `Paper`, `IconButton`, `Avatar`, `List`,
 * `ListItem`, `LinearProgress`, `Chip`-adjacent `Alert` — not from MUI's own
 * upload component, which does not exist as a shared primitive in the library.
 */

/** A small inline icon, so the skin doesn't pull in `@mui/icons-material`. */
const Glyph = ({ path, size = 20 }: { path: ReactNode; size?: number }) => (
  <Box
    component="svg"
    viewBox="0 0 24 24"
    aria-hidden="true"
    sx={{ width: size, height: size, fill: "none", stroke: "currentColor", strokeWidth: 1.8 }}
  >
    {path}
  </Box>
);

/**
 * MUI dropzone
 *
 * An outlined `Paper` that switches to a solid, tinted border while dragging.
 */
const MuiDropzone = (props: DropzoneSlotProps) => (
  <Paper
    variant="outlined"
    sx={{
      display: "flex",
      flexDirection: "column",
      alignItems: "center",
      gap: 1,
      p: 4,
      textAlign: "center",
      borderStyle: "dashed",
      cursor: "pointer",
      transition: "border-color 150ms, background-color 150ms",
      "&:hover": { borderColor: "primary.main", bgcolor: "action.hover" },
      "&[data-dragging]": { borderStyle: "solid", borderColor: "primary.main", bgcolor: "action.selected" },
      "&[data-disabled]": { cursor: "not-allowed", opacity: 0.55 },
    }}
    {...props}
  />
);

/**
 * MUI item
 *
 * A `ListItem` laid out as a grid so the progress bar can span it.
 */
const MuiItem = (props: UploaderItemSlotProps) => (
  <ListItem
    divider
    sx={{
      display: "grid",
      gridTemplateColumns: "auto minmax(0, 1fr) auto",
      alignItems: "center",
      columnGap: 1.5,
      rowGap: 1,
      "&[data-status=error]": { borderColor: "error.main" },
    }}
    {...props}
  />
);

/**
 * MUI thumbnail
 *
 * A rounded `Avatar` holding the preview, or a file glyph.
 */
const MuiThumbnail = ({ src, isImage, alt }: ThumbnailSlotProps) => (
  <Avatar variant="rounded" src={isImage && src ? src : undefined} alt={alt} sx={{ width: 40, height: 40 }}>
    {(!isImage || !src) && (
      <Glyph path={<path d="M14 3v4a1 1 0 0 0 1 1h4M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2Z" />} />
    )}
  </Avatar>
);

/**
 * MUI item meta
 *
 * Name over a secondary line, truncated instead of wrapped.
 */
const MuiItemMeta = ({ item, size, status }: ItemMetaSlotProps) => (
  <Box sx={{ minWidth: 0 }}>
    <Typography variant="body2" noWrap>
      {item.name}
    </Typography>
    <Typography
      variant="caption"
      color={item.status === "error" ? "error" : "text.secondary"}
      noWrap
      component="p"
    >
      {item.status === "error" ? item.error : [size, status].filter(Boolean).join(" · ")}
    </Typography>
  </Box>
);

/**
 * MUI progress
 *
 * `LinearProgress` in determinate mode, spanning the row.
 */
const MuiProgress = ({ value, active, ...aria }: UploaderProgressSlotProps) => (
  <LinearProgress
    variant="determinate"
    value={Math.max(0, Math.min(100, value))}
    data-active={active || undefined}
    sx={{ gridColumn: "1 / -1", borderRadius: 999 }}
    {...aria}
  />
);

/**
 * MUI action
 *
 * One `IconButton` for remove, retry and cancel.
 */
const MuiAction = ({ action, onClick, disabled, ...aria }: UploaderActionSlotProps) => (
  <IconButton
    size="small"
    disabled={disabled}
    color={action === "remove" ? "default" : "primary"}
    onClick={(event) => {
      event.stopPropagation();
      onClick();
    }}
    {...aria}
  >
    <Glyph
      size={18}
      path={
        action === "remove" ? (
          <path d="M18 6 6 18M6 6l12 12" />
        ) : action === "retry" ? (
          <path d="M21 12a9 9 0 1 1-3-6.7M21 4v5h-5" />
        ) : (
          <rect x="6" y="6" width="12" height="12" rx="1" />
        )
      }
    />
  </IconButton>
);

/**
 * MUI components
 *
 * The slot map an MUI v7 project would pass as `components`.
 */
export const muiComponents: Partial<FileUploaderComponents> = {
  Root: (props: UploaderRootSlotProps) => <Stack spacing={1.5} {...props} />,
  Dropzone: MuiDropzone,
  Icon: ({ dragging }: UploaderIconSlotProps) => (
    <Box sx={{ color: dragging ? "primary.main" : "text.secondary", display: "flex" }}>
      <Glyph
        size={24}
        path={
          <>
            <path d="M12 16V4" />
            <path d="m7 9 5-5 5 5" />
            <path d="M4 16v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" />
          </>
        }
      />
    </Box>
  ),
  Empty: ({ title, hint }: UploaderEmptySlotProps) => (
    <Box>
      <Typography variant="body2" fontWeight={500}>
        {title}
      </Typography>
      <Typography variant="caption" color="text.secondary">
        {hint}
      </Typography>
    </Box>
  ),
  Trigger: ({ onClick, disabled, children }: UploaderTriggerSlotProps) => (
    <Button
      size="small"
      variant="outlined"
      disabled={disabled}
      onClick={(event) => {
        event.stopPropagation();
        onClick();
      }}
    >
      {children}
    </Button>
  ),
  List: (props: UploaderListSlotProps) => <List disablePadding {...props} />,
  Item: MuiItem,
  Thumbnail: MuiThumbnail,
  ItemMeta: MuiItemMeta,
  Progress: MuiProgress,
  Action: MuiAction,
  Rejections: ({ rejections, onDismiss, dismissLabel }: RejectionsSlotProps) => (
    <Alert severity="error" onClose={onDismiss} closeText={dismissLabel} role="status">
      {rejections.map((rejection, index) => (
        <div key={`${rejection.file.name}-${index}`}>{rejection.message}</div>
      ))}
    </Alert>
  ),
};

Chakra UI

This adapter renders FileUploader with Chakra UI parts. Copy the file into your app and pass the map to components; every slot you leave out keeps its fallback. It imports @chakra-ui/react.

The demo sits inside Chakra UI's provider, as any app built on it already does.

chakra.tsx

/**
 * FileUploader for Chakra UI
 *
 * A `components` map that renders the slotsmith file uploader with Chakra UI
 * primitives, tested against Chakra UI v3. Copy the file, keep the parts you
 * want, and pass the map as `components={chakraComponents}`; every slot left
 * out keeps its fallback.
 */
import { Alert, Avatar, Box, Button, CloseButton, IconButton, List, Progress, Text } from "@chakra-ui/react";
import type {
  DropzoneSlotProps,
  FileUploaderComponents,
  ItemMetaSlotProps,
  RejectionsSlotProps,
  ThumbnailSlotProps,
  UploaderActionSlotProps,
  UploaderEmptySlotProps,
  UploaderIconSlotProps,
  UploaderItemSlotProps,
  UploaderListSlotProps,
  UploaderProgressSlotProps,
  UploaderRootSlotProps,
  UploaderTriggerSlotProps,
} from "slotsmith/file-uploader";

/**
 * Chakra file uploader parts
 *
 * Built from Chakra UI v3 primitives — `Box`, `Button`, `IconButton`,
 * `Avatar`, `Progress`, `Alert` — not from a Chakra upload recipe, which does
 * not exist as a shared primitive in the library.
 */

/**
 * Chakra dropzone
 *
 * A dashed `Box`, styled off the `data-*` attributes the part already
 * carries.
 */
const ChakraDropzone = (props: DropzoneSlotProps) => (
  <Box
    display="flex"
    flexDirection="column"
    alignItems="center"
    gap="2"
    p="8"
    textAlign="center"
    borderWidth="1px"
    borderStyle="dashed"
    borderRadius="md"
    cursor="pointer"
    css={{
      "&:hover": { borderColor: "colorPalette.solid" },
      "&[data-dragging]": { borderStyle: "solid", borderColor: "colorPalette.solid", bg: "colorPalette.subtle" },
      "&[data-disabled]": { cursor: "not-allowed", opacity: 0.55 },
    }}
    {...props}
  />
);

/**
 * Chakra item
 *
 * `List.Item` laid out as a grid so the progress bar can span it.
 */
const ChakraItem = (props: UploaderItemSlotProps) => (
  <List.Item
    listStyleType="none"
    display="grid"
    gridTemplateColumns="auto minmax(0, 1fr) auto"
    alignItems="center"
    columnGap="3"
    rowGap="1"
    py="2"
    borderBottomWidth="1px"
    css={{ "&:last-of-type": { borderBottomWidth: 0 }, "&[data-status=error]": { borderColor: "border.error" } }}
    {...props}
  />
);

/**
 * Chakra thumbnail
 *
 * `Avatar.Root` holding the preview; Chakra's own avatar machine falls back
 * to `Avatar.Fallback` whenever there is no image or it fails to load, so
 * this slot only decides whether to offer a `src` at all.
 */
const ChakraThumbnail = ({ src, isImage, alt }: ThumbnailSlotProps) => (
  <Avatar.Root size="md" shape="rounded">
    {isImage && src && <Avatar.Image src={src} alt={alt} />}
    <Avatar.Fallback>
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true" width="16" height="16">
        <path d="M14 3v4a1 1 0 0 0 1 1h4" />
        <path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2Z" />
      </svg>
    </Avatar.Fallback>
  </Avatar.Root>
);

/**
 * Chakra item meta
 *
 * Name over a secondary line, truncated instead of wrapped.
 */
const ChakraItemMeta = ({ item, size, status }: ItemMetaSlotProps) => (
  <Box minW="0">
    <Text truncate fontSize="sm">
      {item.name}
    </Text>
    <Text truncate fontSize="xs" color={item.status === "error" ? "fg.error" : "fg.muted"}>
      {item.status === "error" ? item.error : [size, status].filter(Boolean).join(" · ")}
    </Text>
  </Box>
);

/**
 * Chakra progress
 *
 * `Progress.Root` in determinate mode, spanning the row.
 */
const ChakraProgress = ({ value, active, ...aria }: UploaderProgressSlotProps) => (
  <Progress.Root
    value={value}
    min={0}
    max={100}
    size="xs"
    gridColumn="1 / -1"
    data-active={active || undefined}
    {...aria}
  >
    <Progress.Track>
      <Progress.Range />
    </Progress.Track>
  </Progress.Root>
);

/**
 * Chakra action
 *
 * One ghost `IconButton` for remove, retry and cancel.
 */
const ChakraAction = ({ action, onClick, disabled, ...aria }: UploaderActionSlotProps) => (
  <IconButton
    size="xs"
    variant="ghost"
    disabled={disabled}
    onClick={(event) => {
      event.stopPropagation();
      onClick();
    }}
    {...aria}
  >
    {action === "remove" ? "✕" : action === "retry" ? "⟳" : "■"}
  </IconButton>
);

/**
 * Chakra components
 *
 * The slot map a Chakra UI v3 project would pass as `components`.
 */
export const chakraComponents: Partial<FileUploaderComponents> = {
  Root: (props: UploaderRootSlotProps) => <Box display="flex" flexDirection="column" gap="3" {...props} />,
  Dropzone: ChakraDropzone,
  Icon: ({ dragging }: UploaderIconSlotProps) => (
    <Box color={dragging ? "colorPalette.solid" : "fg.muted"} display="flex">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true" width="24" height="24">
        <path d="M12 16V4" />
        <path d="m7 9 5-5 5 5" />
        <path d="M4 16v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" />
      </svg>
    </Box>
  ),
  Empty: ({ title, hint }: UploaderEmptySlotProps) => (
    <Box>
      <Text fontSize="sm" fontWeight="medium">
        {title}
      </Text>
      <Text fontSize="xs" color="fg.muted">
        {hint}
      </Text>
    </Box>
  ),
  Trigger: ({ onClick, disabled, children }: UploaderTriggerSlotProps) => (
    <Button
      size="sm"
      variant="outline"
      disabled={disabled}
      onClick={(event) => {
        event.stopPropagation();
        onClick();
      }}
    >
      {children}
    </Button>
  ),
  List: (props: UploaderListSlotProps) => <List.Root gap="0" p="0" m="0" {...props} />,
  Item: ChakraItem,
  Thumbnail: ChakraThumbnail,
  ItemMeta: ChakraItemMeta,
  Progress: ChakraProgress,
  Action: ChakraAction,
  Rejections: ({ rejections, onDismiss, dismissLabel }: RejectionsSlotProps) => (
    <Alert.Root status="error" role="status">
      <Alert.Content>
        {rejections.map((rejection, index) => (
          <Alert.Description key={`${rejection.file.name}-${index}`}>{rejection.message}</Alert.Description>
        ))}
      </Alert.Content>
      <CloseButton size="xs" aria-label={dismissLabel} onClick={onDismiss} />
    </Alert.Root>
  ),
};

Ant Design

This adapter renders FileUploader with Ant Design parts. Copy the file into your app and pass the map to components; every slot you leave out keeps its fallback. It imports @ant-design/icons antd.

The demo sits inside Ant Design's provider, as any app built on it already does.

antd.tsx

/**
 * FileUploader for Ant Design
 *
 * A `components` map that renders the slotsmith file uploader with Ant Design
 * primitives, tested against Ant Design v6. Copy the file, keep the parts you
 * want, and pass the map as `components={antdComponents}`; every slot left
 * out keeps its fallback.
 */
import { CloseOutlined, FileOutlined, InboxOutlined, ReloadOutlined, StopOutlined } from "@ant-design/icons";
import { Alert, Avatar, Button, Flex, Progress, Typography, theme } from "antd";
import type {
  DropzoneSlotProps,
  FileUploaderComponents,
  ItemMetaSlotProps,
  RejectionsSlotProps,
  ThumbnailSlotProps,
  UploaderActionSlotProps,
  UploaderEmptySlotProps,
  UploaderIconSlotProps,
  UploaderItemSlotProps,
  UploaderListSlotProps,
  UploaderProgressSlotProps,
  UploaderRootSlotProps,
  UploaderTriggerSlotProps,
} from "slotsmith/file-uploader";

/**
 * Ant Design file uploader parts
 *
 * Built from Ant Design v6 primitives — `Button`, `Avatar`, `Progress`,
 * `Alert`, `Typography` and Ant's icons — not from Ant's own `Upload`, which
 * is a competing engine rather than a set of parts. The element parts take
 * the look of Ant's dragger and upload list from the theme's tokens; their
 * state arrives as `data-*` props, so each part reads its own and picks the
 * matching token.
 */

/**
 * Has flag
 *
 * @param props - An element part's props.
 * @param name - A `data-*` attribute.
 * @returns Whether the component set it.
 */
const has = (props: object, name: `data-${string}`) => (props as Record<string, unknown>)[name] !== undefined;

/**
 * Ant dropzone
 *
 * Ant's dragger: a dashed, lightly filled panel whose border turns primary
 * while a file is over it.
 */
function AntDropzone({ style, ...props }: DropzoneSlotProps) {
  const { token } = theme.useToken();
  const dragging = has(props, "data-dragging");
  const disabled = has(props, "data-disabled");
  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        gap: token.paddingXS,
        padding: token.paddingLG,
        textAlign: "center",
        background: token.colorFillAlter,
        border: `${token.lineWidth}px dashed ${dragging ? token.colorPrimary : token.colorBorder}`,
        borderRadius: token.borderRadiusLG,
        cursor: disabled ? "not-allowed" : "pointer",
        opacity: disabled ? 0.55 : undefined,
        transition: `border-color ${token.motionDurationSlow}`,
        ...style,
      }}
      {...props}
    />
  );
}

/**
 * Ant icon
 *
 * The dragger's inbox, in the primary colour while a file is over the zone.
 */
function AntIcon({ dragging }: UploaderIconSlotProps) {
  const { token } = theme.useToken();
  return (
    <span aria-hidden="true" style={{ display: "flex", fontSize: token.fontSizeHeading2, color: dragging ? token.colorPrimary : token.colorTextSecondary }}>
      <InboxOutlined />
    </span>
  );
}

/**
 * Ant empty
 *
 * The dragger's title over its secondary hint.
 */
const AntEmpty = ({ title, hint }: UploaderEmptySlotProps) => (
  <Flex vertical>
    <Typography.Text strong>{title}</Typography.Text>
    <Typography.Text type="secondary">{hint}</Typography.Text>
  </Flex>
);

/**
 * Ant trigger
 *
 * A small `Button`. The click is stopped so it does not also reach the drop
 * zone and open the dialog twice.
 */
const AntTrigger = ({ onClick, disabled, children }: UploaderTriggerSlotProps) => (
  <Button
    size="small"
    disabled={disabled}
    onClick={(event) => {
      event.stopPropagation();
      onClick();
    }}
  >
    {children}
  </Button>
);

/**
 * Ant list
 *
 * The `<ul>` of Ant's upload list.
 */
const AntList = ({ style, ...props }: UploaderListSlotProps) => (
  <ul style={{ margin: 0, padding: 0, listStyle: "none", ...style }} {...props} />
);

/**
 * Ant item
 *
 * A row of Ant's picture list: a grid so the progress bar can span it, and
 * the error colour on the border of a failed file.
 */
function AntItem({ style, ...props }: UploaderItemSlotProps) {
  const { token } = theme.useToken();
  const failed = (props as Record<string, unknown>)["data-status"] === "error";
  return (
    <li
      style={{
        display: "grid",
        gridTemplateColumns: "auto minmax(0, 1fr) auto",
        alignItems: "center",
        columnGap: token.paddingSM,
        rowGap: token.paddingXS,
        marginTop: token.marginXS,
        padding: token.paddingXS,
        border: `${token.lineWidth}px ${token.lineType} ${failed ? token.colorError : token.colorBorder}`,
        borderRadius: token.borderRadiusLG,
        ...style,
      }}
      {...props}
    />
  );
}

/**
 * Ant thumbnail
 *
 * A square `Avatar` holding the preview, or a file icon.
 */
const AntThumbnail = ({ src, isImage, alt }: ThumbnailSlotProps) =>
  isImage && src ? (
    <Avatar shape="square" size={40} src={src} alt={alt} />
  ) : (
    <Avatar shape="square" size={40} icon={<FileOutlined aria-hidden="true" />} />
  );

/**
 * Ant item meta
 *
 * Name over a secondary line, cut short instead of wrapped; the error in the
 * danger colour.
 */
const AntItemMeta = ({ item, size, status }: ItemMetaSlotProps) => {
  const line = { display: "block", overflow: "hidden", whiteSpace: "nowrap", textOverflow: "ellipsis" } as const;
  return (
    <div style={{ minWidth: 0 }}>
      <Typography.Text style={line}>{item.name}</Typography.Text>
      <Typography.Text type={item.status === "error" ? "danger" : "secondary"} style={{ ...line, fontSize: "0.85em" }}>
        {item.status === "error" ? item.error : [size, status].filter(Boolean).join(" · ")}
      </Typography.Text>
    </div>
  );
};

/**
 * Ant progress
 *
 * A thin line `Progress` without its number, spanning the row, animated
 * while the upload is in flight.
 */
const AntProgress = ({ value, active, ...aria }: UploaderProgressSlotProps) => (
  <Progress
    percent={Math.max(0, Math.min(100, value))}
    size="small"
    showInfo={false}
    status={active ? "active" : "normal"}
    style={{ gridColumn: "1 / -1", margin: 0 }}
    {...aria}
  />
);

/**
 * Ant action
 *
 * One text `Button` for remove, retry and cancel; retry and cancel in the
 * primary colour. Ant's `color` prop is a palette, which is why the slot
 * passes no DOM `color`.
 */
const AntAction = ({ action, onClick, disabled, ...aria }: UploaderActionSlotProps) => (
  <Button
    size="small"
    variant="text"
    color={action === "remove" ? "default" : "primary"}
    disabled={disabled}
    icon={action === "remove" ? <CloseOutlined /> : action === "retry" ? <ReloadOutlined /> : <StopOutlined />}
    onClick={(event) => {
      event.stopPropagation();
      onClick();
    }}
    {...aria}
  />
);

/**
 * Ant rejections
 *
 * An error `Alert` listing the refused files. Announced politely, so `role`
 * is `status` rather than Ant's default `alert`; the `closable` object names
 * the close button.
 */
const AntRejections = ({ rejections, onDismiss, dismissLabel }: RejectionsSlotProps) => (
  <Alert
    type="error"
    showIcon
    role="status"
    closable={{ onClose: onDismiss, "aria-label": dismissLabel }}
    title={rejections.map((rejection, index) => (
      <div key={`${rejection.file.name}-${index}`}>{rejection.message}</div>
    ))}
  />
);

/**
 * Ant Design components
 *
 * The slot map an Ant Design v6 project would pass as `components`.
 */
export const antdComponents: Partial<FileUploaderComponents> = {
  Root: ({ style, ...props }: UploaderRootSlotProps) => (
    <div style={{ display: "flex", flexDirection: "column", gap: 12, ...style }} {...props} />
  ),
  Dropzone: AntDropzone,
  Icon: AntIcon,
  Empty: AntEmpty,
  Trigger: AntTrigger,
  List: AntList,
  Item: AntItem,
  Thumbnail: AntThumbnail,
  ItemMeta: AntItemMeta,
  Progress: AntProgress,
  Action: AntAction,
  Rejections: AntRejections,
};

Radix Themes

This adapter renders FileUploader with Radix Themes parts. Copy the file into your app and pass the map to components; every slot you leave out keeps its fallback. It imports @radix-ui/react-icons @radix-ui/themes.

The demo sits inside Radix Themes's provider, as any app built on it already does.

radix.tsx

/**
 * FileUploader for Radix Themes
 *
 * A `components` map that renders the slotsmith file uploader with Radix
 * Themes primitives, tested against Radix Themes v3. Copy the file, keep the
 * parts you want, and pass the map as `components={radixComponents}`; every
 * slot left out keeps its fallback.
 *
 * This is Radix Themes (`@radix-ui/themes`), the styled library; an app on the
 * bare Radix primitives wants the shadcn/ui adapter, which is built on them.
 */
import { Cross2Icon, ExclamationTriangleIcon, FileIcon, ReloadIcon, StopIcon, UploadIcon } from "@radix-ui/react-icons";
import { Avatar, Button, Callout, Flex, IconButton, Progress, Text } from "@radix-ui/themes";
import type {
  DropzoneSlotProps,
  FileUploaderComponents,
  ItemMetaSlotProps,
  RejectionsSlotProps,
  ThumbnailSlotProps,
  UploaderActionSlotProps,
  UploaderEmptySlotProps,
  UploaderIconSlotProps,
  UploaderItemSlotProps,
  UploaderListSlotProps,
  UploaderProgressSlotProps,
  UploaderRootSlotProps,
  UploaderTriggerSlotProps,
} from "slotsmith/file-uploader";

/**
 * Radix Themes file uploader parts
 *
 * Built from Radix Themes v3 — `Button`, `IconButton`, `Avatar`,
 * `Progress`, `Callout`, `Text` and Radix icons. Radix Themes has no upload
 * component, so the drop zone and the rows are plain elements dressed in its
 * variables (`--accent-*`, `--gray-*`, `--red-*`), which makes them follow the
 * app's `<Theme accentColor appearance>`; their state arrives as `data-*`
 * props, so each part reads its own. The app must import
 * `@radix-ui/themes/styles.css`, as every Radix Themes app already does.
 *
 * Radix's `Button` and `IconButton` render a `<button>` with no `type`, which
 * would submit a surrounding form, so each one here is given
 * `type="button"` ahead of the slot's props.
 */

/**
 * Has flag
 *
 * @param props - An element part's props.
 * @param name - A `data-*` attribute.
 * @returns Whether the component set it.
 */
const has = (props: object, name: `data-${string}`) => (props as Record<string, unknown>)[name] !== undefined;

/**
 * Radix dropzone
 *
 * A dashed, lightly filled panel whose border and fill take the accent
 * colour while a file is over it.
 */
function RadixDropzone({ style, ...props }: DropzoneSlotProps) {
  const dragging = has(props, "data-dragging");
  const disabled = has(props, "data-disabled");
  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        gap: "var(--space-2)",
        padding: "var(--space-5)",
        textAlign: "center",
        fontFamily: "var(--default-font-family)",
        backgroundColor: dragging ? "var(--accent-a2)" : "var(--gray-a2)",
        borderWidth: 1,
        borderStyle: "dashed",
        borderColor: dragging ? "var(--accent-8)" : "var(--gray-a7)",
        borderRadius: "var(--radius-4)",
        cursor: disabled ? "not-allowed" : "pointer",
        opacity: disabled ? 0.55 : undefined,
        transition: "border-color 150ms, background-color 150ms",
        ...style,
      }}
      {...props}
    />
  );
}

/**
 * Radix icon
 *
 * An upload arrow, in the accent colour while a file is over the zone.
 */
const RadixIcon = ({ dragging }: UploaderIconSlotProps) => (
  <span aria-hidden="true" style={{ display: "flex", color: dragging ? "var(--accent-11)" : "var(--gray-a11)" }}>
    <UploadIcon width={24} height={24} />
  </span>
);

/**
 * Radix empty
 *
 * The title over a muted hint.
 */
const RadixEmpty = ({ title, hint }: UploaderEmptySlotProps) => (
  <Flex direction="column">
    <Text size="2" weight="medium">
      {title}
    </Text>
    <Text size="2" color="gray">
      {hint}
    </Text>
  </Flex>
);

/**
 * Radix trigger
 *
 * A soft `Button`. The click is stopped so it does not also reach the drop
 * zone and open the dialog twice.
 */
const RadixTrigger = ({ onClick, disabled, children }: UploaderTriggerSlotProps) => (
  <Button
    type="button"
    size="1"
    variant="soft"
    disabled={disabled}
    onClick={(event) => {
      event.stopPropagation();
      onClick();
    }}
  >
    {children}
  </Button>
);

/**
 * Radix list
 *
 * The `<ul>` of rows.
 */
const RadixList = ({ style, ...props }: UploaderListSlotProps) => (
  <ul style={{ display: "flex", flexDirection: "column", gap: "var(--space-2)", margin: 0, padding: 0, listStyle: "none", ...style }} {...props} />
);

/**
 * Radix item
 *
 * A row like a Radix surface card: a grid so the progress bar can span it,
 * and a red hairline around a failed file.
 */
function RadixItem({ style, ...props }: UploaderItemSlotProps) {
  const failed = (props as Record<string, unknown>)["data-status"] === "error";
  return (
    <li
      style={{
        display: "grid",
        gridTemplateColumns: "auto minmax(0, 1fr) auto",
        alignItems: "center",
        columnGap: "var(--space-3)",
        rowGap: "var(--space-2)",
        padding: "var(--space-2)",
        fontFamily: "var(--default-font-family)",
        backgroundColor: "var(--color-surface)",
        boxShadow: `inset 0 0 0 1px ${failed ? "var(--red-a7)" : "var(--gray-a5)"}`,
        borderRadius: "var(--radius-3)",
        ...style,
      }}
      {...props}
    />
  );
}

/**
 * Radix thumbnail
 *
 * The preview in `Avatar`'s rounded square, or an `Avatar` holding a file
 * icon. Radix's `Avatar` draws its image only once it has loaded and shows
 * no `alt` until then, so the preview is a plain `<img>` of the same size and
 * radius, which keeps its accessible name from the first render.
 */
const RadixThumbnail = ({ src, isImage, alt }: ThumbnailSlotProps) =>
  isImage && src ? (
    <img
      src={src}
      alt={alt}
      style={{ display: "block", width: "var(--space-7)", height: "var(--space-7)", objectFit: "cover", borderRadius: "var(--radius-2)" }}
    />
  ) : (
    <Avatar size="3" variant="soft" color="gray" fallback={<FileIcon aria-hidden="true" width={18} height={18} />} />
  );

/**
 * Radix item meta
 *
 * Name over a muted line, cut short instead of wrapped; the error in red.
 */
const RadixItemMeta = ({ item, size, status }: ItemMetaSlotProps) => (
  <Flex direction="column" minWidth="0">
    <Text size="2" truncate>
      {item.name}
    </Text>
    <Text size="1" truncate color={item.status === "error" ? "red" : "gray"}>
      {item.status === "error" ? item.error : [size, status].filter(Boolean).join(" · ")}
    </Text>
  </Flex>
);

/**
 * Radix progress
 *
 * A thin `Progress` spanning the row, in the gray scale once the upload is
 * no longer in flight.
 */
const RadixProgress = ({ value, active, ...aria }: UploaderProgressSlotProps) => (
  <Progress
    size="1"
    value={Math.max(0, Math.min(100, value))}
    color={active ? undefined : "gray"}
    style={{ gridColumn: "1 / -1" }}
    {...aria}
  />
);

/**
 * Radix action
 *
 * One ghost `IconButton` for remove, retry and cancel; remove in gray, retry
 * and cancel in the accent colour.
 */
const RadixAction = ({ action, onClick, disabled, ...aria }: UploaderActionSlotProps) => (
  <IconButton
    type="button"
    size="1"
    variant="ghost"
    color={action === "remove" ? "gray" : undefined}
    disabled={disabled}
    style={{ margin: 0 }}
    onClick={(event) => {
      event.stopPropagation();
      onClick();
    }}
    {...aria}
  >
    {action === "remove" ? <Cross2Icon /> : action === "retry" ? <ReloadIcon /> : <StopIcon />}
  </IconButton>
);

/**
 * Radix rejections
 *
 * A red `Callout` listing the refused files, with a ghost `IconButton` to
 * dismiss it. Announced politely, so its `role` is `status`. `Callout.Text`
 * is a paragraph, so each file gets a block `<span>` rather than a `<div>`.
 */
const RadixRejections = ({ rejections, onDismiss, dismissLabel }: RejectionsSlotProps) => (
  <Callout.Root color="red" size="1" role="status">
    <Callout.Icon>
      <ExclamationTriangleIcon />
    </Callout.Icon>
    <Flex align="start" justify="between" gap="2">
      <Callout.Text>
        {rejections.map((rejection, index) => (
          <span key={`${rejection.file.name}-${index}`} style={{ display: "block" }}>
            {rejection.message}
          </span>
        ))}
      </Callout.Text>
      <IconButton type="button" size="1" variant="ghost" color="red" aria-label={dismissLabel} onClick={onDismiss} style={{ margin: 0 }}>
        <Cross2Icon />
      </IconButton>
    </Flex>
  </Callout.Root>
);

/**
 * Radix Themes components
 *
 * The slot map a Radix Themes v3 project would pass as `components`.
 */
export const radixComponents: Partial<FileUploaderComponents> = {
  Root: ({ style, ...props }: UploaderRootSlotProps) => (
    <div style={{ display: "flex", flexDirection: "column", gap: 12, ...style }} {...props} />
  ),
  Dropzone: RadixDropzone,
  Icon: RadixIcon,
  Empty: RadixEmpty,
  Trigger: RadixTrigger,
  List: RadixList,
  Item: RadixItem,
  Thumbnail: RadixThumbnail,
  ItemMeta: RadixItemMeta,
  Progress: RadixProgress,
  Action: RadixAction,
  Rejections: RadixRejections,
};

Last updated