Attachment
Styled attachment with media, title, description, actions, trigger and group. A pure layout primitive.
Installation
Add this component to your project:
pnpm dlx shadcn@latest add @foldcn/attachmentSource
The component ships as plain source — no build step, no wrapper. Copy it and make it yours.
registry/default/ui/attachment.ts253 linesimport { Button as FoldkitButton } from '@foldkit/ui'
import type { Attribute, Html, HtmlBuilder } from 'foldkit/html'
type Child = Html | string
import { cn } from '@/lib/utils'
export const attachmentStateKeys = ['idle', 'uploading', 'processing', 'error', 'done'] as const
export type AttachmentState = (typeof attachmentStateKeys)[number]
export const attachmentSizeKeys = ['default', 'sm', 'xs'] as const
export type AttachmentSize = (typeof attachmentSizeKeys)[number]
export const attachmentSizes: Record<AttachmentSize, string> = {
default: 'gap-2 has-data-[slot=attachment-content]:px-2.5 has-data-[slot=attachment-content]:py-2 has-data-[slot=attachment-media]:p-2 text-sm',
sm: 'gap-2.5 has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5 has-data-[slot=attachment-media]:p-1.5 text-xs',
xs: 'gap-1.5 has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1 text-xs rounded-lg',
}
export const attachmentOrientationKeys = ['horizontal', 'vertical'] as const
export type AttachmentOrientation = (typeof attachmentOrientationKeys)[number]
export const attachmentOrientations: Record<AttachmentOrientation, string> = {
horizontal: 'min-w-40 items-center',
vertical: 'w-24 has-data-[slot=attachment-content]:w-30 flex-col',
}
export const attachmentClass =
'rounded-xl w-fit focus-within:ring-1 focus-within:ring-ring/50 group/attachment relative flex max-w-full min-w-0 shrink-0 flex-wrap border bg-card text-card-foreground transition-colors has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed'
export const attachmentMediaVariantKeys = ['icon', 'image'] as const
export type AttachmentMediaVariant = (typeof attachmentMediaVariantKeys)[number]
export const attachmentMediaVariants: Record<AttachmentMediaVariant, string> = {
icon: '',
image:
'opacity-60 group-data-[state=idle]/attachment:opacity-100 group-data-[state=done]/attachment:opacity-100 *:[img]:aspect-square *:[img]:w-full *:[img]:object-cover',
}
export const attachmentMediaClass =
'bg-muted text-foreground w-10 rounded-lg group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-md [&_svg:not([class*=\'size-\'])]:size-4 group-data-[size=xs]/attachment:[&_svg:not([class*=\'size-\'])]:size-3.5 group-data-[orientation=vertical]/attachment:w-full group-data-[orientation=vertical]/attachment:[&_svg:not([class*=\'size-\'])]:size-6 group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! relative flex aspect-square shrink-0 items-center justify-center overflow-hidden group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive [&_svg]:pointer-events-none'
export const attachmentContentClass = 'leading-tight group-data-[orientation=vertical]/attachment:px-1 max-w-full min-w-0 flex-1'
export const attachmentTitleClass =
'font-medium block max-w-full min-w-0 truncate group-data-[state=processing]/attachment:shimmer group-data-[state=uploading]/attachment:shimmer'
export const attachmentDescriptionClass =
'mt-0.5 text-xs block min-w-0 truncate text-muted-foreground group-data-[state=error]/attachment:text-destructive/80 max-w-full'
export const attachmentActionsClass = 'group-data-[orientation=vertical]/attachment:absolute group-data-[orientation=vertical]/attachment:top-3 group-data-[orientation=vertical]/attachment:right-3 relative z-20 group-data-[orientation=vertical]/attachment:gap-1 flex shrink-0 items-center'
export const attachmentActionClass =
'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 active:not-aria-[haspopup]:translate-y-px group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none [&_svg]:pointer-events-none [&_svg]:shrink-0 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*=\'size-\'])]:size-3'
export const attachmentTriggerClass = 'absolute inset-0 z-10 outline-none'
export const attachmentGroupClass =
'gap-3 scroll-px-1 py-1 flex min-w-0 scroll-fade-x snap-x snap-mandatory scrollbar-none overflow-x-auto overscroll-x-contain *:data-[slot=attachment]:flex-none *:data-[slot=attachment]:snap-start'
type StyleConfig = Readonly<{ className?: string }>
type AttachmentConfig = Readonly<{
state?: AttachmentState
size?: AttachmentSize
orientation?: AttachmentOrientation
className?: string
}>
type AttachmentMediaConfig = Readonly<{
variant?: AttachmentMediaVariant
className?: string
}>
type ButtonConfig<M> = Readonly<{
onClick?: M
isDisabled?: boolean
type?: 'button' | 'submit' | 'reset'
isAutofocus?: boolean
className?: string
attributes?: ReadonlyArray<Attribute<M>>
}>
const attachmentGroup = <M>(
config: StyleConfig,
children: ReadonlyArray<Child>,
h: HtmlBuilder<M>,
): Html =>
h.div(
[
h.Class(cn(attachmentGroupClass, config.className)),
h.DataAttribute('slot', 'attachment-group'),
],
children,
)
const attachmentContainer = <M>(
config: AttachmentConfig,
children: ReadonlyArray<Child>,
h: HtmlBuilder<M>,
): Html =>
h.div(
[
h.Class(
cn(
attachmentClass,
attachmentSizes[config.size ?? 'default'],
attachmentOrientations[config.orientation ?? 'horizontal'],
config.className,
),
),
h.DataAttribute('slot', 'attachment'),
h.DataAttribute('state', config.state ?? 'done'),
h.DataAttribute('size', config.size ?? 'default'),
h.DataAttribute('orientation', config.orientation ?? 'horizontal'),
],
children,
)
const attachmentMedia = <M>(
config: AttachmentMediaConfig,
children: ReadonlyArray<Child>,
h: HtmlBuilder<M>,
): Html =>
h.div(
[
h.Class(
cn(
attachmentMediaClass,
attachmentMediaVariants[config.variant ?? 'icon'],
config.className,
),
),
h.DataAttribute('slot', 'attachment-media'),
h.DataAttribute('variant', config.variant ?? 'icon'),
],
children,
)
const attachmentContent = <M>(
config: StyleConfig,
children: ReadonlyArray<Child>,
h: HtmlBuilder<M>,
): Html =>
h.div(
[
h.Class(cn(attachmentContentClass, config.className)),
h.DataAttribute('slot', 'attachment-content'),
],
children,
)
const attachmentTitle = <M>(
config: StyleConfig,
children: ReadonlyArray<Child>,
h: HtmlBuilder<M>,
): Html =>
h.span(
[
h.Class(cn(attachmentTitleClass, config.className)),
h.DataAttribute('slot', 'attachment-title'),
],
children,
)
const attachmentDescription = <M>(
config: StyleConfig,
children: ReadonlyArray<Child>,
h: HtmlBuilder<M>,
): Html =>
h.span(
[
h.Class(cn(attachmentDescriptionClass, config.className)),
h.DataAttribute('slot', 'attachment-description'),
],
children,
)
const attachmentActions = <M>(
config: StyleConfig,
children: ReadonlyArray<Child>,
h: HtmlBuilder<M>,
): Html =>
h.div(
[
h.Class(cn(attachmentActionsClass, config.className)),
h.DataAttribute('slot', 'attachment-actions'),
],
children,
)
const attachmentAction = <M>(
config: ButtonConfig<M>,
label: Html | string,
h: HtmlBuilder<M>,
): Html =>
FoldkitButton.view<M>(
{
onClick: config.onClick,
isDisabled: config.isDisabled,
type: config.type,
isAutofocus: config.isAutofocus,
toView: (attributes) =>
h.button(
[
...attributes.button,
h.Class(cn(attachmentActionClass, config.className)),
h.DataAttribute('slot', 'attachment-action'),
...(config.attributes ?? []),
],
[label],
),
},
h,
)
const attachmentTrigger = <M>(config: ButtonConfig<M>, h: HtmlBuilder<M>): Html =>
FoldkitButton.view<M>(
{
onClick: config.onClick,
isDisabled: config.isDisabled,
type: config.type ?? 'button',
isAutofocus: config.isAutofocus,
toView: (attributes) =>
h.button(
[
...attributes.button,
h.Class(cn(attachmentTriggerClass, config.className)),
h.DataAttribute('slot', 'attachment-trigger'),
...(config.attributes ?? []),
],
[],
),
},
h,
)
/** Styled attachment — a file/card preview with `Attachment.group`,
* `Attachment.media`, `Attachment.content`, `Attachment.title`,
* `Attachment.description`, `Attachment.actions`, `Attachment.action`,
* `Attachment.trigger` sub-builders. Mirrors the shadcn v4 `attachment.tsx`.
* Pure layout primitive (no @foldkit/ui submodel). */
export const Attachment = Object.assign(attachmentContainer, {
group: attachmentGroup,
media: attachmentMedia,
content: attachmentContent,
title: attachmentTitle,
description: attachmentDescription,
actions: attachmentActions,
action: attachmentAction,
trigger: attachmentTrigger,
})