Attachment
Show files as rows, cards or chips with a preview, size, actions and upload progress.
New1'use client';23import { Attachment } from '@/components/ui/attachment';45const files = [6 { name: 'q3-board-deck.pdf', type: 'application/pdf', size: 4_820_000 },7 { name: 'hero-final.png', type: 'image/png', size: 1_240_000 },8 { name: 'source-assets.zip', type: 'application/zip', size: 58_300_000 },9];1011export function Default() {12 return (13 <Attachment.List className="w-full max-w-sm">14 {files.map((file) => (15 <Attachment.Item key={file.name}>16 <Attachment>17 <Attachment.Preview type={file.type} name={file.name} />18 <Attachment.Content>19 <Attachment.Name>{file.name}</Attachment.Name>20 <Attachment.Meta>21 <Attachment.Size bytes={file.size} />22 </Attachment.Meta>23 </Attachment.Content>24 <Attachment.Actions>25 <Attachment.Download href="#" />26 <Attachment.Remove />27 </Attachment.Actions>28 </Attachment>29 </Attachment.Item>30 ))}31 </Attachment.List>32 );33}
Installation
Copy and paste the following code into your project.
1'use client';23import * as React from 'react';4import { cn } from '../lib/cn';56type IconProps = React.SVGProps<SVGSVGElement> & {7 size?: number | string;8};910function ArchiveIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {11 return (12 <svg13 xmlns="http://www.w3.org/2000/svg"14 width={size}15 height={size}16 viewBox="0 0 24 24"17 fill="none"18 stroke="currentColor"19 strokeWidth={strokeWidth}20 strokeLinecap="round"21 strokeLinejoin="round"22 aria-hidden="true"23 {...props}24 >25 <rect x="2.5" y="3.5" width="19" height="5.5" rx="2" />26 <path d="M4 9v8.5a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V9" />27 <path d="M10 13.5h4" />28 </svg>29 );30}3132function DownloadIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {33 return (34 <svg35 xmlns="http://www.w3.org/2000/svg"36 width={size}37 height={size}38 viewBox="0 0 24 24"39 fill="none"40 stroke="currentColor"41 strokeWidth={strokeWidth}42 strokeLinecap="round"43 strokeLinejoin="round"44 aria-hidden="true"45 {...props}46 >47 <path d="M12 3v11" />48 <path d="m8 10.5 4 4 4-4" />49 <path d="M3.5 15v1.5A3.5 3.5 0 0 0 7 20h10a3.5 3.5 0 0 0 3.5-3.5V15" />50 </svg>51 );52}5354function FileIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {55 return (56 <svg57 xmlns="http://www.w3.org/2000/svg"58 width={size}59 height={size}60 viewBox="0 0 24 24"61 fill="none"62 stroke="currentColor"63 strokeWidth={strokeWidth}64 strokeLinecap="round"65 strokeLinejoin="round"66 aria-hidden="true"67 {...props}68 >69 <path d="M13.5 3H7.5A2.5 2.5 0 0 0 5 5.5v13A2.5 2.5 0 0 0 7.5 21h9a2.5 2.5 0 0 0 2.5-2.5V8.5L13.5 3Z" />70 <path d="M13.5 3v3.5a2 2 0 0 0 2 2H19" />71 </svg>72 );73}7475function FileTextIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {76 return (77 <svg78 xmlns="http://www.w3.org/2000/svg"79 width={size}80 height={size}81 viewBox="0 0 24 24"82 fill="none"83 stroke="currentColor"84 strokeWidth={strokeWidth}85 strokeLinecap="round"86 strokeLinejoin="round"87 aria-hidden="true"88 {...props}89 >90 <path d="M13.5 3H7.5A2.5 2.5 0 0 0 5 5.5v13A2.5 2.5 0 0 0 7.5 21h9a2.5 2.5 0 0 0 2.5-2.5V8.5L13.5 3Z" />91 <path d="M13.5 3v3.5a2 2 0 0 0 2 2H19" />92 <path d="M8.5 13h7" />93 <path d="M8.5 17h4" />94 </svg>95 );96}9798function ImageIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {99 return (100 <svg101 xmlns="http://www.w3.org/2000/svg"102 width={size}103 height={size}104 viewBox="0 0 24 24"105 fill="none"106 stroke="currentColor"107 strokeWidth={strokeWidth}108 strokeLinecap="round"109 strokeLinejoin="round"110 aria-hidden="true"111 {...props}112 >113 <rect x="2.5" y="3.5" width="19" height="17" rx="3.5" />114 <circle cx="8.5" cy="9" r="2" />115 <path d="m21.5 14.5-3.4-3.4a2 2 0 0 0-2.8 0L7 19.4" />116 </svg>117 );118}119120function MusicIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {121 return (122 <svg123 xmlns="http://www.w3.org/2000/svg"124 width={size}125 height={size}126 viewBox="0 0 24 24"127 fill="none"128 stroke="currentColor"129 strokeWidth={strokeWidth}130 strokeLinecap="round"131 strokeLinejoin="round"132 aria-hidden="true"133 {...props}134 >135 <circle cx="6.5" cy="17.5" r="3" />136 <circle cx="17.5" cy="15.5" r="3" />137 <path d="M9.5 17.5V7.2a2 2 0 0 1 1.5-1.9l8-2a2 2 0 0 1 1.5 1.9v10.3" />138 </svg>139 );140}141142function VideoIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {143 return (144 <svg145 xmlns="http://www.w3.org/2000/svg"146 width={size}147 height={size}148 viewBox="0 0 24 24"149 fill="none"150 stroke="currentColor"151 strokeWidth={strokeWidth}152 strokeLinecap="round"153 strokeLinejoin="round"154 aria-hidden="true"155 {...props}156 >157 <rect x="2.5" y="5.5" width="13" height="13" rx="3" />158 <path d="m15.5 10 4.4-2.6a1 1 0 0 1 1.6.9v7.4a1 1 0 0 1-1.6.9L15.5 14" />159 </svg>160 );161}162163function XIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {164 return (165 <svg166 xmlns="http://www.w3.org/2000/svg"167 width={size}168 height={size}169 viewBox="0 0 24 24"170 fill="none"171 stroke="currentColor"172 strokeWidth={strokeWidth}173 strokeLinecap="round"174 strokeLinejoin="round"175 aria-hidden="true"176 {...props}177 >178 <path d="M18 6 6 18" />179 <path d="m6 6 12 12" />180 </svg>181 );182}183184type AttachmentVariant = 'row' | 'card' | 'chip';185type AttachmentStatus = 'idle' | 'uploading' | 'done' | 'error';186187interface AttachmentContextValue {188 variant: AttachmentVariant;189 status: AttachmentStatus;190 nameId: string | undefined;191 registerName: (id: string) => () => void;192}193194const AttachmentContext = React.createContext<AttachmentContextValue | null>(null);195196function useAttachmentContext(): AttachmentContextValue {197 const context = React.use(AttachmentContext);198 if (!context) throw new Error('Attachment parts must be used within Attachment');199 return context;200}201202const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const;203204function formatFileSize(bytes: number, decimals = 1): string {205 if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';206 const exponent = Math.min(207 Math.max(0, Math.floor(Math.log(bytes) / Math.log(1024))),208 SIZE_UNITS.length - 1,209 );210 const value = bytes / 1024 ** exponent;211 const rounded = exponent === 0 ? value : Number(value.toFixed(decimals));212 return `${rounded} ${SIZE_UNITS[exponent]}`;213}214215const TYPE_ICONS = {216 image: ImageIcon,217 video: VideoIcon,218 audio: MusicIcon,219 document: FileTextIcon,220 archive: ArchiveIcon,221 file: FileIcon,222} satisfies Record<string, React.ComponentType<IconProps>>;223224function kindForType(type: string | undefined, name: string | undefined): keyof typeof TYPE_ICONS {225 const mime = (type ?? '').toLowerCase();226 const extension = (name ?? '').toLowerCase().split('.').pop() ?? '';227228 if (mime.startsWith('image/')) return 'image';229 if (mime.startsWith('video/')) return 'video';230 if (mime.startsWith('audio/')) return 'audio';231 if (mime === 'application/pdf' || extension === 'pdf') return 'document';232 if (233 mime.includes('zip') ||234 mime.includes('compressed') ||235 ['zip', 'rar', '7z', 'gz', 'tar'].includes(extension)236 ) {237 return 'archive';238 }239 return 'file';240}241242function AttachmentTypeIcon({ type, name }: { type?: string; name?: string }) {243 const Icon = TYPE_ICONS[kindForType(type, name)];244 return <Icon aria-hidden="true" />;245}246247interface AttachmentStatusLabels {248 uploading: string;249 error: string;250}251252const DEFAULT_STATUS_LABELS: AttachmentStatusLabels = {253 uploading: 'Uploading',254 error: 'Upload failed',255};256257interface AttachmentProps extends React.HTMLAttributes<HTMLDivElement> {258 variant?: AttachmentVariant;259 status?: AttachmentStatus;260 statusLabels?: Partial<AttachmentStatusLabels>;261}262263const AttachmentRoot = ({264 variant = 'row',265 status = 'idle',266 statusLabels,267 className,268 children,269 ref,270 ...props271}: AttachmentProps & { ref?: React.Ref<HTMLDivElement> }) => {272 const [nameId, setNameId] = React.useState<string | undefined>(undefined);273274 const registerName = React.useCallback((id: string) => {275 setNameId(id);276 return () => setNameId((current) => (current === id ? undefined : current));277 }, []);278279 const context = React.useMemo(280 () => ({ variant, status, nameId, registerName }),281 [variant, status, nameId, registerName],282 );283284 const labels = { ...DEFAULT_STATUS_LABELS, ...statusLabels };285 const statusText =286 status === 'uploading' ? labels.uploading : status === 'error' ? labels.error : '';287288 return (289 <AttachmentContext value={context}>290 <div291 ref={ref}292 data-slot="attachment"293 data-variant={variant}294 data-status={status}295 aria-busy={status === 'uploading' || undefined}296 className={cn(297 'group/attachment border-border bg-card text-card-foreground relative overflow-hidden border transition-colors',298 'data-[status=error]:border-destructive/60 data-[status=uploading]:text-muted-foreground',299 variant === 'row' && 'flex items-center gap-3 rounded-lg p-2.5 pr-2',300 variant === 'card' && 'flex w-40 flex-col rounded-xl',301 variant === 'chip' &&302 'inline-flex h-8 max-w-60 items-center gap-1.5 rounded-full py-1 pr-1 pl-1.5',303 className,304 )}305 {...props}306 >307 {children}308 <span role="status" className="sr-only">309 {statusText}310 </span>311 </div>312 </AttachmentContext>313 );314};315AttachmentRoot.displayName = 'Attachment';316317interface AttachmentPreviewProps extends React.HTMLAttributes<HTMLDivElement> {318 src?: string;319 type?: string;320 name?: string;321 alt?: string;322}323324const AttachmentPreview = ({325 src,326 type,327 name,328 alt,329 className,330 children,331 ref,332 ...props333}: AttachmentPreviewProps & { ref?: React.Ref<HTMLDivElement> }) => {334 const { variant } = useAttachmentContext();335 const isImage = Boolean(src);336337 return (338 <div339 ref={ref}340 data-slot="attachment-preview"341 data-kind={isImage ? 'image' : 'icon'}342 className={cn(343 'bg-muted text-muted-foreground flex shrink-0 items-center justify-center overflow-hidden',344 variant === 'row' && 'size-10 rounded-md [&_svg]:size-5',345 variant === 'card' && 'aspect-square w-full [&_svg]:size-8',346 variant === 'chip' && 'size-6 rounded-full [&_svg]:size-3.5',347 className,348 )}349 {...props}350 >351 {children ??352 (isImage ? (353 <img src={src} alt={alt ?? name ?? ''} className="size-full object-cover" />354 ) : (355 <AttachmentTypeIcon type={type} name={name} />356 ))}357 </div>358 );359};360AttachmentPreview.displayName = 'AttachmentPreview';361362const AttachmentContent = ({363 className,364 ref,365 ...props366}: React.HTMLAttributes<HTMLDivElement> & { ref?: React.Ref<HTMLDivElement> }) => {367 const { variant } = useAttachmentContext();368369 return (370 <div371 ref={ref}372 data-slot="attachment-content"373 className={cn(374 'flex min-w-0 flex-1 flex-col',375 variant === 'row' && 'gap-0.5',376 variant === 'card' && 'gap-0.5 p-2.5',377 variant === 'chip' && 'flex-row items-baseline gap-1',378 className,379 )}380 {...props}381 />382 );383};384AttachmentContent.displayName = 'AttachmentContent';385386const AttachmentName = ({387 className,388 id,389 ref,390 ...props391}: React.HTMLAttributes<HTMLParagraphElement> & { ref?: React.Ref<HTMLParagraphElement> }) => {392 const { variant, registerName } = useAttachmentContext();393 const generatedId = React.useId();394 const nameId = id ?? generatedId;395396 React.useEffect(() => registerName(nameId), [nameId, registerName]);397398 return (399 <p400 ref={ref}401 id={nameId}402 data-slot="attachment-name"403 className={cn('truncate font-medium', variant === 'chip' ? 'text-xs' : 'text-sm', className)}404 {...props}405 />406 );407};408AttachmentName.displayName = 'AttachmentName';409410const AttachmentMeta = ({411 className,412 ref,413 ...props414}: React.HTMLAttributes<HTMLParagraphElement> & { ref?: React.Ref<HTMLParagraphElement> }) => (415 <p416 ref={ref}417 data-slot="attachment-meta"418 className={cn('text-muted-foreground truncate text-xs', className)}419 {...props}420 />421);422AttachmentMeta.displayName = 'AttachmentMeta';423424interface AttachmentSizeProps extends React.HTMLAttributes<HTMLSpanElement> {425 bytes: number;426 decimals?: number;427}428429const AttachmentSize = ({430 bytes,431 decimals,432 className,433 ref,434 ...props435}: AttachmentSizeProps & { ref?: React.Ref<HTMLSpanElement> }) => (436 <span437 ref={ref}438 data-slot="attachment-size"439 className={cn('text-muted-foreground text-xs tabular-nums', className)}440 {...props}441 >442 {formatFileSize(bytes, decimals)}443 </span>444);445AttachmentSize.displayName = 'AttachmentSize';446447const AttachmentActions = ({448 className,449 ref,450 ...props451}: React.HTMLAttributes<HTMLDivElement> & { ref?: React.Ref<HTMLDivElement> }) => {452 const { variant } = useAttachmentContext();453454 return (455 <div456 ref={ref}457 data-slot="attachment-actions"458 className={cn(459 'flex shrink-0 items-center gap-0.5',460 variant === 'card' &&461 'absolute top-1.5 right-1.5 transition-opacity group-focus-within/attachment:opacity-100 group-hover/attachment:opacity-100 [@media(hover:hover)]:opacity-0',462 className,463 )}464 {...props}465 />466 );467};468AttachmentActions.displayName = 'AttachmentActions';469470function joinIds(...ids: (string | undefined)[]) {471 return ids.filter(Boolean).join(' ') || undefined;472}473474const actionClassName =475 'text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:ring-ring relative inline-flex shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-40 pointer-coarse:before:absolute';476477interface AttachmentRemoveProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {478 label?: string;479}480481const AttachmentRemove = ({482 label = 'Remove attachment',483 className,484 children,485 'aria-describedby': describedBy,486 ref,487 ...props488}: AttachmentRemoveProps & { ref?: React.Ref<HTMLButtonElement> }) => {489 const { variant, nameId } = useAttachmentContext();490491 return (492 <button493 ref={ref}494 type="button"495 aria-label={label}496 aria-describedby={joinIds(nameId, describedBy)}497 data-slot="attachment-remove"498 className={cn(499 actionClassName,500 variant === 'chip'501 ? 'size-6 pointer-coarse:before:-inset-1.5 [&_svg]:size-3'502 : 'size-7 pointer-coarse:before:-inset-0.5 [&_svg]:size-3.5',503 variant === 'card' && 'bg-background/80 backdrop-blur-sm',504 className,505 )}506 {...props}507 >508 {children ?? <XIcon aria-hidden="true" />}509 </button>510 );511};512AttachmentRemove.displayName = 'AttachmentRemove';513514interface AttachmentDownloadProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {515 label?: string;516}517518const AttachmentDownload = ({519 label = 'Download attachment',520 className,521 children,522 'aria-describedby': describedBy,523 ref,524 ...props525}: AttachmentDownloadProps & { ref?: React.Ref<HTMLAnchorElement> }) => {526 const { variant, nameId } = useAttachmentContext();527528 return (529 <a530 ref={ref}531 download532 aria-label={label}533 aria-describedby={joinIds(nameId, describedBy)}534 data-slot="attachment-download"535 className={cn(536 actionClassName,537 variant === 'chip'538 ? 'size-6 pointer-coarse:before:-inset-1.5 [&_svg]:size-3'539 : 'size-7 pointer-coarse:before:-inset-0.5 [&_svg]:size-3.5',540 variant === 'card' && 'bg-background/80 backdrop-blur-sm',541 className,542 )}543 {...props}544 >545 {children ?? <DownloadIcon aria-hidden="true" />}546 </a>547 );548};549AttachmentDownload.displayName = 'AttachmentDownload';550551interface AttachmentProgressProps extends React.HTMLAttributes<HTMLDivElement> {552 value: number;553}554555const AttachmentProgress = ({556 value,557 className,558 'aria-label': ariaLabel,559 'aria-labelledby': ariaLabelledBy,560 ref,561 ...props562}: AttachmentProgressProps & { ref?: React.Ref<HTMLDivElement> }) => {563 const nameId = React.use(AttachmentContext)?.nameId;564 const clamped = Math.min(Math.max(value, 0), 100);565 const labelledBy = ariaLabelledBy ?? (ariaLabel ? undefined : nameId);566567 return (568 <div569 ref={ref}570 role="progressbar"571 aria-label={labelledBy ? ariaLabel : (ariaLabel ?? 'Upload progress')}572 aria-labelledby={labelledBy}573 aria-valuemin={0}574 aria-valuemax={100}575 aria-valuenow={Math.round(clamped)}576 data-slot="attachment-progress"577 className={cn('bg-muted absolute inset-x-0 bottom-0 h-0.5', className)}578 {...props}579 >580 <div581 className="bg-primary h-full transition-[width] duration-300"582 style={{ width: `${clamped}%` }}583 />584 </div>585 );586};587AttachmentProgress.displayName = 'AttachmentProgress';588589interface AttachmentListProps extends React.HTMLAttributes<HTMLUListElement> {590 layout?: 'stack' | 'grid' | 'inline';591}592593const AttachmentList = ({594 layout = 'stack',595 className,596 ref,597 ...props598}: AttachmentListProps & { ref?: React.Ref<HTMLUListElement> }) => (599 <ul600 ref={ref}601 data-slot="attachment-list"602 data-layout={layout}603 className={cn(604 'm-0 list-none p-0',605 layout === 'stack' && 'flex flex-col gap-2',606 layout === 'grid' && 'flex flex-wrap gap-3',607 layout === 'inline' && 'flex flex-wrap items-center gap-1.5',608 className,609 )}610 {...props}611 />612);613AttachmentList.displayName = 'AttachmentList';614615const AttachmentItem = ({616 className,617 ref,618 ...props619}: React.LiHTMLAttributes<HTMLLIElement> & { ref?: React.Ref<HTMLLIElement> }) => (620 <li ref={ref} data-slot="attachment-item" className={cn('min-w-0', className)} {...props} />621);622AttachmentItem.displayName = 'AttachmentItem';623624const Attachment = Object.assign(AttachmentRoot, {625 Preview: AttachmentPreview,626 Content: AttachmentContent,627 Name: AttachmentName,628 Meta: AttachmentMeta,629 Size: AttachmentSize,630 Actions: AttachmentActions,631 Remove: AttachmentRemove,632 Download: AttachmentDownload,633 Progress: AttachmentProgress,634 List: AttachmentList,635 Item: AttachmentItem,636});637638export { Attachment, formatFileSize, useAttachmentContext };639export type {640 AttachmentProps,641 AttachmentStatusLabels,642 AttachmentVariant,643 AttachmentStatus,644 AttachmentPreviewProps,645 AttachmentSizeProps,646 AttachmentProgressProps,647 AttachmentListProps,648 AttachmentRemoveProps,649 AttachmentDownloadProps,650};
Update the import paths to match your project structure.
Anatomy
1import { Attachment } from '@/components/ui/attachment';
1<Attachment>2 <Attachment.Preview type="application/pdf" name="deck.pdf" />3 <Attachment.Content>4 <Attachment.Name>deck.pdf</Attachment.Name>5 <Attachment.Meta>6 <Attachment.Size bytes={4820000} />7 </Attachment.Meta>8 </Attachment.Content>9 <Attachment.Actions>10 <Attachment.Download href="/files/deck.pdf" />11 <Attachment.Remove onClick={remove} />12 </Attachment.Actions>13</Attachment>
Attachment is display only. It does not know how the file got there, so it fits an upload queue, a message composer, a comment thread or a list of files already on the server. Pair it with File Upload when you need the picker and the validation.Composition
Attachment.Listโโโ Attachment.Itemโโโ Attachmentโโโ Attachment.Previewโโโ Attachment.Contentโ โโโ Attachment.Nameโ โโโ Attachment.Metaโ โโโ Attachment.Sizeโโโ Attachment.Actionsโ โโโ Attachment.Downloadโ โโโ Attachment.Removeโโโ Attachment.Progress
Variants
Rows
The default
row variant, a stacked list with a type icon, name, size and actions.1'use client';23import { Attachment } from '@/components/ui/attachment';45const files = [6 { name: 'q3-board-deck.pdf', type: 'application/pdf', size: 4_820_000 },7 { name: 'hero-final.png', type: 'image/png', size: 1_240_000 },8 { name: 'source-assets.zip', type: 'application/zip', size: 58_300_000 },9];1011export function Default() {12 return (13 <Attachment.List className="w-full max-w-sm">14 {files.map((file) => (15 <Attachment.Item key={file.name}>16 <Attachment>17 <Attachment.Preview type={file.type} name={file.name} />18 <Attachment.Content>19 <Attachment.Name>{file.name}</Attachment.Name>20 <Attachment.Meta>21 <Attachment.Size bytes={file.size} />22 </Attachment.Meta>23 </Attachment.Content>24 <Attachment.Actions>25 <Attachment.Download href="#" />26 <Attachment.Remove />27 </Attachment.Actions>28 </Attachment>29 </Attachment.Item>30 ))}31 </Attachment.List>32 );33}
Cards
variant="card" for images. The preview fills the top, and the actions appear on hover or focus.office-lobby.jpg
2 MB
team-offsite.jpg
2.9 MB
launch-day.jpg
1.8 MB
1'use client';23import { Attachment } from '@/components/ui/attachment';45const photos = [6 { name: 'office-lobby.jpg', size: 2_140_000, src: 'https://picsum.photos/seed/lobby/320/320' },7 { name: 'team-offsite.jpg', size: 3_020_000, src: 'https://picsum.photos/seed/offsite/320/320' },8 { name: 'launch-day.jpg', size: 1_870_000, src: 'https://picsum.photos/seed/launch/320/320' },9];1011export function Cards() {12 return (13 <Attachment.List layout="grid">14 {photos.map((photo) => (15 <Attachment.Item key={photo.name}>16 <Attachment variant="card">17 <Attachment.Preview src={photo.src} name={photo.name} />18 <Attachment.Content>19 <Attachment.Name>{photo.name}</Attachment.Name>20 <Attachment.Meta>21 <Attachment.Size bytes={photo.size} />22 </Attachment.Meta>23 </Attachment.Content>24 <Attachment.Actions>25 <Attachment.Remove />26 </Attachment.Actions>27 </Attachment>28 </Attachment.Item>29 ))}30 </Attachment.List>31 );32}
Chips
variant="chip" is a compact pill for composers and comment boxes.checkout-spec.md
18 KBpayment-flow.png
392.6 KBstripe-webhook.log
93.8 KB
1'use client';23import * as React from 'react';4import { Attachment } from '@/components/ui/attachment';5import { Button } from '@/components/ui/button';6import { Textarea } from '@/components/ui/textarea';7import { PaperclipIcon } from '@/components/ui/paperclip';8import { SendIcon } from '@/components/ui/send';910const initial = [11 { id: 'spec', name: 'checkout-spec.md', type: 'text/markdown', size: 18_400 },12 { id: 'flow', name: 'payment-flow.png', type: 'image/png', size: 402_000 },13 { id: 'log', name: 'stripe-webhook.log', type: 'text/plain', size: 96_000 },14];1516export function Chips() {17 const [files, setFiles] = React.useState(initial);1819 return (20 <div className="border-border bg-card flex w-full max-w-md flex-col gap-3 rounded-xl border p-3">21 <Textarea22 aria-label="Message"23 placeholder="Write a message to the payments team"24 autoResize25 maxRows={5}26 className="border-0 bg-transparent px-1 shadow-none focus-visible:ring-0 dark:bg-transparent"27 />28 {files.length > 0 && (29 <Attachment.List layout="inline">30 {files.map((file) => (31 <Attachment.Item key={file.id}>32 <Attachment variant="chip">33 <Attachment.Preview type={file.type} name={file.name} />34 <Attachment.Content>35 <Attachment.Name>{file.name}</Attachment.Name>36 <Attachment.Size bytes={file.size} />37 </Attachment.Content>38 <Attachment.Remove39 onClick={() => setFiles((current) => current.filter((f) => f.id !== file.id))}40 />41 </Attachment>42 </Attachment.Item>43 ))}44 </Attachment.List>45 )}46 <div className="flex items-center justify-between">47 <Button type="button" variant="ghost" size="sm">48 <PaperclipIcon size={16} />49 Attach50 </Button>51 <Button type="button" size="sm">52 Send53 <SendIcon size={16} />54 </Button>55 </div>56 </div>57 );58}
Uploading
status drives the look: uploading dims the row, error outlines it. Progress draws a bar along the bottom edge.customer-export.csv
0% of 12 MB
Uploadingonboarding.mp4
0% of 141.1 MB
Uploading- Upload failed
contract-v3.pdf
Upload failed, try again
1'use client';23import * as React from 'react';4import { Attachment } from '@/components/ui/attachment';56const uploads = [7 { name: 'customer-export.csv', type: 'text/csv', size: 12_600_000, speed: 9 },8 { name: 'onboarding.mp4', type: 'video/mp4', size: 148_000_000, speed: 3 },9 { name: 'contract-v3.pdf', type: 'application/pdf', size: 820_000, speed: 0, failed: true },10];1112export function Uploading() {13 const [progress, setProgress] = React.useState(() => uploads.map(() => 0));1415 React.useEffect(() => {16 const timer = setInterval(() => {17 setProgress((current) =>18 current.map((value, index) => {19 const upload = uploads[index];20 if (!upload || upload.failed) return value;21 return Math.min(value + upload.speed, 100);22 }),23 );24 }, 300);25 return () => clearInterval(timer);26 }, []);2728 return (29 <Attachment.List className="w-full max-w-sm">30 {uploads.map((upload, index) => {31 const value = progress[index] ?? 0;32 const status = upload.failed ? 'error' : value >= 100 ? 'done' : 'uploading';3334 return (35 <Attachment.Item key={upload.name}>36 <Attachment status={status}>37 <Attachment.Preview type={upload.type} name={upload.name} />38 <Attachment.Content>39 <Attachment.Name>{upload.name}</Attachment.Name>40 <Attachment.Meta>41 {status === 'error' && (42 <span className="text-destructive">Upload failed, try again</span>43 )}44 {status === 'uploading' && (45 <>46 {Math.round(value)}% of <Attachment.Size bytes={upload.size} />47 </>48 )}49 {status === 'done' && <Attachment.Size bytes={upload.size} />}50 </Attachment.Meta>51 </Attachment.Content>52 <Attachment.Actions>53 <Attachment.Remove54 aria-label={status === 'uploading' ? 'Cancel upload' : 'Remove'}55 />56 </Attachment.Actions>57 {status === 'uploading' && <Attachment.Progress value={value} />}58 </Attachment>59 </Attachment.Item>60 );61 })}62 </Attachment.List>63 );64}
Helpers
formatFileSize(bytes, decimals?) turns a byte count into 1.5 MB. Attachment.Size calls it for you.API Reference
Attachment
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "row" | "card" | "chip" | "row" | Layout of the parts |
status | "idle" | "uploading" | "done" | "error" | "idle" | Exposed as data-status, styles it |
statusLabels | { uploading?: string; error?: string } | { uploading: "Uploading", error: "Upload failed" } | Text screen readers hear for the uploading and error states |
className | string | - | Additional CSS classes |
Attachment.Preview
| Prop | Type | Default | Description |
|---|---|---|---|
src | string | - | Image URL. Renders a thumbnail when present |
type | string | - | MIME type, picks the icon |
name | string | - | File name, used for the icon and the alt text |
alt | string | - | Alt text for the thumbnail |
children | ReactNode | - | Replace the thumbnail or icon entirely |
Attachment.Size
| Prop | Type | Default | Description |
|---|---|---|---|
bytes | number | - | Size to format |
decimals | number | 1 | Decimals to keep |
Attachment.Progress
| Prop | Type | Default | Description |
|---|---|---|---|
value | number | - | Percent complete, 0 to 100 |
The bar takes its accessible name from
Attachment.Name when there is one, and falls back to "Upload progress". Pass aria-label or aria-labelledby to name it yourself.Attachment.List
| Prop | Type | Default | Description |
|---|---|---|---|
layout | "stack" | "grid" | "inline" | "stack" | How the items flow |
Attachment.Item, Attachment.Content, Attachment.Name, Attachment.Meta, Attachment.Actions, Attachment.Remove, Attachment.Download
Presentational parts. Each accepts
className and the attributes of the element it renders. Remove is a button and Download an anchor with the download attribute. Both take a label prop for their accessible name, "Remove attachment" and "Download attachment" by default, and point aria-describedby at Attachment.Name, so a list full of Remove buttons still tells screen reader users which file each one belongs to.Found something to improve?
Notice a bug, typo, or missing detail on this page? Help us make the documentation better by opening a GitHub issue.