Lunia DS

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.

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-chip

Usage

The demo above, as code.

demo.tsx
'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).

PropTypeDefaultDescription
namestring-File name, for example "budget-2027.xlsx". Required.
sizenumberundefinedSize in bytes, shown as B, KB or MB when ready.
typestringundefinedMIME type, for example "application/pdf"; picks the icon (image, document, code or plain file) together with the extension.
previewUrlstringundefinedThumbnail 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.
progressnumber0Upload progress from 0 to 1; drives the ring and the percent.
errorTextstring"Upload failed"Short line shown when status is error.
EventDescription
removeThe remove button was pressed. Setting this listener shows the button.
retryThe 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.

IDTriggerElementPropertyFrom → toTimingReduced motion
ATC1Chip addedrootopacity, translate y0, distance-rise → 1, 0duration-md (260)ease-settleShows at once
ATC2Progress changes while uploadingprogress ringstroke drawnold share → new share (full when the upload ends)duration-lg (320)ease-outResizes at once
ATC3Progress changes while uploadingpercentnumber roll42% → 67%duration-xl (380)ease-outSwaps at once
ATC4Upload endsprogress ringopacity1 → 0duration-xl (380)ease-outHides at once
ATC5Uploading starts or ends; error starts or endspercent (uploading), Try again (error)column width (grid columns)0fr → 1fr (back to 0fr when the state ends)duration-2xl (420)ease-outResizes at once
ATC6Uploading starts; error startspercent (uploading), Try again (error)opacity0 → 1duration-lg (320)ease-outShows at once
ATC7Uploading ends; error endspercent (uploading), Try again (error)opacity1 → 0duration-xs (160)ease-outHides at once
ATC8Status changesstatus linetext morphUploading → Reading, then XLSX, 48 KB (or the error text)duration-3xl (460)ease-outSwaps at once
ATC9While processingstatus linemask-position sheen150% → -50%, repeatsduration-loop (1600)ease-linearNo loop
ATC10Error starts or endstile icons (type icon or thumbnail, alert icon)opacity, blur0, blur-swap → 1, 0 (the leaving icon does the reverse)duration-md (260)ease-outSwaps at once
ATC11Error starts or endstile icons (type icon or thumbnail, alert icon)scalescale-swap → 1 (the leaving icon does the reverse)duration-xl (380)ease-outSwaps at once
ATC12Error starts or endsstatus linetext colortext-tertiary → text-error-primaryduration-lg (320)ease-outSwaps at once
ATC13Image becomes readythumbnailopacity0.5 → 1duration-lg (320)ease-outSwaps at once
ATC14Hover or keyboard focus on the chipremove buttonopacity0 → 1duration-xs (160)ease-outShows 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.