# Attachment

A compact file card that shows a document or image with its name, details, upload state and actions.

Attachment is an open-source component from suiss/ui for React, React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/components/attachment

**React**

**Example**

```tsx
import { XIcon } from "@phosphor-icons/react"
import {
  Attachment,
  AttachmentAction,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentLabel,
  AttachmentMedia,
  AttachmentTitle,
} from "@/components/ui/attachment"

export default function AttachmentDemo() {
  return (
    <Attachment orientation="horizontal" state="done">
      <AttachmentMedia variant="document">
        <AttachmentLabel className="bg-red-600">pdf</AttachmentLabel>
      </AttachmentMedia>
      <AttachmentContent>
        <AttachmentTitle>Quarterly report.pdf</AttachmentTitle>
        <AttachmentDescription>2.4 MB</AttachmentDescription>
      </AttachmentContent>
      <AttachmentActions>
        <AttachmentAction aria-label="Remove">
          <XIcon />
        </AttachmentAction>
      </AttachmentActions>
    </Attachment>
  )
}
```

An `Attachment` typically contains an `AttachmentMedia` thumbnail, an `AttachmentContent` block with `AttachmentTitle` and `AttachmentDescription`, and an `AttachmentActions` slot holding one or more `AttachmentAction` buttons. `AttachmentMedia` can hold an icon, an `img`, or an `AttachmentLabel` file-type tag. During an upload, put an `AttachmentProgress` bar inside the content. Add an `AttachmentTrigger` to make the whole card clickable.

Wrap several attachments in an `AttachmentGroup` to render them as a single list with inset dividers. Use attachments for files in composers, chat messages and upload forms.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/attachment
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add attachment
```

### Manual

1. Install the dependencies and add the [`cn` helper](https://ui.suiss.app/docs/installation#the-cn-helper):

```bash title="Terminal"
npm install @base-ui/react class-variance-authority
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button).

3. Copy the source into `components/ui/attachment.tsx`:

```tsx title="components/ui/attachment.tsx"
import { mergeProps } from "@base-ui/react/merge-props"
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import type * as React from "react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"

const attachmentVariants = cva(
  "group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 flex-wrap rounded-2xl bg-muted text-foreground transition-colors has-[[data-slot=attachment-trigger]:focus-visible]:ring-3 has-[[data-slot=attachment-trigger]:focus-visible]:ring-ring/50 has-[>a,>button]:hover:bg-[color-mix(in_oklab,var(--muted),var(--foreground)_5%)] not-in-data-[slot=attachment-group]:data-[state=idle]:border not-in-data-[slot=attachment-group]:data-[state=idle]:border-dashed not-in-data-[slot=attachment-group]:data-[state=idle]:border-input not-in-data-[slot=attachment-group]:data-[state=idle]:bg-transparent not-in-data-[slot=attachment-group]:data-[state=idle]:has-[>a,>button]:hover:bg-accent in-data-[slot=attachment-group]:w-full in-data-[slot=attachment-group]:rounded-none in-data-[slot=attachment-group]:not-first:before:absolute in-data-[slot=attachment-group]:not-first:before:inset-e-0 in-data-[slot=attachment-group]:not-first:before:top-0 in-data-[slot=attachment-group]:not-first:before:start-4 in-data-[slot=attachment-group]:not-first:before:border-t in-data-[slot=attachment-group]:not-first:before:border-border in-data-[slot=attachment-group]:not-first:has-data-[slot=attachment-media]:before:start-16 not-in-data-[slot=attachment-group]:data-[state=idle]:has-[>a,>button]:hover:text-accent-foreground",
  {
    variants: {
      size: {
        default:
          "gap-3 px-4 py-3 text-sm data-[orientation=vertical]:gap-2 data-[orientation=vertical]:has-data-[slot=attachment-content]:px-2.5 data-[orientation=vertical]:has-data-[slot=attachment-content]:py-2 data-[orientation=vertical]:has-data-[slot=attachment-media]:p-2",
        sm: "gap-2.5 text-xs has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5 has-data-[slot=attachment-media]:p-1.5 in-data-[slot=attachment-group]:not-first:before:start-2 in-data-[slot=attachment-group]:not-first:has-data-[slot=attachment-media]:before:start-12",
        xs: "gap-1.5 rounded-lg text-xs has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1 in-data-[slot=attachment-group]:not-first:before:start-1.5 in-data-[slot=attachment-group]:not-first:has-data-[slot=attachment-media]:before:start-9.5",
      },
      orientation: {
        horizontal: "min-w-40 items-center",
        vertical: "w-24 flex-col has-data-[slot=attachment-content]:w-30",
      },
    },
  }
)

function Attachment({
  className,
  state = "done",
  size = "default",
  orientation = "horizontal",
  ...props
}: React.ComponentProps<"div"> &
  VariantProps<typeof attachmentVariants> & {
    state?: "idle" | "uploading" | "processing" | "error" | "done"
  }) {
  return (
    <div
      data-slot="attachment"
      data-state={state}
      data-size={size}
      data-orientation={orientation}
      className={cn(attachmentVariants({ size, orientation }), className)}
      {...props}
    />
  )
}

const attachmentMediaVariants = cva(
  "relative flex shrink-0 items-center justify-center overflow-hidden rounded-md bg-muted group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-sm group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5",
  {
    variants: {
      variant: {
        icon: "h-11 w-9 rounded-sm border bg-background text-muted-foreground group-data-[orientation=vertical]/attachment:aspect-square group-data-[orientation=vertical]/attachment:h-auto group-data-[size=sm]/attachment:h-10 group-data-[size=xs]/attachment:h-8.5",
        document:
          "h-11 w-9 items-end rounded-sm border bg-background pb-1.5 group-data-[size=sm]/attachment:h-10 group-data-[size=xs]/attachment:h-8.5",
        image:
          "h-11 w-9 group-data-[orientation=vertical]/attachment:aspect-square group-data-[orientation=vertical]/attachment:h-auto group-data-[size=sm]/attachment:h-10 group-data-[size=xs]/attachment:h-8.5 *:[img]:size-full *:[img]:object-cover",
      },
    },
    defaultVariants: {
      variant: "icon",
    },
  }
)

function AttachmentLabel({
  className,
  ...props
}: React.ComponentProps<"span">) {
  return (
    <span
      data-slot="attachment-label"
      className={cn(
        "rounded-xs bg-primary px-1 py-0.5 text-[0.5625rem] leading-none font-semibold text-primary-foreground uppercase",
        className
      )}
      {...props}
    />
  )
}

function AttachmentMedia({
  className,
  variant = "icon",
  ...props
}: React.ComponentProps<"div"> & VariantProps<typeof attachmentMediaVariants>) {
  return (
    <div
      data-slot="attachment-media"
      data-variant={variant}
      className={cn(attachmentMediaVariants({ variant }), className)}
      {...props}
    />
  )
}

function AttachmentContent({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="attachment-content"
      className={cn(
        "max-w-full min-w-0 flex-1 group-data-[orientation=vertical]/attachment:px-1 has-data-[slot=attachment-progress]:*:data-[slot=attachment-title]:pe-12",
        className
      )}
      {...props}
    />
  )
}

function AttachmentTitle({
  className,
  ...props
}: React.ComponentProps<"span">) {
  return (
    <span
      data-slot="attachment-title"
      className={cn(
        "block max-w-full min-w-0 truncate group-data-[size=default]/attachment:text-base group-data-[size=sm]/attachment:font-semibold group-data-[size=xs]/attachment:font-semibold",
        className
      )}
      {...props}
    />
  )
}

function AttachmentDescription({
  className,
  ...props
}: React.ComponentProps<"span">) {
  return (
    <span
      data-slot="attachment-description"
      className={cn(
        "mt-0.5 block max-w-full min-w-0 truncate text-xs text-muted-foreground group-data-[size=default]/attachment:text-sm group-data-[state=error]/attachment:text-destructive",
        className
      )}
      {...props}
    />
  )
}

function AttachmentProgress({
  className,
  ...props
}: ProgressPrimitive.Root.Props) {
  return (
    <ProgressPrimitive.Root
      data-slot="attachment-progress"
      className={cn("relative mt-2", className)}
      {...props}
    >
      <ProgressPrimitive.Value className="absolute inset-e-0 bottom-full mb-2 text-sm text-muted-foreground tabular-nums" />
      <ProgressPrimitive.Track className="h-1 overflow-hidden rounded-full bg-foreground/10">
        <ProgressPrimitive.Indicator
          data-slot="attachment-progress-indicator"
          className="h-full rounded-full bg-primary transition-[width]"
        />
      </ProgressPrimitive.Track>
    </ProgressPrimitive.Root>
  )
}

function AttachmentActions({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="attachment-actions"
      className={cn(
        "relative z-20 flex shrink-0 items-center group-data-[orientation=vertical]/attachment:absolute group-data-[orientation=vertical]/attachment:top-3 group-data-[orientation=vertical]/attachment:inset-e-3 group-data-[orientation=vertical]/attachment:gap-1",
        className
      )}
      {...props}
    />
  )
}

function AttachmentAction({
  className,
  variant = "secondary",
  size,
  ...props
}: React.ComponentProps<typeof Button>) {
  const isText = variant === "link"

  return (
    <Button
      data-slot="attachment-action"
      variant={variant}
      size={size ?? (isText ? "sm" : "icon-xs")}
      className={cn(
        isText
          ? "h-auto px-0 text-base"
          : "relative size-7 text-muted-foreground after:absolute after:-inset-2.25 group-data-[size=sm]/attachment:size-6 group-data-[size=sm]/attachment:after:-inset-2.75 group-data-[size=xs]/attachment:size-5 group-data-[size=xs]/attachment:after:-inset-3.25 hover:text-foreground group-data-[orientation=vertical]/attachment:bg-black/50 group-data-[orientation=vertical]/attachment:text-white group-data-[orientation=vertical]/attachment:backdrop-blur-sm group-data-[orientation=vertical]/attachment:hover:bg-black/60 group-data-[orientation=vertical]/attachment:hover:text-white group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-2.5",
        className
      )}
      {...props}
    />
  )
}

function AttachmentTrigger({
  className,
  render,
  type,
  ...props
}: useRender.ComponentProps<"button">) {
  return useRender({
    defaultTagName: "button",
    props: mergeProps<"button">(
      {
        type: render ? type : (type ?? "button"),
        className: cn("absolute inset-0 z-10 outline-none", className),
      },
      props
    ),
    render,
    state: {
      slot: "attachment-trigger",
    },
  })
}

function AttachmentGroup({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="attachment-group"
      className={cn(
        "flex w-full min-w-0 flex-col overflow-hidden rounded-2xl bg-muted",
        className
      )}
      {...props}
    />
  )
}

export {
  Attachment,
  AttachmentAction,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentGroup,
  AttachmentLabel,
  AttachmentMedia,
  AttachmentProgress,
  AttachmentTitle,
  AttachmentTrigger,
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { Attachment, AttachmentLabel, AttachmentMedia, AttachmentContent, AttachmentTitle, AttachmentDescription, AttachmentProgress, AttachmentActions, AttachmentAction, AttachmentTrigger, AttachmentGroup } from "@/components/ui/attachment"
```

```tsx
<Attachment orientation="horizontal" state="done">
  <AttachmentMedia variant="document">
    <AttachmentLabel className="bg-red-600">pdf</AttachmentLabel>
  </AttachmentMedia>
  <AttachmentContent>
    <AttachmentTitle>Quarterly report.pdf</AttachmentTitle>
    <AttachmentDescription>2.4 MB</AttachmentDescription>
  </AttachmentContent>
  <AttachmentActions>
    <AttachmentAction aria-label="Remove">
      <XIcon />
    </AttachmentAction>
  </AttachmentActions>
</Attachment>
```

Set `state` to reflect the file's lifecycle. `idle` adds a dashed border (outside a group), and `error` colors the description with `text-destructive`. The `state`, `size` and `orientation` values are also exposed as `data-*` attributes for your own styling.

`AttachmentAction` renders a `Button` with the `secondary` variant and an icon size by default. Pass `variant="link"` for a text action such as "Try Again". Icon-only actions need an `aria-label`.

`AttachmentTrigger` is an invisible button stretched over the card. Give it an `aria-label`, or use `render` to turn it into a link. Actions sit above it, so they remain separately clickable.

## Examples

### Sizes

Compares the `default`, `sm` and `xs` sizes.

**Example**

```tsx
import { FileTextIcon, XIcon } from "@phosphor-icons/react"
import {
  Attachment,
  AttachmentAction,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentMedia,
  AttachmentTitle,
} from "@/components/ui/attachment"

export default function AttachmentSizes() {
  return (
    <div className="flex flex-col items-start gap-3">
      {(["default", "sm", "xs"] as const).map((size) => (
        <Attachment
          key={size}
          orientation="horizontal"
          state="done"
          size={size}
        >
          <AttachmentMedia>
            <FileTextIcon />
          </AttachmentMedia>
          <AttachmentContent>
            <AttachmentTitle>Meeting notes.docx</AttachmentTitle>
            <AttachmentDescription>184 KB</AttachmentDescription>
          </AttachmentContent>
          <AttachmentActions>
            <AttachmentAction aria-label="Remove">
              <XIcon />
            </AttachmentAction>
          </AttachmentActions>
        </Attachment>
      ))}
    </div>
  )
}
```

### States

Shows the `idle`, `uploading`, `processing`, `error` and `done` states.

**Example**

```tsx
import { UploadSimpleIcon, XIcon } from "@phosphor-icons/react"
import {
  Attachment,
  AttachmentAction,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentLabel,
  AttachmentMedia,
  AttachmentProgress,
  AttachmentTitle,
} from "@/components/ui/attachment"
import { Spinner } from "@/components/ui/spinner"

export default function AttachmentStates() {
  return (
    <div className="flex w-96 flex-col gap-3">
      <Attachment orientation="horizontal" state="idle" className="w-full">
        <AttachmentMedia>
          <UploadSimpleIcon />
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>Drop a file here</AttachmentTitle>
          <AttachmentDescription>
            PDF, PNG or JPG up to 10 MB
          </AttachmentDescription>
        </AttachmentContent>
        <AttachmentActions>
          <AttachmentAction variant="link">Choose File</AttachmentAction>
        </AttachmentActions>
      </Attachment>
      <Attachment orientation="horizontal" state="uploading" className="w-full">
        <AttachmentMedia variant="document">
          <AttachmentLabel className="bg-red-600">pdf</AttachmentLabel>
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>Invoice March.pdf</AttachmentTitle>
          <AttachmentProgress value={64} aria-label="Uploading" />
        </AttachmentContent>
        <AttachmentActions>
          <AttachmentAction aria-label="Cancel upload">
            <XIcon />
          </AttachmentAction>
        </AttachmentActions>
      </Attachment>
      <Attachment
        orientation="horizontal"
        state="processing"
        className="w-full"
      >
        <AttachmentMedia variant="document">
          <AttachmentLabel className="bg-red-600">pdf</AttachmentLabel>
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>Invoice March.pdf</AttachmentTitle>
          <AttachmentDescription>Processing…</AttachmentDescription>
        </AttachmentContent>
        <AttachmentActions>
          <Spinner className="size-5 text-muted-foreground" />
        </AttachmentActions>
      </Attachment>
      <Attachment orientation="horizontal" state="error" className="w-full">
        <AttachmentMedia variant="document">
          <AttachmentLabel className="bg-orange-700">key</AttachmentLabel>
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>Presentation.key</AttachmentTitle>
          <AttachmentDescription>File type not supported</AttachmentDescription>
        </AttachmentContent>
        <AttachmentActions>
          <AttachmentAction aria-label="Remove">
            <XIcon />
          </AttachmentAction>
        </AttachmentActions>
      </Attachment>
      <Attachment orientation="horizontal" state="done" className="w-full">
        <AttachmentMedia variant="document">
          <AttachmentLabel className="bg-red-600">pdf</AttachmentLabel>
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>Invoice March.pdf</AttachmentTitle>
          <AttachmentDescription>1.1 MB</AttachmentDescription>
        </AttachmentContent>
      </Attachment>
    </div>
  )
}
```

### Drop zone

**Example**

```tsx
import { UploadSimpleIcon } from "@phosphor-icons/react"
import {
  Attachment,
  AttachmentContent,
  AttachmentDescription,
  AttachmentMedia,
  AttachmentTitle,
  AttachmentTrigger,
} from "@/components/ui/attachment"

export default function AttachmentDropZone() {
  return (
    <Attachment orientation="horizontal" state="idle" className="w-96">
      <AttachmentTrigger aria-label="Choose a file to upload" />
      <AttachmentMedia>
        <UploadSimpleIcon />
      </AttachmentMedia>
      <AttachmentContent>
        <AttachmentTitle>Drop a file or click to choose</AttachmentTitle>
        <AttachmentDescription>
          PDF, PNG or JPG up to 10 MB
        </AttachmentDescription>
      </AttachmentContent>
    </Attachment>
  )
}
```

### Image

Uses an image thumbnail with the `image` media variant.

**Example**

```tsx
import { XIcon } from "@phosphor-icons/react"
import {
  Attachment,
  AttachmentAction,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentMedia,
  AttachmentTitle,
} from "@/components/ui/attachment"

const imageUrl =
  "https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=400&q=80"

export default function AttachmentImage() {
  return (
    <Attachment orientation="horizontal" state="done">
      <AttachmentMedia variant="image">
        <img src={imageUrl} alt="Mountain lake" />
      </AttachmentMedia>
      <AttachmentContent>
        <AttachmentTitle>Lake.jpg</AttachmentTitle>
        <AttachmentDescription>3.2 MB</AttachmentDescription>
      </AttachmentContent>
      <AttachmentActions>
        <AttachmentAction aria-label="Remove">
          <XIcon />
        </AttachmentAction>
      </AttachmentActions>
    </Attachment>
  )
}
```

### Vertical

Stacks the media above the content with `orientation="vertical"`.

**Example**

```tsx
import { FilePdfIcon, XIcon } from "@phosphor-icons/react"
import {
  Attachment,
  AttachmentAction,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentMedia,
  AttachmentTitle,
} from "@/components/ui/attachment"

const imageUrl =
  "https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=400&q=80"

export default function AttachmentVertical() {
  return (
    <div className="flex items-start gap-3">
      <Attachment orientation="vertical" state="done">
        <AttachmentMedia variant="image">
          <img src={imageUrl} alt="Mountain lake" />
        </AttachmentMedia>
        <AttachmentActions>
          <AttachmentAction variant="secondary" aria-label="Remove">
            <XIcon />
          </AttachmentAction>
        </AttachmentActions>
      </Attachment>
      <Attachment orientation="vertical" state="done">
        <AttachmentMedia>
          <FilePdfIcon />
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>Contract.pdf</AttachmentTitle>
          <AttachmentDescription>860 KB</AttachmentDescription>
        </AttachmentContent>
      </Attachment>
    </div>
  )
}
```

### Clickable

Makes the whole card a button with `AttachmentTrigger`, alongside a separate download action.

**Example**

```tsx
import { DownloadSimpleIcon } from "@phosphor-icons/react"
import {
  Attachment,
  AttachmentAction,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentLabel,
  AttachmentMedia,
  AttachmentTitle,
  AttachmentTrigger,
} from "@/components/ui/attachment"

export default function AttachmentClickable() {
  return (
    <Attachment orientation="horizontal" state="done">
      <AttachmentTrigger aria-label="Open Brand guidelines.pdf" />
      <AttachmentMedia variant="document">
        <AttachmentLabel className="bg-red-600">pdf</AttachmentLabel>
      </AttachmentMedia>
      <AttachmentContent>
        <AttachmentTitle>Brand guidelines.pdf</AttachmentTitle>
        <AttachmentDescription>5.8 MB</AttachmentDescription>
      </AttachmentContent>
      <AttachmentActions>
        <AttachmentAction aria-label="Download">
          <DownloadSimpleIcon />
        </AttachmentAction>
      </AttachmentActions>
    </Attachment>
  )
}
```

### Group

Combines several attachments in one `AttachmentGroup` list.

**Example**

```tsx
import { CheckCircleIcon, XIcon } from "@phosphor-icons/react"
import {
  Attachment,
  AttachmentAction,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentGroup,
  AttachmentLabel,
  AttachmentMedia,
  AttachmentProgress,
  AttachmentTitle,
} from "@/components/ui/attachment"
import { Spinner } from "@/components/ui/spinner"

const imageUrl =
  "https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=400&q=80"

export default function AttachmentGroupExample() {
  return (
    <div className="w-96">
      <AttachmentGroup>
        <Attachment orientation="horizontal" state="uploading">
          <AttachmentMedia variant="document">
            <AttachmentLabel className="bg-red-600">pdf</AttachmentLabel>
          </AttachmentMedia>
          <AttachmentContent>
            <AttachmentTitle>Invoice_1042.pdf</AttachmentTitle>
            <AttachmentProgress value={64} aria-label="Uploading" />
          </AttachmentContent>
          <AttachmentActions>
            <AttachmentAction aria-label="Cancel upload">
              <XIcon />
            </AttachmentAction>
          </AttachmentActions>
        </Attachment>
        <Attachment orientation="horizontal" state="processing">
          <AttachmentMedia variant="document">
            <AttachmentLabel className="bg-blue-600">docx</AttachmentLabel>
          </AttachmentMedia>
          <AttachmentContent>
            <AttachmentTitle>Contract.docx</AttachmentTitle>
            <AttachmentDescription>Processing…</AttachmentDescription>
          </AttachmentContent>
          <AttachmentActions>
            <Spinner className="size-5 text-muted-foreground" />
          </AttachmentActions>
        </Attachment>
        <Attachment orientation="horizontal" state="done">
          <AttachmentMedia variant="document">
            <AttachmentLabel className="bg-green-700">xlsx</AttachmentLabel>
          </AttachmentMedia>
          <AttachmentContent>
            <AttachmentTitle>Menu_October.xlsx</AttachmentTitle>
            <AttachmentDescription>2.4 MB</AttachmentDescription>
          </AttachmentContent>
          <AttachmentActions>
            <CheckCircleIcon
              aria-label="Uploaded"
              className="size-6 fill-green-600 text-white dark:fill-green-400"
            />
          </AttachmentActions>
        </Attachment>
        <Attachment orientation="horizontal" state="done">
          <AttachmentMedia variant="image">
            <img src={imageUrl} alt="Mountain lake" />
          </AttachmentMedia>
          <AttachmentContent>
            <AttachmentTitle>Table_12.jpg</AttachmentTitle>
            <AttachmentDescription>1.8 MB</AttachmentDescription>
          </AttachmentContent>
          <AttachmentActions>
            <AttachmentAction aria-label="Remove">
              <XIcon />
            </AttachmentAction>
          </AttachmentActions>
        </Attachment>
        <Attachment orientation="horizontal" state="error">
          <AttachmentMedia variant="document">
            <AttachmentLabel className="bg-red-600">pdf</AttachmentLabel>
          </AttachmentMedia>
          <AttachmentContent>
            <AttachmentTitle>Z-Report_30-09.pdf</AttachmentTitle>
            <AttachmentDescription>Upload failed</AttachmentDescription>
          </AttachmentContent>
          <AttachmentActions>
            <AttachmentAction variant="link">Try Again</AttachmentAction>
          </AttachmentActions>
        </Attachment>
      </AttachmentGroup>
    </div>
  )
}
```

### File types

Uses colored `AttachmentLabel` tags to indicate file extensions.

**Example**

```tsx
import {
  Attachment,
  AttachmentContent,
  AttachmentDescription,
  AttachmentLabel,
  AttachmentMedia,
  AttachmentTitle,
} from "@/components/ui/attachment"

export default function AttachmentFileTypes() {
  return (
    <div className="flex flex-col gap-3">
      {[
        {
          name: "Invoice.pdf",
          meta: "128 KB",
          extension: "pdf",
          tone: "bg-red-600",
        },
        {
          name: "Menu.xlsx",
          meta: "2.4 MB",
          extension: "xlsx",
          tone: "bg-green-700",
        },
        {
          name: "Contract.docx",
          meta: "84 KB",
          extension: "docx",
          tone: "bg-blue-600",
        },
        {
          name: "Pitch.key",
          meta: "12 MB",
          extension: "key",
          tone: "bg-orange-700",
        },
        { name: "Archive.zip", meta: "48 MB", extension: "zip", tone: "" },
      ].map((file) => (
        <Attachment key={file.name} orientation="horizontal" state="done">
          <AttachmentMedia variant="document">
            <AttachmentLabel className={file.tone}>
              {file.extension}
            </AttachmentLabel>
          </AttachmentMedia>
          <AttachmentContent>
            <AttachmentTitle>{file.name}</AttachmentTitle>
            <AttachmentDescription>{file.meta}</AttachmentDescription>
          </AttachmentContent>
        </Attachment>
      ))}
      <Attachment orientation="horizontal" state="done">
        <AttachmentMedia variant="document">
          <AttachmentLabel className="bg-blue-600">MD</AttachmentLabel>
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>README.md</AttachmentTitle>
          <AttachmentDescription>4 KB</AttachmentDescription>
        </AttachmentContent>
      </Attachment>
    </div>
  )
}
```

### File list

A full-width list of separate attachment cards.

**Example**

```tsx
import { XIcon } from "@phosphor-icons/react"
import {
  Attachment,
  AttachmentAction,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentLabel,
  AttachmentMedia,
  AttachmentTitle,
} from "@/components/ui/attachment"

const imageUrl =
  "https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=400&q=80"

export default function AttachmentFileList() {
  return (
    <div className="flex w-96 flex-col gap-3">
      <Attachment orientation="horizontal" state="done" className="w-full">
        <AttachmentMedia variant="document">
          <AttachmentLabel className="bg-red-600">pdf</AttachmentLabel>
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>Invoice_1042.pdf</AttachmentTitle>
          <AttachmentDescription>128 KB</AttachmentDescription>
        </AttachmentContent>
        <AttachmentActions>
          <AttachmentAction aria-label="Remove">
            <XIcon />
          </AttachmentAction>
        </AttachmentActions>
      </Attachment>
      <Attachment orientation="horizontal" state="done" className="w-full">
        <AttachmentMedia variant="document">
          <AttachmentLabel className="bg-green-700">xlsx</AttachmentLabel>
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>Menu_October.xlsx</AttachmentTitle>
          <AttachmentDescription>2.4 MB</AttachmentDescription>
        </AttachmentContent>
        <AttachmentActions>
          <AttachmentAction aria-label="Remove">
            <XIcon />
          </AttachmentAction>
        </AttachmentActions>
      </Attachment>
      <Attachment orientation="horizontal" state="done" className="w-full">
        <AttachmentMedia variant="image">
          <img src={imageUrl} alt="Mountain lake" />
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>Table_12.jpg</AttachmentTitle>
          <AttachmentDescription>1.8 MB</AttachmentDescription>
        </AttachmentContent>
        <AttachmentActions>
          <AttachmentAction aria-label="Remove">
            <XIcon />
          </AttachmentAction>
        </AttachmentActions>
      </Attachment>
      <Attachment orientation="horizontal" state="error" className="w-full">
        <AttachmentMedia variant="document">
          <AttachmentLabel className="bg-red-600">pdf</AttachmentLabel>
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>Z-Report_30-09.pdf</AttachmentTitle>
          <AttachmentDescription>Upload failed</AttachmentDescription>
        </AttachmentContent>
        <AttachmentActions>
          <AttachmentAction variant="link">Try Again</AttachmentAction>
        </AttachmentActions>
      </Attachment>
    </div>
  )
}
```

### Uploaded

Marks completed uploads with a check icon in the actions slot.

**Example**

```tsx
import { CheckCircleIcon } from "@phosphor-icons/react"
import {
  Attachment,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentLabel,
  AttachmentMedia,
  AttachmentTitle,
} from "@/components/ui/attachment"

const imageUrl =
  "https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=400&q=80"

export default function AttachmentUploaded() {
  return (
    <div className="flex w-96 flex-col gap-3">
      <Attachment orientation="horizontal" state="done" className="w-full">
        <AttachmentMedia variant="document">
          <AttachmentLabel className="bg-green-700">xlsx</AttachmentLabel>
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>Menu_October.xlsx</AttachmentTitle>
          <AttachmentDescription>2.4 MB</AttachmentDescription>
        </AttachmentContent>
        <AttachmentActions>
          <CheckCircleIcon
            aria-label="Uploaded"
            className="size-6 fill-green-600 text-white dark:fill-green-400"
          />
        </AttachmentActions>
      </Attachment>
      <Attachment orientation="horizontal" state="done" className="w-full">
        <AttachmentMedia variant="image">
          <img src={imageUrl} alt="Mountain lake" />
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>Table_12.jpg</AttachmentTitle>
          <AttachmentDescription>1.8 MB</AttachmentDescription>
        </AttachmentContent>
        <AttachmentActions>
          <CheckCircleIcon
            aria-label="Uploaded"
            className="size-6 fill-green-600 text-white dark:fill-green-400"
          />
        </AttachmentActions>
      </Attachment>
    </div>
  )
}
```

## API Reference

- `Attachment`
- `AttachmentMedia`
  - `AttachmentLabel`
- `AttachmentContent`
  - `AttachmentTitle`
  - `AttachmentDescription`
  - `AttachmentProgress`
- `AttachmentActions`
  - `AttachmentAction`
- `AttachmentTrigger`
- `AttachmentGroup`
- `Attachment`
  - `AttachmentMedia`
    - `AttachmentLabel`
  - `AttachmentContent`
    - `AttachmentTitle`
    - `AttachmentProgress`
    - `AttachmentDescription`
  - `AttachmentActions`
    - `AttachmentAction`

### Attachment

The card container that sets size, orientation and state.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `size` | `"default" \| "sm" \| "xs"` | `"default"` | Overall density: `default`, `sm` or `xs`. |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | `horizontal` places media beside the content; `vertical` stacks it above and overlays actions on the media. |
| `state` | `"idle" \| "uploading" \| "processing" \| "error" \| "done"` | `"done"` | Upload lifecycle state. `idle` adds a dashed border and `error` colors the description red. |

Also accepts the props of `React.ComponentProps<"div">`.

### AttachmentLabel

A small uppercase tag, usually the file extension, shown inside the media.

Also accepts the props of `React.ComponentProps<"span">`.

### AttachmentMedia

The thumbnail area for an icon, document tag or image.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"icon" \| "document" \| "image"` | `"icon"` | Thumbnail style: `icon` for a bordered icon tile, `document` for a page shape with a bottom tag, `image` for a cropped image. |

Also accepts the props of `React.ComponentProps<"div">`.

### AttachmentContent

Wraps the title, description and progress bar.

Also accepts the props of `React.ComponentProps<"div">`.

### AttachmentTitle

The file name, truncated to one line.

Also accepts the props of `React.ComponentProps<"span">`.

### AttachmentDescription

Secondary details such as file size or an error message.

Also accepts the props of `React.ComponentProps<"span">`.

### AttachmentProgress

A progress bar with a value readout for uploads.

Also accepts the props of the primitive part it wraps.

### AttachmentActions

The slot for action buttons or status indicators.

Also accepts the props of `React.ComponentProps<"div">`.

### AttachmentAction

A compact button for actions such as remove, cancel or download.

Also accepts the props of `React.ComponentProps<typeof Button>`.

### AttachmentTrigger

An invisible button covering the card to make it clickable.

Also accepts the props of `useRender.ComponentProps<"button">`.

### AttachmentGroup

Stacks attachments into one rounded list with dividers.

Also accepts the props of `React.ComponentProps<"div">`.

## Accessibility

- `AttachmentProgress` wraps the progress primitive and exposes the `progressbar` role with `aria-valuenow`; give it an `aria-label`.
- `AttachmentTrigger` renders a `button` with `type="button"` by default; give it an `aria-label` that names the file.
- The trigger and each `AttachmentAction` are separate tab stops, in that order.
- The card shows a focus ring when its trigger has keyboard focus.

## Notes

- Styling hooks: `data-slot` values such as `attachment`, `attachment-media`, `attachment-content` and `attachment-action`, plus `data-state`, `data-size` and `data-orientation` on the root and `data-variant` on the media.
- Inside `AttachmentGroup`, attachments fill the width, lose their own rounding and get inset top dividers.
- `AttachmentLabel` uses `bg-primary` by default; override it with a color class per file type.

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/components-attachment--default.light.webp)

```tsx
import { XIcon } from "phosphor-react-native"
import { Attachment, AttachmentAction, AttachmentActions, AttachmentContent, AttachmentDescription, AttachmentLabel, AttachmentMedia, AttachmentTitle } from "@/components/ui/attachment"
import { Icon } from "@/components/ui/icon"

export function AttachmentExample() {
  return (
    <Attachment orientation="horizontal" state="done">
      <AttachmentMedia variant="document">
        <AttachmentLabel className="bg-red-500">pdf</AttachmentLabel>
      </AttachmentMedia>
      <AttachmentContent>
        <AttachmentTitle>Quarterly report.pdf</AttachmentTitle>
        <AttachmentDescription>2.4 MB</AttachmentDescription>
      </AttachmentContent>
      <AttachmentActions>
        <AttachmentAction accessibilityLabel="Remove">
          <Icon as={XIcon} />
        </AttachmentAction>
      </AttachmentActions>
    </Attachment>
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct AttachmentExample: View {
  var body: some View {
    Attachment {
      AttachmentMedia(.document) { AttachmentLabel("pdf", tint: EsuissColor.destructive) }
      AttachmentContent {
        AttachmentTitle("Quarterly report.pdf")
        AttachmentDescription("2.4 MB")
      }
      AttachmentActions {
        AttachmentAction("Remove", icon: Ph.x.bold) {}
      }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.X
import com.esuiss.ui.components.Attachment
import com.esuiss.ui.components.AttachmentAction
import com.esuiss.ui.components.AttachmentActions
import com.esuiss.ui.components.AttachmentContent
import com.esuiss.ui.components.AttachmentDescription
import com.esuiss.ui.components.AttachmentLabel
import com.esuiss.ui.components.AttachmentMedia
import com.esuiss.ui.components.AttachmentMediaVariant
import com.esuiss.ui.components.AttachmentTitle
import com.esuiss.ui.components.Icon
import com.esuiss.ui.theme.Palette

@Composable
fun AttachmentDocument(extension: String, tone: Color) {
    AttachmentMedia(variant = AttachmentMediaVariant.Document) {
        AttachmentLabel(extension, background = tone)
    }
}

@Composable
fun AttachmentIconAction(label: String, icon: androidx.compose.ui.graphics.vector.ImageVector = PhosphorIcons.Regular.X) {
    AttachmentAction(onClick = {}, contentDescription = label) {
        Icon(icon, contentDescription = null)
    }
}

@Composable
fun AttachmentDefault() {
    Row {
        Attachment {
            AttachmentDocument("pdf", Palette.red500)
            AttachmentContent {
                AttachmentTitle("Quarterly report.pdf")
                AttachmentDescription("2.4 MB")
            }
            AttachmentActions { AttachmentIconAction("Remove") }
        }
    }
}
```

`Attachment` is a rounded `View` that lays out an `AttachmentMedia` thumbnail, an `AttachmentContent` column and `AttachmentActions`. It comes in `default`, `sm` and `xs` sizes and in `horizontal` or `vertical` orientation; the vertical layout makes a tile with the actions floating over the media.

The `state` prop reflects the upload lifecycle: `idle` draws a dashed border for an empty drop slot, `error` turns the description red, and `uploading`, `processing` and `done` are passed to the parts through context. Put several attachments in an `AttachmentGroup` to show them as one list with hairlines between rows.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/attachment
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add attachment
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add attachment --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add attachment --platform kotlin
```

The CLI also adds what it builds on: [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Progress](https://ui.suiss.app/docs/components/progress), [Text](https://ui.suiss.app/docs/components/text).

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Progress](https://ui.suiss.app/docs/components/progress), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/progress.tsx`, `components/primitives/types.tsx`, `components/primitives/use-render.tsx`.

3. Copy the source into `components/ui/attachment.tsx`:

```tsx title="uniwind/components/ui/attachment.tsx"
import { cn } from "cn"
import * as React from "react"
import { Pressable, View, type ViewProps } from "react-native"
import * as ProgressPrimitive from "@/components/primitives/progress"
import type { RenderProp } from "@/components/primitives/types"
import { resolveClassName, useRender } from "@/components/primitives/use-render"
import { Button, buttonTextVariants } from "@/components/ui/button"
import { IconClassContext } from "@/components/ui/icon"
import {
  ProgressIndicator,
  ProgressTrack,
  ProgressValue,
} from "@/components/ui/progress"
import { Text, TextClassContext } from "@/components/ui/text"

type AttachmentState = "idle" | "uploading" | "processing" | "error" | "done"

type AttachmentSize = "default" | "sm" | "xs"

type AttachmentOrientation = "horizontal" | "vertical"

type AttachmentContextValue = {
  state: AttachmentState
  size: AttachmentSize
  orientation: AttachmentOrientation
  grouped: boolean
}

const AttachmentContext = React.createContext<AttachmentContextValue>({
  state: "done",
  size: "default",
  orientation: "horizontal",
  grouped: false,
})

const AttachmentGroupItemContext = React.createContext<number | null>(null)

const AttachmentContentContext = React.createContext(false)

const SIZE: Record<AttachmentSize, string> = {
  default: "gap-3 px-4 py-3",
  sm: "gap-2.5",
  xs: "gap-1.5",
}

const SIZE_WITH_CONTENT: Record<AttachmentSize, string> = {
  default: "px-2.5 py-2",
  sm: "px-2 py-1.5",
  xs: "px-1.5 py-1",
}

const SIZE_WITH_MEDIA: Record<AttachmentSize, string> = {
  default: "p-2",
  sm: "p-1.5",
  xs: "p-1",
}

const SEPARATOR_START: Record<AttachmentSize, [string, string]> = {
  default: ["start-4", "start-16"],
  sm: ["start-2", "start-12"],
  xs: ["start-1.5", "start-9.5"],
}

const MEDIA_SIZE: Record<AttachmentSize, string> = {
  default: "h-11 w-9",
  sm: "h-10 w-8",
  xs: "h-8.5 w-7 rounded-sm",
}

const ACTION_SIZE: Record<AttachmentSize, [string, number]> = {
  default: ["size-7", 9],
  sm: ["size-6", 11],
  xs: ["size-5", 13],
}

const LABEL_TEXT = {
  numberOfLines: 1,
  adjustsFontSizeToFit: true,
  minimumFontScale: 0.8,
}

function wrapText(
  children: React.ReactNode,
  textProps?: React.ComponentProps<typeof Text>
) {
  const output: React.ReactNode[] = []
  let run: (string | number)[] = []
  const flush = () => {
    if (run.length === 0) return
    output.push(
      <Text key={`text-${output.length}`} {...textProps}>
        {run.join("")}
      </Text>
    )
    run = []
  }
  for (const child of React.Children.toArray(children)) {
    if (typeof child === "string" || typeof child === "number") {
      run.push(child)
    } else {
      flush()
      output.push(child)
    }
  }
  flush()
  return output
}

function containsType(children: React.ReactNode, type: unknown) {
  let found = false
  React.Children.forEach(children, (child) => {
    if (React.isValidElement(child) && child.type === type) found = true
  })
  return found
}

function Attachment({
  className,
  state = "done",
  size = "default",
  orientation = "horizontal",
  children,
  ...props
}: ViewProps & {
  state?: AttachmentState
  size?: AttachmentSize
  orientation?: AttachmentOrientation
}) {
  const groupIndex = React.useContext(AttachmentGroupItemContext)
  const grouped = groupIndex !== null
  const hasMedia = containsType(children, AttachmentMedia)
  const hasContent = containsType(children, AttachmentContent)
  const vertical = orientation === "vertical"
  const context = React.useMemo(
    () => ({ state, size, orientation, grouped }),
    [state, size, orientation, grouped]
  )
  return (
    <AttachmentContext.Provider value={context}>
      <TextClassContext.Provider
        value={cn(
          "text-foreground",
          size === "default" ? "text-sm" : "text-xs"
        )}
      >
        <View
          className={cn(
            "relative min-w-0 max-w-full shrink-0 rounded-2xl",
            SIZE[size],
            size === "xs" && "rounded-lg",
            vertical
              ? cn("flex-col", hasContent ? "w-30" : "w-24")
              : "min-w-40 flex-row items-center",
            vertical && size === "default" && "gap-2",
            (vertical || size !== "default") &&
              (hasContent
                ? SIZE_WITH_CONTENT[size]
                : hasMedia && SIZE_WITH_MEDIA[size]),
            grouped ? "w-full rounded-none" : "self-start",
            !grouped && state === "idle"
              ? "border border-dashed border-input"
              : "bg-muted",
            className
          )}
          {...props}
        >
          {grouped && groupIndex > 0 ? (
            <View
              className={cn(
                "absolute end-0 top-0 h-px bg-border",
                SEPARATOR_START[size][hasMedia ? 1 : 0]
              )}
            />
          ) : null}
          {children}
        </View>
      </TextClassContext.Provider>
    </AttachmentContext.Provider>
  )
}

function AttachmentMedia({
  className,
  variant = "icon",
  ...props
}: ViewProps & { variant?: "icon" | "document" | "image" }) {
  const { size, orientation } = React.useContext(AttachmentContext)
  const vertical = orientation === "vertical"
  return (
    <IconClassContext.Provider
      value={cn(
        vertical && "size-6",
        !vertical && size === "xs" && "size-3.5",
        !vertical && size !== "xs" && "size-4",
        variant === "icon" && "text-muted-foreground"
      )}
    >
      <View
        className={cn(
          "relative shrink-0 items-center justify-center overflow-hidden rounded-md bg-muted",
          MEDIA_SIZE[size],
          vertical && "w-full",
          variant !== "image" &&
            "rounded-sm border border-border bg-background",
          variant === "document" && "justify-end pb-1.5",
          vertical && variant !== "document" && "aspect-square h-auto",
          className
        )}
        {...props}
      />
    </IconClassContext.Provider>
  )
}

function AttachmentLabel({ className, children, ...props }: ViewProps) {
  return (
    <TextClassContext.Provider value="text-[0.5625rem] leading-[0.6875rem] font-semibold uppercase text-primary-foreground">
      <View
        className={cn("rounded-xs bg-primary px-1 py-0.5", className)}
        {...props}
      >
        {wrapText(children, LABEL_TEXT)}
      </View>
    </TextClassContext.Provider>
  )
}

function AttachmentContent({ className, children, ...props }: ViewProps) {
  const { orientation } = React.useContext(AttachmentContext)
  const progress = containsType(children, AttachmentProgress)
  return (
    <AttachmentContentContext.Provider value={progress}>
      <View
        className={cn(
          "min-w-0 max-w-full",
          orientation === "vertical" ? "px-1" : "shrink grow",
          className
        )}
        {...props}
      >
        {children}
      </View>
    </AttachmentContentContext.Provider>
  )
}

function AttachmentTitle({
  className,
  numberOfLines = 1,
  ...props
}: React.ComponentProps<typeof Text>) {
  const { size } = React.useContext(AttachmentContext)
  const progress = React.useContext(AttachmentContentContext)
  return (
    <Text
      numberOfLines={numberOfLines}
      className={cn(
        "min-w-0 max-w-full",
        size === "default" ? "text-base" : "font-semibold",
        progress && "pe-12",
        className
      )}
      {...props}
    />
  )
}

function AttachmentDescription({
  className,
  numberOfLines = 1,
  ...props
}: React.ComponentProps<typeof Text>) {
  const { size, state } = React.useContext(AttachmentContext)
  return (
    <Text
      numberOfLines={numberOfLines}
      className={cn(
        "mt-0.5 min-w-0 max-w-full text-muted-foreground",
        size === "default" ? "text-sm" : "text-xs",
        state === "error" && "text-destructive",
        className
      )}
      {...props}
    />
  )
}

function AttachmentProgress({
  className,
  ...props
}: Omit<React.ComponentProps<typeof ProgressPrimitive.Root>, "children">) {
  return (
    <ProgressPrimitive.Root
      className={(state) =>
        cn("relative mt-2 w-full", resolveClassName(className, state))
      }
      {...props}
    >
      <ProgressValue className="absolute end-0 bottom-full mb-2 text-sm text-muted-foreground" />
      <ProgressTrack className="h-1 rounded-full bg-foreground/10">
        <ProgressIndicator className="rounded-full bg-primary" />
      </ProgressTrack>
    </ProgressPrimitive.Root>
  )
}

function AttachmentActions({ className, ...props }: ViewProps) {
  const { orientation } = React.useContext(AttachmentContext)
  return (
    <View
      className={cn(
        "relative z-20 shrink-0 flex-row items-center",
        orientation === "vertical" && "absolute end-3 top-3 gap-1",
        className
      )}
      {...props}
    />
  )
}

function triggerIconClass(tiny: boolean, small: boolean) {
  if (tiny) return "size-2.5"
  if (small) return "size-3"
  return "size-4"
}

function AttachmentAction({
  className,
  variant = "secondary",
  size,
  hitSlop,
  children,
  ...props
}: React.ComponentProps<typeof Button>) {
  const { size: attachmentSize, orientation } =
    React.useContext(AttachmentContext)
  const isText = variant === "link"
  const resolvedSize = size ?? (isText ? "sm" : "icon-xs")
  const [actionSize, slop] = ACTION_SIZE[attachmentSize]
  const vertical = orientation === "vertical"
  return (
    <Button
      variant={variant}
      size={resolvedSize}
      hitSlop={hitSlop ?? (isText ? undefined : slop)}
      className={cn(
        isText ? "h-auto px-0" : actionSize,
        !isText && vertical && "bg-black/50",
        className
      )}
      {...props}
    >
      <TextClassContext.Provider
        value={cn(
          buttonTextVariants({ variant, size: resolvedSize }),
          isText && "text-base",
          !isText && (vertical ? "text-white" : "text-muted-foreground")
        )}
      >
        <IconClassContext.Provider
          value={triggerIconClass(
            !isText && attachmentSize === "xs",
            resolvedSize === "xs" || resolvedSize === "icon-xs"
          )}
        >
          {wrapText(children as React.ReactNode)}
        </IconClassContext.Provider>
      </TextClassContext.Provider>
    </Button>
  )
}

function AttachmentTrigger({
  className,
  render,
  ...props
}: React.ComponentProps<typeof Pressable> &
  React.RefAttributes<React.ComponentRef<typeof View>> & {
    render?: RenderProp<Record<string, unknown>, Record<string, never>>
  }) {
  const { size, grouped } = React.useContext(AttachmentContext)
  return useRender({
    defaultComponent: Pressable,
    render,
    props: [
      {
        className: cn(
          "absolute inset-0 z-10 active:bg-background/20",
          grouped && "rounded-none",
          !grouped && size === "xs" && "rounded-lg",
          !grouped && size !== "xs" && "rounded-2xl",
          className
        ),
        role: "button",
      },
      props,
    ],
  })
}

function AttachmentGroup({ className, children, ...props }: ViewProps) {
  let index = 0
  const items = React.Children.map(children, (child) => {
    if (!React.isValidElement(child)) return child
    const value = index
    index += 1
    return (
      <AttachmentGroupItemContext.Provider value={value}>
        {child}
      </AttachmentGroupItemContext.Provider>
    )
  })
  return (
    <View
      className={cn(
        "w-full min-w-0 flex-col overflow-hidden rounded-2xl bg-muted",
        className
      )}
      {...props}
    >
      {items}
    </View>
  )
}

export {
  Attachment,
  AttachmentAction,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentGroup,
  AttachmentLabel,
  AttachmentMedia,
  AttachmentProgress,
  AttachmentTitle,
  AttachmentTrigger,
}
```

4. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Progress](https://ui.suiss.app/docs/components/progress), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/progress.tsx`, `components/primitives/types.tsx`, `components/primitives/use-render.tsx`.

3. Copy the source into `components/ui/attachment.tsx`:

```tsx title="nativewind/components/ui/attachment.tsx"
import { cn } from "cn"
import * as React from "react"
import { Pressable, View, type ViewProps } from "react-native"
import * as ProgressPrimitive from "@/components/primitives/progress"
import type { RenderProp } from "@/components/primitives/types"
import { resolveClassName, useRender } from "@/components/primitives/use-render"
import { Button, buttonTextVariants } from "@/components/ui/button"
import { IconClassContext } from "@/components/ui/icon"
import {
  ProgressIndicator,
  ProgressTrack,
  ProgressValue,
} from "@/components/ui/progress"
import { Text, TextClassContext } from "@/components/ui/text"

type AttachmentState = "idle" | "uploading" | "processing" | "error" | "done"

type AttachmentSize = "default" | "sm" | "xs"

type AttachmentOrientation = "horizontal" | "vertical"

type AttachmentContextValue = {
  state: AttachmentState
  size: AttachmentSize
  orientation: AttachmentOrientation
  grouped: boolean
}

const AttachmentContext = React.createContext<AttachmentContextValue>({
  state: "done",
  size: "default",
  orientation: "horizontal",
  grouped: false,
})

const AttachmentGroupItemContext = React.createContext<number | null>(null)

const AttachmentContentContext = React.createContext(false)

const SIZE: Record<AttachmentSize, string> = {
  default: "gap-3 px-4 py-3",
  sm: "gap-2.5",
  xs: "gap-1.5",
}

const SIZE_WITH_CONTENT: Record<AttachmentSize, string> = {
  default: "px-2.5 py-2",
  sm: "px-2 py-1.5",
  xs: "px-1.5 py-1",
}

const SIZE_WITH_MEDIA: Record<AttachmentSize, string> = {
  default: "p-2",
  sm: "p-1.5",
  xs: "p-1",
}

const SEPARATOR_START: Record<AttachmentSize, [string, string]> = {
  default: ["start-4", "start-16"],
  sm: ["start-2", "start-12"],
  xs: ["start-1.5", "start-[2.375rem]"],
}

const MEDIA_SIZE: Record<AttachmentSize, string> = {
  default: "h-11 w-9",
  sm: "h-10 w-8",
  xs: "h-[2.125rem] w-7 rounded-sm",
}

const ACTION_SIZE: Record<AttachmentSize, [string, number]> = {
  default: ["size-7", 9],
  sm: ["size-6", 11],
  xs: ["size-5", 13],
}

const LABEL_TEXT = {
  numberOfLines: 1,
  adjustsFontSizeToFit: true,
  minimumFontScale: 0.8,
}

function wrapText(
  children: React.ReactNode,
  textProps?: React.ComponentProps<typeof Text>
) {
  const output: React.ReactNode[] = []
  let run: (string | number)[] = []
  const flush = () => {
    if (run.length === 0) return
    output.push(
      <Text key={`text-${output.length}`} {...textProps}>
        {run.join("")}
      </Text>
    )
    run = []
  }
  for (const child of React.Children.toArray(children)) {
    if (typeof child === "string" || typeof child === "number") {
      run.push(child)
    } else {
      flush()
      output.push(child)
    }
  }
  flush()
  return output
}

function containsType(children: React.ReactNode, type: unknown) {
  let found = false
  React.Children.forEach(children, (child) => {
    if (React.isValidElement(child) && child.type === type) found = true
  })
  return found
}

function Attachment({
  className,
  state = "done",
  size = "default",
  orientation = "horizontal",
  children,
  ...props
}: ViewProps & {
  state?: AttachmentState
  size?: AttachmentSize
  orientation?: AttachmentOrientation
}) {
  const groupIndex = React.useContext(AttachmentGroupItemContext)
  const grouped = groupIndex !== null
  const hasMedia = containsType(children, AttachmentMedia)
  const hasContent = containsType(children, AttachmentContent)
  const vertical = orientation === "vertical"
  const context = React.useMemo(
    () => ({ state, size, orientation, grouped }),
    [state, size, orientation, grouped]
  )
  return (
    <AttachmentContext.Provider value={context}>
      <TextClassContext.Provider
        value={cn(
          "text-foreground",
          size === "default" ? "text-sm" : "text-xs"
        )}
      >
        <View
          className={cn(
            "relative min-w-0 max-w-full shrink-0 rounded-2xl",
            SIZE[size],
            size === "xs" && "rounded-lg",
            vertical
              ? cn("flex-col", hasContent ? "w-[7.5rem]" : "w-24")
              : "min-w-40 flex-row items-center",
            vertical && size === "default" && "gap-2",
            (vertical || size !== "default") &&
              (hasContent
                ? SIZE_WITH_CONTENT[size]
                : hasMedia && SIZE_WITH_MEDIA[size]),
            grouped ? "w-full rounded-none" : "self-start",
            !grouped && state === "idle"
              ? "border border-dashed border-input"
              : "bg-muted",
            className
          )}
          {...props}
        >
          {grouped && groupIndex > 0 ? (
            <View
              className={cn(
                "absolute end-0 top-0 h-px bg-border",
                SEPARATOR_START[size][hasMedia ? 1 : 0]
              )}
            />
          ) : null}
          {children}
        </View>
      </TextClassContext.Provider>
    </AttachmentContext.Provider>
  )
}

function AttachmentMedia({
  className,
  variant = "icon",
  ...props
}: ViewProps & { variant?: "icon" | "document" | "image" }) {
  const { size, orientation } = React.useContext(AttachmentContext)
  const vertical = orientation === "vertical"
  return (
    <IconClassContext.Provider
      value={cn(
        vertical && "size-6",
        !vertical && size === "xs" && "size-3.5",
        !vertical && size !== "xs" && "size-4",
        variant === "icon" && "text-muted-foreground"
      )}
    >
      <View
        className={cn(
          "relative shrink-0 items-center justify-center overflow-hidden rounded-md bg-muted",
          MEDIA_SIZE[size],
          vertical && "w-full",
          variant !== "image" &&
            "rounded-sm border border-border bg-background",
          variant === "document" && "justify-end pb-1.5",
          vertical && variant !== "document" && "aspect-square h-auto",
          className
        )}
        {...props}
      />
    </IconClassContext.Provider>
  )
}

function AttachmentLabel({ className, children, ...props }: ViewProps) {
  return (
    <TextClassContext.Provider value="text-[0.5625rem] leading-[0.6875rem] font-semibold uppercase text-primary-foreground">
      <View
        className={cn("rounded-xs bg-primary px-1 py-0.5", className)}
        {...props}
      >
        {wrapText(children, LABEL_TEXT)}
      </View>
    </TextClassContext.Provider>
  )
}

function AttachmentContent({ className, children, ...props }: ViewProps) {
  const { orientation } = React.useContext(AttachmentContext)
  const progress = containsType(children, AttachmentProgress)
  return (
    <AttachmentContentContext.Provider value={progress}>
      <View
        className={cn(
          "min-w-0 max-w-full",
          orientation === "vertical" ? "px-1" : "shrink grow",
          className
        )}
        {...props}
      >
        {children}
      </View>
    </AttachmentContentContext.Provider>
  )
}

function AttachmentTitle({
  className,
  numberOfLines = 1,
  ...props
}: React.ComponentProps<typeof Text>) {
  const { size } = React.useContext(AttachmentContext)
  const progress = React.useContext(AttachmentContentContext)
  return (
    <Text
      numberOfLines={numberOfLines}
      className={cn(
        "min-w-0 max-w-full",
        size === "default" ? "text-base" : "font-semibold",
        progress && "pe-12",
        className
      )}
      {...props}
    />
  )
}

function AttachmentDescription({
  className,
  numberOfLines = 1,
  ...props
}: React.ComponentProps<typeof Text>) {
  const { size, state } = React.useContext(AttachmentContext)
  return (
    <Text
      numberOfLines={numberOfLines}
      className={cn(
        "mt-0.5 min-w-0 max-w-full text-muted-foreground",
        size === "default" ? "text-sm" : "text-xs",
        state === "error" && "text-destructive",
        className
      )}
      {...props}
    />
  )
}

function AttachmentProgress({
  className,
  ...props
}: Omit<React.ComponentProps<typeof ProgressPrimitive.Root>, "children">) {
  return (
    <ProgressPrimitive.Root
      className={(state) =>
        cn("relative mt-2 w-full", resolveClassName(className, state))
      }
      {...props}
    >
      <ProgressValue className="absolute end-0 bottom-full mb-2 text-sm text-muted-foreground" />
      <ProgressTrack className="h-1 rounded-full bg-foreground-10">
        <ProgressIndicator className="rounded-full bg-primary" />
      </ProgressTrack>
    </ProgressPrimitive.Root>
  )
}

function AttachmentActions({ className, ...props }: ViewProps) {
  const { orientation } = React.useContext(AttachmentContext)
  return (
    <View
      className={cn(
        "relative z-20 shrink-0 flex-row items-center",
        orientation === "vertical" && "absolute end-3 top-3 gap-1",
        className
      )}
      {...props}
    />
  )
}

function triggerIconClass(tiny: boolean, small: boolean) {
  if (tiny) return "size-2.5"
  if (small) return "size-3"
  return "size-4"
}

function AttachmentAction({
  className,
  variant = "secondary",
  size,
  hitSlop,
  children,
  ...props
}: React.ComponentProps<typeof Button>) {
  const { size: attachmentSize, orientation } =
    React.useContext(AttachmentContext)
  const isText = variant === "link"
  const resolvedSize = size ?? (isText ? "sm" : "icon-xs")
  const [actionSize, slop] = ACTION_SIZE[attachmentSize]
  const vertical = orientation === "vertical"
  return (
    <Button
      variant={variant}
      size={resolvedSize}
      hitSlop={hitSlop ?? (isText ? undefined : slop)}
      className={cn(
        isText ? "h-auto px-0" : actionSize,
        !isText && vertical && "bg-black/50",
        className
      )}
      {...props}
    >
      <TextClassContext.Provider
        value={cn(
          buttonTextVariants({ variant, size: resolvedSize }),
          isText && "text-base",
          !isText && (vertical ? "text-white" : "text-muted-foreground")
        )}
      >
        <IconClassContext.Provider
          value={triggerIconClass(
            !isText && attachmentSize === "xs",
            resolvedSize === "xs" || resolvedSize === "icon-xs"
          )}
        >
          {wrapText(children as React.ReactNode)}
        </IconClassContext.Provider>
      </TextClassContext.Provider>
    </Button>
  )
}

function AttachmentTrigger({
  className,
  render,
  ...props
}: React.ComponentProps<typeof Pressable> &
  React.RefAttributes<React.ComponentRef<typeof View>> & {
    render?: RenderProp<Record<string, unknown>, Record<string, never>>
  }) {
  const { size, grouped } = React.useContext(AttachmentContext)
  return useRender({
    defaultComponent: Pressable,
    render,
    props: [
      {
        className: cn(
          "absolute inset-0 z-10 active:bg-background-20",
          grouped && "rounded-none",
          !grouped && size === "xs" && "rounded-lg",
          !grouped && size !== "xs" && "rounded-2xl",
          className
        ),
        role: "button",
      },
      props,
    ],
  })
}

function AttachmentGroup({ className, children, ...props }: ViewProps) {
  let index = 0
  const items = React.Children.map(children, (child) => {
    if (!React.isValidElement(child)) return child
    const value = index
    index += 1
    return (
      <AttachmentGroupItemContext.Provider value={value}>
        {child}
      </AttachmentGroupItemContext.Provider>
    )
  })
  return (
    <View
      className={cn(
        "w-full min-w-0 flex-col overflow-hidden rounded-2xl bg-muted",
        className
      )}
      {...props}
    >
      {items}
    </View>
  )
}

export {
  Attachment,
  AttachmentAction,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentGroup,
  AttachmentLabel,
  AttachmentMedia,
  AttachmentProgress,
  AttachmentTitle,
  AttachmentTrigger,
}
```

4. Update the import paths to match your project.

**StyleSheet**

1. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Progress](https://ui.suiss.app/docs/components/progress), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/progress.tsx`, `components/primitives/types.tsx`, `components/primitives/use-render.tsx`.

2. Copy the source into `components/ui/attachment.tsx`:

```tsx title="stylesheet/components/ui/attachment.tsx"
import * as React from "react"
import {
  Pressable,
  type StyleProp,
  StyleSheet,
  View,
  type ViewProps,
  type ViewStyle,
} from "react-native"
import * as ProgressPrimitive from "@/components/primitives/progress"
import type { RenderProp } from "@/components/primitives/types"
import { useRender } from "@/components/primitives/use-render"
import { Button, buttonTextStyle } from "@/components/ui/button"
import { IconSizeContext } from "@/components/ui/icon"
import {
  ProgressIndicator,
  ProgressTrack,
  ProgressValue,
} from "@/components/ui/progress"
import { Text, TextStyleContext } from "@/components/ui/text"
import { fontSize, radius, useTheme } from "@/components/ui/theme"

type AttachmentState = "idle" | "uploading" | "processing" | "error" | "done"

type AttachmentSize = "default" | "sm" | "xs"

type AttachmentOrientation = "horizontal" | "vertical"

type AttachmentContextValue = {
  state: AttachmentState
  size: AttachmentSize
  orientation: AttachmentOrientation
  grouped: boolean
}

const AttachmentContext = React.createContext<AttachmentContextValue>({
  state: "done",
  size: "default",
  orientation: "horizontal",
  grouped: false,
})

const AttachmentGroupItemContext = React.createContext<number | null>(null)

const AttachmentContentContext = React.createContext(false)

const SIZE: Record<AttachmentSize, ViewStyle> = {
  default: { gap: 12, paddingHorizontal: 16, paddingVertical: 12 },
  sm: { gap: 10 },
  xs: { gap: 6, borderRadius: radius.lg },
}

const SIZE_WITH_CONTENT: Record<AttachmentSize, ViewStyle> = {
  default: { paddingHorizontal: 10, paddingVertical: 8 },
  sm: { paddingHorizontal: 8, paddingVertical: 6 },
  xs: { paddingHorizontal: 6, paddingVertical: 4 },
}

const SIZE_WITH_MEDIA: Record<AttachmentSize, ViewStyle> = {
  default: { paddingHorizontal: 8, paddingVertical: 8 },
  sm: { paddingHorizontal: 6, paddingVertical: 6 },
  xs: { paddingHorizontal: 4, paddingVertical: 4 },
}

const SEPARATOR_START: Record<AttachmentSize, [number, number]> = {
  default: [16, 64],
  sm: [8, 48],
  xs: [6, 38],
}

const MEDIA_SIZE: Record<AttachmentSize, ViewStyle> = {
  default: { height: 44, width: 36 },
  sm: { height: 40, width: 32 },
  xs: { height: 34, width: 28, borderRadius: radius.sm },
}

const ACTION_SIZE: Record<AttachmentSize, [number, number]> = {
  default: [28, 9],
  sm: [24, 11],
  xs: [20, 13],
}

const styles = StyleSheet.create({
  root: {
    position: "relative",
    minWidth: 0,
    maxWidth: "100%",
    flexShrink: 0,
    borderRadius: radius["2xl"],
  },
  horizontal: { minWidth: 160, flexDirection: "row", alignItems: "center" },
  vertical: { flexDirection: "column" },
  dashed: { borderWidth: 1, borderStyle: "dashed" },
  separator: { position: "absolute", top: 0, end: 0, height: 1 },
  media: {
    position: "relative",
    flexShrink: 0,
    alignItems: "center",
    justifyContent: "center",
    overflow: "hidden",
    borderRadius: radius.md,
  },
  mediaFramed: { borderRadius: radius.sm, borderWidth: 1 },
  document: { justifyContent: "flex-end", paddingBottom: 6 },
  square: { width: "100%", height: undefined, aspectRatio: 1 },
  label: {
    borderRadius: radius.xs,
    paddingHorizontal: 4,
    paddingVertical: 2,
  },
  labelText: {
    fontSize: 9,
    lineHeight: 11,
    fontWeight: "600",
    textTransform: "uppercase",
  },
  content: { minWidth: 0, maxWidth: "100%" },
  progress: { position: "relative", marginTop: 8, width: "100%" },
  progressValue: {
    ...fontSize.sm,
    position: "absolute",
    end: 0,
    bottom: "100%",
    marginBottom: 8,
  },
  track: { height: 4, borderRadius: radius.full },
  indicator: { borderRadius: radius.full },
  actions: {
    position: "relative",
    zIndex: 20,
    flexShrink: 0,
    flexDirection: "row",
    alignItems: "center",
  },
  actionsVertical: { position: "absolute", top: 12, end: 12, gap: 4 },
  trigger: {
    position: "absolute",
    top: 0,
    right: 0,
    bottom: 0,
    left: 0,
    zIndex: 10,
  },
  group: {
    width: "100%",
    minWidth: 0,
    flexDirection: "column",
    overflow: "hidden",
    borderRadius: radius["2xl"],
  },
})

function withAlpha(color: string, alpha: number) {
  if (!color.startsWith("#") || color.length !== 7) return color
  const r = Number.parseInt(color.slice(1, 3), 16)
  const g = Number.parseInt(color.slice(3, 5), 16)
  const b = Number.parseInt(color.slice(5, 7), 16)
  return `rgba(${r}, ${g}, ${b}, ${alpha})`
}

const LABEL_TEXT = {
  numberOfLines: 1,
  adjustsFontSizeToFit: true,
  minimumFontScale: 0.8,
}

function wrapText(
  children: React.ReactNode,
  textProps?: React.ComponentProps<typeof Text>
) {
  const output: React.ReactNode[] = []
  let run: (string | number)[] = []
  const flush = () => {
    if (run.length === 0) return
    output.push(
      <Text key={`text-${output.length}`} {...textProps}>
        {run.join("")}
      </Text>
    )
    run = []
  }
  for (const child of React.Children.toArray(children)) {
    if (typeof child === "string" || typeof child === "number") {
      run.push(child)
    } else {
      flush()
      output.push(child)
    }
  }
  flush()
  return output
}

function containsType(children: React.ReactNode, type: unknown) {
  let found = false
  React.Children.forEach(children, (child) => {
    if (React.isValidElement(child) && child.type === type) found = true
  })
  return found
}

function Attachment({
  style,
  state = "done",
  size = "default",
  orientation = "horizontal",
  children,
  ...props
}: ViewProps & {
  state?: AttachmentState
  size?: AttachmentSize
  orientation?: AttachmentOrientation
}) {
  const { colors } = useTheme()
  const groupIndex = React.useContext(AttachmentGroupItemContext)
  const grouped = groupIndex !== null
  const hasMedia = containsType(children, AttachmentMedia)
  const hasContent = containsType(children, AttachmentContent)
  const vertical = orientation === "vertical"
  const context = React.useMemo(
    () => ({ state, size, orientation, grouped }),
    [state, size, orientation, grouped]
  )
  let padding: ViewStyle | null = null
  if (vertical || size !== "default") {
    if (hasContent) padding = SIZE_WITH_CONTENT[size]
    else if (hasMedia) padding = SIZE_WITH_MEDIA[size]
  }
  return (
    <AttachmentContext.Provider value={context}>
      <TextStyleContext.Provider
        value={{
          ...(size === "default" ? fontSize.sm : fontSize.xs),
          color: colors.foreground,
        }}
      >
        <View
          style={[
            styles.root,
            (grouped || state !== "idle") && {
              backgroundColor: colors.muted,
            },
            SIZE[size],
            vertical
              ? [styles.vertical, { width: hasContent ? 120 : 96 }]
              : styles.horizontal,
            vertical && size === "default" && { gap: 8 },
            padding,
            grouped
              ? { width: "100%", borderRadius: 0 }
              : { alignSelf: "flex-start" },
            !grouped &&
              state === "idle" && [
                styles.dashed,
                { borderColor: colors.input },
              ],
            style,
          ]}
          {...props}
        >
          {grouped && groupIndex > 0 ? (
            <View
              style={[
                styles.separator,
                {
                  start: SEPARATOR_START[size][hasMedia ? 1 : 0],
                  backgroundColor: colors.border,
                },
              ]}
            />
          ) : null}
          {children}
        </View>
      </TextStyleContext.Provider>
    </AttachmentContext.Provider>
  )
}

function AttachmentMedia({
  style,
  variant = "icon",
  ...props
}: ViewProps & { variant?: "icon" | "document" | "image" }) {
  const { colors } = useTheme()
  const { size, orientation } = React.useContext(AttachmentContext)
  const vertical = orientation === "vertical"
  let iconSize = size === "xs" ? 14 : 16
  if (vertical) iconSize = 24
  return (
    <TextStyleContext.Provider
      value={variant === "icon" ? { color: colors.mutedForeground } : undefined}
    >
      <IconSizeContext.Provider value={iconSize}>
        <View
          style={[
            styles.media,
            { backgroundColor: colors.muted },
            MEDIA_SIZE[size],
            vertical && { width: "100%" },
            variant !== "image" && [
              styles.mediaFramed,
              {
                borderColor: colors.border,
                backgroundColor: colors.background,
              },
            ],
            variant === "document" && styles.document,
            vertical && variant !== "document" && styles.square,
            style,
          ]}
          {...props}
        />
      </IconSizeContext.Provider>
    </TextStyleContext.Provider>
  )
}

function AttachmentLabel({ style, children, ...props }: ViewProps) {
  const { colors } = useTheme()
  return (
    <TextStyleContext.Provider
      value={{ ...styles.labelText, color: colors.primaryForeground }}
    >
      <View
        style={[styles.label, { backgroundColor: colors.primary }, style]}
        {...props}
      >
        {wrapText(children, LABEL_TEXT)}
      </View>
    </TextStyleContext.Provider>
  )
}

function AttachmentContent({ style, children, ...props }: ViewProps) {
  const { orientation } = React.useContext(AttachmentContext)
  const progress = containsType(children, AttachmentProgress)
  return (
    <AttachmentContentContext.Provider value={progress}>
      <View
        style={[
          styles.content,
          orientation === "vertical"
            ? { paddingHorizontal: 4 }
            : { flexGrow: 1, flexShrink: 1 },
          style,
        ]}
        {...props}
      >
        {children}
      </View>
    </AttachmentContentContext.Provider>
  )
}

function AttachmentTitle({
  style,
  numberOfLines = 1,
  ...props
}: React.ComponentProps<typeof Text>) {
  const { size } = React.useContext(AttachmentContext)
  const progress = React.useContext(AttachmentContentContext)
  return (
    <Text
      numberOfLines={numberOfLines}
      style={[
        size === "default" ? fontSize.base : { fontWeight: "600" },
        progress && { paddingEnd: 48 },
        style,
      ]}
      {...props}
    />
  )
}

function AttachmentDescription({
  style,
  numberOfLines = 1,
  ...props
}: React.ComponentProps<typeof Text>) {
  const { colors } = useTheme()
  const { size, state } = React.useContext(AttachmentContext)
  return (
    <Text
      numberOfLines={numberOfLines}
      style={[
        size === "default" ? fontSize.sm : fontSize.xs,
        {
          marginTop: 2,
          color:
            state === "error" ? colors.destructive : colors.mutedForeground,
        },
        style,
      ]}
      {...props}
    />
  )
}

function AttachmentProgress({
  style,
  ...props
}: Omit<React.ComponentProps<typeof ProgressPrimitive.Root>, "children">) {
  const { colors } = useTheme()
  return (
    <ProgressPrimitive.Root
      style={(state) => [
        styles.progress,
        typeof style === "function" ? style(state) : style,
      ]}
      {...props}
    >
      <ProgressValue style={styles.progressValue} />
      <ProgressTrack
        style={[
          styles.track,
          { backgroundColor: withAlpha(colors.foreground, 0.1) },
        ]}
      >
        <ProgressIndicator style={styles.indicator} />
      </ProgressTrack>
    </ProgressPrimitive.Root>
  )
}

function AttachmentActions({ style, ...props }: ViewProps) {
  const { orientation } = React.useContext(AttachmentContext)
  return (
    <View
      style={[
        styles.actions,
        orientation === "vertical" && styles.actionsVertical,
        style,
      ]}
      {...props}
    />
  )
}

function AttachmentAction({
  style,
  variant = "secondary",
  size,
  hitSlop,
  children,
  ...props
}: Omit<React.ComponentProps<typeof Button>, "style"> & {
  style?: StyleProp<ViewStyle>
}) {
  const { colors } = useTheme()
  const { size: attachmentSize, orientation } =
    React.useContext(AttachmentContext)
  const isText = variant === "link"
  const resolvedSize = size ?? (isText ? "sm" : "icon-xs")
  const [actionSize, slop] = ACTION_SIZE[attachmentSize]
  const vertical = orientation === "vertical"
  const textStyle = buttonTextStyle(variant, resolvedSize, colors)
  let iconSize = resolvedSize === "xs" || resolvedSize === "icon-xs" ? 12 : 16
  if (!isText && attachmentSize === "xs") iconSize = 10
  return (
    <Button
      variant={variant}
      size={resolvedSize}
      hitSlop={hitSlop ?? (isText ? undefined : slop)}
      style={[
        isText
          ? { height: undefined, paddingHorizontal: 0 }
          : { width: actionSize, height: actionSize },
        !isText && vertical && { backgroundColor: "rgba(0, 0, 0, 0.5)" },
        style,
      ]}
      {...props}
    >
      <TextStyleContext.Provider
        value={
          isText
            ? { ...textStyle, ...fontSize.base }
            : {
                ...textStyle,
                color: vertical
                  ? colors.primaryForeground
                  : colors.mutedForeground,
              }
        }
      >
        <IconSizeContext.Provider value={iconSize}>
          {wrapText(children as React.ReactNode)}
        </IconSizeContext.Provider>
      </TextStyleContext.Provider>
    </Button>
  )
}

function AttachmentTrigger({
  style,
  render,
  ...props
}: Omit<React.ComponentProps<typeof Pressable>, "style"> &
  React.RefAttributes<React.ComponentRef<typeof View>> & {
    style?: StyleProp<ViewStyle>
    render?: RenderProp<Record<string, unknown>, Record<string, never>>
  }) {
  const { size, grouped } = React.useContext(AttachmentContext)
  let borderRadius: number = size === "xs" ? radius.lg : radius["2xl"]
  if (grouped) borderRadius = 0
  return useRender({
    defaultComponent: Pressable,
    render,
    props: [
      {
        style: ({ pressed }: { pressed: boolean }) => [
          styles.trigger,
          { borderRadius },
          pressed && { opacity: 0.8 },
          style,
        ],
        role: "button",
      },
      props,
    ],
  })
}

function AttachmentGroup({ style, children, ...props }: ViewProps) {
  const { colors } = useTheme()
  let index = 0
  const items = React.Children.map(children, (child) => {
    if (!React.isValidElement(child)) return child
    const value = index
    index += 1
    return (
      <AttachmentGroupItemContext.Provider value={value}>
        {child}
      </AttachmentGroupItemContext.Provider>
    )
  })
  return (
    <View
      style={[styles.group, { backgroundColor: colors.muted }, style]}
      {...props}
    >
      {items}
    </View>
  )
}

export {
  Attachment,
  AttachmentAction,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentGroup,
  AttachmentLabel,
  AttachmentMedia,
  AttachmentProgress,
  AttachmentTitle,
  AttachmentTrigger,
}
```

3. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

3. Copy the source and every file it uses:

```swift title="swift/Components/Attachment.swift"
import SwiftUI

public enum AttachmentState: CaseIterable, Sendable {
  case idle, uploading, processing, error, done
}

public enum AttachmentSize: CaseIterable, Sendable {
  case `default`, sm, xs
}

public enum AttachmentOrientation: CaseIterable, Sendable {
  case horizontal, vertical
}

public enum AttachmentMediaVariant: CaseIterable, Sendable {
  case icon, document, image
}

struct AttachmentContext: Sendable {
  var state: AttachmentState = .done
  var size: AttachmentSize = .default
  var orientation: AttachmentOrientation = .horizontal
  var radius: CGFloat = Radius.xl2
}

enum AttachmentRole: Sendable {
  case other, media, content, actions, trigger
}

struct AttachmentRoleKey: LayoutValueKey {
  static let defaultValue = AttachmentRole.other
}

struct AttachmentParts: Equatable {
  var media = false
  var content = false
  var progress = false
}

struct AttachmentPartsKey: PreferenceKey {
  static var defaultValue: AttachmentParts { AttachmentParts() }

  static func reduce(value: inout AttachmentParts, nextValue: () -> AttachmentParts) {
    let next = nextValue()
    value.media = value.media || next.media
    value.content = value.content || next.content
    value.progress = value.progress || next.progress
  }
}

struct AttachmentGroupRow {
  let bounds: Anchor<CGRect>
  let inset: CGFloat
}

struct AttachmentGroupRowKey: PreferenceKey {
  static var defaultValue: [AttachmentGroupRow] { [] }

  static func reduce(value: inout [AttachmentGroupRow], nextValue: () -> [AttachmentGroupRow]) {
    value.append(contentsOf: nextValue())
  }
}

public struct Attachment<Content: View>: View {
  public typealias Size = AttachmentSize
  public typealias Orientation = AttachmentOrientation

  let state: AttachmentState
  let size: Size
  let orientation: Orientation
  let block: Bool
  let content: Content

  @State private var parts = AttachmentParts()
  @Environment(\.attachmentGrouped) private var grouped

  public init(
    state: AttachmentState = .done,
    size: Size = .default,
    orientation: Orientation = .horizontal,
    block: Bool = false,
    @ViewBuilder content: () -> Content
  ) {
    self.state = state
    self.size = size
    self.orientation = orientation
    self.block = block
    self.content = content()
  }

  public var body: some View {
    let vertical = orientation == .vertical
    let radius = grouped ? 0 : (size == .xs ? Radius.lg : Radius.xl2)
    let shape = RoundedRectangle(cornerRadius: radius, style: .continuous)
    let insets = insets
    let fill = grouped || (block && !vertical)
    AttachmentLayout(orientation: orientation, spacing: spacing, insets: insets, fill: fill) {
      content
    }
    .padding(insets)
    .frame(width: vertical ? (parts.content ? 120 : 96) : nil)
    .frame(minWidth: vertical ? nil : 160, alignment: .leading)
    .background {
      if !grouped && state != .idle {
        shape.fill(EsuissColor.muted)
      }
    }
    .overlay {
      if state == .idle && !grouped {
        shape.strokeBorder(EsuissColor.input, style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
      }
    }
    .scaledFont(size == .default ? .sm : .xs)
    .foregroundStyle(EsuissColor.foreground)
    .environment(
      \.attachment,
      AttachmentContext(state: state, size: size, orientation: orientation, radius: radius)
    )
    .onPreferenceChange(AttachmentPartsKey.self) { parts = $0 }
    .transformPreference(AttachmentPartsKey.self) { $0 = AttachmentParts() }
    .anchorPreference(key: AttachmentGroupRowKey.self, value: .bounds) { anchor in
      grouped ? [AttachmentGroupRow(bounds: anchor, inset: separatorInset)] : []
    }
    .accessibilityElement(children: .contain)
  }

  private var spacing: CGFloat {
    switch size {
    case .default: orientation == .vertical ? 8 : 12
    case .sm: 10
    case .xs: 6
    }
  }

  private var insets: EdgeInsets {
    func edges(_ horizontal: CGFloat, _ vertical: CGFloat) -> EdgeInsets {
      EdgeInsets(top: vertical, leading: horizontal, bottom: vertical, trailing: horizontal)
    }
    switch size {
    case .default:
      if orientation == .vertical {
        return parts.content ? edges(10, 8) : parts.media ? edges(8, 8) : edges(16, 12)
      }
      return edges(16, 12)
    case .sm:
      return parts.content ? edges(8, 6) : parts.media ? edges(6, 6) : edges(0, 0)
    case .xs:
      return parts.content ? edges(6, 4) : parts.media ? edges(4, 4) : edges(0, 0)
    }
  }

  private var separatorInset: CGFloat {
    switch size {
    case .default: parts.media ? 64 : 16
    case .sm: parts.media ? 48 : 8
    case .xs: parts.media ? 38 : 6
    }
  }
}

struct AttachmentLayout: Layout {
  let orientation: AttachmentOrientation
  let spacing: CGFloat
  let insets: EdgeInsets
  let fill: Bool

  private struct Frames {
    var size: CGSize
    var frames: [CGRect]
  }

  private func arrange(_ proposal: ProposedViewSize, subviews: Subviews) -> Frames {
    orientation == .vertical ? vertical(proposal, subviews: subviews) : horizontal(proposal, subviews: subviews)
  }

  private func horizontal(_ proposal: ProposedViewSize, subviews: Subviews) -> Frames {
    let flow = subviews.filter { $0[AttachmentRoleKey.self] != .trigger && !floating($0) }
    var fixed: CGFloat = 0
    var flexible: [LayoutSubview] = []
    var ideal: CGFloat = 0
    for subview in flow {
      if subview[AttachmentRoleKey.self] == .content || subview[AttachmentRoleKey.self] == .other {
        flexible.append(subview)
        ideal += subview.sizeThatFits(.unspecified).width
      } else {
        fixed += subview.sizeThatFits(.unspecified).width
      }
    }
    let gaps = spacing * CGFloat(max(flow.count - 1, 0))
    let minimum = max(0, 160 - insets.leading - insets.trailing)
    var width: CGFloat
    if let available = proposal.width, available.isFinite {
      width = fill ? available : min(available, max(minimum, fixed + gaps + ideal))
    } else {
      width = max(minimum, fixed + gaps + ideal)
    }
    let share = flexible.isEmpty ? 0 : max(0, width - fixed - gaps) / CGFloat(flexible.count)
    var sizes: [CGSize] = []
    var height: CGFloat = 0
    for subview in flow {
      let role = subview[AttachmentRoleKey.self]
      let size = role == .content || role == .other
        ? subview.sizeThatFits(ProposedViewSize(width: share, height: nil))
        : subview.sizeThatFits(.unspecified)
      sizes.append(CGSize(width: role == .content || role == .other ? share : size.width, height: size.height))
      height = max(height, size.height)
    }
    if flexible.isEmpty {
      width = max(minimum, fixed + gaps)
      if fill, let available = proposal.width, available.isFinite { width = available }
    }
    var frames: [CGRect] = []
    var x: CGFloat = 0
    var index = 0
    for subview in subviews {
      if subview[AttachmentRoleKey.self] == .trigger || floating(subview) {
        frames.append(.zero)
        continue
      }
      let size = sizes[index]
      frames.append(CGRect(x: x, y: (height - size.height) / 2, width: size.width, height: size.height))
      x += size.width + spacing
      index += 1
    }
    return Frames(size: CGSize(width: width, height: height), frames: frames)
  }

  private func vertical(_ proposal: ProposedViewSize, subviews: Subviews) -> Frames {
    let width = proposal.width.flatMap { $0.isFinite ? $0 : nil } ?? 80
    var frames: [CGRect] = []
    var y: CGFloat = 0
    var count = 0
    for subview in subviews {
      if subview[AttachmentRoleKey.self] == .trigger || floating(subview) {
        frames.append(.zero)
        continue
      }
      if count > 0 { y += spacing }
      let size = subview.sizeThatFits(ProposedViewSize(width: width, height: nil))
      frames.append(CGRect(x: 0, y: y, width: width, height: size.height))
      y += size.height
      count += 1
    }
    return Frames(size: CGSize(width: width, height: y), frames: frames)
  }

  private func floating(_ subview: LayoutSubview) -> Bool {
    orientation == .vertical && subview[AttachmentRoleKey.self] == .actions
  }

  func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
    arrange(proposal, subviews: subviews).size
  }

  func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
    let arranged = arrange(ProposedViewSize(width: bounds.width, height: bounds.height), subviews: subviews)
    let outer = CGRect(
      x: bounds.minX - insets.leading,
      y: bounds.minY - insets.top,
      width: bounds.width + insets.leading + insets.trailing,
      height: bounds.height + insets.top + insets.bottom
    )
    for (index, subview) in subviews.enumerated() {
      if subview[AttachmentRoleKey.self] == .trigger {
        subview.place(at: outer.origin, proposal: ProposedViewSize(outer.size))
        continue
      }
      if floating(subview) {
        let size = subview.sizeThatFits(.unspecified)
        subview.place(at: CGPoint(x: outer.maxX - 12 - size.width, y: outer.minY + 12), proposal: ProposedViewSize(size))
        continue
      }
      let frame = arranged.frames[index]
      subview.place(
        at: CGPoint(x: bounds.minX + frame.minX, y: bounds.minY + frame.minY),
        proposal: ProposedViewSize(frame.size)
      )
    }
  }
}

public struct AttachmentMedia<Content: View>: View {
  public typealias Variant = AttachmentMediaVariant

  let variant: Variant
  let content: Content

  @Environment(\.attachment) private var attachment

  public init(_ variant: Variant = .icon, @ViewBuilder content: () -> Content) {
    self.variant = variant
    self.content = content()
  }

  public var body: some View {
    let vertical = attachment.orientation == .vertical
    let radius = attachment.size == .xs || variant != .image ? Radius.sm : Radius.md
    let shape = RoundedRectangle(cornerRadius: radius, style: .continuous)
    let square = vertical && variant != .document
    Group {
      if variant == .document {
        content
          .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
          .padding(.bottom, 6)
      } else {
        content
          .frame(maxWidth: .infinity, maxHeight: .infinity)
      }
    }
    .foregroundStyle(EsuissColor.mutedForeground)
    .environment(\.attachmentIconSize, iconSize)
    .frame(width: vertical ? nil : width, height: square ? nil : height)
    .frame(maxWidth: vertical ? .infinity : nil)
    .aspectRatio(square ? 1 : nil, contentMode: .fit)
    .background(variant == .image ? EsuissColor.muted : EsuissColor.background)
    .clipShape(shape)
    .overlay {
      if variant != .image {
        shape.strokeBorder(EsuissColor.border, lineWidth: 1)
      }
    }
    .layoutValue(key: AttachmentRoleKey.self, value: .media)
    .preference(key: AttachmentPartsKey.self, value: AttachmentParts(media: true))
  }

  private var width: CGFloat {
    switch attachment.size {
    case .default: 36
    case .sm: 32
    case .xs: 28
    }
  }

  private var height: CGFloat {
    switch attachment.size {
    case .default: 44
    case .sm: 40
    case .xs: 34
    }
  }

  private var iconSize: CGFloat {
    if attachment.orientation == .vertical { return 24 }
    return attachment.size == .xs ? 14 : 16
  }
}

extension AttachmentMedia where Content == AttachmentIcon {
  public init(icon: Image) {
    self.init(.icon) { AttachmentIcon(icon) }
  }
}

extension AttachmentMedia where Content == AttachmentImage {
  public init(image: Image) {
    self.init(.image) { AttachmentImage(image) }
  }

  public init(url: URL?) {
    self.init(.image) { AttachmentImage(url: url) }
  }
}

public struct AttachmentIcon: View {
  let image: Image

  @Environment(\.attachmentIconSize) private var size

  public init(_ image: Image) {
    self.image = image
  }

  public var body: some View {
    image
      .resizable()
      .scaledToFit()
      .frame(width: size, height: size)
      .accessibilityHidden(true)
  }
}

public struct AttachmentImage: View {
  let source: AvatarImage.Source

  public init(_ image: Image) {
    source = .image(image)
  }

  public init(url: URL?) {
    source = .url(url)
  }

  public var body: some View {
    Color.clear
      .overlay {
        switch source {
        case .image(let image):
          image.resizable().scaledToFill()
        case .url(let url):
          AsyncImage(url: url) { phase in
            if let image = phase.image {
              image.resizable().scaledToFill()
            } else {
              Color.clear
            }
          }
        }
      }
      .clipped()
  }
}

public struct AttachmentLabel: View {
  let text: Text
  let tint: Color

  @ScaledMetric(relativeTo: .caption2) private var fontSize: CGFloat = 9

  public init(_ text: LocalizedStringKey, tint: Color = EsuissColor.primary) {
    self.text = Text(text)
    self.tint = tint
  }

  public init(verbatim text: String, tint: Color = EsuissColor.primary) {
    self.text = Text(verbatim: text)
    self.tint = tint
  }

  public var body: some View {
    text
      .textCase(.uppercase)
      .font(.system(size: fontSize, weight: .semibold))
      .lineLimit(1)
      .fixedSize()
      .foregroundStyle(EsuissColor.primaryForeground)
      .padding(.horizontal, 4)
      .padding(.vertical, 2)
      .background(tint, in: RoundedRectangle(cornerRadius: Radius.xs, style: .continuous))
  }
}

public struct AttachmentContent<Content: View>: View {
  let content: Content

  @State private var progress = false
  @Environment(\.attachment) private var attachment

  public init(@ViewBuilder content: () -> Content) {
    self.content = content()
  }

  public var body: some View {
    VStack(alignment: .leading, spacing: 0) {
      content
    }
    .frame(maxWidth: .infinity, alignment: .leading)
    .padding(.horizontal, attachment.orientation == .vertical ? 4 : 0)
    .environment(\.attachmentHasProgress, progress)
    .onPreferenceChange(AttachmentPartsKey.self) { progress = $0.progress }
    .transformPreference(AttachmentPartsKey.self) { $0 = AttachmentParts(content: true) }
    .layoutValue(key: AttachmentRoleKey.self, value: .content)
  }
}

public struct AttachmentTitle: View {
  let text: Text

  @Environment(\.attachment) private var attachment
  @Environment(\.attachmentHasProgress) private var progress

  public init(_ text: LocalizedStringKey) {
    self.text = Text(text)
  }

  public init(verbatim text: String) {
    self.text = Text(verbatim: text)
  }

  public var body: some View {
    text
      .scaledFont(attachment.size == .default ? .base : .xs, weight: attachment.size == .default ? .regular : .semibold)
      .lineLimit(1)
      .truncationMode(.middle)
      .foregroundStyle(EsuissColor.foreground)
      .padding(.trailing, progress ? 48 : 0)
      .frame(maxWidth: .infinity, alignment: .leading)
  }
}

public struct AttachmentDescription: View {
  let text: Text

  @Environment(\.attachment) private var attachment

  public init(_ text: LocalizedStringKey) {
    self.text = Text(text)
  }

  public init(verbatim text: String) {
    self.text = Text(verbatim: text)
  }

  public var body: some View {
    text
      .scaledFont(attachment.size == .default ? .sm : .xs)
      .lineLimit(1)
      .foregroundStyle(attachment.state == .error ? EsuissColor.destructive : EsuissColor.mutedForeground)
      .padding(.top, 2)
      .frame(maxWidth: .infinity, alignment: .leading)
  }
}

public struct AttachmentProgress: View {
  let value: Double
  let total: Double
  let label: Text

  public init(value: Double, total: Double = 100, label: LocalizedStringKey = "Uploading") {
    self.value = value
    self.total = total
    self.label = Text(label)
  }

  public var body: some View {
    ProgressView(value: min(max(value, 0), total), total: total) {
      label
    }
    .progressViewStyle(AttachmentProgressStyle())
    .padding(.top, 8)
    .preference(key: AttachmentPartsKey.self, value: AttachmentParts(progress: true))
  }
}

private struct AttachmentProgressStyle: ProgressViewStyle {
  func makeBody(configuration: Configuration) -> some View {
    AttachmentProgressBar(fraction: configuration.fractionCompleted ?? 0)
  }
}

private struct AttachmentProgressBar: View {
  let fraction: Double

  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  var body: some View {
    let fraction = min(max(fraction, 0), 1)
    Capsule()
      .fill(EsuissColor.foreground.opacity(0.1))
      .frame(height: 4)
      .overlay(alignment: .leading) {
        GeometryReader { proxy in
          Capsule()
            .fill(EsuissColor.primary)
            .frame(width: proxy.size.width * fraction)
            .animation(reduceMotion ? nil : .spring(response: 0.4, dampingFraction: 1), value: fraction)
        }
      }
      .overlay(alignment: .topTrailing) {
        Text(fraction, format: .percent.precision(.fractionLength(0)))
          .scaledFont(.sm)
          .monospacedDigit()
          .foregroundStyle(EsuissColor.mutedForeground)
          .fixedSize()
          .alignmentGuide(.top) { $0[.bottom] + 8 }
          .accessibilityHidden(true)
      }
      .accessibilityElement()
      .accessibilityValue(Text(fraction, format: .percent.precision(.fractionLength(0))))
  }
}

public struct AttachmentActions<Content: View>: View {
  let content: Content

  @Environment(\.attachment) private var attachment

  public init(@ViewBuilder content: () -> Content) {
    self.content = content()
  }

  public var body: some View {
    HStack(spacing: attachment.orientation == .vertical ? 4 : 0) {
      content
    }
    .zIndex(2)
    .layoutValue(key: AttachmentRoleKey.self, value: .actions)
  }
}

public struct AttachmentAction<Label: View>: View {
  let variant: VariantButtonStyle.Variant
  let action: () -> Void
  let label: Label

  @Environment(\.attachment) private var attachment

  public init(
    variant: VariantButtonStyle.Variant = .secondary,
    action: @escaping () -> Void,
    @ViewBuilder label: () -> Label
  ) {
    self.variant = variant
    self.action = action
    self.label = label()
  }

  public var body: some View {
    if variant == .link {
      Button(action: action) {
        label
      }
      .buttonStyle(VariantButtonStyle(.link, size: .sm, metrics: ButtonMetrics(horizontalPadding: 0, spacing: 4, iconSize: 16, textStyle: .base)))
    } else {
      let dimension = dimension
      let reach = max(0, (44 - dimension) / 2)
      Button(action: action) {
        label
      }
      .buttonStyle(AttachmentActionStyle(variant: variant, dimension: dimension, overlay: attachment.orientation == .vertical, iconSize: attachment.size == .xs ? 10 : 12))
      .padding(reach)
      .contentShape(Rectangle())
      .padding(-reach)
    }
  }

  private var dimension: CGFloat {
    switch attachment.size {
    case .default: 28
    case .sm: 24
    case .xs: 20
    }
  }
}

extension AttachmentAction where Label == AttachmentActionIcon {
  public init(
    _ title: LocalizedStringKey,
    icon: Image,
    variant: VariantButtonStyle.Variant = .secondary,
    action: @escaping () -> Void
  ) {
    self.init(variant: variant, action: action) { AttachmentActionIcon(title, image: icon) }
  }
}

extension AttachmentAction where Label == Text {
  public init(
    _ title: LocalizedStringKey,
    variant: VariantButtonStyle.Variant = .link,
    action: @escaping () -> Void
  ) {
    self.init(variant: variant, action: action) { Text(title) }
  }
}

public struct AttachmentActionIcon: View {
  let title: Text
  let image: Image

  @Environment(\.attachmentActionIconSize) private var size

  public init(_ title: LocalizedStringKey, image: Image) {
    self.title = Text(title)
    self.image = image
  }

  public var body: some View {
    image
      .resizable()
      .scaledToFit()
      .frame(width: size, height: size)
      .accessibilityLabel(title)
  }
}

private struct AttachmentActionStyle: ButtonStyle {
  let variant: VariantButtonStyle.Variant
  let dimension: CGFloat
  let overlay: Bool
  let iconSize: CGFloat

  func makeBody(configuration: Configuration) -> some View {
    AttachmentActionBody(
      configuration: configuration,
      variant: variant,
      dimension: dimension,
      overlay: overlay,
      iconSize: iconSize
    )
  }
}

private struct AttachmentActionBody: View {
  let configuration: ButtonStyleConfiguration
  let variant: VariantButtonStyle.Variant
  let dimension: CGFloat
  let overlay: Bool
  let iconSize: CGFloat

  @Environment(\.isEnabled) private var isEnabled

  var body: some View {
    configuration.label
      .environment(\.attachmentActionIconSize, iconSize)
      .foregroundStyle(foreground)
      .frame(width: dimension, height: dimension)
      .background(background, in: Circle())
      .overlay {
        if variant == .outline && !overlay {
          Circle().strokeBorder(EsuissColor.border)
        }
      }
      .contentShape(Circle())
      .opacity(isEnabled ? (pressed ? 0.8 : 1) : 0.5)
  }

  private var pressed: Bool { configuration.isPressed }

  private var foreground: Color {
    if overlay { return EsuissColor.primaryForeground }
    switch variant {
    case .default: return EsuissColor.primaryForeground
    case .destructive: return EsuissColor.destructive
    case .tinted, .plain: return EsuissColor.primary
    case .secondary, .outline, .ghost, .link: return pressed ? EsuissColor.foreground : EsuissColor.mutedForeground
    }
  }

  private var background: Color {
    if overlay { return Palette.black.opacity(0.5) }
    switch variant {
    case .default: return EsuissColor.primary
    case .secondary: return EsuissColor.secondary
    case .tinted: return Color.adaptive(light: EsuissColor.primary.opacity(0.1), dark: EsuissColor.primary.opacity(0.2))
    case .destructive: return Color.adaptive(light: EsuissColor.destructive.opacity(0.1), dark: EsuissColor.destructive.opacity(0.2))
    case .outline: return EsuissColor.background
    case .ghost, .plain, .link: return .clear
    }
  }
}

public struct AttachmentTrigger: View {
  let label: Text
  let action: () -> Void

  @Environment(\.attachment) private var attachment

  public init(_ label: LocalizedStringKey, action: @escaping () -> Void) {
    self.label = Text(label)
    self.action = action
  }

  public var body: some View {
    Button(action: action) {
      Color.clear
    }
    .buttonStyle(AttachmentTriggerStyle(radius: attachment.radius))
    .accessibilityLabel(label)
    .zIndex(1)
    .layoutValue(key: AttachmentRoleKey.self, value: .trigger)
  }
}

private struct AttachmentTriggerStyle: ButtonStyle {
  let radius: CGFloat

  func makeBody(configuration: Configuration) -> some View {
    let shape = RoundedRectangle(cornerRadius: radius, style: .continuous)
    shape
      .fill(EsuissColor.background.opacity(configuration.isPressed ? 0.2 : 0))
      .contentShape(shape)
  }
}

public struct AttachmentGroup<Content: View>: View {
  let content: Content

  public init(@ViewBuilder content: () -> Content) {
    self.content = content()
  }

  public var body: some View {
    let shape = RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous)
    VStack(alignment: .leading, spacing: 0) {
      content
    }
    .frame(maxWidth: .infinity, alignment: .leading)
    .environment(\.attachmentGrouped, true)
    .overlayPreferenceValue(AttachmentGroupRowKey.self) { rows in
      GeometryReader { proxy in
        let rects = rows.map { (proxy[$0.bounds], $0.inset) }.sorted { $0.0.minY < $1.0.minY }
        ForEach(Array(rects.dropFirst().enumerated()), id: \.offset) { _, row in
          Rectangle()
            .fill(EsuissColor.border)
            .frame(width: max(row.0.width - row.1, 0), height: 1)
            .offset(x: row.0.minX + row.1, y: row.0.minY)
        }
      }
      .allowsHitTesting(false)
      .accessibilityHidden(true)
    }
    .transformPreference(AttachmentGroupRowKey.self) { $0 = [] }
    .background(EsuissColor.muted, in: shape)
    .clipShape(shape)
    .accessibilityElement(children: .contain)
  }
}

extension EnvironmentValues {
  @Entry var attachment = AttachmentContext()
  @Entry var attachmentGrouped = false
  @Entry var attachmentHasProgress = false
  @Entry var attachmentIconSize: CGFloat = 16
  @Entry var attachmentActionIconSize: CGFloat = 12
}
```

- `swift/Components/Avatar.swift`: see [Avatar](https://ui.suiss.app/docs/components/avatar.md)

- `swift/Components/Button.swift`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `swift/Components/ButtonGroup.swift`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `swift/Components/TextVariant.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/TextVariant.swift)

4. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

```kt title="kotlin/components/Attachment.kt"
package com.esuiss.ui.components

import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.semantics.ProgressBarRangeInfo
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.progressBarRangeInfo
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.constrainHeight
import androidx.compose.ui.unit.constrainWidth
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.esuiss.ui.theme.Colors
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography
import kotlin.math.roundToInt

enum class AttachmentState { Idle, Uploading, Processing, Error, Done }

enum class AttachmentSize { Default, Sm, Xs }

enum class AttachmentOrientation { Horizontal, Vertical }

enum class AttachmentMediaVariant { Icon, Document, Image }

private enum class AttachmentSlot { Content, Actions }

@Stable
private class AttachmentInfo(
    val state: AttachmentState,
    val size: AttachmentSize,
    val orientation: AttachmentOrientation,
) {
    var media by mutableIntStateOf(0)
    var contents by mutableIntStateOf(0)
    var progress by mutableIntStateOf(0)
    var first by mutableStateOf(true)
}

private class AttachmentGroupMarker

private val LocalAttachment = staticCompositionLocalOf<AttachmentInfo?> { null }

private val LocalAttachmentGroup = staticCompositionLocalOf<AttachmentGroupMarker?> { null }

private val AttachmentInfo?.vertical: Boolean
    get() = this?.orientation == AttachmentOrientation.Vertical

private val AttachmentInfo?.sizeOrDefault: AttachmentSize
    get() = this?.size ?: AttachmentSize.Default

@Composable
private fun Register(info: AttachmentInfo?, field: (AttachmentInfo, Int) -> Unit) {
    if (info == null) return
    DisposableEffect(info) {
        field(info, 1)
        onDispose { field(info, -1) }
    }
}

@Composable
fun Attachment(
    modifier: Modifier = Modifier,
    state: AttachmentState = AttachmentState.Done,
    size: AttachmentSize = AttachmentSize.Default,
    orientation: AttachmentOrientation = AttachmentOrientation.Horizontal,
    onClick: (() -> Unit)? = null,
    onClickLabel: String? = null,
    content: @Composable () -> Unit,
) {
    val colors = EsuissTheme.colors
    val grouped = LocalAttachmentGroup.current != null
    val info = remember(state, size, orientation) { AttachmentInfo(state, size, orientation) }
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val vertical = orientation == AttachmentOrientation.Vertical
    val hasMedia = info.media > 0
    val hasContent = info.contents > 0
    val shape = when {
        grouped -> RectangleShape
        size == AttachmentSize.Xs -> RoundedCornerShape(Radius.lg)
        else -> RoundedCornerShape(Radius.xl2)
    }
    val idle = state == AttachmentState.Idle && !grouped
    val background = if (idle) Color.Transparent else colors.muted
    val padding = attachmentPadding(size, vertical, hasMedia, hasContent)
    val gap = when (size) {
        AttachmentSize.Default -> if (vertical) 8.dp else 12.dp
        AttachmentSize.Sm -> 10.dp
        AttachmentSize.Xs -> 6.dp
    }
    val sizing = when {
        grouped -> Modifier.fillMaxWidth()
        vertical -> Modifier.width(if (hasContent) 120.dp else 96.dp)
        else -> Modifier
    }
    val separatorStart = when (size) {
        AttachmentSize.Default -> if (hasMedia) 64.dp else 16.dp
        AttachmentSize.Sm -> if (hasMedia) 48.dp else 8.dp
        AttachmentSize.Xs -> if (hasMedia) 38.dp else 6.dp
    }
    CompositionLocalProvider(
        LocalAttachment provides info,
        LocalAttachmentGroup provides null,
        LocalContentStyle provides (if (size == AttachmentSize.Default) Typography.sm else Typography.xs).copy(color = colors.foreground),
        LocalContentColor provides colors.foreground,
    ) {
        Layout(
            content = content,
            modifier = modifier
                .layoutId(info)
                .then(sizing)
                .alpha(if (pressed) 0.8f else 1f)
                .clip(shape)
                .background(background, shape)
                .then(
                    if (state == AttachmentState.Idle && !grouped) {
                        Modifier.drawWithContent {
                            drawContent()
                            val stroke = 1.dp.toPx()
                            val radius = if (size == AttachmentSize.Xs) Radius.lg.toPx() else Radius.xl2.toPx()
                            drawRoundRect(
                                color = colors.input,
                                topLeft = Offset(stroke / 2, stroke / 2),
                                size = Size(this.size.width - stroke, this.size.height - stroke),
                                cornerRadius = CornerRadius(radius),
                                style = Stroke(stroke, pathEffect = PathEffect.dashPathEffect(floatArrayOf(4.dp.toPx(), 3.dp.toPx()))),
                            )
                        }
                    } else {
                        Modifier
                    },
                )
                .then(
                    if (grouped) {
                        Modifier.drawWithContent {
                            drawContent()
                            if (!info.first) {
                                val inset = separatorStart.toPx()
                                val rtl = layoutDirection == LayoutDirection.Rtl
                                drawRect(
                                    color = colors.border,
                                    topLeft = Offset(if (rtl) 0f else inset, 0f),
                                    size = Size(this.size.width - inset, 1.dp.toPx()),
                                )
                            }
                        }
                    } else {
                        Modifier
                    },
                )
                .then(
                    if (onClick != null) {
                        Modifier.clickable(
                            interactionSource = interaction,
                            indication = null,
                            onClickLabel = onClickLabel,
                            role = Role.Button,
                            onClick = onClick,
                        )
                    } else {
                        Modifier
                    },
                ),
        ) { measurables, constraints ->
            val start = padding.start.roundToPx()
            val top = padding.top.roundToPx()
            val space = gap.roundToPx()
            if (vertical) {
                val inner = if (constraints.hasBoundedWidth) (constraints.maxWidth - start * 2).coerceAtLeast(0) else Constraints.Infinity
                val flow = measurables.filter { it.layoutId != AttachmentSlot.Actions }
                    .map {
                        it.measure(
                            if (inner == Constraints.Infinity) Constraints() else Constraints(minWidth = inner, maxWidth = inner),
                        )
                    }
                val actions = measurables.filter { it.layoutId == AttachmentSlot.Actions }.map { it.measure(Constraints()) }
                val width = constraints.constrainWidth(
                    if (inner == Constraints.Infinity) (flow.maxOfOrNull { it.width } ?: 0) + start * 2 else constraints.maxWidth,
                )
                val height = flow.sumOf { it.height } + space * (flow.size - 1).coerceAtLeast(0) + top * 2
                layout(width, constraints.constrainHeight(height)) {
                    var y = top
                    flow.forEach {
                        it.placeRelative(start, y)
                        y += it.height + space
                    }
                    val edge = 12.dp.roundToPx()
                    actions.forEach { it.placeRelative(width - edge - it.width, edge, 1f) }
                }
            } else {
                val minWidth = if (grouped) 0 else 160.dp.roundToPx()
                val maxInner = if (constraints.hasBoundedWidth) constraints.maxWidth - start * 2 else Constraints.Infinity
                val fixed = measurables.filter { it.layoutId != AttachmentSlot.Content }
                    .associateWith { it.measure(Constraints(maxWidth = maxInner.coerceAtLeast(0))) }
                val flexible = measurables.filter { it.layoutId == AttachmentSlot.Content }
                val used = fixed.values.sumOf { it.width } + space * (measurables.size - 1).coerceAtLeast(0)
                val natural = flexible.associateWith { it.maxIntrinsicWidth(constraints.maxHeight) }
                val available = if (maxInner == Constraints.Infinity) Constraints.Infinity else (maxInner - used).coerceAtLeast(0)
                val stretch = grouped || constraints.minWidth > 0
                val contentWidth = flexible.associateWith { measurable ->
                    val share = if (available == Constraints.Infinity) Constraints.Infinity else available / flexible.size
                    when {
                        share == Constraints.Infinity -> natural.getValue(measurable)
                        stretch -> share
                        else -> natural.getValue(measurable).coerceAtMost(share)
                    }
                }
                var innerWidth = used + contentWidth.values.sum()
                val floor = (minWidth.coerceAtMost(if (constraints.hasBoundedWidth) constraints.maxWidth else minWidth) - start * 2).coerceAtLeast(0)
                val extra = (floor - innerWidth).coerceAtLeast(0)
                val measured = flexible.associateWith {
                    val w = contentWidth.getValue(it) + if (flexible.isNotEmpty()) extra / flexible.size else 0
                    it.measure(Constraints(minWidth = w, maxWidth = w))
                }
                innerWidth += if (flexible.isNotEmpty()) extra else 0
                val placeables = measurables.map { fixed[it] ?: measured.getValue(it) }
                val width = constraints.constrainWidth(
                    if (grouped && constraints.hasBoundedWidth) constraints.maxWidth else maxOf(innerWidth, floor) + start * 2,
                )
                val innerHeight = placeables.maxOfOrNull { it.height } ?: 0
                layout(width, constraints.constrainHeight(innerHeight + top * 2)) {
                    var x = start
                    placeables.forEach {
                        it.placeRelative(x, top + (innerHeight - it.height) / 2)
                        x += it.width + space
                    }
                }
            }
        }
    }
}

private class AttachmentPadding(val start: Dp, val top: Dp)

private fun attachmentPadding(size: AttachmentSize, vertical: Boolean, media: Boolean, content: Boolean): AttachmentPadding =
    when (size) {
        AttachmentSize.Default -> when {
            !vertical -> AttachmentPadding(16.dp, 12.dp)
            media -> AttachmentPadding(8.dp, 8.dp)
            content -> AttachmentPadding(10.dp, 8.dp)
            else -> AttachmentPadding(16.dp, 12.dp)
        }
        AttachmentSize.Sm -> when {
            media -> AttachmentPadding(6.dp, 6.dp)
            content -> AttachmentPadding(8.dp, 6.dp)
            else -> AttachmentPadding(0.dp, 0.dp)
        }
        AttachmentSize.Xs -> when {
            media -> AttachmentPadding(4.dp, 4.dp)
            content -> AttachmentPadding(6.dp, 4.dp)
            else -> AttachmentPadding(0.dp, 0.dp)
        }
    }

@Composable
fun AttachmentGroup(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit,
) {
    val colors = EsuissTheme.colors
    val shape = RoundedCornerShape(Radius.xl2)
    val marker = remember { AttachmentGroupMarker() }
    CompositionLocalProvider(LocalAttachmentGroup provides marker) {
        Layout(
            content = content,
            modifier = modifier
                .fillMaxWidth()
                .clip(shape)
                .background(colors.muted, shape),
        ) { measurables, constraints ->
            val width = constraints.maxWidth
            val placeables = measurables.map { it.measure(Constraints(minWidth = width, maxWidth = width)) }
            measurables.forEachIndexed { index, measurable ->
                (measurable.layoutId as? AttachmentInfo)?.first = index == 0
            }
            layout(width, constraints.constrainHeight(placeables.sumOf { it.height })) {
                var y = 0
                placeables.forEach {
                    it.placeRelative(0, y)
                    y += it.height
                }
            }
        }
    }
}

@Composable
fun AttachmentMedia(
    modifier: Modifier = Modifier,
    variant: AttachmentMediaVariant = AttachmentMediaVariant.Icon,
    content: @Composable BoxScope.() -> Unit,
) {
    val colors = EsuissTheme.colors
    val info = LocalAttachment.current
    Register(info) { target, delta -> target.media += delta }
    val size = info.sizeOrDefault
    val vertical = info.vertical
    val radius = when {
        variant != AttachmentMediaVariant.Image || size == AttachmentSize.Xs -> Radius.sm
        else -> Radius.md
    }
    val shape = RoundedCornerShape(radius)
    val bordered = variant != AttachmentMediaVariant.Image
    val sizing = if (vertical) {
        if (variant == AttachmentMediaVariant.Document) Modifier.fillMaxWidth().height(44.dp) else Modifier.fillMaxWidth().aspectRatio(1f)
    } else {
        when (size) {
            AttachmentSize.Default -> Modifier.size(36.dp, 44.dp)
            AttachmentSize.Sm -> Modifier.size(32.dp, 40.dp)
            AttachmentSize.Xs -> Modifier.size(28.dp, 34.dp)
        }
    }
    val iconSize = when {
        vertical -> 24.dp
        size == AttachmentSize.Xs -> 14.dp
        else -> 16.dp
    }
    Box(
        modifier = modifier
            .then(sizing)
            .clip(shape)
            .background(if (bordered) colors.background else colors.muted, shape)
            .then(if (bordered) Modifier.border(1.dp, colors.border, shape) else Modifier)
            .then(if (variant == AttachmentMediaVariant.Document) Modifier.padding(bottom = 6.dp) else Modifier),
        contentAlignment = if (variant == AttachmentMediaVariant.Document) Alignment.BottomCenter else Alignment.Center,
    ) {
        CompositionLocalProvider(
            LocalContentColor provides if (variant == AttachmentMediaVariant.Icon) colors.mutedForeground else colors.foreground,
            LocalIconSize provides iconSize,
        ) {
            content()
        }
    }
}

@Composable
fun AttachmentMedia(
    image: Painter,
    contentDescription: String?,
    modifier: Modifier = Modifier,
) {
    AttachmentMedia(modifier, AttachmentMediaVariant.Image) {
        Image(
            painter = image,
            contentDescription = contentDescription,
            modifier = Modifier.fillMaxSize(),
            contentScale = ContentScale.Crop,
        )
    }
}

@Composable
fun AttachmentLabel(
    text: String,
    modifier: Modifier = Modifier,
    background: Color = EsuissTheme.colors.primary,
) {
    val colors = EsuissTheme.colors
    BasicText(
        text.uppercase(),
        modifier = modifier
            .background(background, RoundedCornerShape(Radius.xs))
            .padding(horizontal = 4.dp, vertical = 2.dp),
        style = TextStyle(fontSize = 9.sp, lineHeight = 9.sp, fontWeight = FontWeight.SemiBold, color = colors.primaryForeground),
        maxLines = 1,
    )
}

@Composable
fun AttachmentContent(
    modifier: Modifier = Modifier,
    content: @Composable ColumnScope.() -> Unit,
) {
    val info = LocalAttachment.current
    Register(info) { target, delta -> target.contents += delta }
    Column(
        modifier = modifier
            .layoutId(AttachmentSlot.Content)
            .then(if (info.vertical) Modifier.padding(horizontal = 4.dp) else Modifier),
        content = content,
    )
}

@Composable
fun AttachmentTitle(text: String, modifier: Modifier = Modifier) {
    val colors = EsuissTheme.colors
    val info = LocalAttachment.current
    val size = info.sizeOrDefault
    val style = when (size) {
        AttachmentSize.Default -> Typography.base
        else -> Typography.xs.copy(fontWeight = FontWeight.SemiBold)
    }
    BasicText(
        text,
        modifier = modifier.padding(end = if ((info?.progress ?: 0) > 0) 48.dp else 0.dp),
        style = style.copy(color = colors.foreground),
        maxLines = 1,
        overflow = TextOverflow.Ellipsis,
    )
}

@Composable
fun AttachmentDescription(text: String, modifier: Modifier = Modifier) {
    val colors = EsuissTheme.colors
    val info = LocalAttachment.current
    val style = if (info.sizeOrDefault == AttachmentSize.Default) Typography.sm else Typography.xs
    BasicText(
        text,
        modifier = modifier.padding(top = 2.dp),
        style = style.copy(color = if (info?.state == AttachmentState.Error) colors.destructive else colors.mutedForeground),
        maxLines = 1,
        overflow = TextOverflow.Ellipsis,
    )
}

@Composable
fun AttachmentProgress(
    value: Float,
    modifier: Modifier = Modifier,
    max: Float = 100f,
    contentDescription: String? = null,
) {
    val colors = EsuissTheme.colors
    val info = LocalAttachment.current
    Register(info) { target, delta -> target.progress += delta }
    val fraction = if (max <= 0f) 0f else (value / max).coerceIn(0f, 1f)
    val animated by animateFloatAsState(
        targetValue = fraction,
        animationSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = 200f),
        label = "attachment-progress",
    )
    Layout(
        content = {
            BasicText(
                "${(fraction * 100).roundToInt()}%",
                style = Typography.sm.copy(color = colors.mutedForeground, fontFeatureSettings = "tnum"),
                maxLines = 1,
            )
            Box(
                Modifier
                    .fillMaxWidth()
                    .height(4.dp)
                    .clip(CircleShape)
                    .background(colors.foreground.copy(alpha = 0.1f)),
            ) {
                Box(
                    Modifier
                        .fillMaxHeight()
                        .fillMaxWidth(animated)
                        .background(colors.primary, CircleShape),
                )
            }
        },
        modifier = modifier
            .fillMaxWidth()
            .padding(top = 8.dp)
            .semantics(mergeDescendants = true) {
                progressBarRangeInfo = ProgressBarRangeInfo(value.coerceIn(0f, max), 0f..max)
                if (contentDescription != null) this.contentDescription = contentDescription
            },
    ) { measurables, constraints ->
        val label = measurables[0].measure(Constraints())
        val track = measurables[1].measure(constraints.copy(minHeight = 0))
        layout(track.width, track.height) {
            track.placeRelative(0, 0)
            label.placeRelative(track.width - label.width, -label.height - 8.dp.roundToPx())
        }
    }
}

@Composable
fun AttachmentActions(
    modifier: Modifier = Modifier,
    content: @Composable RowScope.() -> Unit,
) {
    val info = LocalAttachment.current
    Row(
        modifier = modifier.layoutId(AttachmentSlot.Actions),
        horizontalArrangement = Arrangement.spacedBy(if (info.vertical) 4.dp else 0.dp),
        verticalAlignment = Alignment.CenterVertically,
        content = content,
    )
}

@Composable
fun AttachmentAction(
    onClick: () -> Unit,
    contentDescription: String?,
    modifier: Modifier = Modifier,
    variant: ButtonVariant = ButtonVariant.Secondary,
    enabled: Boolean = true,
    content: @Composable () -> Unit,
) {
    val colors = EsuissTheme.colors
    val info = LocalAttachment.current
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val size = info.sizeOrDefault
    val vertical = info.vertical
    val diameter = when (size) {
        AttachmentSize.Default -> 28.dp
        AttachmentSize.Sm -> 24.dp
        AttachmentSize.Xs -> 20.dp
    }
    val background = if (vertical) Palette.black.copy(alpha = 0.5f) else attachmentActionBackground(variant, colors)
    val foreground = when {
        vertical || variant == ButtonVariant.Default -> colors.primaryForeground
        variant == ButtonVariant.Destructive -> colors.destructive
        else -> colors.mutedForeground
    }
    Box(
        modifier = modifier
            .size(diameter)
            .alpha(
                when {
                    !enabled -> 0.5f
                    pressed -> 0.8f
                    else -> 1f
                },
            )
            .clip(CircleShape)
            .background(background, CircleShape)
            .clickable(
                interactionSource = interaction,
                indication = null,
                enabled = enabled,
                role = Role.Button,
                onClick = onClick,
            )
            .then(if (contentDescription != null) Modifier.semantics { this.contentDescription = contentDescription } else Modifier),
        contentAlignment = Alignment.Center,
    ) {
        CompositionLocalProvider(
            LocalContentColor provides foreground,
            LocalIconSize provides if (size == AttachmentSize.Xs) 10.dp else 12.dp,
        ) {
            content()
        }
    }
}

@Composable
fun AttachmentAction(
    text: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
) {
    val colors = EsuissTheme.colors
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    BasicText(
        text,
        modifier = modifier
            .alpha(
                when {
                    !enabled -> 0.5f
                    pressed -> 0.8f
                    else -> 1f
                },
            )
            .clickable(
                interactionSource = interaction,
                indication = null,
                enabled = enabled,
                role = Role.Button,
                onClick = onClick,
            ),
        style = Typography.base.copy(color = colors.primary),
        maxLines = 1,
    )
}

private fun attachmentActionBackground(variant: ButtonVariant, colors: Colors): Color = when (variant) {
    ButtonVariant.Default -> colors.primary
    ButtonVariant.Destructive -> colors.destructive.copy(alpha = if (colors.isDark) 0.2f else 0.1f)
    ButtonVariant.Outline -> colors.background
    ButtonVariant.Ghost, ButtonVariant.Plain, ButtonVariant.Link -> Color.Transparent
    else -> colors.secondary
}
```

- `kotlin/components/Button.kt`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `kotlin/components/ButtonGroup.kt`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `kotlin/components/Content.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Content.kt)

- `kotlin/components/Icon.kt`: see [Icon](https://ui.suiss.app/docs/components/icon.md)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { Attachment, AttachmentMedia, AttachmentLabel, AttachmentContent, AttachmentTitle, AttachmentDescription, AttachmentProgress, AttachmentActions, AttachmentAction, AttachmentTrigger, AttachmentGroup } from "@/components/ui/attachment"
```

```tsx
<Attachment orientation="horizontal" state="done">
  <AttachmentMedia variant="document">
    <AttachmentLabel className="bg-red-500">pdf</AttachmentLabel>
  </AttachmentMedia>
  <AttachmentContent>
    <AttachmentTitle>Quarterly report.pdf</AttachmentTitle>
    <AttachmentDescription>2.4 MB</AttachmentDescription>
  </AttachmentContent>
  <AttachmentActions>
    <AttachmentAction accessibilityLabel="Remove">
      <Icon as={XIcon} />
    </AttachmentAction>
  </AttachmentActions>
</Attachment>
```

`AttachmentMedia` has three variants: `icon` for a glyph, `document` for a page shape (pair it with `AttachmentLabel` to show a file extension), and `image` for a thumbnail. Put `AttachmentTitle` and `AttachmentDescription` in `AttachmentContent`, and add `AttachmentProgress` with a `value` while uploading.

`AttachmentAction` is a `Button` that defaults to the `secondary` variant and a small icon size scaled to the attachment, with an enlarged hit area. Use `variant="link"` for a text action.

To make the whole card pressable, add an `AttachmentTrigger`; it covers the card while actions stay pressable above it.

## Examples

### Sizes

The `default`, `sm` and `xs` sizes.

![Preview](https://ui.suiss.app/previews/components-attachment--sizes.light.webp)

```tsx
import { FileTextIcon, XIcon } from "phosphor-react-native"
import { View } from "react-native"
import { Attachment, AttachmentAction, AttachmentActions, AttachmentContent, AttachmentDescription, AttachmentMedia, AttachmentTitle } from "@/components/ui/attachment"
import { Icon } from "@/components/ui/icon"

export function AttachmentSizesExample() {
  return (
    <View className="items-start gap-3">
      {(["default", "sm", "xs"] as const).map((size) => (
        <Attachment key={size} orientation="horizontal" state="done" size={size}>
          <AttachmentMedia>
            <Icon as={FileTextIcon} />
          </AttachmentMedia>
          <AttachmentContent>
            <AttachmentTitle>Meeting notes.docx</AttachmentTitle>
            <AttachmentDescription>184 KB</AttachmentDescription>
          </AttachmentContent>
          <AttachmentActions>
            <AttachmentAction accessibilityLabel="Remove">
              <Icon as={XIcon} />
            </AttachmentAction>
          </AttachmentActions>
        </Attachment>
      ))}
    </View>
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct AttachmentSizesExample: View {
  var body: some View {
    VStack(alignment: .leading, spacing: 12) {
      ForEach(AttachmentSize.allCases, id: \.self) { size in
        Attachment(size: size) {
          AttachmentMedia(icon: Ph.fileText.regular)
          AttachmentContent {
            AttachmentTitle("Meeting notes.docx")
            AttachmentDescription("184 KB")
          }
          AttachmentActions {
            AttachmentAction("Remove", icon: Ph.x.bold) {}
          }
        }
      }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.FileText
import com.adamglin.phosphoricons.regular.X
import com.esuiss.ui.components.Attachment
import com.esuiss.ui.components.AttachmentAction
import com.esuiss.ui.components.AttachmentActions
import com.esuiss.ui.components.AttachmentContent
import com.esuiss.ui.components.AttachmentDescription
import com.esuiss.ui.components.AttachmentMedia
import com.esuiss.ui.components.AttachmentSize
import com.esuiss.ui.components.AttachmentTitle
import com.esuiss.ui.components.Icon

@Composable
fun AttachmentIconAction(label: String, icon: androidx.compose.ui.graphics.vector.ImageVector = PhosphorIcons.Regular.X) {
    AttachmentAction(onClick = {}, contentDescription = label) {
        Icon(icon, contentDescription = null)
    }
}

@Composable
fun AttachmentSizes() {
    Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
        AttachmentSize.entries.forEach { size ->
            Attachment(size = size) {
                AttachmentMedia { Icon(PhosphorIcons.Regular.FileText, contentDescription = null) }
                AttachmentContent {
                    AttachmentTitle("Meeting notes.docx")
                    AttachmentDescription("184 KB")
                }
                AttachmentActions { AttachmentIconAction("Remove") }
            }
        }
    }
}
```

### States

The `idle`, uploading, processing, error and done states.

![Preview](https://ui.suiss.app/previews/components-attachment--states.light.webp)

```tsx
import { UploadSimpleIcon, XIcon } from "phosphor-react-native"
import { View } from "react-native"
import { Attachment, AttachmentAction, AttachmentActions, AttachmentContent, AttachmentDescription, AttachmentLabel, AttachmentMedia, AttachmentProgress, AttachmentTitle } from "@/components/ui/attachment"
import { Icon } from "@/components/ui/icon"
import { Spinner } from "@/components/ui/spinner"

export function AttachmentStatesExample() {
  return (
    <View className="gap-3">
      <Attachment orientation="horizontal" state="idle" className="w-full">
        <AttachmentMedia>
          <Icon as={UploadSimpleIcon} />
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>Add a file</AttachmentTitle>
          <AttachmentDescription>
            PDF, PNG or JPG up to 10 MB
          </AttachmentDescription>
        </AttachmentContent>
        <AttachmentActions>
          <AttachmentAction variant="link">Choose File</AttachmentAction>
        </AttachmentActions>
      </Attachment>
      <Attachment orientation="horizontal" state="uploading" className="w-full">
        <AttachmentMedia variant="document">
          <AttachmentLabel className="bg-red-500">pdf</AttachmentLabel>
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>Invoice March.pdf</AttachmentTitle>
          <AttachmentProgress value={64} accessibilityLabel="Uploading" />
        </AttachmentContent>
        <AttachmentActions>
          <AttachmentAction accessibilityLabel="Cancel upload">
            <Icon as={XIcon} />
          </AttachmentAction>
        </AttachmentActions>
      </Attachment>
      <Attachment orientation="horizontal" state="processing" className="w-full">
        <AttachmentMedia variant="document">
          <AttachmentLabel className="bg-red-500">pdf</AttachmentLabel>
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>Invoice March.pdf</AttachmentTitle>
          <AttachmentDescription>Processing…</AttachmentDescription>
        </AttachmentContent>
        <AttachmentActions>
          <Spinner size={20} />
        </AttachmentActions>
      </Attachment>
      <Attachment orientation="horizontal" state="error" className="w-full">
        <AttachmentMedia variant="document">
          <AttachmentLabel className="bg-orange-500">key</AttachmentLabel>
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>Presentation.key</AttachmentTitle>
          <AttachmentDescription>File type not supported</AttachmentDescription>
        </AttachmentContent>
        <AttachmentActions>
          <AttachmentAction accessibilityLabel="Remove">
            <Icon as={XIcon} />
          </AttachmentAction>
        </AttachmentActions>
      </Attachment>
      <Attachment orientation="horizontal" state="done" className="w-full">
        <AttachmentMedia variant="document">
          <AttachmentLabel className="bg-red-500">pdf</AttachmentLabel>
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>Invoice March.pdf</AttachmentTitle>
          <AttachmentDescription>1.1 MB</AttachmentDescription>
        </AttachmentContent>
      </Attachment>
    </View>
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct AttachmentStatesExample: View {
  var body: some View {
    VStack(spacing: 12) {
      Attachment(state: .idle, block: true) {
        AttachmentMedia(icon: Ph.uploadSimple.regular)
        AttachmentContent {
          AttachmentTitle("Add a file")
          AttachmentDescription("PDF, PNG or JPG up to 10 MB")
        }
        AttachmentActions {
          AttachmentAction("Choose File") {}
        }
      }
      Attachment(state: .uploading, block: true) {
        AttachmentMedia(.document) { AttachmentLabel("pdf", tint: EsuissColor.destructive) }
        AttachmentContent {
          AttachmentTitle("Invoice March.pdf")
          AttachmentProgress(value: 64, label: "Uploading")
        }
        AttachmentActions {
          AttachmentAction("Cancel upload", icon: Ph.x.bold) {}
        }
      }
      Attachment(state: .processing, block: true) {
        AttachmentMedia(.document) { AttachmentLabel("pdf", tint: EsuissColor.destructive) }
        AttachmentContent {
          AttachmentTitle("Invoice March.pdf")
          AttachmentDescription("Processing…")
        }
        AttachmentActions {
          Spinner(dimension: 20)
        }
      }
      Attachment(state: .error, block: true) {
        AttachmentMedia(.document) { AttachmentLabel("key", tint: Palette.amber500) }
        AttachmentContent {
          AttachmentTitle("Presentation.key")
          AttachmentDescription("File type not supported")
        }
        AttachmentActions {
          AttachmentAction("Remove", icon: Ph.x.bold) {}
        }
      }
      Attachment(state: .done, block: true) {
        AttachmentMedia(.document) { AttachmentLabel("pdf", tint: EsuissColor.destructive) }
        AttachmentContent {
          AttachmentTitle("Invoice March.pdf")
          AttachmentDescription("1.1 MB")
        }
      }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.UploadSimple
import com.adamglin.phosphoricons.regular.X
import com.esuiss.ui.components.Attachment
import com.esuiss.ui.components.AttachmentAction
import com.esuiss.ui.components.AttachmentActions
import com.esuiss.ui.components.AttachmentContent
import com.esuiss.ui.components.AttachmentDescription
import com.esuiss.ui.components.AttachmentLabel
import com.esuiss.ui.components.AttachmentMedia
import com.esuiss.ui.components.AttachmentMediaVariant
import com.esuiss.ui.components.AttachmentProgress
import com.esuiss.ui.components.AttachmentState
import com.esuiss.ui.components.AttachmentTitle
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Spinner
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette

@Composable
fun AttachmentDocument(extension: String, tone: Color) {
    AttachmentMedia(variant = AttachmentMediaVariant.Document) {
        AttachmentLabel(extension, background = tone)
    }
}

@Composable
fun AttachmentIconAction(label: String, icon: androidx.compose.ui.graphics.vector.ImageVector = PhosphorIcons.Regular.X) {
    AttachmentAction(onClick = {}, contentDescription = label) {
        Icon(icon, contentDescription = null)
    }
}

@Composable
fun AttachmentStates() {
    val colors = EsuissTheme.colors
    val fill = Modifier.fillMaxWidth()
    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
        Attachment(fill, state = AttachmentState.Idle) {
            AttachmentMedia { Icon(PhosphorIcons.Regular.UploadSimple, contentDescription = null) }
            AttachmentContent {
                AttachmentTitle("Add a file")
                AttachmentDescription("PDF, PNG or JPG up to 10 MB")
            }
            AttachmentActions { AttachmentAction("Choose File", onClick = {}) }
        }
        Attachment(fill, state = AttachmentState.Uploading) {
            AttachmentDocument("pdf", Palette.red500)
            AttachmentContent {
                AttachmentTitle("Invoice March.pdf")
                AttachmentProgress(64f, contentDescription = "Uploading")
            }
            AttachmentActions { AttachmentIconAction("Cancel upload") }
        }
        Attachment(fill, state = AttachmentState.Processing) {
            AttachmentDocument("pdf", Palette.red500)
            AttachmentContent {
                AttachmentTitle("Invoice March.pdf")
                AttachmentDescription("Processing…")
            }
            AttachmentActions { Spinner(size = 20.dp) }
        }
        Attachment(fill, state = AttachmentState.Error) {
            AttachmentDocument("key", Palette.orange500)
            AttachmentContent {
                AttachmentTitle("Presentation.key")
                AttachmentDescription("File type not supported")
            }
            AttachmentActions { AttachmentIconAction("Remove") }
        }
        Attachment(fill, state = AttachmentState.Done) {
            AttachmentDocument("pdf", Palette.red500)
            AttachmentContent {
                AttachmentTitle("Invoice March.pdf")
                AttachmentDescription("1.1 MB")
            }
        }
    }
}
```

### Image

An image thumbnail with the `image` media variant.

![Preview](https://ui.suiss.app/previews/components-attachment--image.light.webp)

```tsx
import { XIcon } from "phosphor-react-native"
import { Image as NativeImage } from "react-native"
import { Attachment, AttachmentAction, AttachmentActions, AttachmentContent, AttachmentDescription, AttachmentMedia, AttachmentTitle } from "@/components/ui/attachment"
import { Icon } from "@/components/ui/icon"

const imageUrl =
"https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=400&q=80"

function Thumbnail() {
  return (
    <NativeImage
      source={{ uri: imageUrl }}
      accessibilityLabel="Mountain lake"
      className="size-full"
      resizeMode="cover"
    />
  )
}

export function AttachmentImageExample() {
  return (
    <Attachment orientation="horizontal" state="done">
      <AttachmentMedia variant="image">
        <Thumbnail />
      </AttachmentMedia>
      <AttachmentContent>
        <AttachmentTitle>Lake.jpg</AttachmentTitle>
        <AttachmentDescription>3.2 MB</AttachmentDescription>
      </AttachmentContent>
      <AttachmentActions>
        <AttachmentAction accessibilityLabel="Remove">
          <Icon as={XIcon} />
        </AttachmentAction>
      </AttachmentActions>
    </Attachment>
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct AttachmentImageExample: View {
  var body: some View {
    Attachment {
      AttachmentThumbnail()
      AttachmentContent {
        AttachmentTitle("Lake.jpg")
        AttachmentDescription("3.2 MB")
      }
      AttachmentActions {
        AttachmentAction("Remove", icon: Ph.x.bold) {}
      }
    }
  }
}

struct AttachmentThumbnail: View {
  var body: some View {
    AttachmentMedia(url: URL(string: "https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=400&q=80"))
      .accessibilityLabel(Text("Mountain lake"))
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.painter.BrushPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.semantics.contentDescription
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.X
import com.esuiss.ui.components.Attachment
import com.esuiss.ui.components.AttachmentAction
import com.esuiss.ui.components.AttachmentActions
import com.esuiss.ui.components.AttachmentContent
import com.esuiss.ui.components.AttachmentDescription
import com.esuiss.ui.components.AttachmentMedia
import com.esuiss.ui.components.AttachmentTitle
import com.esuiss.ui.components.Icon
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette

@Composable
fun attachmentPhoto(): Painter {
    val colors = EsuissTheme.colors
    return remember(colors) { BrushPainter(Brush.linearGradient(listOf(Palette.teal500, Palette.blue500, Palette.indigo500))) }
}

@Composable
fun AttachmentIconAction(label: String, icon: androidx.compose.ui.graphics.vector.ImageVector = PhosphorIcons.Regular.X) {
    AttachmentAction(onClick = {}, contentDescription = label) {
        Icon(icon, contentDescription = null)
    }
}

@Composable
fun AttachmentImage() {
    val photo = attachmentPhoto()
    Row {
        Attachment {
            AttachmentMedia(photo, contentDescription = "Mountain lake")
            AttachmentContent {
                AttachmentTitle("Lake.jpg")
                AttachmentDescription("3.2 MB")
            }
            AttachmentActions { AttachmentIconAction("Remove") }
        }
    }
}
```

### Vertical

Tiles with `orientation="vertical"`.

![Preview](https://ui.suiss.app/previews/components-attachment--vertical.light.webp)

```tsx
import { FilePdfIcon, XIcon } from "phosphor-react-native"
import { Image as NativeImage, View } from "react-native"
import { Attachment, AttachmentAction, AttachmentActions, AttachmentContent, AttachmentDescription, AttachmentMedia, AttachmentTitle } from "@/components/ui/attachment"
import { Icon } from "@/components/ui/icon"

const imageUrl =
"https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=400&q=80"

function Thumbnail() {
  return (
    <NativeImage
      source={{ uri: imageUrl }}
      accessibilityLabel="Mountain lake"
      className="size-full"
      resizeMode="cover"
    />
  )
}

export function AttachmentVerticalExample() {
  return (
    <View className="flex-row items-start gap-3">
      <Attachment orientation="vertical" state="done">
        <AttachmentMedia variant="image">
          <Thumbnail />
        </AttachmentMedia>
        <AttachmentActions>
          <AttachmentAction accessibilityLabel="Remove">
            <Icon as={XIcon} />
          </AttachmentAction>
        </AttachmentActions>
      </Attachment>
      <Attachment orientation="vertical" state="done">
        <AttachmentMedia>
          <Icon as={FilePdfIcon} />
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>Contract.pdf</AttachmentTitle>
          <AttachmentDescription>860 KB</AttachmentDescription>
        </AttachmentContent>
      </Attachment>
    </View>
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct AttachmentVerticalExample: View {
  var body: some View {
    HStack(alignment: .top, spacing: 12) {
      Attachment(orientation: .vertical) {
        AttachmentThumbnail()
        AttachmentActions {
          AttachmentAction("Remove", icon: Ph.x.bold) {}
        }
      }
      Attachment(orientation: .vertical) {
        AttachmentMedia(icon: Ph.filePdf.regular)
        AttachmentContent {
          AttachmentTitle("Contract.pdf")
          AttachmentDescription("860 KB")
        }
      }
    }
  }
}

struct AttachmentThumbnail: View {
  var body: some View {
    AttachmentMedia(url: URL(string: "https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=400&q=80"))
      .accessibilityLabel(Text("Mountain lake"))
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.painter.BrushPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.FilePdf
import com.adamglin.phosphoricons.regular.X
import com.esuiss.ui.components.Attachment
import com.esuiss.ui.components.AttachmentAction
import com.esuiss.ui.components.AttachmentActions
import com.esuiss.ui.components.AttachmentContent
import com.esuiss.ui.components.AttachmentDescription
import com.esuiss.ui.components.AttachmentMedia
import com.esuiss.ui.components.AttachmentOrientation
import com.esuiss.ui.components.AttachmentTitle
import com.esuiss.ui.components.Icon
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette

@Composable
fun attachmentPhoto(): Painter {
    val colors = EsuissTheme.colors
    return remember(colors) { BrushPainter(Brush.linearGradient(listOf(Palette.teal500, Palette.blue500, Palette.indigo500))) }
}

@Composable
fun AttachmentIconAction(label: String, icon: androidx.compose.ui.graphics.vector.ImageVector = PhosphorIcons.Regular.X) {
    AttachmentAction(onClick = {}, contentDescription = label) {
        Icon(icon, contentDescription = null)
    }
}

@Composable
fun AttachmentVertical() {
    val photo = attachmentPhoto()
    Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.Top) {
        Attachment(orientation = AttachmentOrientation.Vertical) {
            AttachmentMedia(photo, contentDescription = "Mountain lake")
            AttachmentActions { AttachmentIconAction("Remove") }
        }
        Attachment(orientation = AttachmentOrientation.Vertical) {
            AttachmentMedia { Icon(PhosphorIcons.Regular.FilePdf, contentDescription = null) }
            AttachmentContent {
                AttachmentTitle("Contract.pdf")
                AttachmentDescription("860 KB")
            }
        }
    }
}
```

### Pressable

A card made pressable with `AttachmentTrigger`.

![Preview](https://ui.suiss.app/previews/components-attachment--pressable.light.webp)

```tsx
import { DownloadSimpleIcon } from "phosphor-react-native"
import { Attachment, AttachmentAction, AttachmentActions, AttachmentContent, AttachmentDescription, AttachmentLabel, AttachmentMedia, AttachmentTitle, AttachmentTrigger } from "@/components/ui/attachment"
import { Icon } from "@/components/ui/icon"

export function AttachmentPressableExample() {
  return (
    <Attachment orientation="horizontal" state="done">
      <AttachmentTrigger
        accessibilityLabel="Open Brand guidelines.pdf"
        onPress={() => undefined}
      />
      <AttachmentMedia variant="document">
        <AttachmentLabel className="bg-red-500">pdf</AttachmentLabel>
      </AttachmentMedia>
      <AttachmentContent>
        <AttachmentTitle>Brand guidelines.pdf</AttachmentTitle>
        <AttachmentDescription>5.8 MB</AttachmentDescription>
      </AttachmentContent>
      <AttachmentActions>
        <AttachmentAction accessibilityLabel="Download">
          <Icon as={DownloadSimpleIcon} />
        </AttachmentAction>
      </AttachmentActions>
    </Attachment>
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct AttachmentPressableExample: View {
  var body: some View {
    Attachment {
      AttachmentTrigger("Open Brand guidelines.pdf") {}
      AttachmentMedia(.document) { AttachmentLabel("pdf", tint: EsuissColor.destructive) }
      AttachmentContent {
        AttachmentTitle("Brand guidelines.pdf")
        AttachmentDescription("5.8 MB")
      }
      AttachmentActions {
        AttachmentAction("Download", icon: Ph.downloadSimple.bold) {}
      }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.DownloadSimple
import com.adamglin.phosphoricons.regular.X
import com.esuiss.ui.components.Attachment
import com.esuiss.ui.components.AttachmentAction
import com.esuiss.ui.components.AttachmentActions
import com.esuiss.ui.components.AttachmentContent
import com.esuiss.ui.components.AttachmentDescription
import com.esuiss.ui.components.AttachmentLabel
import com.esuiss.ui.components.AttachmentMedia
import com.esuiss.ui.components.AttachmentMediaVariant
import com.esuiss.ui.components.AttachmentTitle
import com.esuiss.ui.components.Icon
import com.esuiss.ui.theme.Palette

@Composable
fun AttachmentDocument(extension: String, tone: Color) {
    AttachmentMedia(variant = AttachmentMediaVariant.Document) {
        AttachmentLabel(extension, background = tone)
    }
}

@Composable
fun AttachmentIconAction(label: String, icon: androidx.compose.ui.graphics.vector.ImageVector = PhosphorIcons.Regular.X) {
    AttachmentAction(onClick = {}, contentDescription = label) {
        Icon(icon, contentDescription = null)
    }
}

@Composable
fun AttachmentPressable() {
    Row {
        Attachment(onClick = {}, onClickLabel = "Open Brand guidelines.pdf") {
            AttachmentDocument("pdf", Palette.red500)
            AttachmentContent {
                AttachmentTitle("Brand guidelines.pdf")
                AttachmentDescription("5.8 MB")
            }
            AttachmentActions { AttachmentIconAction("Download", PhosphorIcons.Regular.DownloadSimple) }
        }
    }
}
```

### Group

Several attachments in an `AttachmentGroup`, one of them uploading.

![Preview](https://ui.suiss.app/previews/components-attachment--group.light.webp)

```tsx
import { CheckCircleIcon, XIcon } from "phosphor-react-native"
import { Image as NativeImage, View } from "react-native"
import { Attachment, AttachmentAction, AttachmentActions, AttachmentContent, AttachmentDescription, AttachmentGroup, AttachmentLabel, AttachmentMedia, AttachmentProgress, AttachmentTitle } from "@/components/ui/attachment"
import { Icon } from "@/components/ui/icon"
import { Spinner } from "@/components/ui/spinner"

const imageUrl =
"https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=400&q=80"

function Thumbnail() {
  return (
    <NativeImage
      source={{ uri: imageUrl }}
      accessibilityLabel="Mountain lake"
      className="size-full"
      resizeMode="cover"
    />
  )
}

export function AttachmentGroupExample() {
  return (
    <AttachmentGroup>
      <Attachment orientation="horizontal" state="uploading">
        <AttachmentMedia variant="document">
          <AttachmentLabel className="bg-red-500">pdf</AttachmentLabel>
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>Invoice_1042.pdf</AttachmentTitle>
          <AttachmentProgress value={64} accessibilityLabel="Uploading" />
        </AttachmentContent>
        <AttachmentActions>
          <AttachmentAction accessibilityLabel="Cancel upload">
            <Icon as={XIcon} />
          </AttachmentAction>
        </AttachmentActions>
      </Attachment>
      <Attachment orientation="horizontal" state="processing">
        <AttachmentMedia variant="document">
          <AttachmentLabel className="bg-blue-500">docx</AttachmentLabel>
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>Contract.docx</AttachmentTitle>
          <AttachmentDescription>Processing…</AttachmentDescription>
        </AttachmentContent>
        <AttachmentActions>
          <Spinner size={20} />
        </AttachmentActions>
      </Attachment>
      <Attachment orientation="horizontal" state="done">
        <AttachmentMedia variant="document">
          <AttachmentLabel className="bg-green-500">xlsx</AttachmentLabel>
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>Menu_October.xlsx</AttachmentTitle>
          <AttachmentDescription>2.4 MB</AttachmentDescription>
        </AttachmentContent>
        <AttachmentActions>
          <View accessible accessibilityLabel="Uploaded">
            <Icon
              as={CheckCircleIcon}
              weight="fill"
              className="size-6 text-green-600 dark:text-green-400"
            />
          </View>
        </AttachmentActions>
      </Attachment>
      <Attachment orientation="horizontal" state="done">
        <AttachmentMedia variant="image">
          <Thumbnail />
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>Table_12.jpg</AttachmentTitle>
          <AttachmentDescription>1.8 MB</AttachmentDescription>
        </AttachmentContent>
        <AttachmentActions>
          <AttachmentAction accessibilityLabel="Remove">
            <Icon as={XIcon} />
          </AttachmentAction>
        </AttachmentActions>
      </Attachment>
      <Attachment orientation="horizontal" state="error">
        <AttachmentMedia variant="document">
          <AttachmentLabel className="bg-red-500">pdf</AttachmentLabel>
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>Z-Report_30-09.pdf</AttachmentTitle>
          <AttachmentDescription>Upload failed</AttachmentDescription>
        </AttachmentContent>
        <AttachmentActions>
          <AttachmentAction variant="link">Try Again</AttachmentAction>
        </AttachmentActions>
      </Attachment>
    </AttachmentGroup>
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct AttachmentGroupExample: View {
  var body: some View {
    AttachmentGroup {
      Attachment(state: .uploading) {
        AttachmentMedia(.document) { AttachmentLabel("pdf", tint: EsuissColor.destructive) }
        AttachmentContent {
          AttachmentTitle("Invoice_1042.pdf")
          AttachmentProgress(value: 64, label: "Uploading")
        }
        AttachmentActions {
          AttachmentAction("Cancel upload", icon: Ph.x.bold) {}
        }
      }
      Attachment(state: .processing) {
        AttachmentMedia(.document) { AttachmentLabel("docx", tint: EsuissColor.primary) }
        AttachmentContent {
          AttachmentTitle("Contract.docx")
          AttachmentDescription("Processing…")
        }
        AttachmentActions {
          Spinner(dimension: 20)
        }
      }
      Attachment {
        AttachmentMedia(.document) { AttachmentLabel("xlsx", tint: Palette.green500) }
        AttachmentContent {
          AttachmentTitle("Menu_October.xlsx")
          AttachmentDescription("2.4 MB")
        }
        AttachmentActions {
          Ph.checkCircle.fill
            .frame(width: 24, height: 24)
            .foregroundStyle(Color.adaptive(light: Palette.green600, dark: Palette.green400))
            .accessibilityElement()
            .accessibilityLabel(Text("Uploaded"))
        }
      }
      Attachment {
        AttachmentThumbnail()
        AttachmentContent {
          AttachmentTitle("Table_12.jpg")
          AttachmentDescription("1.8 MB")
        }
        AttachmentActions {
          AttachmentAction("Remove", icon: Ph.x.bold) {}
        }
      }
      Attachment(state: .error) {
        AttachmentMedia(.document) { AttachmentLabel("pdf", tint: EsuissColor.destructive) }
        AttachmentContent {
          AttachmentTitle("Z-Report_30-09.pdf")
          AttachmentDescription("Upload failed")
        }
        AttachmentActions {
          AttachmentAction("Try Again") {}
        }
      }
    }
  }
}

struct AttachmentThumbnail: View {
  var body: some View {
    AttachmentMedia(url: URL(string: "https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=400&q=80"))
      .accessibilityLabel(Text("Mountain lake"))
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.BrushPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Fill
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.fill.CheckCircle
import com.adamglin.phosphoricons.regular.X
import com.esuiss.ui.components.Attachment
import com.esuiss.ui.components.AttachmentAction
import com.esuiss.ui.components.AttachmentActions
import com.esuiss.ui.components.AttachmentContent
import com.esuiss.ui.components.AttachmentDescription
import com.esuiss.ui.components.AttachmentGroup
import com.esuiss.ui.components.AttachmentLabel
import com.esuiss.ui.components.AttachmentMedia
import com.esuiss.ui.components.AttachmentMediaVariant
import com.esuiss.ui.components.AttachmentProgress
import com.esuiss.ui.components.AttachmentState
import com.esuiss.ui.components.AttachmentTitle
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Spinner
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette

@Composable
fun attachmentPhoto(): Painter {
    val colors = EsuissTheme.colors
    return remember(colors) { BrushPainter(Brush.linearGradient(listOf(Palette.teal500, Palette.blue500, Palette.indigo500))) }
}

@Composable
fun AttachmentDocument(extension: String, tone: Color) {
    AttachmentMedia(variant = AttachmentMediaVariant.Document) {
        AttachmentLabel(extension, background = tone)
    }
}

@Composable
fun AttachmentIconAction(label: String, icon: androidx.compose.ui.graphics.vector.ImageVector = PhosphorIcons.Regular.X) {
    AttachmentAction(onClick = {}, contentDescription = label) {
        Icon(icon, contentDescription = null)
    }
}

@Composable
fun AttachmentGroupStory() {
    val colors = EsuissTheme.colors
    val photo = attachmentPhoto()
    AttachmentGroup {
        Attachment(state = AttachmentState.Uploading) {
            AttachmentDocument("pdf", Palette.red500)
            AttachmentContent {
                AttachmentTitle("Invoice_1042.pdf")
                AttachmentProgress(64f, contentDescription = "Uploading")
            }
            AttachmentActions { AttachmentIconAction("Cancel upload") }
        }
        Attachment(state = AttachmentState.Processing) {
            AttachmentDocument("docx", Palette.blue500)
            AttachmentContent {
                AttachmentTitle("Contract.docx")
                AttachmentDescription("Processing…")
            }
            AttachmentActions { Spinner(size = 20.dp) }
        }
        Attachment {
            AttachmentDocument("xlsx", Palette.green500)
            AttachmentContent {
                AttachmentTitle("Menu_October.xlsx")
                AttachmentDescription("2.4 MB")
            }
            AttachmentActions {
                Box(Modifier.clearAndSetSemantics { contentDescription = "Uploaded" }) {
                    Icon(PhosphorIcons.Fill.CheckCircle, contentDescription = null, size = 24.dp, tint = if (colors.isDark) Palette.green400 else Palette.green600)
                }
            }
        }
        Attachment {
            AttachmentMedia(photo, contentDescription = "Mountain lake")
            AttachmentContent {
                AttachmentTitle("Table_12.jpg")
                AttachmentDescription("1.8 MB")
            }
            AttachmentActions { AttachmentIconAction("Remove") }
        }
        Attachment(state = AttachmentState.Error) {
            AttachmentDocument("pdf", Palette.red500)
            AttachmentContent {
                AttachmentTitle("Z-Report_30-09.pdf")
                AttachmentDescription("Upload failed")
            }
            AttachmentActions { AttachmentAction("Try Again", onClick = {}) }
        }
    }
}
```

### File types

Document media with colored extension labels.

![Preview](https://ui.suiss.app/previews/components-attachment--file-types.light.webp)

```tsx
import { View } from "react-native"
import { Attachment, AttachmentContent, AttachmentDescription, AttachmentLabel, AttachmentMedia, AttachmentTitle } from "@/components/ui/attachment"

export function AttachmentFileTypesExample() {
  return (
    <View className="items-start gap-3">
      {[
        {
          name: "Invoice.pdf",
          meta: "128 KB",
          extension: "pdf",
          tone: "bg-red-500",
        },
        {
          name: "Menu.xlsx",
          meta: "2.4 MB",
          extension: "xlsx",
          tone: "bg-green-500",
        },
        {
          name: "Contract.docx",
          meta: "84 KB",
          extension: "docx",
          tone: "bg-blue-500",
        },
        {
          name: "Pitch.key",
          meta: "12 MB",
          extension: "key",
          tone: "bg-orange-500",
        },
        { name: "Archive.zip", meta: "48 MB", extension: "zip", tone: "" },
      ].map((file) => (
        <Attachment key={file.name} orientation="horizontal" state="done">
          <AttachmentMedia variant="document">
            <AttachmentLabel className={file.tone}>
              {file.extension}
            </AttachmentLabel>
          </AttachmentMedia>
          <AttachmentContent>
            <AttachmentTitle>{file.name}</AttachmentTitle>
            <AttachmentDescription>{file.meta}</AttachmentDescription>
          </AttachmentContent>
        </Attachment>
      ))}
    </View>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct AttachmentFileTypesExample: View {
  var body: some View {
    VStack(alignment: .leading, spacing: 12) {
      ForEach(AttachmentFile.all, id: \.name) { file in
        Attachment {
          AttachmentMedia(.document) { AttachmentLabel(verbatim: file.ext, tint: file.tint) }
          AttachmentContent {
            AttachmentTitle(verbatim: file.name)
            AttachmentDescription(verbatim: file.meta)
          }
        }
      }
    }
  }
}

struct AttachmentFile {
  let name: String
  let meta: String
  let ext: String
  let tint: Color

  static let all: [AttachmentFile] = [
    AttachmentFile(name: "Invoice.pdf", meta: "128 KB", ext: "pdf", tint: EsuissColor.destructive),
    AttachmentFile(name: "Menu.xlsx", meta: "2.4 MB", ext: "xlsx", tint: Palette.green500),
    AttachmentFile(name: "Contract.docx", meta: "84 KB", ext: "docx", tint: EsuissColor.primary),
    AttachmentFile(name: "Pitch.key", meta: "12 MB", ext: "key", tint: Palette.amber500),
    AttachmentFile(name: "Archive.zip", meta: "48 MB", ext: "zip", tint: EsuissColor.primary),
  ]
}

let all: [AttachmentFile] = [
  AttachmentFile(name: "Invoice.pdf", meta: "128 KB", ext: "pdf", tint: EsuissColor.destructive),
  AttachmentFile(name: "Menu.xlsx", meta: "2.4 MB", ext: "xlsx", tint: Palette.green500),
  AttachmentFile(name: "Contract.docx", meta: "84 KB", ext: "docx", tint: EsuissColor.primary),
  AttachmentFile(name: "Pitch.key", meta: "12 MB", ext: "key", tint: Palette.amber500),
  AttachmentFile(name: "Archive.zip", meta: "48 MB", ext: "zip", tint: EsuissColor.primary),
]
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Attachment
import com.esuiss.ui.components.AttachmentContent
import com.esuiss.ui.components.AttachmentDescription
import com.esuiss.ui.components.AttachmentLabel
import com.esuiss.ui.components.AttachmentMedia
import com.esuiss.ui.components.AttachmentMediaVariant
import com.esuiss.ui.components.AttachmentTitle
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette

@Composable
fun AttachmentDocument(extension: String, tone: Color) {
    AttachmentMedia(variant = AttachmentMediaVariant.Document) {
        AttachmentLabel(extension, background = tone)
    }
}

class AttachmentFile(val name: String, val meta: String, val extension: String, val tone: @Composable () -> Color)

val attachmentFiles = listOf(
    AttachmentFile("Invoice.pdf", "128 KB", "pdf") { Palette.red500 },
    AttachmentFile("Menu.xlsx", "2.4 MB", "xlsx") { Palette.green500 },
    AttachmentFile("Contract.docx", "84 KB", "docx") { Palette.blue500 },
    AttachmentFile("Pitch.key", "12 MB", "key") { Palette.orange500 },
    AttachmentFile("Archive.zip", "48 MB", "zip") { EsuissTheme.colors.primary },
)

@Composable
fun AttachmentFileTypes() {
    Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
        attachmentFiles.forEach { file ->
            Attachment {
                AttachmentDocument(file.extension, file.tone())
                AttachmentContent {
                    AttachmentTitle(file.name)
                    AttachmentDescription(file.meta)
                }
            }
        }
    }
}
```

## API Reference

### Attachment

Card that provides state, size and orientation to its parts.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `state` | `AttachmentState` | `"done"` | `idle`, `uploading`, `processing`, `error` or `done`. Defaults to `done`. |
| `size` | `AttachmentSize` | `"default"` | `default`, `sm` or `xs`. |
| `orientation` | `AttachmentOrientation` | `"horizontal"` | `horizontal` or `vertical`. |

Also accepts the props of `ViewProps`.

### AttachmentMedia

Thumbnail area for an icon, document shape or image.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"icon" \| "document" \| "image"` | `"icon"` | `icon`, `document` or `image`. |

Also accepts the props of `ViewProps`.

### AttachmentLabel

Small uppercase tag, such as a file extension.

Also accepts the props of `ViewProps`.

### AttachmentContent

Column for the title, description and progress.

Also accepts the props of `ViewProps`.

### AttachmentTitle

File name, one line by default.

Also accepts the props of `React.ComponentProps<typeof Text>`.

### AttachmentDescription

Secondary details, red in the `error` state.

Also accepts the props of `React.ComponentProps<typeof Text>`.

### AttachmentProgress

Progress bar with a percentage value.

Also accepts the props of `React.ComponentProps<typeof ProgressPrimitive.Root>`.

### AttachmentActions

Row of actions; floats over the media in vertical layout.

Also accepts the props of `ViewProps`.

### AttachmentAction

Small button sized to the attachment.

Also accepts the props of `React.ComponentProps<typeof Button>`.

### AttachmentTrigger

Pressable overlay that makes the whole card pressable.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `RenderProp<Record<string, unknown>, Record<string, never>>` | - |  |

Also accepts the props of `React.ComponentProps<typeof Pressable>`, `React.RefAttributes<View>`.

### AttachmentGroup

List container that joins attachments with separators.

Also accepts the props of `ViewProps`.

## Accessibility

- `AttachmentProgress` has the `progressbar` role with its value exposed.
- `AttachmentTrigger` has the `button` role; give it an `accessibilityLabel` such as "Open Invoice.pdf".
- Icon-only actions need an `accessibilityLabel`.

## Notes

- Uses `bg-muted`, `border-border`, `bg-primary` and `text-destructive` tokens; light and dark follow the system appearance.
- Separators and action positions use start and end offsets, so they flip in right-to-left layouts.

