AI input
Attachment chip
A file on its way into a prompt, where a ring fills while it uploads, one status line morphs from Uploading to Reading to the file type and size, and a failed upload blurs its icon into an alert and opens Try again.
Live preview
Loading preview
Three chips in a row: a spreadsheet that uploads (ring and percent rising), turns to Reading and then to XLSX, 48 KB; an image with a dimmed thumbnail that becomes clear when ready; and a PDF that fails with "Upload failed" and offers Try again. Replay restarts all three.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add attachment-chipUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { AttachmentChip, type AttachmentStatus } from './attachment-chip';
// A plain drawing, so the demo needs no photo.
const PHOTO = `data:image/svg+xml;utf8,${encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#7a8cc9"/><stop offset="1" stop-color="#d9a98c"/></linearGradient></defs><rect width="72" height="72" fill="url(#g)"/><circle cx="50" cy="22" r="8" fill="#fbf0d6"/><path d="M0 72V52l22-18 20 16 14-10 16 12v20z" fill="#2f3b63"/></svg>',
)}`;
type Item = { status: AttachmentStatus; progress: number };
type State = [Item, Item, Item];
const REST: State = [
{ status: 'ready', progress: 1 },
{ status: 'ready', progress: 1 },
{ status: 'error', progress: 0.3 },
];
const START: State = [
{ status: 'uploading', progress: 0 },
{ status: 'uploading', progress: 0 },
{ status: 'uploading', progress: 0 },
];
type Step = [at: number, index: 0 | 1 | 2, item: Item];
const SCRIPT: Step[] = [
[300, 0, { status: 'uploading', progress: 0.42 }],
[350, 1, { status: 'uploading', progress: 0.5 }],
[400, 2, { status: 'uploading', progress: 0.3 }],
[1000, 0, { status: 'uploading', progress: 0.67 }],
[1500, 0, { status: 'uploading', progress: 1 }],
[1600, 2, { status: 'error', progress: 0.3 }],
[1700, 0, { status: 'processing', progress: 1 }],
[2200, 1, { status: 'uploading', progress: 0.9 }],
[2700, 1, { status: 'ready', progress: 1 }],
[3500, 0, { status: 'ready', progress: 1 }],
];
export default function AttachmentChipDemo({ replay = 0 }: { replay?: number }) {
const [items, setItems] = React.useState<State>(REST);
const timers = React.useRef<number[]>([]);
const clear = React.useCallback(() => {
for (const id of timers.current) window.clearTimeout(id);
timers.current = [];
}, []);
const set = React.useCallback((index: number, item: Item) => {
setItems((now) => now.map((it, i) => (i === index ? item : it)) as State);
}, []);
// Replay starts all three uploads again: one finishes and is read, one clears, one fails.
React.useEffect(() => {
if (replay === 0) return;
clear();
setItems(START);
timers.current = SCRIPT.map(([at, index, item]) => window.setTimeout(() => set(index, item), at));
return clear;
}, [replay, clear, set]);
// Try again restarts the failed upload and lets it finish.
const retry = () => {
clear();
set(2, { status: 'uploading', progress: 0.1 });
timers.current = [
window.setTimeout(() => set(2, { status: 'uploading', progress: 0.6 }), 500),
window.setTimeout(() => set(2, { status: 'processing', progress: 1 }), 1100),
window.setTimeout(() => set(2, { status: 'ready', progress: 1 }), 2200),
];
};
const [sheet, photo, pdf] = items;
return (
<div className="flex w-full max-w-[640px] flex-wrap items-center gap-x-4 gap-y-6 rounded-xl border border-secondary bg-primary p-6">
<AttachmentChip
key={`a${replay}`}
name="budget-2027.xlsx"
size={48_000}
status={sheet.status}
progress={sheet.progress}
onRemove={() => {}}
/>
<AttachmentChip
key={`b${replay}`}
name="team-photo.png"
size={1_200_000}
type="image/png"
previewUrl={PHOTO}
status={photo.status}
progress={photo.progress}
onRemove={() => {}}
/>
<AttachmentChip
key={`c${replay}`}
name="roadmap-q4.pdf"
size={220_000}
status={pdf.status}
progress={pdf.progress}
onRemove={() => {}}
onRetry={retry}
/>
</div>
);
}
API
Parts: root, icon tile (type icon or image thumbnail, alert icon, progress ring), file name, status line (morphing text), percent (number roll, uploading only), Try again button (error only), remove button (corner, on hover or focus), live region (visually hidden).
| Prop | Type | Default | Description |
|---|---|---|---|
| name | string | - | File name, for example "budget-2027.xlsx". Required. |
| size | number | undefined | Size in bytes, shown as B, KB or MB when ready. |
| type | string | undefined | MIME type, for example "application/pdf"; picks the icon (image, document, code or plain file) together with the extension. |
| previewUrl | string | undefined | Thumbnail URL for images; shown dimmed until the file is ready. |
| status | "uploading" | "processing" | "ready" | "error" | "ready" | Where the file is: uploading, being read by the model, ready, or failed. |
| progress | number | 0 | Upload progress from 0 to 1; drives the ring and the percent. |
| errorText | string | "Upload failed" | Short line shown when status is error. |
| Event | Description |
|---|---|
| remove | The remove button was pressed. Setting this listener shows the button. |
| retry | The person pressed Try again after an error. Setting this listener shows the button in the error state. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| ATC1 | Chip added | root | opacity, translate y | 0, distance-rise → 1, 0 | duration-md (260)ease-settle | Shows at once |
| ATC2 | Progress changes while uploading | progress ring | stroke drawn | old share → new share (full when the upload ends) | duration-lg (320)ease-out | Resizes at once |
| ATC3 | Progress changes while uploading | percent | number roll | 42% → 67% | duration-xl (380)ease-out | Swaps at once |
| ATC4 | Upload ends | progress ring | opacity | 1 → 0 | duration-xl (380)ease-out | Hides at once |
| ATC5 | Uploading starts or ends; error starts or ends | percent (uploading), Try again (error) | column width (grid columns) | 0fr → 1fr (back to 0fr when the state ends) | duration-2xl (420)ease-out | Resizes at once |
| ATC6 | Uploading starts; error starts | percent (uploading), Try again (error) | opacity | 0 → 1 | duration-lg (320)ease-out | Shows at once |
| ATC7 | Uploading ends; error ends | percent (uploading), Try again (error) | opacity | 1 → 0 | duration-xs (160)ease-out | Hides at once |
| ATC8 | Status changes | status line | text morph | Uploading → Reading, then XLSX, 48 KB (or the error text) | duration-3xl (460)ease-out | Swaps at once |
| ATC9 | While processing | status line | mask-position sheen | 150% → -50%, repeats | duration-loop (1600)ease-linear | No loop |
| ATC10 | Error starts or ends | tile icons (type icon or thumbnail, alert icon) | opacity, blur | 0, blur-swap → 1, 0 (the leaving icon does the reverse) | duration-md (260)ease-out | Swaps at once |
| ATC11 | Error starts or ends | tile icons (type icon or thumbnail, alert icon) | scale | scale-swap → 1 (the leaving icon does the reverse) | duration-xl (380)ease-out | Swaps at once |
| ATC12 | Error starts or ends | status line | text color | text-tertiary → text-error-primary | duration-lg (320)ease-out | Swaps at once |
| ATC13 | Image becomes ready | thumbnail | opacity | 0.5 → 1 | duration-lg (320)ease-out | Swaps at once |
| ATC14 | Hover or keyboard focus on the chip | remove button | opacity | 0 → 1 | duration-xs (160)ease-out | Shows at once |
Accessibility
- A visually hidden role="status" aria-live="polite" line announces each step once, never each percent: "Uploading budget-2027.xlsx", "Reading budget-2027.xlsx", "budget-2027.xlsx is ready", or the file name and the error text.
- The visible status line, the percent, the icons and the ring are aria-hidden; the live line carries the meaning.
- The error state uses the alert icon, red text and a red border together, never color alone.
- The remove button is named "Remove budget-2027.xlsx"; it shows on hover and on keyboard focus, and is always visible on touch screens (hover: none).
- Try again is a real button, inert while hidden, named "Try again, upload budget-2027.xlsx" so it is clear which file it acts on.
- The remove button is 20px; Lunia gives it a 24px hit area so it meets the target size.