Chat

Attachment.

Small file chips with an icon, name and size, plus idle, uploading, error and done states.

startup.lua2.4 KB
tunnel-map.png184 KB
hub-rules.txt1.1 KB

Installation#

With the CLI

$npx lanterncn add attachment

This 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

  1. Install the dependencies

    npm install class-variance-authority radix-ui
  2. Copy the source into your project

    components/ui/attachment.tsx
    import * 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,
    };
  3. Update the import paths to match your project

    The source imports cn from @/lib/utils and 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

Drop a fileUp to 1 MB
excavate.luaUploading 64%
world-backup.zipToo large, 48 MB
go.lua820 B
fuel.lua