Installation
pnpm dlx shadcn@latest add https://rdf.dev.360online.com/r/attachment.jsonInstalls to @/components/ui/attachment.
Manual installation
Install the dependencies:
pnpm add @base-ui/react@^1.8.0 class-variance-authority@^0.7.1Add the components it builds on: button.
Copy the source into your project:
import { mergeProps } from '@base-ui/react/merge-props'
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-xl border bg-card text-card-foreground transition-colors focus-within:ring-2 focus-within:ring-ring has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive-border data-[state=idle]:border-dashed',
{
variants: {
size: {
default:
'gap-2 text-sm has-data-[slot=attachment-content]:px-2.5 has-data-[slot=attachment-content]:py-2 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',
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',
},
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 aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-foreground 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-md group-data-[state=error]/attachment:bg-destructive-subtle group-data-[state=error]/attachment:text-destructive-subtle-foreground 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: '',
image:
'opacity-60 group-data-[state=done]/attachment:opacity-100 group-data-[state=idle]/attachment:opacity-100 *:[img]:aspect-square *:[img]:w-full *:[img]:object-cover',
},
},
defaultVariants: {
variant: 'icon',
},
},
)
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 leading-tight group-data-[orientation=vertical]/attachment:px-1',
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 font-medium group-data-[state=processing]/attachment:shimmer group-data-[state=uploading]/attachment:shimmer',
className,
)}
{...props}
/>
)
}
function AttachmentDescription({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="attachment-description"
className={cn(
'mt-0.5 block min-w-0 truncate text-xs text-muted-foreground group-data-[state=error]/attachment:text-destructive-subtle-foreground',
'max-w-full',
className,
)}
{...props}
/>
)
}
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:right-3 group-data-[orientation=vertical]/attachment:gap-1',
className,
)}
{...props}
/>
)
}
function AttachmentAction({ className, variant, size = 'icon-xs', ...props }: React.ComponentProps<typeof Button>) {
return (
<Button data-slot="attachment-action" variant={variant ?? 'ghost'} size={size} className={cn(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 min-w-0 scroll-fade-x snap-x snap-mandatory scroll-px-1 scrollbar-none gap-3 overflow-x-auto overscroll-x-contain py-1 *:data-[slot=attachment]:flex-none *:data-[slot=attachment]:snap-start',
className,
)}
{...props}
/>
)
}
export {
Attachment,
AttachmentGroup,
AttachmentMedia,
AttachmentContent,
AttachmentTitle,
AttachmentDescription,
AttachmentActions,
AttachmentAction,
AttachmentTrigger,
}Displays a file attached to a message, form or case, with media, title, description and actions. It communicates upload state (idle, uploading, processing, error and done), comes in three sizes and two orientations, and can be grouped into a horizontally scrolling row.
Features
- Five states: idle, uploading, processing, error and done
- Sizes: default, sm and xs
- Horizontal and vertical orientation
- Icon or image media
- Inline actions such as remove, retry or download
- AttachmentTrigger makes the whole attachment clickable, e.g. to open a preview dialog
- AttachmentGroup for a scroll-snapping row of attachments
Usage
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
} from '@/components/ui/attachment'
import { FileText, X } from 'lucide-react'
export function ApplicationAttachment() {
return (
<Attachment state="done">
<AttachmentMedia>
<FileText />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>building-permit-application.pdf</AttachmentTitle>
<AttachmentDescription>PDF · 2.4 MB</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Remove building-permit-application.pdf">
<X />
</AttachmentAction>
</AttachmentActions>
</Attachment>
)
}Examples
Default
A completed attachment with a remove action.
States
All five upload states, from an idle drop target to a finished file.
Sizes
The default, sm and xs sizes.
Vertical
Vertical orientation for grid or gallery layouts.
Image Media
Thumbnails via variant="image" on AttachmentMedia. Put an img inside; the example uses a token-based
placeholder instead of a photo.
Group
AttachmentGroup lays attachments out in a scroll-snapping row, here inside a card where a resident sends a supplement
to a building permit case.
With Trigger
The whole attachment opens a preview dialog through AttachmentTrigger.
Accessibility
- Give every icon-only AttachmentAction an aria-label that names the file
- AttachmentTrigger renders a button covering the attachment; give it an aria-label describing the action
- Actions are layered above the trigger so they remain individually focusable
- Error state is conveyed by colour and by the description text, not colour alone