View as Markdown

Attachment

Displays a file or image attachment with media, metadata, upload state, and actions.

100%
Loading...
<div class="mx-auto flex w-full max-w-sm flex-col gap-3">
    <twig:Attachment:Group>
        <twig:Attachment orientation="vertical">
            <twig:Attachment:Media variant="image">
                <img src="https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=900&auto=format&fit=crop&q=80" alt="Workspace" />
            </twig:Attachment:Media>
            <twig:Attachment:Content>
                <twig:Attachment:Title>workspace.png</twig:Attachment:Title>
                <twig:Attachment:Description>PNG · 820 KB</twig:Attachment:Description>
            </twig:Attachment:Content>
        </twig:Attachment>
        <twig:Attachment orientation="vertical">
            <twig:Attachment:Media variant="image">
                <img src="https://images.unsplash.com/photo-1497215728101-856f4ea42174?w=900&auto=format&fit=crop&q=80" alt="Desk" />
            </twig:Attachment:Media>
            <twig:Attachment:Content>
                <twig:Attachment:Title>desk-reference.jpg</twig:Attachment:Title>
                <twig:Attachment:Description>JPG · 1.1 MB</twig:Attachment:Description>
            </twig:Attachment:Content>
        </twig:Attachment>
        <twig:Attachment orientation="vertical">
            <twig:Attachment:Media variant="image">
                <img src="https://images.unsplash.com/photo-1497366811353-6870744d04b2?w=900&auto=format&fit=crop&q=80" alt="Office" />
            </twig:Attachment:Media>
            <twig:Attachment:Content>
                <twig:Attachment:Title>office-reference.jpg</twig:Attachment:Title>
                <twig:Attachment:Description>JPG · 940 KB</twig:Attachment:Description>
            </twig:Attachment:Content>
        </twig:Attachment>
    </twig:Attachment:Group>
    <twig:Attachment state="uploading" class="w-full">
        <twig:Attachment:Media>
            <twig:Spinner />
        </twig:Attachment:Media>
        <twig:Attachment:Content>
            <twig:Attachment:Title>sales-dashboard.pdf</twig:Attachment:Title>
            <twig:Attachment:Description>Uploading · 64%</twig:Attachment:Description>
        </twig:Attachment:Content>
        <twig:Attachment:Actions>
            <twig:Attachment:Action aria-label="Cancel upload">
                <twig:ux:icon name="lucide:x" />
            </twig:Attachment:Action>
        </twig:Attachment:Actions>
    </twig:Attachment>
    <twig:Attachment class="w-full">
        <twig:Attachment:Media>
            <twig:ux:icon name="lucide:file-code" />
        </twig:Attachment:Media>
        <twig:Attachment:Content>
            <twig:Attachment:Title>message-renderer.tsx</twig:Attachment:Title>
            <twig:Attachment:Description>TypeScript · 12 KB</twig:Attachment:Description>
        </twig:Attachment:Content>
        <twig:Attachment:Actions>
            <twig:Attachment:Action aria-label="Remove message-renderer.tsx">
                <twig:ux:icon name="lucide:x" />
            </twig:Attachment:Action>
        </twig:Attachment:Actions>
    </twig:Attachment>
</div>

Installation

Note

Available since UX Toolkit 3.5.

php bin/console ux:install attachment --kit shadcn

Install the following Composer dependencies:

composer require twig/extra-bundle twig/html-extra:^3.24.0 symfony/ux-twig-component:^3.5 tales-from-a-dev/twig-tailwind-extra:^1.3.0

Copy the following file(s) into your app:

templates/components/Attachment.html.twig
{%- props
    ## 'idle'|'uploading'|'processing'|'error'|'done' The upload state, which drives the styling.
    state = 'done',
    ## 'default'|'sm'|'xs' The attachment size.
    size = 'default',
    ## 'horizontal'|'vertical' Whether the media sits beside or above the content.
    orientation = 'horizontal'
-%}
{%- set style = html_cva(
    base: 'group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 flex-wrap rounded-xl border bg-card text-card-foreground transition-colors focus-within:ring-1 focus-within:ring-ring/50 has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive/30 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',
        },
    },
) -%}
<div
    data-slot="attachment"
    data-state="{{ state }}"
    data-size="{{ size }}"
    data-orientation="{{ orientation }}"
    {{ attributes.defaults({
        class: style.apply({size: size, orientation: orientation})|tailwind_classes,
    }) }}
>
    {##- The attachment content, typically includes `Attachment:Media`, `Attachment:Content`, `Attachment:Actions` and/or `Attachment:Trigger`. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Attachment/Action.html.twig
{%- props
    ## 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The visual style variant.
    variant = 'ghost',
    ## 'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' The button size.
    size = 'icon-xs'
-%}
<twig:Button variant="{{ variant }}" size="{{ size }}" data-slot="attachment-action" {{ ...attributes }}>
    {##- The action label, usually an icon. -#}
    {{- block(outerBlocks.content) -}}
</twig:Button>
templates/components/Attachment/Actions.html.twig
<div
    data-slot="attachment-actions"
    {{ attributes.defaults({
        class: '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:end-3 group-data-[orientation=vertical]/attachment:gap-1'|tailwind_classes,
    }) }}
>
    {##- The actions of the attachment, typically one or more `Attachment:Action`. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Attachment/Content.html.twig
<div
    data-slot="attachment-content"
    {{ attributes.defaults({
        class: 'max-w-full min-w-0 flex-1 leading-tight group-data-[orientation=vertical]/attachment:px-1'|tailwind_classes,
    }) }}
>
    {##- The main content area, typically includes `Attachment:Title` and `Attachment:Description`. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Attachment/Description.html.twig
<span
    data-slot="attachment-description"
    {{ attributes.defaults({
        class: 'mt-0.5 block max-w-full min-w-0 truncate text-xs text-muted-foreground group-data-[state=error]/attachment:text-destructive/80'|tailwind_classes,
    }) }}
>
    {##- The secondary metadata, such as the file type, size or upload status. -#}
    {%- block content %}{% endblock -%}
</span>
templates/components/Attachment/Group.html.twig
<div
    data-slot="attachment-group"
    {{ attributes.defaults({
        class: 'flex min-w-0 snap-x snap-mandatory scroll-px-1 no-scrollbar gap-3 overflow-x-auto overscroll-x-contain py-1 *:data-[slot=attachment]:flex-none *:data-[slot=attachment]:snap-start'|tailwind_classes,
    }) }}
>
    {##- The grouped attachments, typically multiple `Attachment` components. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Attachment/Media.html.twig
{%- props
    ## 'icon'|'image' Whether the media holds an icon or an image.
    variant = 'icon'
-%}
{%- set style = html_cva(
    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-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: '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',
        },
    },
) -%}
<div
    data-slot="attachment-media"
    data-variant="{{ variant }}"
    {{ attributes.defaults({
        class: style.apply({variant: variant})|tailwind_classes,
    }) }}
>
    {##- The visual element, typically an icon, a `Spinner` or an `img` tag. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Attachment/Title.html.twig
<span
    data-slot="attachment-title"
    {{ attributes.defaults({
        class: 'block max-w-full min-w-0 truncate font-medium group-data-[state=processing]/attachment:animate-pulse group-data-[state=uploading]/attachment:animate-pulse'|tailwind_classes,
    }) }}
>
    {##- The attachment name. -#}
    {%- block content %}{% endblock -%}
</span>
templates/components/Attachment/Trigger.html.twig
{%- props
    ## 'button'|'a' The HTML tag to render; use `a` when the trigger is a link.
    as = 'button'
-%}
<{{ as }}
    data-slot="attachment-trigger"
    {{ attributes.defaults({
        class: 'absolute inset-0 z-10 outline-none'|tailwind_classes,
        type: as == 'button' ? 'button' : false,
    }) }}
>
    {##- The optional trigger label, usually left empty as the trigger covers the whole attachment. -#}
    {%- block content %}{% endblock -%}
</{{ as }}>
templates/components/Button.html.twig
{%- props
    ## 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The visual style variant.
    variant = 'default',
    ## 'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' The button size.
    size = 'default',
    ## 'button'|'a' The HTML tag to render; use `a` when the button navigates.
    as = 'button'
-%}
{%- set style = html_cva(
    base: "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
    variants: {
        variant: {
            default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
            outline: 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
            secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
            ghost: 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
            destructive: 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
            link: 'text-primary underline-offset-4 hover:underline',
        },
        size: {
            default: 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2',
            xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3",
            sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3.5",
            lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2',
            icon: 'size-8',
            'icon-xs': "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
            'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
            'icon-lg': 'size-9',
        },
    },
) -%}
<{{ as }}
    data-slot="button"
    data-size="{{ size }}"
    data-variant="{{ variant }}"
    {{ attributes.defaults({
        class: style.apply({variant: variant, size: size})|tailwind_classes,
        type: as == 'button' ? 'button' : false,
    }) }}
>
    {##- The button label and/or icon. -#}
    {%- block content %}{% endblock -%}
</{{ as }}>

Usage

<twig:Attachment>
    <twig:Attachment:Media>
        <twig:ux:icon name="lucide:file-text" />
    </twig:Attachment:Media>
    <twig:Attachment:Content>
        <twig:Attachment:Title>sales-dashboard.pdf</twig:Attachment:Title>
        <twig:Attachment:Description>PDF · 2.4 MB</twig:Attachment:Description>
    </twig:Attachment:Content>
    <twig:Attachment:Actions>
        <twig:Attachment:Action aria-label="Remove sales-dashboard.pdf">
            <twig:ux:icon name="lucide:x" />
        </twig:Attachment:Action>
    </twig:Attachment:Actions>
</twig:Attachment>

Examples

Image

Set variant="image" on Attachment:Media and render an img tag inside it. Use orientation="vertical" to stack the media above the content.

100%
Loading...
{% set images = [
    { src: 'https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=900&auto=format&fit=crop&q=80', alt: 'Workspace', name: 'workspace.png', meta: 'PNG · 820 KB' },
    { src: 'https://images.unsplash.com/photo-1497215728101-856f4ea42174?w=900&auto=format&fit=crop&q=80', alt: 'Desk', name: 'desk-reference.jpg', meta: 'JPG · 1.1 MB' },
    { src: 'https://images.unsplash.com/photo-1497366811353-6870744d04b2?w=900&auto=format&fit=crop&q=80', alt: 'Office', name: 'office-reference.jpg', meta: 'JPG · 940 KB' },
] %}
<div class="mx-auto w-full max-w-sm">
    <twig:Attachment:Group class="w-full">
        {% for image in images %}
            <twig:Attachment orientation="vertical">
                <twig:Attachment:Media variant="image">
                    <img src="{{ image.src }}" alt="{{ image.alt }}" />
                </twig:Attachment:Media>
                <twig:Attachment:Content>
                    <twig:Attachment:Title>{{ image.name }}</twig:Attachment:Title>
                    <twig:Attachment:Description>{{ image.meta }}</twig:Attachment:Description>
                </twig:Attachment:Content>
                <twig:Attachment:Actions>
                    <twig:Attachment:Action aria-label="Remove {{ image.name }}">
                        <twig:ux:icon name="lucide:x" />
                    </twig:Attachment:Action>
                </twig:Attachment:Actions>
                <twig:Attachment:Trigger
                    as="a"
                    href="{{ image.src }}"
                    target="_blank"
                    rel="noreferrer"
                    aria-label="Open {{ image.name }}"
                />
            </twig:Attachment>
        {% endfor %}
    </twig:Attachment:Group>
</div>

States

Set state to reflect the upload lifecycle. uploading and processing animate the title, and error switches to a destructive treatment.

100%
Loading...
<div class="mx-auto flex w-full max-w-sm flex-col gap-2">
    <twig:Attachment state="idle" class="w-full">
        <twig:Attachment:Media>
            <twig:ux:icon name="lucide:clock" />
        </twig:Attachment:Media>
        <twig:Attachment:Content>
            <twig:Attachment:Title>selected-file.pdf</twig:Attachment:Title>
            <twig:Attachment:Description>Ready to upload</twig:Attachment:Description>
        </twig:Attachment:Content>
        <twig:Attachment:Actions>
            <twig:Attachment:Action aria-label="Remove selected-file.pdf">
                <twig:ux:icon name="lucide:x" />
            </twig:Attachment:Action>
        </twig:Attachment:Actions>
    </twig:Attachment>
    <twig:Attachment state="uploading" class="w-full">
        <twig:Attachment:Media>
            <twig:Spinner />
        </twig:Attachment:Media>
        <twig:Attachment:Content>
            <twig:Attachment:Title>design-system.zip</twig:Attachment:Title>
            <twig:Attachment:Description>Uploading · 64%</twig:Attachment:Description>
        </twig:Attachment:Content>
        <twig:Attachment:Actions>
            <twig:Attachment:Action aria-label="Cancel upload">
                <twig:ux:icon name="lucide:x" />
            </twig:Attachment:Action>
        </twig:Attachment:Actions>
    </twig:Attachment>
    <twig:Attachment state="processing" class="w-full">
        <twig:Attachment:Media>
            <twig:ux:icon name="lucide:file-text" />
        </twig:Attachment:Media>
        <twig:Attachment:Content>
            <twig:Attachment:Title>market-research.pdf</twig:Attachment:Title>
            <twig:Attachment:Description>Processing document</twig:Attachment:Description>
        </twig:Attachment:Content>
        <twig:Attachment:Actions>
            <twig:Attachment:Action aria-label="Remove market-research.pdf">
                <twig:ux:icon name="lucide:x" />
            </twig:Attachment:Action>
        </twig:Attachment:Actions>
    </twig:Attachment>
    <twig:Attachment state="error" class="w-full">
        <twig:Attachment:Media>
            <twig:ux:icon name="lucide:file-warning" />
        </twig:Attachment:Media>
        <twig:Attachment:Content>
            <twig:Attachment:Title>financial-model.xlsx</twig:Attachment:Title>
            <twig:Attachment:Description>Upload failed. Try again.</twig:Attachment:Description>
        </twig:Attachment:Content>
        <twig:Attachment:Actions>
            <twig:Attachment:Action aria-label="Retry upload">
                <twig:ux:icon name="lucide:refresh-cw" />
            </twig:Attachment:Action>
            <twig:Attachment:Action aria-label="Remove financial-model.xlsx">
                <twig:ux:icon name="lucide:x" />
            </twig:Attachment:Action>
        </twig:Attachment:Actions>
    </twig:Attachment>
    <twig:Attachment state="done" class="w-full">
        <twig:Attachment:Media>
            <twig:ux:icon name="lucide:check" />
        </twig:Attachment:Media>
        <twig:Attachment:Content>
            <twig:Attachment:Title>uploaded-report.pdf</twig:Attachment:Title>
            <twig:Attachment:Description>Uploaded · 1.8 MB</twig:Attachment:Description>
        </twig:Attachment:Content>
        <twig:Attachment:Actions>
            <twig:Attachment:Action aria-label="Remove uploaded-report.pdf">
                <twig:ux:icon name="lucide:x" />
            </twig:Attachment:Action>
        </twig:Attachment:Actions>
    </twig:Attachment>
</div>

Sizes

Use size to switch between default, sm, and xs.

100%
Loading...
<div class="mx-auto flex w-full max-w-sm flex-col gap-3">
    <twig:Attachment size="default" class="w-full">
        <twig:Attachment:Media>
            <twig:ux:icon name="lucide:file-text" />
        </twig:Attachment:Media>
        <twig:Attachment:Content>
            <twig:Attachment:Title>Default attachment</twig:Attachment:Title>
            <twig:Attachment:Description>PDF · 2.4 MB</twig:Attachment:Description>
        </twig:Attachment:Content>
    </twig:Attachment>
    <twig:Attachment size="sm" class="w-full">
        <twig:Attachment:Media>
            <twig:ux:icon name="lucide:file-text" />
        </twig:Attachment:Media>
        <twig:Attachment:Content>
            <twig:Attachment:Title>Small attachment</twig:Attachment:Title>
            <twig:Attachment:Description>PDF · 2.4 MB</twig:Attachment:Description>
        </twig:Attachment:Content>
    </twig:Attachment>
    <twig:Attachment size="xs" class="w-full">
        <twig:Attachment:Media>
            <twig:ux:icon name="lucide:file-text" />
        </twig:Attachment:Media>
        <twig:Attachment:Content>
            <twig:Attachment:Title>Extra small attachment</twig:Attachment:Title>
        </twig:Attachment:Content>
    </twig:Attachment>
</div>

Group

Wrap attachments in Attachment:Group to lay them out in a horizontally scrollable, snapping row.

100%
Loading...
{% set files = [
    { icon: 'lucide:file-text', name: 'briefing-notes.pdf', meta: 'PDF · 1.4 MB' },
    { src: 'https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=900&auto=format&fit=crop&q=80', name: 'workspace.png', meta: 'PNG · 820 KB' },
    { icon: 'lucide:table', name: 'customers.csv', meta: 'CSV · 18 KB' },
    { icon: 'lucide:file-code', name: 'renderer.tsx', meta: 'TSX · 12 KB' },
] %}
<div class="mx-auto w-full max-w-sm">
    <twig:Attachment:Group class="w-full">
        {% for file in files %}
            <twig:Attachment class="w-64">
                {% if file.src is defined %}
                    <twig:Attachment:Media variant="image">
                        <img src="{{ file.src }}" alt="{{ file.name }}" />
                    </twig:Attachment:Media>
                {% else %}
                    <twig:Attachment:Media>
                        <twig:ux:icon name="{{ file.icon }}" />
                    </twig:Attachment:Media>
                {% endif %}
                <twig:Attachment:Content>
                    <twig:Attachment:Title>{{ file.name }}</twig:Attachment:Title>
                    <twig:Attachment:Description>{{ file.meta }}</twig:Attachment:Description>
                </twig:Attachment:Content>
                <twig:Attachment:Actions>
                    <twig:Attachment:Action aria-label="Remove {{ file.name }}">
                        <twig:ux:icon name="lucide:x" />
                    </twig:Attachment:Action>
                </twig:Attachment:Actions>
            </twig:Attachment>
        {% endfor %}
    </twig:Attachment:Group>
</div>

Trigger

Add an Attachment:Trigger to make the whole card open a link or a dialog. It fills the card behind the actions, so the actions stay clickable.

100%
Loading...
<div class="mx-auto w-full max-w-sm">
    <twig:Dialog id="attachment-preview">
        <twig:Attachment class="w-full">
            <twig:Attachment:Media>
                <twig:ux:icon name="lucide:file-search" />
            </twig:Attachment:Media>
            <twig:Attachment:Content>
                <twig:Attachment:Title>research-summary.pdf</twig:Attachment:Title>
                <twig:Attachment:Description>Open preview dialog</twig:Attachment:Description>
            </twig:Attachment:Content>
            <twig:Attachment:Actions>
                <twig:Attachment:Action aria-label="Copy link">
                    <twig:ux:icon name="lucide:copy" />
                </twig:Attachment:Action>
                <twig:Attachment:Action aria-label="Remove research-summary.pdf">
                    <twig:ux:icon name="lucide:x" />
                </twig:Attachment:Action>
            </twig:Attachment:Actions>
            <twig:Dialog:Trigger>
                <twig:Attachment:Trigger {{ ...dialog_trigger_attrs }} aria-label="Preview research-summary.pdf" />
            </twig:Dialog:Trigger>
        </twig:Attachment>
        <twig:Dialog:Content class="sm:max-w-md">
            <twig:Dialog:Header>
                <twig:Dialog:Title>research-summary.pdf</twig:Dialog:Title>
                <twig:Dialog:Description>
                    The attachment trigger fills the card and opens the dialog, while the actions stay independently clickable above it.
                </twig:Dialog:Description>
            </twig:Dialog:Header>
        </twig:Dialog:Content>
    </twig:Dialog>
</div>

RTL

To enable RTL support, set the dir="rtl" attribute on the root element.

100%
Loading...
<div class="flex w-full flex-col items-center gap-8">
    {# Arabic #}
    <div class="flex w-full max-w-sm flex-col gap-3" dir="rtl">
        <twig:Attachment state="uploading" class="w-full">
            <twig:Attachment:Media>
                <twig:Spinner />
            </twig:Attachment:Media>
            <twig:Attachment:Content>
                <twig:Attachment:Title>تقرير-المبيعات.pdf</twig:Attachment:Title>
                <twig:Attachment:Description>جارٍ الرفع · ٦٤٪</twig:Attachment:Description>
            </twig:Attachment:Content>
            <twig:Attachment:Actions>
                <twig:Attachment:Action aria-label="إلغاء الرفع">
                    <twig:ux:icon name="lucide:x" />
                </twig:Attachment:Action>
            </twig:Attachment:Actions>
        </twig:Attachment>
        <twig:Attachment state="error" class="w-full">
            <twig:Attachment:Media>
                <twig:ux:icon name="lucide:file-warning" />
            </twig:Attachment:Media>
            <twig:Attachment:Content>
                <twig:Attachment:Title>النموذج-المالي.xlsx</twig:Attachment:Title>
                <twig:Attachment:Description>فشل الرفع. حاول مرة أخرى.</twig:Attachment:Description>
            </twig:Attachment:Content>
            <twig:Attachment:Actions>
                <twig:Attachment:Action aria-label="إعادة المحاولة">
                    <twig:ux:icon name="lucide:refresh-cw" />
                </twig:Attachment:Action>
            </twig:Attachment:Actions>
        </twig:Attachment>
    </div>

    {# Hebrew #}
    <div class="flex w-full max-w-sm flex-col gap-3" dir="rtl">
        <twig:Attachment orientation="vertical" class="w-full">
            <twig:Attachment:Media variant="image">
                <img src="https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=900&auto=format&fit=crop&q=80" alt="שולחן עבודה" />
            </twig:Attachment:Media>
            <twig:Attachment:Content>
                <twig:Attachment:Title>שולחן-עבודה.png</twig:Attachment:Title>
                <twig:Attachment:Description>PNG · 820 ק״ב</twig:Attachment:Description>
            </twig:Attachment:Content>
            <twig:Attachment:Actions>
                <twig:Attachment:Action aria-label="הסר את שולחן-עבודה.png">
                    <twig:ux:icon name="lucide:x" />
                </twig:Attachment:Action>
            </twig:Attachment:Actions>
        </twig:Attachment>
    </div>
</div>

Accessibility

  • Attachment:Action is usually icon-only, so give each one an aria-label naming the action and its target, such as aria-label="Remove sales-dashboard.pdf".
  • Attachment:Trigger covers the whole card and carries no text of its own. Give it an aria-label describing what activating it does. It sits behind the actions in the stacking order, so an action and the trigger never trap each other.
  • Attachment:Group scrolls horizontally. Keyboard users reach off-screen attachments by tabbing to their trigger or actions. For a row of purely presentational attachments, make the group itself reachable with tabindex="0", role="group" and an aria-label.
  • The error state is shown with a destructive colour. Keep the reason for the failure in Attachment:Description so the state is not conveyed by colour alone.
  • An icon inside Attachment:Media is decorative and <twig:ux:icon> hides it from assistive tech, so the name of the file belongs in Attachment:Title.

API Reference

<twig:Attachment>

Prop Type Default
state 
'idle'|'uploading'|'processing'|'error'|'done' 'done'
size 
'default'|'sm'|'xs' 'default'
orientation 
'horizontal'|'vertical' 'horizontal'
Block Description
content The attachment content, typically includes Attachment:Media, Attachment:Content, Attachment:Actions and/or Attachment:Trigger.

<twig:Attachment:Action>

Prop Type Default
variant 
'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' 'ghost'
size 
'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' 'icon-xs'
Block Description
content The action label, usually an icon.

<twig:Attachment:Actions>

Block Description
content The actions of the attachment, typically one or more Attachment:Action.

<twig:Attachment:Content>

Block Description
content The main content area, typically includes Attachment:Title and Attachment:Description.

<twig:Attachment:Description>

Block Description
content The secondary metadata, such as the file type, size or upload status.

<twig:Attachment:Group>

Block Description
content The grouped attachments, typically multiple Attachment components.

<twig:Attachment:Media>

Prop Type Default
variant 
'icon'|'image' 'icon'
Block Description
content The visual element, typically an icon, a Spinner or an img tag.

<twig:Attachment:Title>

Block Description
content The attachment name.

<twig:Attachment:Trigger>

Prop Type Default
as 
'button'|'a' 'button'
Block Description
content The optional trigger label, usually left empty as the trigger covers the whole attachment.