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.
States
Section titled “States”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.
Orientation
Section titled “Orientation”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.
Drop zone
Section titled “Drop zone”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.
Composition
Section titled “Composition”Use the following composition to build an Attachment:
Attachment├── AttachmentMedia├── AttachmentContent│ ├── AttachmentTitle│ └── AttachmentDescription├── AttachmentActions│ └── AttachmentAction└── AttachmentTriggerAccessibility
Section titled “Accessibility”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>Attachment
Section titled “Attachment”Displays a file or image attachment. Renders a <div> element and accepts
standard <div> props.
orientation
Section titled “orientation”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".
AttachmentAction
Section titled “AttachmentAction”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
Section titled “className”className?: stringCSS class applied to the button.
focusableWhenDisabled
Section titled “focusableWhenDisabled”focusableWhenDisabled?: booleanWhether a disabled button stays focusable. Defaults to false.
nativeButton
Section titled “nativeButton”nativeButton?: booleanWhether the element passed to render is a native button. Defaults to
true.
render
Section titled “render”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
Section titled “variant”variant?: "outline" | "link" | "secondary" | "default" | "ghost" | "destructive" | null = "ghost"Controls the button’s appearance only. Defaults to "ghost".
AttachmentActions
Section titled “AttachmentActions”Contains Attachment actions. Renders a <div> element and accepts standard
<div> props.
AttachmentContent
Section titled “AttachmentContent”Wraps the Attachment title and description. Renders a <div> element and
accepts standard <div> props.
AttachmentDescription
Section titled “AttachmentDescription”Provides secondary metadata such as file type, size, or upload status.
Renders a <span> element and accepts standard <span> props.
AttachmentDropZone
Section titled “AttachmentDropZone”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
Section titled “accept”accept?: stringFile types the file input accepts, as a comma-separated list.
disabled
Section titled “disabled”disabled?: booleanWhether the file input is disabled.
id?: stringIdentifier of the file input, so a Label can target it with htmlFor.
multiple
Section titled “multiple”multiple?: booleanWhether the file input accepts several files at once.
name?: stringName of the file input in form data.
onFiles
Section titled “onFiles”onFiles: (files: File[]) => voidCalled with the files dropped onto the zone or chosen through the file picker.
AttachmentGroup
Section titled “AttachmentGroup”Lays out Attachments in a horizontally scrollable row. Renders a <div>
element and accepts standard <div> props.
style?: React.CSSPropertiesApplied to the scrolling row inside the group element.
AttachmentMedia
Section titled “AttachmentMedia”Contains an Attachment icon or image. Renders a <div> element and accepts
standard <div> props.
variant
Section titled “variant”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.
AttachmentTitle
Section titled “AttachmentTitle”Provides the Attachment name. Renders a <span> element and accepts standard
<span> props.
AttachmentTrigger
Section titled “AttachmentTrigger”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
Section titled “render”render?: React.ReactElement<any, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>Allows replacing the trigger element or composing it with another component.