- Accordion
- Agent Thinking
- Alert
- Alert Dialog
- Announcement
- Aspect Ratio
- Attachment
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Checkbox
- Checkbox Card
- Code Block
- Collapsible
- Color Picker
- Combobox
- Command
- Composer
- Composer Loader
- Context Menu
- Data Grid
- Data Table
- Date Picker
- Dialog
- Direction
- Drawer
- Dropdown Menu
- Empty
- Field
- File Upload
- Hover Card
- Input
- Input Group
- Input Otp
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Meter
- Native Select
- Navigation Menu
- Number Field
- Pagination
- Period Switcher
- Popover
- Progress
- Questionnaire
- Radio Card
- Radio Group
- Resizable
- Reveal
- Scroll Area
- Search Field
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Social Button
- Sonner
- Spinner
- Status Dot
- Switch
- Switch Card
- Table
- Tabs
- Tag Group
- Textarea
- Theme Toggle
- Time Field
- Toggle
- Toggle Group
- Tooltip
- Trace
- Tree View
- Agent Creator 01
- Agent Limits Card 01
- Agent Progress 01
- AI Chat 01
- AI Image Generation 01
- AI Profile 01
- Alerts Card 01
- Auth Card 01
- Calendar 01
- Composer 01
- Composer Attachments 01
- Composer Panel 01
- Finance Dashboard 01
- Home Dashboard 01
- Marketing Dashboard 01
- Medical Profile 01
- Meeting Scheduler 01
- Multi Agent Chat 01
- Notification Center 01
- Patient Info Card 01
- Project Board 01
- Questionnaire 01
- Settings Modal 01
- Sidebar 01
- Stat Cards 01
- Task List 01
- Web Search 01
- Activity Rings Card 01
- Area Chart Card 01
- Bar List Card 01
- Combo Chart Card 01
- Contributions Card 01
- Earnings Chart Card 01
- Funnel Chart Card 01
- Heatmap Chart Card 01
- Line Chart Card 01
- Most Active Days Card 01
- Orders Chart Card 01
- Radar Chart Card 01
- Radial Chart Card 01
- Revenue Chart Card 01
- Sankey Chart Card 01
- Scatter Chart Card 01
- Sleep Score Card 01
- Stage Bars Card 01
- Steps Card 01
Upload queue
Add a few files to see each upload complete in turn.
File types
Images and documents share the same compact attachment strip.
Notes.pdf
Budget.csv
Brief.key
App.tsx
Clip.mov
Usage
Pass an array of attachments and update it with onAttachmentsChange. Omit progress for completed files, or provide a number from 0 to 100 to display upload progress. The standalone strip can be used outside the composer. Uploads and the lifetime of any supplied image URLs are managed by the caller.
Source
"use client";
import {
RiAddLine,
RiArrowUpLine,
RiCloseLine,
RiFileCodeLine,
RiFileExcel2Line,
RiFileLine,
RiFileTextLine,
RiFilmLine,
RiFolderLine,
RiGitBranchLine,
RiImageLine,
RiMicLine,
RiPresentationLine,
} from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";
import { useReducedMotion } from "@/components/hooks/use-reduced-motion";
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentGroup,
AttachmentMedia,
AttachmentTitle,
} from "@/components/ui/attachment";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Composer } from "@/components/ui/composer";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
export type AttachmentKind =
| "image"
| "document"
| "spreadsheet"
| "presentation"
| "code"
| "video";
export interface ComposerAttachment {
id: string;
name: string;
kind: AttachmentKind;
src?: string;
file?: File;
/** Omit when complete; otherwise a value from 0 to 100. */
progress?: number;
}
/** File-kind icons, coloured from the badge tones (each has a dark pair). */
const icons: Record<AttachmentKind, React.ReactNode> = {
image: (
<RiImageLine
aria-hidden
className="size-5 text-(--badge-cyan-foreground,var(--muted-foreground))"
/>
),
document: (
<RiFileTextLine
aria-hidden
className="size-5 text-(--file-upload-icon,var(--muted-foreground))"
/>
),
spreadsheet: (
<RiFileExcel2Line
aria-hidden
className="size-5 text-(--badge-blue-foreground,var(--muted-foreground))"
/>
),
presentation: (
<RiPresentationLine
aria-hidden
className="size-5 text-(--badge-purple-foreground,var(--muted-foreground))"
/>
),
code: (
<RiFileCodeLine
aria-hidden
className="size-5 text-(--badge-cyan-foreground,var(--muted-foreground))"
/>
),
video: (
<RiFilmLine
aria-hidden
className="size-5 text-(--badge-blue-foreground,var(--muted-foreground))"
/>
),
};
/** The count-up from orders-chart-card-01: 320ms, soft landing. */
function useCountUp(target: number, reduced: boolean) {
const [display, setDisplay] = React.useState(target);
const fromRef = React.useRef(target);
React.useEffect(() => {
const from = fromRef.current;
if (reduced) {
fromRef.current = target;
setDisplay(target);
return;
}
if (from === target) {
return;
}
const start = performance.now();
let frame = 0;
const tick = (now: number) => {
const t = Math.min(1, (now - start) / 320);
const value = Math.round(from + (target - from) * (1 - (1 - t) ** 3));
fromRef.current = value;
setDisplay(value);
if (t < 1) {
frame = requestAnimationFrame(tick);
} else {
fromRef.current = target;
}
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [target, reduced]);
return reduced ? target : display;
}
/**
* Starts at zero so the arc draws in when a tile mounts, then follows each
* progress update with the primitive's transition.
*/
function useRingProgress(target: number, reduced: boolean) {
const [value, setValue] = React.useState(0);
React.useEffect(() => {
if (reduced) {
setValue(target);
return;
}
const frame = requestAnimationFrame(() => setValue(target));
return () => cancelAnimationFrame(frame);
}, [target, reduced]);
return value;
}
export function ComposerAttachmentTile({
attachment,
index = 0,
onRemove,
}: {
attachment: ComposerAttachment;
index?: number;
onRemove?: (id: string) => void;
}) {
const icon = icons[attachment.kind] ?? (
<RiFileLine
aria-hidden
className="size-5 text-(--file-upload-icon,var(--muted-foreground))"
/>
);
const progress =
attachment.progress === undefined
? undefined
: Math.max(0, Math.min(100, attachment.progress));
const uploading = progress !== undefined && progress < 100;
const reduced = useReducedMotion();
const display = useCountUp(progress ?? 0, reduced);
const ring = useRingProgress(progress ?? 0, reduced);
return (
<div
className="composer-attachments-01-in shrink-0 snap-start"
style={{ "--delay": `${index * 40}ms` } as React.CSSProperties}
>
<Attachment
variant="outline"
orientation="vertical"
size="xs"
state={uploading ? "uploading" : "done"}
// oxlint-disable-next-line shadcn/no-restyle -- reference: the attachment tile is 56px
className="size-14! flex-nowrap gap-0 overflow-hidden"
>
{attachment.kind === "image" && attachment.src ? (
<AttachmentMedia variant="image" className="size-full!">
{/* User-provided previews can be local object URLs. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
alt={attachment.name}
className="size-full object-cover"
src={attachment.src}
/>
</AttachmentMedia>
) : (
<>
<AttachmentMedia
variant="bare"
className="m-1 size-5! shrink-0 self-start"
>
{icon}
</AttachmentMedia>
<AttachmentContent
// oxlint-disable-next-line shadcn/no-restyle -- reference: the 56px tile sets the filename about 7px above its bottom edge (the tile's own 4px padding plus the line box)
className="mt-auto flex min-w-0 flex-col justify-end px-1 pb-0.5 leading-none"
>
{/* oxlint-disable-next-line shadcn/no-restyle, shadcn/no-arbitrary-values -- reference: 9px tile filename */}
<AttachmentTitle className="text-[9px] leading-3 font-normal text-muted-foreground">
{attachment.name}
</AttachmentTitle>
</AttachmentContent>
</>
)}
{uploading ? (
<div
aria-label={`${attachment.name}: ${Math.round(progress)}% uploaded`}
className={cn(
"absolute inset-0 rounded-[8px]",
attachment.kind === "image" &&
attachment.src &&
"bg-background/50"
)}
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(progress)}
>
<svg
aria-hidden
className="absolute inset-0 size-full -rotate-90 text-primary"
viewBox="0 0 56 56"
>
<rect
x="1.5"
y="1.5"
width="53"
height="53"
rx="8"
fill="none"
stroke="currentColor"
strokeWidth="2"
pathLength="100"
strokeDasharray={`${ring} 100`}
className="composer-attachments-01-ring"
/>
</svg>
{/* oxlint-disable-next-line shadcn/no-arbitrary-values -- reference: 9px upload percentage */}
<span className="absolute top-1.5 right-1.5 text-[9px] font-medium text-primary tabular-nums">
{display}%
</span>
</div>
) : onRemove ? (
<AttachmentActions className="top-1! right-1! z-20">
<Tooltip>
<TooltipTrigger
render={
<AttachmentAction
aria-label={`Remove ${attachment.name}`}
size="icon-xs"
variant="filled"
shape="pill"
className="size-4"
onClick={() => onRemove(attachment.id)}
/>
}
>
<RiCloseLine className="size-3" />
</TooltipTrigger>
<TooltipContent>Remove {attachment.name}</TooltipContent>
</Tooltip>
</AttachmentActions>
) : null}
</Attachment>
</div>
);
}
export function ComposerAttachmentStrip({
attachments,
onRemove,
className,
}: {
attachments: ComposerAttachment[];
onRemove?: (id: string) => void;
className?: string;
}) {
return (
<AttachmentGroup
className={cn("flex-wrap gap-1.5 overflow-visible", className)}
>
{attachments.map((attachment, index) => (
<ComposerAttachmentTile
key={attachment.id}
attachment={attachment}
index={index}
onRemove={onRemove}
/>
))}
</AttachmentGroup>
);
}
export function ComposerAttachments({
attachments,
onAttachmentsChange,
onSend,
className,
}: {
attachments: ComposerAttachment[];
onAttachmentsChange: (attachments: ComposerAttachment[]) => void;
onSend?: (message: string, attachments: ComposerAttachment[]) => void;
className?: string;
}) {
const inputRef = React.useRef<HTMLInputElement>(null);
const [message, setMessage] = React.useState("");
const urls = React.useRef(new Set<string>());
const remove = (id: string) => {
const src = attachments.find((item) => item.id === id)?.src;
if (src && urls.current.delete(src)) {
URL.revokeObjectURL(src);
}
onAttachmentsChange(attachments.filter((item) => item.id !== id));
};
const addFiles = (files: FileList | null) => {
if (!files) {
return;
}
const items = Array.from(
files,
(file): ComposerAttachment => ({
id: crypto.randomUUID(),
name: file.name,
file,
kind: file.type.startsWith("image/")
? "image"
: file.type.startsWith("video/")
? "video"
: /\.(csv|xlsx?)$/i.test(file.name)
? "spreadsheet"
: /\.(pptx?|key)$/i.test(file.name)
? "presentation"
: /\.(js|ts|tsx|py|json)$/i.test(file.name)
? "code"
: "document",
src: file.type.startsWith("image/")
? URL.createObjectURL(file)
: undefined,
})
);
for (const item of items) {
if (item.src) {
urls.current.add(item.src);
}
}
onAttachmentsChange([...attachments, ...items]);
};
React.useEffect(() => {
const ownedUrls = urls.current;
return () => {
for (const url of ownedUrls) {
URL.revokeObjectURL(url);
}
};
}, []);
return (
<Composer
className={cn("@container gap-0", className)}
value={message}
onValueChange={setMessage}
onSend={(text) => onSend?.(text, attachments)}
>
<TooltipProvider>
<div className="mx-7 flex h-8.5 min-w-0 items-center justify-between gap-2 rounded-[12px] rounded-b-none bg-surface-strong px-3 text-xs text-muted-foreground">
<span className="flex min-w-0 items-center gap-2 overflow-hidden">
<span className="flex items-center gap-1 whitespace-nowrap">
<RiGitBranchLine className="size-3.5" /> trunk
</span>
<span className="flex items-center gap-1 whitespace-nowrap @max-[280px]:hidden">
<RiFolderLine className="size-3.5" /> studio
</span>
</span>
<span className="shrink-0 whitespace-nowrap">ā 42%</span>
</div>
<Card radius={20} size="sm">
<CardContent>
{attachments.length > 0 && (
<ComposerAttachmentStrip
attachments={attachments}
onRemove={remove}
/>
)}
<Textarea
variant="ghost"
aria-label="Message"
value={message}
onChange={(event) => setMessage(event.target.value)}
placeholder="Ask about your work..."
rows={1}
resize="vertical"
className="min-h-8"
/>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<input
ref={inputRef}
className="sr-only"
type="file"
multiple
onChange={(event) => {
addFiles(event.target.files);
event.target.value = "";
}}
/>
<Button
tooltip="Add attachments"
type="button"
variant="filled"
size="icon"
shape="pill"
onClick={() => inputRef.current?.click()}
>
<RiAddLine />
</Button>
<span className="whitespace-nowrap text-xs text-muted-foreground @max-[320px]:hidden">
ā Auto
</span>
</div>
<div className="flex min-w-0 items-center gap-2">
<span className="whitespace-nowrap text-xs text-muted-foreground @max-[380px]:hidden">
Standard
</span>
<Button
tooltip="Voice input"
type="button"
variant="outline"
size="icon"
shape="pill"
disabled
>
<RiMicLine />
</Button>
<Button
tooltip="Send message"
type="submit"
size="icon"
shape="pill"
>
<RiArrowUpLine />
</Button>
</div>
</div>
</CardContent>
</Card>
</TooltipProvider>
</Composer>
);
}