Skip to content
Star on GitHub
ConversationAttachment tray
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Attachment tray

Drop files for the agent and watch each one upload. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add attachment-tray
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add attachment-tray

React

$npx designme add attachment-tray --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

Attachment tray is a drop zone plus a list of the files a person is giving the agent. Each file wears its type's colour: a badge, a progress ring and a wash that fills its row as it uploads. Once it is up the agent reads it, and the file says what the agent found, like "12 pages", with a line underneath saying how many files the agent can read.

  • When people hand your agent documents, images or spreadsheets to work from.
  • When uploads take long enough that people should see each file's progress before they ask anything.
  • When you want a sample-files link so new users can try the agent without their own files.
03

Usage

Import it and drive it from your own state.

Import

import { AttachmentTray } from '@/components/designme/attachment-tray/AttachmentTray';

Example

The demo above as a React component, 46 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useEffect, useRef, useState } from 'react';
import { useStory } from '../lib/useScript';
import { AttachmentTray, type Attachment } from './AttachmentTray';

/* ---------- Attachment tray: dropped or picked files upload one by one; sample files show the flow ---------- */
const SAMPLES: [string, number, string?][] = [['brief.pdf', 2.4e6, '12 pages'], ['Pricing.fig', 8.1e6, '6 frames'], ['signups.csv', 3.2e5, '1,204 rows']];

export default function AttachmentTrayDemo() {
  const [files, setFiles] = useState<Attachment[]>([]);
  const [dragging, setDragging] = useState(false);
  const count = useRef(0);
  count.current = files.length;
  const timers = useRef<ReturnType<typeof setInterval>[]>([]);
  useEffect(() => () => timers.current.forEach(t => { clearInterval(t); clearTimeout(t); }), []);
  // a real app would report upload progress here; the demo fakes it, a little faster or slower each tick
  const add = (list: [string, number, string?][]) => list.forEach(([name, size, detail]) => {
    const id = Math.random().toString(36).slice(2);
    setFiles(fs => [...fs, { id, name, size, progress: 0 }]);
    let p = 0;
    const iv = setInterval(() => {
      p = Math.min(100, p + 2.5 + Math.random() * 5);
      setFiles(fs => fs.map(f => f.id === id ? { ...f, progress: p, reading: p >= 100 } : f));
      if (p < 100) return;
      clearInterval(iv);
      // once it is up, the agent reads it and says what it found
      timers.current.push(setTimeout(() => setFiles(fs => fs.map(f => f.id === id ? { ...f, reading: false, detail } : f)), 900));
    }, 70);
    timers.current.push(iv);
  });
  // once it has entered, three sample files drop in and upload, unless files are already there or you have taken over
  const ref = useRef<HTMLDivElement>(null);
  useStory(ref, async s => {
    await s.wait(950); if (count.current) return;
    setDragging(true); s.taken(() => setDragging(false)); await s.wait(1300); setDragging(false);
    if (!count.current) add(SAMPLES);
  });
  return (
    <div ref={ref} style={{ width: '100%' }}>
      <AttachmentTray files={files} dragging={dragging} action={{ label: 'add sample files', onClick: () => add(SAMPLES) }}
        onFiles={fs => add(fs.map(f => [f.name, f.size]))} onRemove={id => setFiles(fs => fs.filter(f => f.id !== id))} />
    </div>
  );
}
04

Props

Everything AttachmentTray takes.

PropTypeWhat it does
filesrequiredAttachment[]
onFiles(files: File[]) => voidFiles the person dropped on the tray or picked with the file dialog
onRemove(id: string) => voidAdds a remove button to each file. Called once the file has animated out
titleReactNodeThe drop zone's heading
action{ label: string; onClick: () => void }A link after "or" in the drop zone, like "add sample files"
acceptstringPassed to the file input, like "image/*,.pdf"
summarystringThe line under the list. Defaults to how many files the agent can read
draggingbooleanShows the drop zone as if a file were being dragged over it, for a scripted demo. A real drag shows it by itself
classNamestring
05

Code

The full source, as the CLI installs it.

AttachmentTray.tsxReact · 140 lines
'use client';
import { useEffect, useRef, useState, type CSSProperties, type DragEvent, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useIsoLayoutEffect } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Swap, Tip } from '../lib/motion';
import '../designme.css';
import './attachment-tray.css';

export interface Attachment {
  id: string;
  name: string;
  /** In bytes */
  size: number;
  /** 0 to 100. The ring closes and the row fills with the file type's colour; at 100 the ring turns into a check */
  progress: number;
  /** After the upload, while the agent reads the file: a spinner, and "Reading" shimmering after the size */
  reading?: boolean;
  /** What the agent found once it has read the file, like "12 pages", shown after the size */
  detail?: string;
}

export interface AttachmentTrayProps {
  files: Attachment[];
  /** Files the person dropped on the tray or picked with the file dialog */
  onFiles?: (files: File[]) => void;
  /** Adds a remove button to each file. Called once the file has animated out */
  onRemove?: (id: string) => void;
  /** The drop zone's heading */
  title?: ReactNode;
  /** A link after "or" in the drop zone, like "add sample files" */
  action?: { label: string; onClick: () => void };
  /** Passed to the file input, like "image/*,.pdf" */
  accept?: string;
  /** The line under the list. Defaults to how many files the agent can read */
  summary?: string;
  /** Shows the drop zone as if a file were being dragged over it, for a scripted demo. A real drag shows it by itself */
  dragging?: boolean;
  className?: string;
}

// the badge for a file type: its letters, its colour and its background
const kind = (name: string): [string, string, string] => {
  const e = (name.split('.').pop() || '').toLowerCase();
  if (e === 'pdf') return ['PDF', 'var(--dm-pr)', 'var(--dm-pr-bg)'];
  if (/png|jpg|jpeg|gif|webp|fig/.test(e)) return [e.toUpperCase().slice(0, 3), 'var(--dm-pl)', 'var(--dm-pl-bg)'];
  if (/csv|xlsx/.test(e)) return ['CSV', 'var(--dm-pm)', 'var(--dm-pm-bg)'];
  return [e.toUpperCase().slice(0, 3) || 'TXT', 'var(--dm-pb)', 'var(--dm-pb-bg)'];
};
const bytes = (b: number) => b > 1e6 ? (b / 1e6).toFixed(1) + ' MB' : Math.max(1, Math.round(b / 1e3)) + ' KB';
const RING = 50.3;

function Item({ file, delay, onRemove }: { file: Attachment; delay: number; onRemove?: (id: string) => void }) {
  const [leaving, setLeaving] = useState(false);
  const t = useRef<ReturnType<typeof setTimeout>>(undefined);
  useEffect(() => () => clearTimeout(t.current), []);
  const [label, fg, bg] = kind(file.name), p = Math.max(0, Math.min(100, file.progress)) / 100, up = p >= 1;
  const meta = !up ? `${bytes(file.size)} · ${Math.round(p * 100)}%` : file.detail && !file.reading ? `${bytes(file.size)} · ${file.detail}` : bytes(file.size);
  return (
    <div className={cx('dm-atx-it', up && 'dm-up', leaving ? 'dm-leave' : 'dm-enter')}
      style={{ '--dm-c': fg, '--dm-cb': bg, '--dm-p': `${(p * 100).toFixed(1)}%`, ...(leaving ? {} : { animationDelay: delay + 'ms' }) } as CSSProperties}>
      <span className="dm-atx-ti">{label}</span>
      <span className="dm-atx-n"><span className="dm-atx-nm">{file.name}</span>
        {/* the percentage counts in place; the line crossfades when the file moves on to being read and to what was found */}
        {!up ? <span className="dm-atx-m dm-num">{meta}</span>
          : file.reading ? <span className="dm-atx-m"><span className="dm-shim">{bytes(file.size)} · Reading</span></span> : <Swap className="dm-atx-m">{meta}</Swap>}
      </span>
      {up
        ? file.reading ? <span className="dm-atx-rd"><span className="dm-spin" aria-hidden="true" /></span> : <span className="dm-atx-ok"><Icon name="ok" /></span>
        : <svg className="dm-atx-ring" viewBox="0 0 22 22" role="progressbar" aria-label={`Uploading ${file.name}`} aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(p * 100)}>
            <circle cx="11" cy="11" r="8" fill="none" stroke="var(--dm-line)" strokeWidth="2" />
            <circle cx="11" cy="11" r="8" fill="none" stroke={fg} strokeWidth="2" strokeLinecap="round" strokeDasharray={RING} transform="rotate(-90 11 11)" style={{ strokeDashoffset: RING * (1 - p) }} />
          </svg>}
      {onRemove && (
        <Tip label="Remove">
          <button type="button" className="dm-ib" aria-label={`Remove ${file.name}`} disabled={leaving}
            onClick={() => { setLeaving(true); t.current = setTimeout(() => onRemove(file.id), 320); }}><Icon name="x" size="xs" /></button>
        </Tip>
      )}
    </div>
  );
}

/** The dashed edge of the drop zone, drawn in SVG so its dashes can march around the corners. It follows the zone's size and
 *  corner radius, and spaces the dashes so they meet evenly all the way round */
function Ants() {
  const svg = useRef<SVGSVGElement>(null);
  const [g, setG] = useState<{ w: number; h: number; rx: number; ap: number; ag: number } | null>(null);
  useIsoLayoutEffect(() => {
    // the drop zone is the parent; its own ref isn't attached yet when this first runs
    const el = svg.current?.parentElement; if (!el) return;
    const fit = () => {
      const w = el.offsetWidth, h = el.offsetHeight; if (!w || !h) return;
      const k = .75, rx = Math.max(0, Math.min(parseFloat(getComputedStyle(el).borderTopLeftRadius) || 0, w / 2, h / 2) - k);
      const per = 2 * (w + h - 4 * k) - (8 - 2 * Math.PI) * rx, step = per / Math.max(8, Math.round(per / 11));
      setG({ w: w - 2 * k, h: h - 2 * k, rx, ap: step, ag: step - 4.5 });
    };
    fit();
    const ro = new ResizeObserver(fit); ro.observe(el);
    return () => ro.disconnect();
  }, []);
  return (
    <svg ref={svg} className="dm-atx-ants" aria-hidden="true">
      {g && <rect x={.75} y={.75} width={g.w} height={g.h} rx={g.rx} style={{ '--dm-ap': g.ap + 'px', '--dm-ag': g.ag + 'px' } as CSSProperties} />}
    </svg>
  );
}

/** Drop files for the agent and watch each one upload, in its file type's colour, then get read. */
export function AttachmentTray({ files, onFiles, onRemove, title = 'Drop files here', action, accept, summary, dragging, className }: AttachmentTrayProps) {
  const [over, setOver] = useState(false);
  // files that arrive together come in one after another
  const ids = files.map(f => f.id).join('\u0000');
  const [seen, setSeen] = useState<{ ids: string; delay: Record<string, number> }>({ ids, delay: {} });
  if (seen.ids !== ids) {
    const old = new Set(seen.ids.split('\u0000')), delay = { ...seen.delay };
    let k = 0; for (const f of files) if (!old.has(f.id)) delay[f.id] = k++ * 80;
    setSeen({ ids, delay });
  }
  const hold = (e: DragEvent) => { e.preventDefault(); setOver(true); };
  const read = files.filter(f => f.progress >= 100 && !f.reading).length;
  const line = summary ?? (read ? `The agent can read ${read} ${read > 1 ? 'files' : 'file'} now` : '');

  return (
    <div data-dm="" className={cx('dm-atx', className)}>
      <label className={cx('dm-atx-drop', (over || dragging) && 'dm-over')} onDragEnter={hold} onDragOver={hold} onDragLeave={() => setOver(false)}
        onDrop={e => { e.preventDefault(); setOver(false); if (e.dataTransfer.files.length) onFiles?.(Array.from(e.dataTransfer.files)); }}>
        <input type="file" multiple accept={accept} className="dm-atx-file" aria-label="Choose files"
          onChange={e => { if (e.target.files?.length) onFiles?.(Array.from(e.target.files)); e.target.value = ''; }} />
        <Ants />
        <span className="dm-atx-ic"><Icon name="upload" /></span>
        <b>{title}</b>
        {action && <span className="dm-muted">or <button type="button" className="dm-atx-sample" onClick={action.onClick}>{action.label}</button></span>}
      </label>
      <div className="dm-atx-list">{files.map(f => <Item key={f.id} file={f} delay={seen.delay[f.id] ?? 0} onRemove={onRemove} />)}</div>
      <Swap className="dm-atx-sum dm-muted">{line}</Swap>
    </div>
  );
}
attachment-tray.cssReact · 58 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Attachment tray ---------- */
/* a drop zone, then the files as plain rows. Each file wears its type's hue: its badge, its ring and the wash that fills its
   row from the left as it uploads. Once it is up the agent reads it (the line shimmers) and says what it found, like 12 pages */
.dm-atx{display:flex;flex-direction:column;gap:10px}
.dm-atx-drop{display:flex;flex-direction:column;align-items:center;gap:6px;padding:26px var(--dm-s-4);border-radius:var(--dm-r-card);background:none;cursor:pointer;position:relative;font-size:13.5px;transition:background .35s,box-shadow .35s,transform .45s var(--dm-ease)}
/* the dashed edge is drawn in SVG so its dashes can march around the corners: still at rest, taking the accent under the
   pointer, and marching while a file is dragged over */
.dm-atx-ants{position:absolute;inset:0;width:100%;height:100%;overflow:visible;pointer-events:none}
.dm-atx-ants rect{fill:none;stroke:var(--dm-faint);stroke-width:1.5;stroke-linecap:round;stroke-dasharray:4.5px var(--dm-ag,6.5px);transition:stroke .35s}
@keyframes dm-atxants{to{stroke-dashoffset:calc(var(--dm-ap,11px) * -1)}}
.dm-atx-drop.dm-over{background:var(--dm-accent-bg)}
@media (hover:hover) and (pointer:fine){.dm-atx-drop:hover{background:var(--dm-accent-bg)}}
.dm-atx-drop.dm-over .dm-atx-ants rect{stroke:var(--dm-accent-mark,var(--dm-accent))}
@media (hover:hover) and (pointer:fine){.dm-atx-drop:hover .dm-atx-ants rect{stroke:var(--dm-accent-mark,var(--dm-accent))}}
.dm-atx-drop.dm-over .dm-atx-ants rect{animation:dm-atxants .9s linear infinite}
.dm-atx-drop.dm-over{transform:scale(1.015)}
.dm-atx-file{position:absolute;inset:0;opacity:0;cursor:pointer}
/* the file input is clear over the drop zone, so the zone shows its focus */
.dm-atx-drop:has(.dm-atx-file:focus-visible){outline:2px solid var(--dm-focus);outline-offset:2px}
.dm-atx-ic{position:relative;width:34px;height:34px;border-radius:11px;background:var(--dm-surface);box-shadow:var(--dm-shadow);display:grid;place-items:center;color:var(--dm-fg2);transition:transform .45s var(--dm-ease),background .35s,color .35s}
.dm-atx-drop.dm-over .dm-atx-ic{transform:translateY(-3px);background:var(--dm-surface);color:var(--dm-accent-fg,var(--dm-accent))}
@media (hover:hover) and (pointer:fine){.dm-atx-drop:hover .dm-atx-ic{transform:translateY(-3px);background:var(--dm-surface);color:var(--dm-accent-fg,var(--dm-accent))}}
/* while a file is over the zone its arrow bobs, and rings spread from the badge */
.dm-atx-drop.dm-over .dm-atx-ic .dm-i{animation:dm-atxbob .9s var(--dm-ease-io) infinite}
@keyframes dm-atxbob{50%{transform:translateY(-2px)}}
.dm-atx-ic::before,.dm-atx-ic::after{content:"";position:absolute;inset:0;border-radius:inherit;box-shadow:0 0 0 1.5px var(--dm-accent-mark,var(--dm-accent));opacity:0;pointer-events:none}
.dm-atx-drop.dm-over .dm-atx-ic::before{animation:dm-atxring 1.4s var(--dm-ease) infinite}
.dm-atx-drop.dm-over .dm-atx-ic::after{animation:dm-atxring 1.4s var(--dm-ease) .7s infinite}
@keyframes dm-atxring{from{opacity:.5;transform:scale(1)}to{opacity:0;transform:scale(1.9)}}
.dm-atx-sample{color:var(--dm-accent-fg,var(--dm-accent));font-weight:500;position:relative;z-index:1}
.dm-atx-list{display:flex;flex-direction:column;gap:2px}
.dm-atx-it{--dm-p:0%;position:relative;display:flex;align-items:center;gap:10px;padding:var(--dm-s-2) 6px var(--dm-s-2) var(--dm-s-2);border-radius:var(--dm-r-in);overflow:hidden;background:linear-gradient(90deg,color-mix(in srgb,var(--dm-c,var(--dm-accent)) 8%,transparent) calc(var(--dm-p) - 24px),color-mix(in srgb,var(--dm-c,var(--dm-accent)) 16%,transparent) calc(var(--dm-p) - 1px),transparent var(--dm-p)) no-repeat}
/* the wash fades once the file is up */
.dm-atx-it.dm-up{background-size:0 100%;transition:background-size .6s var(--dm-ease) .2s}
.dm-atx-ti{width:30px;height:30px;border-radius:9px;display:grid;place-items:center;flex:none;font-size:9.5px;font-weight:500;letter-spacing:.02em;color:var(--dm-c);background:var(--dm-cb)}
.dm-atx-n{flex:1;min-width:0;font-size:13px}
.dm-atx-nm{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-atx-m{display:block;font-size:12px;color:var(--dm-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-atx-m .dm-shim{font-size:inherit}
/* on the wash, the line steps up a grey so it keeps its contrast */
.dm-atx-it:not(.dm-up) .dm-atx-m{color:var(--dm-fg2)}
.dm-atx-ring{width:22px;height:22px;flex:none}
.dm-atx-ring circle{transition:stroke-dashoffset .3s linear}
.dm-atx-ok{display:grid;place-items:center;width:22px;flex:none;color:var(--dm-ok)}
.dm-atx-ok .dm-i{animation:dm-atxpop .45s var(--dm-ease) backwards}
@keyframes dm-atxpop{0%{transform:scale(.4);opacity:0}60%{transform:scale(1.15);opacity:1}100%{transform:none}}
.dm-atx-rd{display:grid;place-items:center;width:22px;flex:none;color:var(--dm-c)}
.dm-atx-rd .dm-spin{width:12px;height:12px}
.dm-atx-it .dm-ib{width:26px;height:26px;flex:none}
.dm-atx-sum{font-size:12.5px;text-align:center;min-height:18px}
:root:not([data-style="soft"]) .dm-atx-it:not(:where([data-style="soft"],[data-style="soft"] *)){background:linear-gradient(var(--dm-c,var(--dm-accent)),var(--dm-c,var(--dm-accent))) 0 100%/var(--dm-p) 2px no-repeat}
:root:not([data-style="soft"]) .dm-atx-it.dm-up:not(:where([data-style="soft"],[data-style="soft"] *)){background-size:0 2px}
:root:not([data-style="soft"]) .dm-atx-drop.dm-over .dm-atx-ic:not(:where([data-style="soft"],[data-style="soft"] *))::before{animation:dm-atxringbp 1s steps(4,end) infinite}
:root:not([data-style="soft"]) .dm-atx-drop.dm-over .dm-atx-ic:not(:where([data-style="soft"],[data-style="soft"] *))::after{animation:none}
@keyframes dm-atxringbp{from{opacity:.6;transform:scale(1)}to{opacity:0;transform:scale(1.6)}}
:root:not([data-style="soft"]) .dm-atx-ok .dm-i:not(:where([data-style="soft"],[data-style="soft"] *)){animation:none}
attachment-tray.htmlHTML · 12 lines
<div class="card" data-c="attachment-tray">
  <div class="stage-in">
    <div class="atx">
      <label class="atx-drop"><input type="file" multiple class="atx-file" aria-label="Choose files"><svg class="atx-ants" aria-hidden="true"><rect/></svg>
        <span class="atx-ic"><svg class="i s"><use href="#i-upload"/></svg></span><b>Drop files here</b><span class="muted">or <span class="atx-sample">add sample files</span></span>
      </label>
      <div class="atx-list"></div>
      <div class="atx-sum muted"></div>
    </div>
  </div>
</div>
attachment-tray.cssHTML · 57 lines
/* ---------- Attachment tray ---------- */
/* a drop zone, then the files as plain rows. Each file wears its type's hue: its badge, its ring and the wash that fills its
   row from the left as it uploads. Once it is up the agent reads it (the line shimmers) and says what it found, like 12 pages */
.atx{display:flex;flex-direction:column;gap:10px}
.atx-drop{display:flex;flex-direction:column;align-items:center;gap:6px;padding:26px var(--s-4);border-radius:var(--r-card);background:none;cursor:pointer;position:relative;font-size:13.5px;transition:background .35s,box-shadow .35s,transform .45s var(--ease)}
/* the dashed edge is drawn in SVG so its dashes can march around the corners: still at rest, taking the accent under the
   pointer, and marching while a file is dragged over */
.atx-ants{position:absolute;inset:0;width:100%;height:100%;overflow:visible;pointer-events:none}
.atx-ants rect{fill:none;stroke:var(--faint);stroke-width:1.5;stroke-linecap:round;stroke-dasharray:4.5px var(--ag,6.5px);transition:stroke .35s}
@keyframes atxants{to{stroke-dashoffset:calc(var(--ap,11px) * -1)}}
.atx-drop.over{background:var(--accent-bg)}
@media (hover:hover) and (pointer:fine){.atx-drop:hover{background:var(--accent-bg)}}
.atx-drop.over .atx-ants rect{stroke:var(--accent-mark,var(--accent))}
@media (hover:hover) and (pointer:fine){.atx-drop:hover .atx-ants rect{stroke:var(--accent-mark,var(--accent))}}
.atx-drop.over .atx-ants rect{animation:atxants .9s linear infinite}
.atx-drop.over{transform:scale(1.015)}
.atx-file{position:absolute;inset:0;opacity:0;cursor:pointer}
/* the file input is clear over the drop zone, so the zone shows its focus */
.atx-drop:has(.atx-file:focus-visible){outline:2px solid var(--focus);outline-offset:2px}
.atx-ic{position:relative;width:34px;height:34px;border-radius:11px;background:var(--surface);box-shadow:var(--shadow);display:grid;place-items:center;color:var(--fg2);transition:transform .45s var(--ease),background .35s,color .35s}
.atx-drop.over .atx-ic{transform:translateY(-3px);background:var(--surface);color:var(--accent-fg,var(--accent))}
@media (hover:hover) and (pointer:fine){.atx-drop:hover .atx-ic{transform:translateY(-3px);background:var(--surface);color:var(--accent-fg,var(--accent))}}
/* while a file is over the zone its arrow bobs, and rings spread from the badge */
.atx-drop.over .atx-ic .i{animation:atxbob .9s var(--ease-io) infinite}
@keyframes atxbob{50%{transform:translateY(-2px)}}
.atx-ic::before,.atx-ic::after{content:"";position:absolute;inset:0;border-radius:inherit;box-shadow:0 0 0 1.5px var(--accent-mark,var(--accent));opacity:0;pointer-events:none}
.atx-drop.over .atx-ic::before{animation:atxring 1.4s var(--ease) infinite}
.atx-drop.over .atx-ic::after{animation:atxring 1.4s var(--ease) .7s infinite}
@keyframes atxring{from{opacity:.5;transform:scale(1)}to{opacity:0;transform:scale(1.9)}}
.atx-sample{color:var(--accent-fg,var(--accent));font-weight:500;position:relative;z-index:1}
.atx-list{display:flex;flex-direction:column;gap:2px}
.atx-it{--p:0%;position:relative;display:flex;align-items:center;gap:10px;padding:var(--s-2) 6px var(--s-2) var(--s-2);border-radius:var(--r-in);overflow:hidden;background:linear-gradient(90deg,color-mix(in srgb,var(--c,var(--accent)) 8%,transparent) calc(var(--p) - 24px),color-mix(in srgb,var(--c,var(--accent)) 16%,transparent) calc(var(--p) - 1px),transparent var(--p)) no-repeat}
/* the wash fades once the file is up */
.atx-it.up{background-size:0 100%;transition:background-size .6s var(--ease) .2s}
.atx-ti{width:30px;height:30px;border-radius:9px;display:grid;place-items:center;flex:none;font-size:9.5px;font-weight:500;letter-spacing:.02em;color:var(--c);background:var(--cb)}
.atx-n{flex:1;min-width:0;font-size:13px}
.atx-nm{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.atx-m{display:block;font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.atx-m .shim{font-size:inherit}
/* on the wash, the line steps up a grey so it keeps its contrast */
.atx-it:not(.up) .atx-m{color:var(--fg2)}
.atx-ring{width:22px;height:22px;flex:none}
.atx-ring circle{transition:stroke-dashoffset .3s linear}
.atx-ok{display:grid;place-items:center;width:22px;flex:none;color:var(--ok)}
.atx-ok .i{animation:atxpop .45s var(--ease) backwards}
@keyframes atxpop{0%{transform:scale(.4);opacity:0}60%{transform:scale(1.15);opacity:1}100%{transform:none}}
.atx-rd{display:grid;place-items:center;width:22px;flex:none;color:var(--c)}
.atx-rd .spin{width:12px;height:12px}
.atx-it .ib{width:26px;height:26px;flex:none}
.atx-sum{font-size:12.5px;text-align:center;min-height:18px}
:root:not([data-style="soft"]) .atx-it:not(:where([data-style="soft"],[data-style="soft"] *)){background:linear-gradient(var(--c,var(--accent)),var(--c,var(--accent))) 0 100%/var(--p) 2px no-repeat}
:root:not([data-style="soft"]) .atx-it.up:not(:where([data-style="soft"],[data-style="soft"] *)){background-size:0 2px}
:root:not([data-style="soft"]) .atx-drop.over .atx-ic:not(:where([data-style="soft"],[data-style="soft"] *))::before{animation:atxringbp 1s steps(4,end) infinite}
:root:not([data-style="soft"]) .atx-drop.over .atx-ic:not(:where([data-style="soft"],[data-style="soft"] *))::after{animation:none}
@keyframes atxringbp{from{opacity:.6;transform:scale(1)}to{opacity:0;transform:scale(1.6)}}
:root:not([data-style="soft"]) .atx-ok .i:not(:where([data-style="soft"],[data-style="soft"] *)){animation:none}
attachment-tray.jsHTML · 51 lines
DM.def('attachment-tray', (r, c) => {
  r.innerHTML = r.innerHTML.replace('{upload}', I('upload'));
  const drop = $('.atx-drop', r), list = $('.atx-list', r), sum = $('.atx-sum', r);
  const kind = n => { const e = (n.split('.').pop() || '').toLowerCase(); return e === 'pdf' ? ['PDF','var(--pr)','var(--pr-bg)'] : /png|jpg|jpeg|gif|webp|fig/.test(e) ? [e.toUpperCase().slice(0,3),'var(--pl)','var(--pl-bg)'] : /csv|xlsx/.test(e) ? ['CSV','var(--pm)','var(--pm-bg)'] : [e.toUpperCase().slice(0,3) || 'TXT','var(--pb)','var(--pb-bg)']; };
  const size = b => b > 1e6 ? (b / 1e6).toFixed(1) + ' MB' : Math.max(1, Math.round(b / 1e3)) + ' KB';
  const esc = s => s.replace(/&/g,'&amp;').replace(/</g,'&lt;');
  let read = 0;
  // a file uploads (its row fills with its hue and its ring closes), then the agent reads it and says what it found
  function add(name, bytes, i = 0, found){
    const k = kind(name), sz = size(bytes);
    const it = html(`<div class="atx-it" style="--c:${k[1]};--cb:${k[2]}"><span class="atx-ti">${k[0]}</span><span class="atx-n"><span class="atx-nm">${esc(name)}</span><span class="atx-m"><span>${sz} · <span class="num pc">0%</span></span></span></span><svg class="atx-ring" viewBox="0 0 22 22" role="progressbar" aria-label="Uploading ${esc(name).replace(/"/g, '&quot;')}" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><circle cx="11" cy="11" r="8" fill="none" stroke="var(--line)" stroke-width="2"/><circle cx="11" cy="11" r="8" fill="none" stroke="${k[1]}" stroke-width="2" stroke-linecap="round" stroke-dasharray="50.3" stroke-dashoffset="50.3" transform="rotate(-90 11 11)"/></svg><button class="ib" aria-label="Remove ${esc(name).replace(/"/g, '&quot;')}" data-tip="Remove">${I('x','xs')}</button></div>`);
    list.appendChild(it); enter(it, i * 80);
    $('.ib', it).onclick = () => { it.classList.add('leave'); setTimeout(() => it.remove(), 320); };
    const svg = $('.atx-ring', it), ring = $$('circle', it)[1], m = $('.atx-m', it); let p = 0;
    const iv = c.every(70, () => {
      p = Math.min(1, p + .025 + Math.random() * .05); ring.style.strokeDashoffset = 50.3 * (1 - p);
      it.style.setProperty('--p', (p * 100).toFixed(1) + '%'); svg.setAttribute('aria-valuenow', Math.round(p * 100)); const pc = $('.pc', m); if (pc) pc.textContent = Math.round(p * 100) + '%';
      if (p < 1) return;
      clearInterval(iv); it.classList.add('up');
      // reading: the ring gives way to a spinner and the line shimmers, then says what the agent found
      svg.outerHTML = '<span class="atx-rd"><span class="spin"></span></span>'; swap(m, `<span class="shim">${sz} · Reading</span>`, true);
      c.later(REDUCE ? 0 : 900, () => {
        const rd = $('.atx-rd', it); if (rd) rd.outerHTML = `<span class="atx-ok">${I('ok')}</span>`;
        swap(m, found ? `${sz} · ${found}` : sz); read++; swap(sum, 'The agent can read ' + read + (read > 1 ? ' files' : ' file') + ' now');
      });
    });
  }
  const samples = () => [['brief.pdf', 2.4e6, '12 pages'],['Pricing.fig', 8.1e6, '6 frames'],['signups.csv', 3.2e5, '1,204 rows']].forEach((f, i) => add(f[0], f[1], i, f[2]));
  $('.atx-sample', r).onclick = e => { e.preventDefault(); samples(); };
  $('.atx-file', r).onchange = e => { Array.from(e.target.files).forEach((f, i) => add(f.name, f.size, i)); e.target.value = ''; };
  ['dragenter','dragover'].forEach(ev => c.on(drop, ev, e => { e.preventDefault(); drop.classList.add('over'); }));
  ['dragleave','drop'].forEach(ev => c.on(drop, ev, () => drop.classList.remove('over')));
  c.on(drop, 'drop', e => { e.preventDefault(); Array.from(e.dataTransfer.files).forEach((f, i) => add(f.name, f.size, i)); });
  /* the dashed edge follows the drop zone's size and corners, with the dashes spaced to meet evenly all the way round */
  const ants = $('.atx-ants rect', r);
  const fit = () => {
    const w = drop.offsetWidth, h = drop.offsetHeight; if (!w || !h) return;
    const k = .75, rad = Math.max(0, Math.min(parseFloat(getComputedStyle(drop).borderTopLeftRadius) || 0, w / 2, h / 2) - k);
    ants.setAttribute('x', k); ants.setAttribute('y', k); ants.setAttribute('width', w - 2 * k); ants.setAttribute('height', h - 2 * k); ants.setAttribute('rx', rad);
    const per = 2 * (w + h - 4 * k) - (8 - 2 * Math.PI) * rad, step = per / Math.max(8, Math.round(per / 11));
    ants.style.setProperty('--ap', step + 'px'); ants.style.setProperty('--ag', (step - 4.5) + 'px');
  };
  fit(); const ro = new ResizeObserver(fit); ro.observe(drop); c.offs.push(() => ro.disconnect());
  /* once it has entered, three sample files are dragged over, drop in and upload, so the tray shows what it does */
  c.run(async () => {
    await c.step(950); if (list.children.length) return;
    drop.classList.add('over'); c.taken(() => drop.classList.remove('over')); await c.step(1300); drop.classList.remove('over');
    if (!list.children.length) samples();
  });
});
06

Questions

What people ask about it.

Does the tray upload the files for me?
No. It hands you the files through onFiles. You upload them, add each one to files with an id, name and size, and update its progress as the upload runs.
How do I restrict which file types people can pick?
Pass accept, for example "image/*,.pdf". It is set on the file input, so it applies to the file dialog. Dropped files are passed on as they are, so check their type in onFiles too.
Can I change the line under the list?
Yes. Pass summary and it replaces the default count of files the agent can read.
07

More in Conversation

Everything for asking, answering and replying, by text or by voice.

Live answerAnswers that stream in with numbered sources and next steps.
ComposerMessage input with attachments, what to work on, a mode and dictation.
Thread panelSwitch between agent threads and reply in place.
Artifact cardA file the agent made, with its versions, Open, Copy and Download.
Rich text editorA minimal editor with an inline AI menu that streams a suggestion to accept or discard.
Tool callEach tool the agent calls, with its input and output.
Voice captureTalk to the agent, then fix what it misheard before sending.
Voice orbA hands-free voice chat, with an orb that shows who is talking.
All 15 in Conversation →