Skip to content

Attachment

The Attachment component displays a file or image attachment, its media, name, and metadata, with optional actions and upload state.

Code
import {
Attachment,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
AttachmentTrigger,
FileArchiveIcon,
} from "@falcon/ui-kit";
export function Example() {
return (
<div className="tw:flex tw:w-full tw:max-w-[518px] tw:flex-col tw:gap-2">
<Attachment state="done" className="tw:w-full">
<AttachmentMedia variant="image">
<img src="/field-north.jpg" alt="" />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>field-north.jpg</AttachmentTitle>
<AttachmentDescription>JPG · 300 KB</AttachmentDescription>
</AttachmentContent>
<AttachmentTrigger
aria-label="Open field-north.jpg"
render={
<a
href="/field-north.jpg"
target="_blank"
rel="noopener noreferrer"
/>
}
/>
</Attachment>
<Attachment state="done" className="tw:w-full">
<AttachmentMedia variant="image">
<img src="/delivery-note.png" alt="" />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>delivery-note.pdf</AttachmentTitle>
<AttachmentDescription>PDF · 180 KB</AttachmentDescription>
</AttachmentContent>
<AttachmentTrigger
aria-label="Open delivery-note.pdf"
render={
<a
href="/delivery-note.pdf"
target="_blank"
rel="noopener noreferrer"
/>
}
/>
</Attachment>
<Attachment state="done" className="tw:w-full">
<AttachmentMedia>
<FileArchiveIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>product-catalog.zip</AttachmentTitle>
<AttachmentDescription>ZIP · 64 KB</AttachmentDescription>
</AttachmentContent>
<AttachmentTrigger
aria-label="Download product-catalog.zip"
render={
<a href="/product-catalog.zip" download="product-catalog.zip" />
}
/>
</Attachment>
</div>
);
}

AttachmentMedia shows an icon by default. Set variant="image" and pass an <img> to show a thumbnail; a thumbnail for a non-image file, such as the PDF above, is an image rendered ahead of time. The component does not generate previews or detect file types.

AttachmentTrigger makes the whole card interactive. Render it as a link that opens an image or PDF in a new tab or downloads any other file, and leave it out while the file is uploading or has failed.

Code
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
FileTextIcon,
FileWarningIcon,
RefreshCwIcon,
Spinner,
XIcon,
} from "@falcon/ui-kit";
export function Example() {
return (
<div className="tw:flex tw:w-full tw:max-w-[518px] tw:flex-col tw:gap-2">
<Attachment state="uploading" className="tw:w-full">
<AttachmentMedia>
<Spinner />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>product-catalog.zip</AttachmentTitle>
<AttachmentDescription>Uploading · 64%</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Cancel upload">
<XIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
<Attachment state="processing" className="tw:w-full">
<AttachmentMedia>
<Spinner />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>soil-samples.csv</AttachmentTitle>
<AttachmentDescription>Processing</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Cancel processing">
<XIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
<Attachment state="error" className="tw:w-full">
<AttachmentMedia>
<FileWarningIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>price-sheet.xlsx</AttachmentTitle>
<AttachmentDescription>
Upload failed. Try again.
</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Retry upload">
<RefreshCwIcon />
</AttachmentAction>
<AttachmentAction aria-label="Remove price-sheet.xlsx">
<XIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
<Attachment state="done" className="tw:w-full">
<AttachmentMedia>
<FileTextIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>message-renderer.pdf</AttachmentTitle>
<AttachmentDescription>PDF · 12 KB</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Remove message-renderer.pdf">
<XIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
</div>
);
}

Set state to reflect the upload lifecycle: uploading and processing pulse the title, and error switches to a destructive treatment. Keep the failure reason in AttachmentDescription so the error state is not conveyed by color alone.

Code
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
FileTextIcon,
XIcon,
} from "@falcon/ui-kit";
export function Example() {
return (
<div className="tw:flex tw:flex-wrap tw:gap-3">
<Attachment orientation="vertical">
<AttachmentMedia variant="image">
<img src="/field-north.jpg" alt="" />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>field-north.jpg</AttachmentTitle>
<AttachmentDescription>JPG · 300 KB</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Remove field-north.jpg">
<XIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
<Attachment orientation="vertical">
<AttachmentMedia>
<FileTextIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>delivery-note.pdf</AttachmentTitle>
<AttachmentDescription>PDF · 180 KB</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Remove delivery-note.pdf">
<XIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
</div>
);
}

Use orientation="vertical" to stack the media above the content.

Code
import {
Attachment,
AttachmentContent,
AttachmentDescription,
AttachmentGroup,
AttachmentMedia,
AttachmentTitle,
FileSpreadsheetIcon,
FileTextIcon,
ImageIcon,
} from "@falcon/ui-kit";
export function Example() {
return (
<AttachmentGroup>
<Attachment state="done">
<AttachmentMedia>
<FileTextIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>delivery-note.pdf</AttachmentTitle>
<AttachmentDescription>PDF · 120 KB</AttachmentDescription>
</AttachmentContent>
</Attachment>
<Attachment state="done">
<AttachmentMedia>
<FileSpreadsheetIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>price-sheet.xlsx</AttachmentTitle>
<AttachmentDescription>XLSX · 84 KB</AttachmentDescription>
</AttachmentContent>
</Attachment>
<Attachment state="done">
<AttachmentMedia>
<ImageIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>field-photo.jpg</AttachmentTitle>
<AttachmentDescription>JPG · 300 KB</AttachmentDescription>
</AttachmentContent>
</Attachment>
</AttachmentGroup>
);
}

AttachmentGroup lays out attachments in a horizontally scrollable, snapping row with an edge fade.

Code
import { useEffect, useRef, useState } from "react";
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentDropZone,
AttachmentMedia,
AttachmentTitle,
AttachmentTrigger,
Field,
FileTextIcon,
Label,
Spinner,
XIcon,
} from "@falcon/ui-kit";
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
const kb = bytes / 1024;
if (kb < 1024) return `${kb < 10 ? kb.toFixed(1) : Math.round(kb)} KB`;
return `${(kb / 1024).toFixed(1)} MB`;
}
type AttachmentFile = {
id: number;
file: File;
phase: "uploading" | "done";
url: string;
preview?: string;
};
function opensInBrowser(file: File) {
return (
file.type.startsWith("image/") ||
file.type === "application/pdf" ||
file.name.toLowerCase().endsWith(".pdf")
);
}
let nextId = 0;
export function Example() {
const started = useRef(new Set<number>());
const [files, setFiles] = useState<AttachmentFile[]>([]);
useEffect(() => {
for (const item of files) {
if (item.phase !== "uploading" || started.current.has(item.id)) continue;
started.current.add(item.id);
const id = item.id;
window.setTimeout(() => {
setFiles((current) =>
current.map((entry) =>
entry.id === id && entry.phase === "uploading"
? { ...entry, phase: "done" }
: entry,
),
);
}, 1200);
}
}, [files]);
function take(next: File[]) {
setFiles((current) => [
...current,
...next.map((file) => {
const url = URL.createObjectURL(file);
return {
id: nextId++,
file,
phase: "uploading" as const,
url,
preview: file.type.startsWith("image/") ? url : undefined,
};
}),
]);
}
return (
<Field className="tw:w-full tw:max-w-[518px]">
<Label htmlFor="attachment-file">Attachments</Label>
<AttachmentDropZone id="attachment-file" multiple onFiles={take} />
{files.map((item) => (
<Attachment key={item.id} state={item.phase} className="tw:w-full">
<AttachmentMedia
variant={item.phase === "done" && item.preview ? "image" : "icon"}
>
{item.phase === "uploading" ? (
<Spinner />
) : item.preview ? (
<img src={item.preview} alt="" />
) : (
<FileTextIcon />
)}
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>{item.file.name}</AttachmentTitle>
<AttachmentDescription>
{item.phase === "uploading"
? "Uploading…"
: formatSize(item.file.size)}
</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction
aria-label={
item.phase === "uploading"
? "Cancel upload"
: `Remove ${item.file.name}`
}
onClick={() =>
setFiles((current) => {
const removed = current.find((entry) => entry.id === item.id);
if (removed) URL.revokeObjectURL(removed.url);
return current.filter((entry) => entry.id !== item.id);
})
}
>
<XIcon />
</AttachmentAction>
</AttachmentActions>
{item.phase === "done" ? (
<AttachmentTrigger
aria-label={
opensInBrowser(item.file)
? `Open ${item.file.name}`
: `Download ${item.file.name}`
}
render={
opensInBrowser(item.file) ? (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
/>
) : (
<a href={item.url} download={item.file.name} />
)
}
/>
) : null}
</Attachment>
))}
</Field>
);
}

AttachmentDropZone takes dropped files and opens the file picker on click; both paths call onFiles with the chosen files. Give it an id so a Label can target its file input, and render each accepted file as an Attachment whose state follows the upload.

Code
import { useState } from "react";
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentDropZone,
AttachmentMedia,
AttachmentTitle,
Field,
ImageIcon,
Label,
XIcon,
} from "@falcon/ui-kit";
type Photo = { id: number; file: File; url: string };
let nextId = 0;
export function Example() {
const [photos, setPhotos] = useState<Photo[]>([]);
function add(files: File[]) {
setPhotos((current) => [
...current,
...files.map((file) => ({
id: nextId++,
file,
url: URL.createObjectURL(file),
})),
]);
}
function remove(photo: Photo) {
URL.revokeObjectURL(photo.url);
setPhotos((current) => current.filter((entry) => entry.id !== photo.id));
}
return (
<Field className="tw:w-full tw:max-w-[518px]">
<Label htmlFor="field-photos">Field photos</Label>
<AttachmentDropZone
id="field-photos"
accept="image/*"
multiple
onFiles={add}
>
<ImageIcon />
<span>Drop photos here, JPG or PNG up to 10 MB each</span>
</AttachmentDropZone>
{photos.map((photo) => (
<Attachment key={photo.id} className="tw:w-full">
<AttachmentMedia variant="image">
<img src={photo.url} alt="" />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>{photo.file.name}</AttachmentTitle>
<AttachmentDescription>
{Math.round(photo.file.size / 1024)} KB
</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction
aria-label={`Remove ${photo.file.name}`}
onClick={() => remove(photo)}
>
<XIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
))}
</Field>
);
}

Pass children to replace the default prompt.

Use the following composition to build an Attachment:

Attachment
├── AttachmentMedia
├── AttachmentContent
│ ├── AttachmentTitle
│ └── AttachmentDescription
├── AttachmentActions
│ └── AttachmentAction
└── AttachmentTrigger

AttachmentAction is usually icon-only, so give each one an aria-label describing the action and its target.

<AttachmentAction aria-label="Remove message-renderer.pdf">
<XIcon />
</AttachmentAction>

Displays a file or image attachment. Renders a <div> element and accepts standard <div> props.

orientation?: "horizontal" | "vertical" | null = "horizontal"

Whether media is laid out beside (horizontal) or above (vertical) the content. Defaults to "horizontal".

state?: "error" | "done" | "idle" | "uploading" | "processing" = "done"

The upload lifecycle. Defaults to "done".

An action on an Attachment. Renders a <button> element by default and accepts standard <button> props. Use render to replace the element. variant defaults to "ghost" and size to "icon-xs".

className?: string

CSS class applied to the button.

focusableWhenDisabled?: boolean

Whether a disabled button stays focusable. Defaults to false.

nativeButton?: boolean

Whether the element passed to render is a native button. Defaults to true.

render?: React.ReactElement<any, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, ButtonState>

Allows replacing the button element or composing it with another component.

size?: "xs" | "sm" | "lg" | "default" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | null = "icon-xs"

Controls the button’s size. The icon sizes are square and require an accessible name. Defaults to "icon-xs".

style?: React.CSSProperties | ((state: ButtonState) => React.CSSProperties | undefined)

Style applied to the button, or a function based on its state.

variant?: "outline" | "link" | "secondary" | "default" | "ghost" | "destructive" | null = "ghost"

Controls the button’s appearance only. Defaults to "ghost".

Contains Attachment actions. Renders a <div> element and accepts standard <div> props.

Wraps the Attachment title and description. Renders a <div> element and accepts standard <div> props.

Provides secondary metadata such as file type, size, or upload status. Renders a <span> element and accepts standard <span> props.

Accepts files by drag and drop, or through the file picker it opens on click. Renders a <div> element around a visually hidden file <input> and accepts standard <div> props; id, name, accept, multiple and disabled apply to the input, and the drag-and-drop handlers are owned by the component. children replace the default “Drop files here or click to select” prompt.

accept?: string

File types the file input accepts, as a comma-separated list.

disabled?: boolean

Whether the file input is disabled.

id?: string

Identifier of the file input, so a Label can target it with htmlFor.

multiple?: boolean

Whether the file input accepts several files at once.

name?: string

Name of the file input in form data.

onFiles: (files: File[]) => void

Called with the files dropped onto the zone or chosen through the file picker.

Lays out Attachments in a horizontally scrollable row. Renders a <div> element and accepts standard <div> props.

style?: React.CSSProperties

Applied to the scrolling row inside the group element.

Contains an Attachment icon or image. Renders a <div> element and accepts standard <div> props.

variant?: "image" | "icon" | null = "icon"

Controls how the media is displayed. The icon variant sizes a nested icon automatically, so omit size on it; the image variant sizes and clips a nested image.

Provides the Attachment name. Renders a <span> element and accepts standard <span> props.

A full-card control that activates the Attachment. Renders a <button> element by default and accepts standard <button> props. Use render to replace the element. Unlike a native button, type defaults to "button".

render?: React.ReactElement<any, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>

Allows replacing the trigger element or composing it with another component.