Attachment

Displays a file or image attachment with media, metadata, upload state, and actions.

Files
Horizontal
sales-dashboard.pdf PDF · 2.4 MB
customer-import.csv CSV · 18 KB
message-renderer.tsx TypeScript · 12 KB
Vertical
sales-dashboard.pdf PDF · 2.4 MB
customer-import.csv CSV · 18 KB
message-renderer.tsx TypeScript · 12 KB
source-assets.zip ZIP · 4.2 MB
quarterly-review.key Keynote · 9 MB
Content Only
Title
React Documentation
Tailwind CSS
shadcn/ui
Title and Description
Building accessible components react.dev · 8 min read
Utility-first CSS framework tailwindcss.com
Compound components in React ui.shadcn.com/docs
States
Horizontal
selected-file.pdf Ready to upload
design-system.zip Uploading · 64%
market-research.pdf Processing document
financial-model.xlsx Upload failed. Try again.
uploaded-report.pdf Uploaded · 1.8 MB
Vertical
selected-file.pdf Ready
design-system.zip Uploading
market-research.pdf Processing
financial-model.xlsx Failed
uploaded-report.pdf Done
Images
Horizontal
Workspace
workspace.png PNG · 820 KB
Desk
desk-reference.jpg JPG · 1.1 MB
Office
office-reference.jpg JPG · 940 KB
Vertical
Workspace
workspace.png PNG · 820 KB
Desk
desk-reference.jpg JPG · 1.1 MB
Office
office-reference.jpg JPG · 940 KB
Outdoor patio
patio-reference.jpg JPG · 1.6 MB
House exterior
house-exterior.jpg JPG · 1.4 MB
Image States
Horizontal
Office
office-reference.jpg Ready to upload
Workspace
workspace.png Uploading · 72%
Desk
desk-reference.jpg Processing image
Office
office-reference.jpg Upload failed
Workspace
workspace.png Uploaded · 1.2 MB
Vertical
Office
office-reference.jpg Ready
Workspace
workspace.png Uploading
Desk
desk-reference.jpg Processing
Office
office-reference.jpg Failed
Workspace
workspace.png Done
Sizes
Default attachment PDF · 2.4 MB
Small attachment PDF · 2.4 MB
Extra small attachment
Scrollable Group
briefing-notes.pdf PDF · 1.4 MB
Workspace
workspace.png PNG · 820 KB
customers.csv CSV · 18 KB
renderer.tsx TSX · 12 KB
invoice.pdf PDF
Desk
desk.jpg JPG
assets.zip ZIP
Office
office.jpg JPG
notes.pdf PDF
Triggers
contract-review.pdf PDF · 820 KB
research-summary.pdf Open preview dialog

Installation

Install the dependencies

pnpm add cn class-variance-authority github:sveltery/base

Copy the component

Copy the following files into src/lib/components/ui/attachment.

It imports the button component, so copy its folder too.

src/lib/components/ui/attachment/Attachment.svelte
<script lang="ts">
  import type { AttachmentProps } from './types.js';
  import { cn } from 'cn';
  import { attachmentVariants } from './variants.js';
  let { children, class: classProp, state = 'done', size = 'default', orientation = 'horizontal', ...props }: AttachmentProps = $props();
</script>
<div data-slot="attachment" data-state={state} data-size={size} data-orientation={orientation} {...props} class={cn(attachmentVariants({ size, orientation }), classProp)}>
  {@render children?.()}
</div>
src/lib/components/ui/attachment/AttachmentAction.svelte
<script lang="ts">
  import { Button } from '../button/index.js';
  import type { AttachmentActionProps } from './types.js';
  import { cn } from 'cn';
  let { children, class: classProp, variant, size = 'icon-xs', ...props }: AttachmentActionProps = $props();
</script>
<Button data-slot="attachment-action" {...props} variant={variant ?? 'ghost'} {size} {children} class={cn('', classProp)} />
src/lib/components/ui/attachment/AttachmentActions.svelte
<script lang="ts">
  import type { AttachmentActionsProps } from './types.js';
  import { cn } from 'cn';
  let { children, class: classProp, ...props }: AttachmentActionsProps = $props();
</script>
<div data-slot="attachment-actions" {...props} class={cn('group-data-[orientation=vertical]/attachment:absolute group-data-[orientation=vertical]/attachment:top-3 group-data-[orientation=vertical]/attachment:right-3 relative z-20 group-data-[orientation=vertical]/attachment:gap-1 flex shrink-0 items-center', classProp)}>
  {@render children?.()}
</div>
src/lib/components/ui/attachment/AttachmentContent.svelte
<script lang="ts">
  import type { AttachmentContentProps } from './types.js';
  import { cn } from 'cn';
  let { children, class: classProp, ...props }: AttachmentContentProps = $props();
</script>
<div data-slot="attachment-content" {...props} class={cn('leading-tight group-data-[orientation=vertical]/attachment:px-1 max-w-full min-w-0 flex-1', classProp)}>
  {@render children?.()}
</div>
src/lib/components/ui/attachment/AttachmentDescription.svelte
<script lang="ts">
  import type { AttachmentDescriptionProps } from './types.js';
  import { cn } from 'cn';
  let { children, class: classProp, ...props }: AttachmentDescriptionProps = $props();
</script>
<span data-slot="attachment-description" {...props} class={cn('mt-0.5 text-xs block min-w-0 truncate text-muted-foreground group-data-[state=error]/attachment:text-destructive/80', 'max-w-full', classProp)}>
  {@render children?.()}
</span>
src/lib/components/ui/attachment/AttachmentGroup.svelte
<script lang="ts">
  import type { AttachmentGroupProps } from './types.js';
  import { cn } from 'cn';
  let { children, class: classProp, ...props }: AttachmentGroupProps = $props();
</script>
<div data-slot="attachment-group" {...props} class={cn('gap-3 scroll-px-1 py-1 flex min-w-0 scroll-fade-x snap-x snap-mandatory scrollbar-none overflow-x-auto overscroll-x-contain *:data-[slot=attachment]:flex-none *:data-[slot=attachment]:snap-start', classProp)}>
  {@render children?.()}
</div>
src/lib/components/ui/attachment/AttachmentMedia.svelte
<script lang="ts">
  import type { AttachmentMediaProps } from './types.js';
  import { cn } from 'cn';
  import { attachmentMediaVariants } from './variants.js';
  let { children, class: classProp, variant = 'icon', ...props }: AttachmentMediaProps = $props();
</script>
<div data-slot="attachment-media" data-variant={variant} {...props} class={cn(attachmentMediaVariants({ variant }), classProp)}>
  {@render children?.()}
</div>
src/lib/components/ui/attachment/AttachmentTitle.svelte
<script lang="ts">
  import type { AttachmentTitleProps } from './types.js';
  import { cn } from 'cn';
  let { children, class: classProp, ...props }: AttachmentTitleProps = $props();
</script>
<span data-slot="attachment-title" {...props} class={cn('font-medium block max-w-full min-w-0 truncate group-data-[state=processing]/attachment:shimmer group-data-[state=uploading]/attachment:shimmer', classProp)}>
  {@render children?.()}
</span>
src/lib/components/ui/attachment/AttachmentTrigger.svelte
<script lang="ts">
  import type { AttachmentTriggerProps, AttachmentTriggerState } from './types.js';
  import { cn } from 'cn';
  let { children, class: classProp, render, type, ...props }: AttachmentTriggerProps = $props();
  const state: AttachmentTriggerState = { slot: 'attachment-trigger' };
  const hostProps = $derived({
    'data-slot': state.slot,
    type: render ? type : (type ?? 'button'),
    ...props,
    class: cn('absolute inset-0 z-10 outline-none', classProp),
  });
</script>
{#if render}
  {@render render(hostProps, state, children)}
{:else}
  <button {...hostProps}>{@render children?.()}</button>
{/if}
src/lib/components/ui/attachment/types.ts
import type { HTMLAttributes } from 'svelte/elements';
import type { ButtonProps } from '../button/index.js';
import type { RenderSnippet } from '../shared/render.js';
import type { AttachmentMediaVariants, AttachmentVariants } from './variants.js';

type NativeDivProps = HTMLAttributes<HTMLDivElement>;
type NativeSpanProps = HTMLAttributes<HTMLSpanElement>;

export type AttachmentStatus = 'idle' | 'uploading' | 'processing' | 'error' | 'done';
export type AttachmentProps = NativeDivProps & AttachmentVariants & { state?: AttachmentStatus };
export type AttachmentGroupProps = NativeDivProps;
export type AttachmentMediaProps = NativeDivProps & AttachmentMediaVariants;
export type AttachmentContentProps = NativeDivProps;
export type AttachmentTitleProps = NativeSpanProps;
export type AttachmentDescriptionProps = NativeSpanProps;
export type AttachmentActionsProps = NativeDivProps;
export type AttachmentActionProps = ButtonProps;
export type AttachmentTriggerState = { slot: 'attachment-trigger' };
export type AttachmentTriggerProps = HTMLAttributes<HTMLElement> & {
  type?: 'button' | 'submit' | 'reset' | null;
  render?: RenderSnippet<AttachmentTriggerState>;
};
src/lib/components/ui/attachment/variants.ts
// Adapted from shadcn-ui/ui d75a96ab; MIT: packages/ui/THIRD_PARTY_NOTICES.md.
import { cva } from 'class-variance-authority';
import type { VariantProps } from 'class-variance-authority';
export const attachmentVariants = cva('rounded-xl w-fit focus-within:ring-1 focus-within:ring-ring/50 group/attachment relative flex max-w-full min-w-0 shrink-0 flex-wrap border bg-card text-card-foreground transition-colors has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed', {
  variants: {
    size: { default: 'gap-2 has-data-[slot=attachment-content]:px-2.5 has-data-[slot=attachment-content]:py-2 has-data-[slot=attachment-media]:p-2 text-sm', sm: 'gap-2.5 has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5 has-data-[slot=attachment-media]:p-1.5 text-xs', xs: 'gap-1.5 has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1 text-xs rounded-lg' },
    orientation: { horizontal: 'min-w-40 items-center', vertical: 'w-24 has-data-[slot=attachment-content]:w-30 flex-col' },
  },
});
export const attachmentMediaVariants = cva("bg-muted text-foreground w-10 rounded-lg group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-md [&_svg:not([class*='size-'])]:size-4 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5 group-data-[orientation=vertical]/attachment:w-full group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! relative flex aspect-square shrink-0 items-center justify-center overflow-hidden group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive [&_svg]:pointer-events-none", {
  variants: {
    variant: {
      icon: '',
      image: 'opacity-60 group-data-[state=idle]/attachment:opacity-100 group-data-[state=done]/attachment:opacity-100 *:[img]:aspect-square *:[img]:w-full *:[img]:object-cover',
    },
  },
  defaultVariants: { variant: 'icon' },
});
export type AttachmentVariants = VariantProps<typeof attachmentVariants>;
export type AttachmentMediaVariants = VariantProps<typeof attachmentMediaVariants>;
src/lib/components/ui/shared/render.ts
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';

/**
 * Replaces a part's default element, like the `render` snippets of Sveltery Base.
 * Spread `props` onto the host element and render `children` inside it.
 * `Host` is the element the part renders by default, so its typed event handlers carry over.
 */
export type RenderSnippet<State, Host extends HTMLElement = HTMLElement> = Snippet<[props: HTMLAttributes<Host>, state: State, children: Snippet | undefined]>;
src/lib/components/ui/attachment/index.ts
export { default as Attachment } from './Attachment.svelte';
export { default as AttachmentGroup } from './AttachmentGroup.svelte';
export { default as AttachmentMedia } from './AttachmentMedia.svelte';
export { default as AttachmentContent } from './AttachmentContent.svelte';
export { default as AttachmentTitle } from './AttachmentTitle.svelte';
export { default as AttachmentDescription } from './AttachmentDescription.svelte';
export { default as AttachmentActions } from './AttachmentActions.svelte';
export { default as AttachmentAction } from './AttachmentAction.svelte';
export { default as AttachmentTrigger } from './AttachmentTrigger.svelte';
export { attachmentVariants, attachmentMediaVariants } from './variants.js';
export type { AttachmentVariants, AttachmentMediaVariants } from './variants.js';
export type {
  AttachmentProps, AttachmentStatus, AttachmentGroupProps, AttachmentMediaProps, AttachmentContentProps, AttachmentTitleProps,
  AttachmentDescriptionProps, AttachmentActionsProps, AttachmentActionProps, AttachmentTriggerProps, AttachmentTriggerState,
} from './types.js';

Update the import paths

Change the imports to match your project setup.

Usage

<script lang="ts">
  import { Attachment, AttachmentAction, AttachmentActions, AttachmentContent, AttachmentDescription, AttachmentMedia, AttachmentTitle } from '$lib/components/ui/attachment';
</script>
<Attachment state="uploading">
  <AttachmentMedia>
    <FileIcon />
  </AttachmentMedia>
  <AttachmentContent>
    <AttachmentTitle>report.pdf</AttachmentTitle>
    <AttachmentDescription>Uploading, 2.4 MB</AttachmentDescription>
  </AttachmentContent>
  <AttachmentActions>
    <AttachmentAction aria-label="Remove attachment">
      <XIcon />
    </AttachmentAction>
  </AttachmentActions>
</Attachment>

state is one of idle, uploading, processing, error or done, and sets data-state for styling.

Trigger

AttachmentTrigger covers the whole attachment with a button, so a click anywhere opens a preview. Pass a render snippet to make it a link or another component's trigger. Spread props onto it.

<AttachmentTrigger aria-label="Preview report.pdf" onclick={openPreview} />