Skip to content
edmi-ui

Attachment

A file or image with metadata, upload state and actions. States idle, uploading, processing, error, done.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
thesis.pdfPDF, 2.4 MB
weights.csvUploading 64%
report.pdfProcessing
backtest.xlsxUpload failed
mandate.pdfPDF, 540 KB
thesis.pdfPDF, 2.4 MB
weights.csvUploading 64%
report.pdfProcessing
backtest.xlsxUpload failed
mandate.pdfPDF, 540 KB
thesis.pdf PDF, 2.4 MB
weights.csv Uploading 64%
report.pdf Processing
backtest.xlsx Upload failed
mandate.pdf PDF, 540 KB
Terminal window
npx shadcn@latest add @edmi-ui/attachment
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/attachment
Terminal window
yarn dlx shadcn@latest add @edmi-ui/attachment
Terminal window
bunx shadcn@latest add @edmi-ui/attachment

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/attachment
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/attachment
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/attachment
Terminal window
bunx shadcn-vue@latest add @edmi-ui/attachment

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/attachment.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/attachment.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/attachment.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/attachment.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
} from "@/components/ui/attachment";
import { Spinner } from "@/components/ui/spinner";
<div className="flex flex-wrap items-start gap-3">
<Attachment>
<AttachmentMedia>
<FileTextIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>thesis.pdf</AttachmentTitle>
<AttachmentDescription>PDF, 2.4 MB</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Remove">
<XIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
<Attachment state="uploading">
<AttachmentMedia>
<Spinner />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>weights.csv</AttachmentTitle>
<AttachmentDescription>Uploading 64%</AttachmentDescription>
</AttachmentContent>
</Attachment>
{/* … */}
</div>
<script setup lang="ts">
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
} from "@/components/ui/attachment";
import { Spinner } from "@/components/ui/spinner";
</script>
<template>
<div class="flex flex-wrap items-start gap-3">
<Attachment>
<AttachmentMedia><FileTextIcon /></AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>thesis.pdf</AttachmentTitle>
<AttachmentDescription>PDF, 2.4 MB</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Remove"><XIcon /></AttachmentAction>
</AttachmentActions>
</Attachment>
<Attachment state="uploading">
<AttachmentMedia><Spinner /></AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>weights.csv</AttachmentTitle>
<AttachmentDescription>Uploading 64%</AttachmentDescription>
</AttachmentContent>
</Attachment>
<Attachment state="processing">
<AttachmentMedia><Spinner /></AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>report.pdf</AttachmentTitle>
<AttachmentDescription>Processing</AttachmentDescription>
</AttachmentContent>
</Attachment>
<!-- … -->
</div>
</template>
<script lang="ts">
import * as Attachment from "$lib/components/ui/attachment";
import { Spinner } from "$lib/components/ui/spinner";
</script>
<div class="flex flex-wrap items-start gap-3">
<Attachment.Root>
<Attachment.Media><FileTextIcon /></Attachment.Media>
<Attachment.Content>
<Attachment.Title>thesis.pdf</Attachment.Title>
<Attachment.Description>PDF, 2.4 MB</Attachment.Description>
</Attachment.Content>
<Attachment.Actions>
<Attachment.Action aria-label="Remove"><XIcon /></Attachment.Action>
</Attachment.Actions>
</Attachment.Root>
<Attachment.Root state="uploading">
<Attachment.Media><Spinner /></Attachment.Media>
<Attachment.Content>
<Attachment.Title>weights.csv</Attachment.Title>
<Attachment.Description>Uploading 64%</Attachment.Description>
</Attachment.Content>
</Attachment.Root>
<Attachment.Root state="processing">
<Attachment.Media><Spinner /></Attachment.Media>
<Attachment.Content>
<Attachment.Title>report.pdf</Attachment.Title>
<Attachment.Description>Processing</Attachment.Description>
</Attachment.Content>
</Attachment.Root>
<!-- … -->
</div>
registry/ui/attachment.tsx
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "cn";
import type * as React from "react";
import { Button } from "@/registry/edmi/ui/button";
const attachmentVariants = cva(
"group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 flex-wrap rounded-xl border border-border bg-card text-card-foreground transition-colors focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-ring has-[>a,>button]:hover:bg-accent data-[state=error]:border-[color-mix(in_srgb,var(--destructive)_30%,var(--popover))] data-[state=idle]:border-dashed",
{
variants: {
size: {
default:
"gap-2 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.5 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-lg 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-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-foreground group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-md group-data-[state=error]/attachment:bg-[color-mix(in_srgb,var(--destructive)_10%,var(--card))] 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:
"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-medium group-data-[state=processing]/attachment:bg-linear-to-r group-data-[state=processing]/attachment:from-muted-foreground group-data-[state=processing]/attachment:via-foreground group-data-[state=processing]/attachment:to-muted-foreground group-data-[state=processing]/attachment:bg-clip-text group-data-[state=processing]/attachment:text-transparent group-data-[state=processing]/attachment:animate-pulse group-data-[state=uploading]/attachment:bg-linear-to-r group-data-[state=uploading]/attachment:from-muted-foreground group-data-[state=uploading]/attachment:via-foreground group-data-[state=uploading]/attachment:to-muted-foreground group-data-[state=uploading]/attachment:bg-clip-text group-data-[state=uploading]/attachment:text-transparent group-data-[state=uploading]/attachment:animate-pulse",
className,
)}
{...props}
/>
);
}
function AttachmentDescription({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="attachment-description"
className={cn(
"mt-0.5 block min-w-0 truncate text-xs text-muted-foreground group-data-[state=error]/attachment:text-destructive/80",
"max-w-full",
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-3 group-data-[orientation=vertical]/attachment:right-3 group-data-[orientation=vertical]/attachment:gap-1",
className,
)}
{...props}
/>
);
}
function AttachmentAction({
className,
variant,
size = "icon-xs",
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="attachment-action"
variant={variant ?? "ghost"}
size={size}
className={cn(className)}
{...props}
/>
);
}
function AttachmentTrigger({
className,
render,
type,
...props
}: useRender.ComponentProps<"button">) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
type: render ? type : (type ?? "button"),
className: cn("absolute inset-0 z-10 outline-none", className),
},
props,
),
render,
state: {
slot: "attachment-trigger",
},
});
}
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 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden gap-3 overflow-x-auto overscroll-x-contain py-1 *:data-[slot=attachment]:flex-none *:data-[slot=attachment]:snap-start",
className,
)}
{...props}
/>
);
}
export {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentGroup,
AttachmentMedia,
AttachmentTitle,
AttachmentTrigger,
};
registry/ui/attachment/Attachment.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { AttachmentVariants } from '.'
import { cn } from '@/registry/edmi/lib/utils'
import { attachmentVariants } from '.'
const props = withDefaults(defineProps<{
class?: HTMLAttributes['class']
state?: 'idle' | 'uploading' | 'processing' | 'error' | 'done'
size?: AttachmentVariants['size']
orientation?: AttachmentVariants['orientation']
}>(), {
state: 'done',
size: 'default',
orientation: 'horizontal',
})
</script>
<template>
<div
data-slot="attachment"
:data-state="state"
:data-size="size"
:data-orientation="orientation"
:class="cn(attachmentVariants({ size, orientation }), props.class)"
>
<slot />
</div>
</template>
registry/ui/attachment/AttachmentAction.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '@/registry/edmi/ui/button'
import { cn } from '@/registry/edmi/lib/utils'
import { Button } from '@/registry/edmi/ui/button'
const props = withDefaults(defineProps<{
class?: HTMLAttributes['class']
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
}>(), {
variant: 'ghost',
size: 'icon-xs',
})
</script>
<template>
<Button
data-slot="attachment-action"
:variant="variant"
:size="size"
:class="cn(props.class)"
>
<slot />
</Button>
</template>
registry/ui/attachment/AttachmentActions.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="attachment-actions"
:class="cn('relative z-20 flex shrink-0 items-center group-data-[orientation=vertical]/attachment:absolute group-data-[orientation=vertical]/attachment:top-3 group-data-[orientation=vertical]/attachment:right-3 group-data-[orientation=vertical]/attachment:gap-1', props.class)"
>
<slot />
</div>
</template>
registry/ui/attachment/AttachmentContent.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="attachment-content"
:class="cn('max-w-full min-w-0 flex-1 leading-tight group-data-[orientation=vertical]/attachment:px-1', props.class)"
>
<slot />
</div>
</template>
registry/ui/attachment/AttachmentDescription.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<span
data-slot="attachment-description"
:class="cn('mt-0.5 block min-w-0 truncate text-xs text-muted-foreground group-data-[state=error]/attachment:text-destructive/80 max-w-full', props.class)"
>
<slot />
</span>
</template>
registry/ui/attachment/AttachmentGroup.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="attachment-group"
:class="cn('flex min-w-0 snap-x snap-mandatory scroll-px-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden gap-3 overflow-x-auto overscroll-x-contain py-1 *:data-[slot=attachment]:flex-none *:data-[slot=attachment]:snap-start', props.class)"
>
<slot />
</div>
</template>
registry/ui/attachment/AttachmentMedia.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { AttachmentMediaVariants } from '.'
import { cn } from '@/registry/edmi/lib/utils'
import { attachmentMediaVariants } from '.'
const props = withDefaults(defineProps<{
class?: HTMLAttributes['class']
variant?: AttachmentMediaVariants['variant']
}>(), {
variant: 'icon',
})
</script>
<template>
<div
data-slot="attachment-media"
:data-variant="variant"
:class="cn(attachmentMediaVariants({ variant }), props.class)"
>
<slot />
</div>
</template>
registry/ui/attachment/AttachmentTitle.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<span
data-slot="attachment-title"
:class="cn('block max-w-full min-w-0 truncate font-medium group-data-[state=processing]/attachment:bg-linear-to-r group-data-[state=processing]/attachment:from-muted-foreground group-data-[state=processing]/attachment:via-foreground group-data-[state=processing]/attachment:to-muted-foreground group-data-[state=processing]/attachment:bg-clip-text group-data-[state=processing]/attachment:text-transparent group-data-[state=processing]/attachment:animate-pulse group-data-[state=uploading]/attachment:bg-linear-to-r group-data-[state=uploading]/attachment:from-muted-foreground group-data-[state=uploading]/attachment:via-foreground group-data-[state=uploading]/attachment:to-muted-foreground group-data-[state=uploading]/attachment:bg-clip-text group-data-[state=uploading]/attachment:text-transparent group-data-[state=uploading]/attachment:animate-pulse', props.class)"
>
<slot />
</span>
</template>
registry/ui/attachment/AttachmentTrigger.vue
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { Primitive } from 'reka-ui'
import { cn } from '@/registry/edmi/lib/utils'
const props = withDefaults(defineProps<PrimitiveProps & {
class?: HTMLAttributes['class']
}>(), {
as: 'button',
})
</script>
<template>
<Primitive
data-slot="attachment-trigger"
:as="as"
:as-child="asChild"
:type="as === 'button' ? 'button' : undefined"
:class="cn('absolute inset-0 z-10 outline-none', props.class)"
>
<slot />
</Primitive>
</template>
registry/ui/attachment/index.ts
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
export { default as Attachment } from "./Attachment.vue";
export { default as AttachmentAction } from "./AttachmentAction.vue";
export { default as AttachmentActions } from "./AttachmentActions.vue";
export { default as AttachmentContent } from "./AttachmentContent.vue";
export { default as AttachmentDescription } from "./AttachmentDescription.vue";
export { default as AttachmentGroup } from "./AttachmentGroup.vue";
export { default as AttachmentMedia } from "./AttachmentMedia.vue";
export { default as AttachmentTitle } from "./AttachmentTitle.vue";
export { default as AttachmentTrigger } from "./AttachmentTrigger.vue";
export const attachmentVariants = cva(
"group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 flex-wrap rounded-xl border border-border bg-card text-card-foreground transition-colors focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-ring has-[>a,>button]:hover:bg-accent data-[state=error]:border-[color-mix(in_srgb,var(--destructive)_30%,var(--popover))] data-[state=idle]:border-dashed",
{
variants: {
size: {
default:
"gap-2 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.5 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-lg 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",
},
},
},
);
export type AttachmentVariants = VariantProps<typeof attachmentVariants>;
export const attachmentMediaVariants = cva(
"relative flex aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-foreground group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-md group-data-[state=error]/attachment:bg-[color-mix(in_srgb,var(--destructive)_10%,var(--card))] 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:
"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",
},
},
);
export type AttachmentMediaVariants = VariantProps<
typeof attachmentMediaVariants
>;
src/lib/registry/ui/attachment/attachment-action.svelte
<script lang="ts">
import { Button, type ButtonProps } from "$lib/registry/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
variant = "ghost",
size = "icon-xs",
...restProps
}: ButtonProps = $props();
</script>
<Button
bind:ref
data-slot="attachment-action"
{variant}
{size}
class={cn(className)}
{...restProps}
/>
src/lib/registry/ui/attachment/attachment-actions.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="attachment-actions"
class={cn(
"relative z-20 flex shrink-0 items-center group-data-[orientation=vertical]/attachment:absolute group-data-[orientation=vertical]/attachment:top-3 group-data-[orientation=vertical]/attachment:right-3 group-data-[orientation=vertical]/attachment:gap-1",
className
)}
{...restProps}
>
{@render children?.()}
</div>
src/lib/registry/ui/attachment/attachment-content.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="attachment-content"
class={cn(
"max-w-full min-w-0 flex-1 leading-tight group-data-[orientation=vertical]/attachment:px-1",
className
)}
{...restProps}
>
{@render children?.()}
</div>
src/lib/registry/ui/attachment/attachment-description.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="attachment-description"
class={cn(
"mt-0.5 block min-w-0 truncate text-xs text-muted-foreground group-data-[state=error]/attachment:text-destructive/80 max-w-full",
className
)}
{...restProps}
>
{@render children?.()}
</span>
src/lib/registry/ui/attachment/attachment-group.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="attachment-group"
class={cn(
"flex min-w-0 snap-x snap-mandatory scroll-px-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden gap-3 overflow-x-auto overscroll-x-contain py-1 *:data-[slot=attachment]:flex-none *:data-[slot=attachment]:snap-start",
className
)}
{...restProps}
>
{@render children?.()}
</div>
src/lib/registry/ui/attachment/attachment-media.svelte
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
export const attachmentMediaVariants = tv({
base: "relative flex aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-foreground group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-md group-data-[state=error]/attachment:bg-[color-mix(in_srgb,var(--destructive)_10%,var(--card))] 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: "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",
},
});
export type AttachmentMediaVariant = VariantProps<typeof attachmentMediaVariants>["variant"];
</script>
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
variant = "icon",
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { variant?: AttachmentMediaVariant } = $props();
</script>
<div
bind:this={ref}
data-slot="attachment-media"
data-variant={variant}
class={cn(attachmentMediaVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</div>
src/lib/registry/ui/attachment/attachment-title.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="attachment-title"
class={cn(
"block max-w-full min-w-0 truncate font-medium group-data-[state=processing]/attachment:bg-linear-to-r group-data-[state=processing]/attachment:from-muted-foreground group-data-[state=processing]/attachment:via-foreground group-data-[state=processing]/attachment:to-muted-foreground group-data-[state=processing]/attachment:bg-clip-text group-data-[state=processing]/attachment:text-transparent group-data-[state=processing]/attachment:animate-pulse group-data-[state=uploading]/attachment:bg-linear-to-r group-data-[state=uploading]/attachment:from-muted-foreground group-data-[state=uploading]/attachment:via-foreground group-data-[state=uploading]/attachment:to-muted-foreground group-data-[state=uploading]/attachment:bg-clip-text group-data-[state=uploading]/attachment:text-transparent group-data-[state=uploading]/attachment:animate-pulse",
className
)}
{...restProps}
>
{@render children?.()}
</span>
src/lib/registry/ui/attachment/attachment-trigger.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { Snippet } from "svelte";
import type { HTMLButtonAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
child,
children,
type = "button",
...restProps
}: WithElementRef<HTMLButtonAttributes> & {
child?: Snippet<[{ props: Record<string, unknown> }]>;
} = $props();
const mergedProps = $derived({
class: cn("absolute inset-0 z-10 outline-none", className),
"data-slot": "attachment-trigger",
...restProps,
});
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button bind:this={ref} {type} {...mergedProps}>
{@render children?.()}
</button>
{/if}
src/lib/registry/ui/attachment/attachment.svelte
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
export const attachmentVariants = tv({
base: "group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 flex-wrap rounded-xl border border-border bg-card text-card-foreground transition-colors focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-ring has-[>a,>button]:hover:bg-accent data-[state=error]:border-[color-mix(in_srgb,var(--destructive)_30%,var(--popover))] data-[state=idle]:border-dashed",
variants: {
size: {
default: "gap-2 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.5 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-lg 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",
},
},
defaultVariants: {
size: "default",
orientation: "horizontal",
},
});
export type AttachmentSize = VariantProps<typeof attachmentVariants>["size"];
export type AttachmentOrientation = VariantProps<typeof attachmentVariants>["orientation"];
export type AttachmentState = "idle" | "uploading" | "processing" | "error" | "done";
</script>
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
state = "done",
size = "default",
orientation = "horizontal",
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
state?: AttachmentState;
size?: AttachmentSize;
orientation?: AttachmentOrientation;
} = $props();
</script>
<div
bind:this={ref}
data-slot="attachment"
data-state={state}
data-size={size}
data-orientation={orientation}
class={cn(attachmentVariants({ size, orientation }), className)}
{...restProps}
>
{@render children?.()}
</div>
src/lib/registry/ui/attachment/index.ts
import Root from "./attachment.svelte";
import Action from "./attachment-action.svelte";
import Actions from "./attachment-actions.svelte";
import Content from "./attachment-content.svelte";
import Description from "./attachment-description.svelte";
import Group from "./attachment-group.svelte";
import Media from "./attachment-media.svelte";
import Title from "./attachment-title.svelte";
import Trigger from "./attachment-trigger.svelte";
export {
Action,
Action as AttachmentAction,
Actions,
Actions as AttachmentActions,
Content,
Content as AttachmentContent,
Description,
Description as AttachmentDescription,
Group,
Group as AttachmentGroup,
Media,
Media as AttachmentMedia,
Root,
//
Root as Attachment,
Title,
Title as AttachmentTitle,
Trigger,
Trigger as AttachmentTrigger,
};
Prop Type Default
state idle | uploading | processing | error | done done
size default | sm | xs default
orientation horizontal | vertical horizontal
AttachmentMedia variant icon | image icon

Parts: Attachment, AttachmentGroup, AttachmentMedia, AttachmentContent, AttachmentTitle, AttachmentDescription, AttachmentActions, AttachmentAction, AttachmentTrigger.

  • Uploading and processing titles shimmer (gradient text); idle is dashed, error uses the destructive tint; hard 1px lip.