Displays a file or image attachment with media, metadata, upload state, and actions.
Installation
Install the dependencies
pnpm add cn class-variance-authority github:sveltery/baseCopy the component
Copy the following files into src/lib/components/ui/attachment.
It imports the button component, so copy its folder too.
<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><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)} /><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><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><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><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><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><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><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}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>;
};// 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>;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]>;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} />