rapidUI

Attachment

File attachment chip with name, size and remove action, for chat and forms.

Installation

pnpm dlx shadcn@latest add https://rdf.dev.360online.com/r/attachment.json

Installs to @/components/ui/attachment.

Manual installation

Install the dependencies:

pnpm add @base-ui/react@^1.8.0 class-variance-authority@^0.7.1

Add the components it builds on: button.

Copy the source into your project:

components/ui/attachment.tsx
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