Chat
Attachment.
Small file chips with an icon, name and size, plus idle, uploading, error and done states.
"use client";
import * as React from "react";
import { FileCode, FileText, Image as ImageIcon, X } from "lucide-react";
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentGroup,
AttachmentMedia,
AttachmentTitle,
} from "@/components/ui/attachment";
import { Button } from "@/components/ui/button";
const initial = [
{ id: "1", name: "startup.lua", size: "2.4 KB", icon: FileCode },
{ id: "2", name: "tunnel-map.png", size: "184 KB", icon: ImageIcon },
{ id: "3", name: "hub-rules.txt", size: "1.1 KB", icon: FileText },
];
export default function AttachmentDemo() {
const [files, setFiles] = React.useState(initial);
return (
<div className="flex w-full max-w-md flex-col gap-3">
<AttachmentGroup>
{files.map((file) => (
<Attachment key={file.id}>
<AttachmentMedia>
<file.icon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>{file.name}</AttachmentTitle>
<AttachmentDescription>{file.size}</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction
aria-label={`Remove ${file.name}`}
onClick={() => setFiles((current) => current.filter((f) => f.id !== file.id))}
>
<X />
</AttachmentAction>
</AttachmentActions>
</Attachment>
))}
</AttachmentGroup>
{files.length < initial.length && (
<Button variant="link" size="sm" className="self-start" onClick={() => setFiles(initial)}>
Restore files
</Button>
)}
</div>
);
}Installation#
With the CLI
$npx lanterncn add attachmentThis also installs the Lantern theme and any Lantern UI components it depends on. The shadcn CLI works directly too: npx shadcn@latest add httptim/lantern-ui/attachment. See Installation if your project is not set up yet.
Manually
Install the dependencies
npm install class-variance-authority radix-uiCopy the source into your project
components/ui/attachment.tsximport * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; import { Slot } from "radix-ui"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; const attachmentVariants = cva( "group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 flex-wrap rounded-md border border-border bg-card text-card-foreground transition-colors focus-within:border-primary focus-within:ring-2 focus-within:ring-ring/25 has-[>a,>button]:hover:border-input has-[>a,>button]:hover:bg-secondary data-[state=error]:border-destructive/40 data-[state=idle]:border-dashed data-[state=idle]:border-input", { variants: { size: { default: "gap-2.5 text-sm has-data-[slot=attachment-content]:px-2.5 has-data-[slot=attachment-content]:py-2 has-data-[slot=attachment-media]:p-2", sm: "gap-2 text-xs has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5 has-data-[slot=attachment-media]:p-1.5", xs: "gap-1.5 rounded-sm text-xs has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1", }, orientation: { horizontal: "min-w-40 items-center", vertical: "w-24 flex-col has-data-[slot=attachment-content]:w-30", }, }, }, ); function Attachment({ className, state = "done", size = "default", orientation = "horizontal", ...props }: React.ComponentProps<"div"> & VariantProps<typeof attachmentVariants> & { state?: "idle" | "uploading" | "processing" | "error" | "done"; }) { return ( <div data-slot="attachment" data-state={state} data-size={size} data-orientation={orientation} className={cn(attachmentVariants({ size, orientation }), className)} {...props} /> ); } const attachmentMediaVariants = cva( "relative flex aspect-square w-9 shrink-0 items-center justify-center overflow-hidden rounded-sm border border-border bg-accent text-[#b7ca9e] group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-7 group-data-[size=xs]/attachment:w-6 group-data-[state=error]/attachment:border-destructive/30 group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5", { variants: { variant: { icon: "", image: "bg-grid opacity-60 group-data-[state=done]/attachment:opacity-100 group-data-[state=idle]/attachment:opacity-100 *:[img]:aspect-square *:[img]:w-full *:[img]:object-cover", }, }, defaultVariants: { variant: "icon" }, }, ); function AttachmentMedia({ className, variant = "icon", ...props }: React.ComponentProps<"div"> & VariantProps<typeof attachmentMediaVariants>) { return ( <div data-slot="attachment-media" data-variant={variant} className={cn(attachmentMediaVariants({ variant }), className)} {...props} /> ); } function AttachmentContent({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="attachment-content" className={cn("max-w-full min-w-0 flex-1 leading-tight group-data-[orientation=vertical]/attachment:px-1", className)} {...props} /> ); } function AttachmentTitle({ className, ...props }: React.ComponentProps<"span">) { return ( <span data-slot="attachment-title" className={cn( "block max-w-full min-w-0 truncate font-mono text-xs font-medium group-data-[state=processing]/attachment:animate-lantern-pulse group-data-[state=uploading]/attachment:animate-lantern-pulse", className, )} {...props} /> ); } function AttachmentDescription({ className, ...props }: React.ComponentProps<"span">) { return ( <span data-slot="attachment-description" className={cn( "mt-1 block max-w-full min-w-0 truncate font-mono text-[10px] tracking-[0.12em] text-muted-foreground uppercase group-data-[state=error]/attachment:text-destructive/90", className, )} {...props} /> ); } function AttachmentActions({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="attachment-actions" className={cn( "relative z-20 flex shrink-0 items-center group-data-[orientation=vertical]/attachment:absolute group-data-[orientation=vertical]/attachment:top-2 group-data-[orientation=vertical]/attachment:right-2 group-data-[orientation=vertical]/attachment:gap-1", className, )} {...props} /> ); } function AttachmentAction({ className, variant, size = "icon-sm", ...props }: React.ComponentProps<typeof Button>) { return ( <Button data-slot="attachment-action" variant={variant ?? "ghost"} size={size} className={cn("size-6 rounded-sm [&_svg:not([class*='size-'])]:size-3.5", className)} {...props} /> ); } function AttachmentTrigger({ className, asChild = false, type, ...props }: React.ComponentProps<"button"> & { asChild?: boolean }) { const Comp = asChild ? Slot.Root : "button"; return ( <Comp data-slot="attachment-trigger" type={asChild ? undefined : (type ?? "button")} className={cn("absolute inset-0 z-10 cursor-pointer rounded-md outline-none", className)} {...props} /> ); } function AttachmentGroup({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="attachment-group" className={cn( "flex min-w-0 snap-x snap-mandatory scroll-px-1 gap-2 overflow-x-auto overscroll-x-contain py-1 pe-8 [mask-image:linear-gradient(to_right,black_calc(100%-2rem),transparent)] rtl:[mask-image:linear-gradient(to_left,black_calc(100%-2rem),transparent)] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden *:data-[slot=attachment]:flex-none *:data-[slot=attachment]:snap-start", className, )} {...props} /> ); } export { Attachment, AttachmentGroup, AttachmentMedia, AttachmentContent, AttachmentTitle, AttachmentDescription, AttachmentActions, AttachmentAction, AttachmentTrigger, };Update the import paths to match your project
The source imports
cnfrom@/lib/utilsand uses button.
Usage#
import {
Attachment,
AttachmentGroup,
AttachmentMedia,
AttachmentContent,
AttachmentTitle,
AttachmentDescription,
AttachmentActions,
AttachmentAction,
AttachmentTrigger,
} from "@/components/ui/attachment";<Attachment>
<AttachmentMedia>
<FileCode />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>startup.lua</AttachmentTitle>
<AttachmentDescription>2.4 KB</AttachmentDescription>
</AttachmentContent>
</Attachment>AttachmentGroup scrolls sideways with snapping when the chips do not fit.
Add AttachmentTrigger to make the whole chip clickable, for example to open a preview. It sits under AttachmentActions, so remove buttons still work.
Examples#
States and sizes
import { FileCode, FileWarning, Upload } from "lucide-react";
import {
Attachment,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
AttachmentTrigger,
} from "@/components/ui/attachment";
import { Spinner } from "@/components/ui/spinner";
export default function AttachmentStates() {
return (
<div className="flex w-full max-w-md flex-col gap-3">
<Attachment state="idle">
<AttachmentMedia>
<Upload />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>Drop a file</AttachmentTitle>
<AttachmentDescription>Up to 1 MB</AttachmentDescription>
</AttachmentContent>
<AttachmentTrigger aria-label="Choose a file" />
</Attachment>
<Attachment state="uploading">
<AttachmentMedia>
<Spinner />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>excavate.lua</AttachmentTitle>
<AttachmentDescription>Uploading 64%</AttachmentDescription>
</AttachmentContent>
</Attachment>
<Attachment state="error">
<AttachmentMedia>
<FileWarning />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>world-backup.zip</AttachmentTitle>
<AttachmentDescription>Too large, 48 MB</AttachmentDescription>
</AttachmentContent>
</Attachment>
<div className="flex gap-2">
<Attachment size="sm">
<AttachmentMedia>
<FileCode />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>go.lua</AttachmentTitle>
<AttachmentDescription>820 B</AttachmentDescription>
</AttachmentContent>
</Attachment>
<Attachment size="xs" className="min-w-0">
<AttachmentMedia>
<FileCode />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>fuel.lua</AttachmentTitle>
</AttachmentContent>
</Attachment>
</div>
</div>
);
}