Skip to main content
Nexus Design System
GitHub

Attachment

Attachment shows one file with its preview, name, size and upload state. Use it in upload queues and message composers. For a general list row, use Item.

report.pdf

2.4 MB

'use client';

import { FilePdfIcon, XIcon } from '@phosphor-icons/react/ssr';

import {
  Attachment,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentMedia,
  AttachmentTitle,
} from '@/components/nxds/components/attachment/attachment';
import { Button } from '@/components/nxds/components/button/button';

export default function AttachmentDefault() {
  return (
    <Attachment className="nx:w-80">
      <AttachmentMedia variant="icon">
        <FilePdfIcon />
      </AttachmentMedia>
      <AttachmentContent>
        <AttachmentTitle>report.pdf</AttachmentTitle>
        <AttachmentDescription>2.4 MB</AttachmentDescription>
      </AttachmentContent>
      <AttachmentActions>
        <Button variant="ghost" size="icon-sm" aria-label="Remove report.pdf">
          <XIcon />
        </Button>
      </AttachmentActions>
    </Attachment>
  );
}

This example also needs: Button.

Installation

npx nxds-cli@latest add attachment

Run npx nxds-cli@latest init first if nxds isn’t set up in your app yet. See Install.

Usage

import {
  Attachment,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentGroup,
  AttachmentMedia,
  AttachmentProgress,
  AttachmentTitle,
} from '@/components/nxds/components/attachment/attachment';

Composition

Attachment
├── AttachmentMedia
├── AttachmentContent
│   ├── AttachmentTitle
│   ├── AttachmentDescription
│   └── AttachmentProgress
└── AttachmentActions
AttachmentGroup
└── Attachment
    ├── AttachmentMedia
    └── AttachmentContent
        ├── AttachmentTitle
        └── AttachmentDescription

Examples

States

Show each upload state, from empty drop target to done.

Drop a file here

PDF or PNG, up to 10 MB

Uploading
report.pdf

62% of 2.4 MB

Processing
scan.png

Processing…

Upload failed
archive.zip

Upload failed — file too large

notes.txt

8 KB · Uploaded just now

'use client';

import {
  ArrowClockwiseIcon,
  CheckCircleIcon,
  DownloadSimpleIcon,
  FilePdfIcon,
  FileTxtIcon,
  UploadSimpleIcon,
  WarningIcon,
  XIcon,
} from '@phosphor-icons/react/ssr';

import {
  Attachment,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentMedia,
  AttachmentProgress,
  AttachmentTitle,
} from '@/components/nxds/components/attachment/attachment';
import { Button } from '@/components/nxds/components/button/button';
import { Spinner } from '@/components/nxds/components/spinner/spinner';

export default function AttachmentStates() {
  return (
    <div className="nx:flex nx:w-80 nx:flex-col nx:gap-3">
      <Attachment state="idle">
        <AttachmentMedia variant="icon">
          <UploadSimpleIcon />
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>Drop a file here</AttachmentTitle>
          <AttachmentDescription>PDF or PNG, up to 10 MB</AttachmentDescription>
        </AttachmentContent>
        <AttachmentActions>
          <Button variant="ghost" size="icon-sm" aria-label="Dismiss">
            <XIcon />
          </Button>
        </AttachmentActions>
      </Attachment>
      <Attachment state="uploading">
        <AttachmentMedia variant="icon">
          <FilePdfIcon />
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>report.pdf</AttachmentTitle>
          <AttachmentDescription>62% of 2.4 MB</AttachmentDescription>
          <AttachmentProgress value={62} aria-label="Uploading report.pdf" />
        </AttachmentContent>
        <AttachmentActions>
          <Button
            variant="ghost"
            size="icon-sm"
            aria-label="Cancel upload of report.pdf"
          >
            <XIcon />
          </Button>
        </AttachmentActions>
      </Attachment>
      <Attachment state="processing">
        <AttachmentMedia variant="icon">
          <Spinner />
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>scan.png</AttachmentTitle>
          <AttachmentDescription>Processing…</AttachmentDescription>
        </AttachmentContent>
        <AttachmentActions>
          <Button
            variant="ghost"
            size="icon-sm"
            aria-label="Cancel processing of scan.png"
          >
            <XIcon />
          </Button>
        </AttachmentActions>
      </Attachment>
      <Attachment state="error">
        <AttachmentMedia variant="icon">
          <WarningIcon />
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>archive.zip</AttachmentTitle>
          <AttachmentDescription>
            Upload failed — file too large
          </AttachmentDescription>
        </AttachmentContent>
        <AttachmentActions>
          <Button
            variant="ghost"
            size="icon-sm"
            aria-label="Retry upload of archive.zip"
          >
            <ArrowClockwiseIcon />
          </Button>
          <Button
            variant="ghost"
            size="icon-sm"
            aria-label="Dismiss archive.zip"
          >
            <XIcon />
          </Button>
        </AttachmentActions>
      </Attachment>
      <Attachment state="done">
        <AttachmentMedia variant="icon">
          <FileTxtIcon />
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle
            trailing={
              <CheckCircleIcon
                weight="fill"
                className="nx:size-4 nx:text-success-subtle-foreground"
              />
            }
          >
            notes.txt
          </AttachmentTitle>
          <AttachmentDescription>
            8 KB · Uploaded just now
          </AttachmentDescription>
        </AttachmentContent>
        <AttachmentActions>
          <Button
            variant="ghost"
            size="icon-sm"
            aria-label="Download notes.txt"
          >
            <DownloadSimpleIcon />
          </Button>
          <Button variant="ghost" size="icon-sm" aria-label="Remove notes.txt">
            <XIcon />
          </Button>
        </AttachmentActions>
      </Attachment>
    </div>
  );
}

Sizes

Compare the default and small sizes.

report.pdf

2.4 MB

notes.txt

8 KB

'use client';

import { FilePdfIcon, FileTxtIcon, XIcon } from '@phosphor-icons/react/ssr';

import {
  Attachment,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentMedia,
  AttachmentTitle,
} from '@/components/nxds/components/attachment/attachment';
import { Button } from '@/components/nxds/components/button/button';

export default function AttachmentSizes() {
  return (
    <div className="nx:flex nx:w-80 nx:flex-col nx:gap-3">
      <Attachment size="default">
        <AttachmentMedia variant="icon">
          <FilePdfIcon />
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>report.pdf</AttachmentTitle>
          <AttachmentDescription>2.4 MB</AttachmentDescription>
        </AttachmentContent>
        <AttachmentActions>
          <Button variant="ghost" size="icon-sm" aria-label="Remove report.pdf">
            <XIcon />
          </Button>
        </AttachmentActions>
      </Attachment>
      <Attachment size="sm">
        <AttachmentMedia variant="icon">
          <FileTxtIcon />
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>notes.txt</AttachmentTitle>
          <AttachmentDescription>8 KB</AttachmentDescription>
        </AttachmentContent>
        <AttachmentActions>
          <Button variant="ghost" size="icon-sm" aria-label="Remove notes.txt">
            <XIcon />
          </Button>
        </AttachmentActions>
      </Attachment>
    </div>
  );
}

Grouped

Scroll several attachments sideways in an AttachmentGroup.

cover.png

412 KB

report.pdf

2.4 MB

notes.txt

8 KB

archive.zip

18 MB

brief.docx

96 KB

'use client';

import {
  FileDocIcon,
  FilePdfIcon,
  FileTxtIcon,
  FileZipIcon,
} from '@phosphor-icons/react/ssr';

import {
  Attachment,
  AttachmentContent,
  AttachmentDescription,
  AttachmentGroup,
  AttachmentMedia,
  AttachmentTitle,
} from '@/components/nxds/components/attachment/attachment';

const THUMB =
  "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='80' height='80'%3E%3Crect width='80' height='80' fill='%23999'/%3E%3C/svg%3E";

export default function AttachmentGrouped() {
  return (
    <AttachmentGroup aria-label="Attached files" className="nx:w-80">
      {[
        {
          name: 'cover.png',
          size: '412 KB',
          media: <img src={THUMB} alt="" />,
        },
        { name: 'report.pdf', size: '2.4 MB', media: <FilePdfIcon /> },
        { name: 'notes.txt', size: '8 KB', media: <FileTxtIcon /> },
        { name: 'archive.zip', size: '18 MB', media: <FileZipIcon /> },
        { name: 'brief.docx', size: '96 KB', media: <FileDocIcon /> },
      ].map((file) => (
        <Attachment key={file.name} orientation="vertical">
          <AttachmentMedia
            variant={file.name.endsWith('.png') ? 'image' : 'icon'}
          >
            {file.media}
          </AttachmentMedia>
          <AttachmentContent>
            <AttachmentTitle>{file.name}</AttachmentTitle>
            <AttachmentDescription>{file.size}</AttachmentDescription>
          </AttachmentContent>
        </Attachment>
      ))}
    </AttachmentGroup>
  );
}

This example also needs:

npm install @phosphor-icons/react@^2.1.10

Props

Attachment

Attachment props
PropTypeDefaultDescription
orientation"horizontal" | "vertical" | null"horizontal"No description
size"default" | "sm" | null"default"No description
stateAttachmentState"done"

Upload state. Screen readers announce in-progress and error states, and idle or error change the border.

statusLabelstringNo default documented

Replaces the built-in status text, such as 'Uploading'. Ignored while idle or done.

AttachmentActions

AttachmentActions adds no props of its own; it accepts the props of the element or primitive it wraps.

AttachmentContent

AttachmentContent adds no props of its own; it accepts the props of the element or primitive it wraps.

AttachmentDescription

AttachmentDescription adds no props of its own; it accepts the props of the element or primitive it wraps.

AttachmentGroup

AttachmentGroup props
PropTypeDefaultDescription
aria-labelstringNo default documentedNo description
aria-labelledbystringNo default documentedNo description

AttachmentMedia

AttachmentMedia props
PropTypeDefaultDescription
variant"default" | "icon" | "image" | null"default"No description

AttachmentProgress

AttachmentProgress adds no props of its own; it accepts the props of the element or primitive it wraps.

AttachmentTitle

AttachmentTitle props
PropTypeDefaultDescription
trailingReactNodeNo default documented

Badge or status icon after the file name. Keeps its width while the name truncates.

AttachmentTrigger

AttachmentTrigger props
PropTypeDefaultDescription
asChildbooleanfalse

Renders the child element instead, such as an <a> that opens the file.

Attachment shows one file with its preview, name, size and upload state. Use it in upload queues and message composers. For a general list row, use Item.

<script setup lang="ts">
import { PhFilePdf, PhX } from '@phosphor-icons/vue';

import {
  Attachment,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentMedia,
  AttachmentTitle,
} from '@/components/attachment';
import { Button } from '@/components/button';
</script>

<template>
  <Attachment class="nx:w-80">
    <AttachmentMedia variant="icon"><PhFilePdf /></AttachmentMedia>
    <AttachmentContent>
      <AttachmentTitle>report.pdf</AttachmentTitle>
      <AttachmentDescription>2.4 MB</AttachmentDescription>
    </AttachmentContent>
    <AttachmentActions>
      <Button variant="ghost" size="icon-sm" aria-label="Remove report.pdf">
        <PhX />
      </Button>
    </AttachmentActions>
  </Attachment>
</template>

This example also needs: Button.

npm install @phosphor-icons/vue@^2.2.1

Installation

  1. Install these components first:

    Item, Progress, Separator.

  2. Copy and paste the following code into your project.

    components/attachment/Attachment.vue
    <script setup lang="ts">
    import { computed, type HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    import { Item } from '../item';
    
    import {
      type AttachmentState,
      type AttachmentVariants,
      attachmentVariants,
    } from '.';
    
    export interface AttachmentProps {
      /**
       * Upload state. Screen readers announce in-progress and error states, and `idle` or `error` change the border.
       */
      state?: AttachmentState;
      /**
       * Replaces the built-in status text, such as 'Uploading'. Ignored while `idle` or `done`.
       */
      statusLabel?: string;
      orientation?: AttachmentVariants['orientation'];
      size?: AttachmentVariants['size'];
      class?: HTMLAttributes['class'];
    }
    
    const props = withDefaults(defineProps<AttachmentProps>(), {
      state: 'done',
      orientation: 'horizontal',
      size: 'default',
    });
    
    /**
     * Announced states only — a settled card (`idle` / `done`) stays silent, so it
     * has no entry here and the live region renders empty.
     */
    const ATTACHMENT_STATUS_TEXT: Partial<Record<AttachmentState, string>> = {
      uploading: 'Uploading',
      processing: 'Processing',
      error: 'Upload failed',
    };
    
    const statusText = computed(() => ATTACHMENT_STATUS_TEXT[props.state]);
    </script>
    
    <template>
      <!-- Attachment pins Item's variant and exposes none, so it drops Item's data-variant. -->
      <Item
        data-slot="attachment"
        :data-state="state"
        :data-orientation="orientation"
        :data-variant="undefined"
        variant="outline"
        :size="size"
        :class="cn(attachmentVariants({ size, orientation }), props.class)"
      >
        <!-- The region stays mounted and empty while settled: a live region inserted together with its text is not reliably announced. -->
        <span data-slot="attachment-status" role="status" class="nx:sr-only">{{
          statusText ? (statusLabel ?? statusText) : ''
        }}</span>
        <slot />
      </Item>
    </template>
    
    components/attachment/AttachmentActions.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    import { ItemActions } from '../item';
    
    export interface AttachmentActionsProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<AttachmentActionsProps>();
    </script>
    
    <template>
      <ItemActions
        data-slot="attachment-actions"
        :class="cn('nx:relative nx:z-20', props.class)"
      >
        <slot />
      </ItemActions>
    </template>
    
    components/attachment/AttachmentContent.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { ItemContent } from '../item';
    
    export interface AttachmentContentProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<AttachmentContentProps>();
    </script>
    
    <template>
      <ItemContent data-slot="attachment-content" :class="props.class">
        <slot />
      </ItemContent>
    </template>
    
    components/attachment/AttachmentDescription.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    import { ItemDescription } from '../item';
    
    export interface AttachmentDescriptionProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<AttachmentDescriptionProps>();
    </script>
    
    <template>
      <ItemDescription
        data-slot="attachment-description"
        :class="
          cn(
            'nx:group-data-[state=error]/attachment:text-error-subtle-foreground',
            props.class
          )
        "
      >
        <slot />
      </ItemDescription>
    </template>
    
    components/attachment/AttachmentGroup.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    type AttachmentGroupLabel =
      | { ariaLabel: string; ariaLabelledby?: never }
      | { ariaLabelledby: string; ariaLabel?: never };
    
    export type AttachmentGroupProps = AttachmentGroupLabel & {
      class?: HTMLAttributes['class'];
    };
    
    const props = defineProps<AttachmentGroupProps>();
    </script>
    
    <template>
      <!-- A scrollable region with no focusable children must itself be keyboard-focusable to scroll (WCAG 2.1.1). -->
      <div
        data-slot="attachment-group"
        role="group"
        tabindex="0"
        :aria-label="ariaLabel"
        :aria-labelledby="ariaLabelledby"
        :class="
          cn(
            'nx:flex nx:min-w-0 nx:snap-x nx:gap-3 nx:overflow-x-auto nx:overscroll-x-contain nx:py-1',
            'nx:focus-visible:outline-2 nx:focus-visible:outline-focus-default',
            'nx:*:data-[slot=attachment]:flex-none nx:*:data-[slot=attachment]:snap-start',
            props.class
          )
        "
      >
        <slot />
      </div>
    </template>
    
    components/attachment/AttachmentMedia.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    import { ItemMedia, type ItemMediaVariants } from '../item';
    
    export interface AttachmentMediaProps {
      variant?: ItemMediaVariants['variant'];
      class?: HTMLAttributes['class'];
    }
    
    const props = withDefaults(defineProps<AttachmentMediaProps>(), {
      variant: 'default',
    });
    </script>
    
    <template>
      <!-- translate-y-0 cancels ItemMedia's row nudge, which is meant for a media atom beside a description, not a full-width thumbnail. -->
      <ItemMedia
        data-slot="attachment-media"
        :variant="variant"
        :class="
          cn(
            'nx:group-data-[orientation=vertical]/attachment:size-auto nx:group-data-[orientation=vertical]/attachment:aspect-square nx:group-data-[orientation=vertical]/attachment:w-full nx:group-data-[orientation=vertical]/attachment:translate-y-0 nx:group-data-[orientation=vertical]/attachment:self-auto',
            'nx:group-data-[orientation=vertical]/attachment:[&_svg]:size-8',
            'nx:group-data-[state=error]/attachment:border-error-border nx:group-data-[state=error]/attachment:bg-error-subtle nx:group-data-[state=error]/attachment:text-error-subtle-foreground',
            props.class
          )
        "
      >
        <slot />
      </ItemMedia>
    </template>
    
    components/attachment/AttachmentProgress.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import type { ProgressRootProps } from 'reka-ui';
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    import { Progress } from '../progress';
    
    export interface AttachmentProgressProps extends ProgressRootProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<AttachmentProgressProps>();
    
    const delegated = reactiveOmit(props, 'class');
    </script>
    
    <template>
      <!-- A consumer's aria-label falls through over this default. -->
      <Progress
        v-bind="delegated"
        data-slot="attachment-progress"
        aria-label="Upload progress"
        :class="cn('nx:mt-1 nx:h-1', props.class)"
      />
    </template>
    
    components/attachment/AttachmentTitle.vue
    <script setup lang="ts">
    import type { HTMLAttributes, VNode } from 'vue';
    
    import { cn } from '../../lib/utils';
    import { ItemTitle } from '../item';
    
    export interface AttachmentTitleProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<AttachmentTitleProps>();
    
    const slots = defineSlots<{
      default?: () => VNode[];
      /** Badge or status icon after the file name. Keeps its width while the name truncates. */
      trailing?: () => VNode[];
    }>();
    </script>
    
    <template>
      <ItemTitle
        data-slot="attachment-title"
        :class="cn('nx:w-full nx:min-w-0', props.class)"
      >
        <span class="nx:min-w-0 nx:truncate"><slot /></span>
        <span v-if="slots.trailing" class="nx:shrink-0"
          ><slot name="trailing"
        /></span>
      </ItemTitle>
    </template>
    
    components/attachment/AttachmentTrigger.vue
    <script setup lang="ts">
    import { Primitive, type PrimitiveProps } from 'reka-ui';
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface AttachmentTriggerProps extends PrimitiveProps {
      /**
       * Renders the child element instead, such as an `<a>` that opens the file.
       */
      asChild?: boolean;
      class?: HTMLAttributes['class'];
    }
    
    const props = withDefaults(defineProps<AttachmentTriggerProps>(), {
      as: 'button',
      asChild: false,
    });
    </script>
    
    <template>
      <!-- A consumer's type falls through over this default. -->
      <Primitive
        data-slot="attachment-trigger"
        :as="as"
        :as-child="asChild"
        :type="as === 'button' && !asChild ? 'button' : undefined"
        :class="
          cn(
            'nx:absolute nx:inset-0 nx:z-10 nx:cursor-pointer nx:rounded-[inherit] nx:border-0 nx:bg-transparent nx:p-0 nx:no-underline',
            'nx:focus-visible:outline-2 nx:focus-visible:outline-focus-default',
            props.class
          )
        "
      >
        <slot />
      </Primitive>
    </template>
    
    components/attachment/index.ts
    import { cva, type VariantProps } from 'class-variance-authority';
    
    export { default as Attachment, type AttachmentProps } from './Attachment.vue';
    export {
      default as AttachmentActions,
      type AttachmentActionsProps,
    } from './AttachmentActions.vue';
    export {
      default as AttachmentContent,
      type AttachmentContentProps,
    } from './AttachmentContent.vue';
    export {
      default as AttachmentDescription,
      type AttachmentDescriptionProps,
    } from './AttachmentDescription.vue';
    export {
      default as AttachmentGroup,
      type AttachmentGroupProps,
    } from './AttachmentGroup.vue';
    export {
      default as AttachmentMedia,
      type AttachmentMediaProps,
    } from './AttachmentMedia.vue';
    export {
      default as AttachmentProgress,
      type AttachmentProgressProps,
    } from './AttachmentProgress.vue';
    export {
      default as AttachmentTitle,
      type AttachmentTitleProps,
    } from './AttachmentTitle.vue';
    export {
      default as AttachmentTrigger,
      type AttachmentTriggerProps,
    } from './AttachmentTrigger.vue';
    
    /**
     * Upload lifecycle of one attachment, from empty drop target to done.
     */
    export type AttachmentState =
      | 'idle'
      | 'uploading'
      | 'processing'
      | 'error'
      | 'done';
    
    export const attachmentVariants = cva(
      [
        // isolate: AttachmentTrigger and AttachmentActions stack inside the card,
        // so the card must be their stacking context rather than the page.
        'nx:group/attachment nx:relative nx:isolate nx:min-w-0',
        'nx:data-[state=idle]:border-dashed',
        'nx:data-[state=error]:border-error-border',
      ],
      {
        variants: {
          size: {
            default: 'nx:gap-2.5 nx:p-2.5',
            sm: 'nx:gap-2 nx:p-2',
          },
          orientation: {
            horizontal: 'nx:flex-nowrap nx:items-center',
            vertical: 'nx:w-32 nx:flex-col nx:items-stretch',
          },
        },
        defaultVariants: {
          size: 'default',
          orientation: 'horizontal',
        },
      }
    );
    
    export type AttachmentVariants = VariantProps<typeof attachmentVariants>;
    
  3. Keep each file at the path shown above it.

    Paths are relative to packages/vue/src/. Keep the same folders under your @/ root, and the imports between files work without edits.

Usage

import { Attachment, AttachmentActions, AttachmentContent, AttachmentDescription, AttachmentGroup, AttachmentMedia, AttachmentProgress, AttachmentTitle } from '@/components/attachment';

Examples

States

Show each upload state, from empty drop target to done.

<script setup lang="ts">
import {
  PhArrowClockwise,
  PhCheckCircle,
  PhDownloadSimple,
  PhFilePdf,
  PhFileTxt,
  PhUploadSimple,
  PhWarning,
  PhX,
} from '@phosphor-icons/vue';

import {
  Attachment,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentMedia,
  AttachmentProgress,
  AttachmentTitle,
} from '@/components/attachment';
import { Button } from '@/components/button';
import { Spinner } from '@/components/spinner';
</script>

<template>
  <div class="nx:flex nx:w-80 nx:flex-col nx:gap-3">
    <Attachment state="idle">
      <AttachmentMedia variant="icon"><PhUploadSimple /></AttachmentMedia>
      <AttachmentContent>
        <AttachmentTitle>Drop a file here</AttachmentTitle>
        <AttachmentDescription>PDF or PNG, up to 10 MB</AttachmentDescription>
      </AttachmentContent>
      <AttachmentActions>
        <Button variant="ghost" size="icon-sm" aria-label="Dismiss">
          <PhX />
        </Button>
      </AttachmentActions>
    </Attachment>
    <Attachment state="uploading">
      <AttachmentMedia variant="icon"><PhFilePdf /></AttachmentMedia>
      <AttachmentContent>
        <AttachmentTitle>report.pdf</AttachmentTitle>
        <AttachmentDescription>62% of 2.4 MB</AttachmentDescription>
        <AttachmentProgress
          :model-value="62"
          aria-label="Uploading report.pdf"
        />
      </AttachmentContent>
      <AttachmentActions>
        <Button
          variant="ghost"
          size="icon-sm"
          aria-label="Cancel upload of report.pdf"
        >
          <PhX />
        </Button>
      </AttachmentActions>
    </Attachment>
    <Attachment state="processing">
      <AttachmentMedia variant="icon"><Spinner /></AttachmentMedia>
      <AttachmentContent>
        <AttachmentTitle>scan.png</AttachmentTitle>
        <AttachmentDescription>Processing…</AttachmentDescription>
      </AttachmentContent>
      <AttachmentActions>
        <Button
          variant="ghost"
          size="icon-sm"
          aria-label="Cancel processing of scan.png"
        >
          <PhX />
        </Button>
      </AttachmentActions>
    </Attachment>
    <Attachment state="error">
      <AttachmentMedia variant="icon"><PhWarning /></AttachmentMedia>
      <AttachmentContent>
        <AttachmentTitle>archive.zip</AttachmentTitle>
        <AttachmentDescription
          >Upload failed — file too large</AttachmentDescription
        >
      </AttachmentContent>
      <AttachmentActions>
        <Button
          variant="ghost"
          size="icon-sm"
          aria-label="Retry upload of archive.zip"
        >
          <PhArrowClockwise />
        </Button>
        <Button variant="ghost" size="icon-sm" aria-label="Dismiss archive.zip">
          <PhX />
        </Button>
      </AttachmentActions>
    </Attachment>
    <Attachment state="done">
      <AttachmentMedia variant="icon"><PhFileTxt /></AttachmentMedia>
      <AttachmentContent>
        <AttachmentTitle
          >notes.txt<template #trailing>
            <PhCheckCircle
              weight="fill"
              class="nx:size-4 nx:text-success-subtle-foreground"
            /> </template
        ></AttachmentTitle>
        <AttachmentDescription>8 KB · Uploaded just now</AttachmentDescription>
      </AttachmentContent>
      <AttachmentActions>
        <Button variant="ghost" size="icon-sm" aria-label="Download notes.txt">
          <PhDownloadSimple />
        </Button>
        <Button variant="ghost" size="icon-sm" aria-label="Remove notes.txt">
          <PhX />
        </Button>
      </AttachmentActions>
    </Attachment>
  </div>
</template>

This example also needs: Spinner.

Sizes

Compare the default and small sizes.

<script setup lang="ts">
import { PhFilePdf, PhFileTxt, PhX } from '@phosphor-icons/vue';

import {
  Attachment,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentMedia,
  AttachmentTitle,
} from '@/components/attachment';
import { Button } from '@/components/button';
</script>

<template>
  <div class="nx:flex nx:w-80 nx:flex-col nx:gap-3">
    <Attachment size="default">
      <AttachmentMedia variant="icon"><PhFilePdf /></AttachmentMedia>
      <AttachmentContent>
        <AttachmentTitle>report.pdf</AttachmentTitle>
        <AttachmentDescription>2.4 MB</AttachmentDescription>
      </AttachmentContent>
      <AttachmentActions>
        <Button variant="ghost" size="icon-sm" aria-label="Remove report.pdf">
          <PhX />
        </Button>
      </AttachmentActions>
    </Attachment>
    <Attachment size="sm">
      <AttachmentMedia variant="icon"><PhFileTxt /></AttachmentMedia>
      <AttachmentContent>
        <AttachmentTitle>notes.txt</AttachmentTitle>
        <AttachmentDescription>8 KB</AttachmentDescription>
      </AttachmentContent>
      <AttachmentActions>
        <Button variant="ghost" size="icon-sm" aria-label="Remove notes.txt">
          <PhX />
        </Button>
      </AttachmentActions>
    </Attachment>
  </div>
</template>

Grouped

Scroll several attachments sideways in an AttachmentGroup.

<script setup lang="ts">
import {
  PhFileDoc,
  PhFilePdf,
  PhFileTxt,
  PhFileZip,
} from '@phosphor-icons/vue';

import {
  Attachment,
  AttachmentContent,
  AttachmentDescription,
  AttachmentGroup,
  AttachmentMedia,
  AttachmentTitle,
} from '@/components/attachment';

const thumb =
  "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='80' height='80'%3E%3Crect width='80' height='80' fill='%23999'/%3E%3C/svg%3E";
const files = [
  { name: 'cover.png', size: '412 KB' },
  { name: 'report.pdf', size: '2.4 MB' },
  { name: 'notes.txt', size: '8 KB' },
  { name: 'archive.zip', size: '18 MB' },
  { name: 'brief.docx', size: '96 KB' },
];
</script>

<template>
  <AttachmentGroup aria-label="Attached files" class="nx:w-80">
    <Attachment v-for="file in files" :key="file.name" orientation="vertical">
      <AttachmentMedia :variant="file.name.endsWith('.png') ? 'image' : 'icon'">
        <img v-if="file.name.endsWith('.png')" :src="thumb" alt="" />
        <PhFilePdf v-else-if="file.name.endsWith('.pdf')" />
        <PhFileTxt v-else-if="file.name.endsWith('.txt')" />
        <PhFileZip v-else-if="file.name.endsWith('.zip')" />
        <PhFileDoc v-else />
      </AttachmentMedia>
      <AttachmentContent>
        <AttachmentTitle>{{ file.name }}</AttachmentTitle>
        <AttachmentDescription>{{ file.size }}</AttachmentDescription>
      </AttachmentContent>
    </Attachment>
  </AttachmentGroup>
</template>

Props

Attachment

Attachment props
PropTypeDefaultDescription
orientation"vertical" | "horizontal" | null"horizontal"No description
size"default" | "sm" | null"default"No description
stateAttachmentState"done"

Upload state. Screen readers announce in-progress and error states, and idle or error change the border.

statusLabelstringNo default documented

Replaces the built-in status text, such as 'Uploading'. Ignored while idle or done.

AttachmentActions

AttachmentActions adds no props of its own; it accepts the props of the element or primitive it wraps.

AttachmentContent

AttachmentContent adds no props of its own; it accepts the props of the element or primitive it wraps.

AttachmentDescription

AttachmentDescription adds no props of its own; it accepts the props of the element or primitive it wraps.

AttachmentGroup

AttachmentGroup props
PropTypeDefaultDescription
ariaLabelstringNo default documentedNo description
ariaLabelledbystringNo default documentedNo description

AttachmentMedia

AttachmentMedia props
PropTypeDefaultDescription
variant"default" | "icon" | "image" | null"default"No description

AttachmentProgress

AttachmentProgress adds no props of its own; it accepts the props of the element or primitive it wraps.

AttachmentTitle

AttachmentTitle adds no props of its own; it accepts the props of the element or primitive it wraps.

AttachmentTrigger

AttachmentTrigger props
PropTypeDefaultDescription
asChildbooleanfalse

Renders the child element instead, such as an <a> that opens the file.