ConversationAttachment tray
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callAttachment 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.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add attachment-trayReact
npx designme add attachment-tray --reactThe 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.
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.
- Files dropped on the zone or chosen with the file dialog go to
onFilesas a list ofFileobjects.acceptlimits the dialog to certain types. - Each file gets a coloured badge from its extension, like PDF, PNG or CSV, and its size in KB or MB. The badge's colour, red for PDF, violet for images and Figma, green for spreadsheets and blue for the rest, also draws its ring and its upload wash.
progressfrom 0 to 100 closes the ring, shows the percentage after the size and fills the row from the left. At 100 the wash fades and the ring becomes a check. Files that arrive together come in one after another.- Set
readingonce a file is up and the agent is reading it: a spinner takes the ring's place and "Reading" shimmers after the size. Then setdetailto what the agent found, like "12 pages" or "1,204 rows", and it shows after the size. onRemoveadds a remove button to each file and is called after the file has animated out.- The line under the list counts files that are up and read, like "The agent can read 2 files now", unless you pass your own
summary. - The drop zone takes the accent under the pointer. While a file is dragged over it, its dashed edge marches, its arrow bobs and rings spread from the badge.
titlechanges its heading,actionadds a link after "or", anddraggingshows the drag state for a scripted demo.
- The drop zone wraps a real file input labelled "Choose files", so it also works without dragging.
- Each upload ring is a
role="progressbar"witharia-valuenowfrom 0 to 100 and a label naming the file. - Remove buttons are labelled with the file name, like "Remove brief.pdf".
- With reduced motion the edge, the arrow, the rings and the shimmer stay still; the ring and the percentage still show the progress.
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>
);
}
Props
Everything AttachmentTray takes.
| Prop | Type | What it does |
|---|---|---|
filesrequired | Attachment[] | |
onFiles | (files: File[]) => void | Files the person dropped on the tray or picked with the file dialog |
onRemove | (id: string) => void | Adds a remove button to each file. Called once the file has animated out |
title | ReactNode | The drop zone's heading |
action | { label: string; onClick: () => void } | A link after "or" in the drop zone, like "add sample files" |
accept | string | Passed to the file input, like "image/*,.pdf" |
summary | string | The line under the list. Defaults to how many files the agent can read |
dragging | boolean | Shows the drop zone as if a file were being dragged over it, for a scripted demo. A real drag shows it by itself |
className | string |
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,'&').replace(/</g,'<');
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, '"')}" 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, '"')}" 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();
});
});
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 tofileswith anid,nameandsize, and update itsprogressas 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 inonFilestoo. - Can I change the line under the list?
- Yes. Pass
summaryand it replaces the default count of files the agent can read.
More in Conversation
Everything for asking, answering and replying, by text or by voice.