Attachment
Attachment presents one file or media item. It provides stable layout for a
media preview, metadata, and optional actions, draws the lifecycle status, and
offers the two controls every composer needs — remove and retry — while leaving
upload state, selection, and navigation in the application. Each public slot is
styleable and accepts arbitrary GPUI children.
The component is intentionally a composition primitive. AttachmentActions
does not invent an attachment-specific action model; put Button, Link, or
another semantic control inside it. AttachmentGroup only owns horizontal
spacing and scrolling. Selection and preview behavior remain application
concerns.
#Import
use gpui_kit::{Axis, ParentElement as _, Styled as _};
use gpui_kit::component::{
ActiveTheme as _, Colorize as _, Icon, IconName, Sizable as _, Size,
attachment::{
Attachment, AttachmentActions, AttachmentContent, AttachmentDescription,
AttachmentGroup, AttachmentMedia, AttachmentStatus, AttachmentTitle,
},
button::{Button, ButtonVariants as _},
badge::Badge,
progress::Progress,
shimmer::ShimmerStyle,
spinner::Spinner,
};// Installed in ui/components. That directory is on the Slint include path, so import by file name:
import { AttachmentStatus, AttachmentAxis, AttachmentGroup, Attachment } from "attachment.slint";use iced::widget::{column, container, mouse_area, row, scrollable, svg, text};
use iced::{Alignment, Background, Color, Element, Length, Task, Theme};#Anatomy and basic usage
// File metadata: one chip at the default density with a remove control, and
// one small chip without — iced draws the remove control only when the card
// carries a `Remove` message, which is how Slint's `closable: false` reads.
let metadata = column![
Attachment::new("quarterly-report.pdf")
.description("PDF · 2.4 MB")
.icon(glyph("PDF"))
.on_remove(Message::Remove)
.width(232.0),
Attachment::new("research-data.csv")
.description("CSV · 840 KB")
.density(ControlSize::Small)
.icon(glyph("CSV"))
.width(200.0),
]
.spacing(theme::SPACING_MD);The typed builders make the common file shape explicit:
Attachment::new()
.media(AttachmentMedia::new().child(Icon::new(IconName::FileText)))
.content(
AttachmentContent::new()
.title(AttachmentTitle::new("quarterly-report.pdf"))
.description(AttachmentDescription::new("PDF · 2.4 MB")),
)
.actions(
AttachmentActions::new().child(
Button::new("remove-report")
.ghost()
.xsmall()
.icon(IconName::Close)
.label("Remove"),
),
)Attachment {
name: "quarterly-report.pdf";
size: "PDF · 2.4 MB";
}
Attachment {
name: "research-data.csv";
size: "CSV · 840 KB";
density: ControlSize.small;
closable: false;
}The slots are optional. A media-only attachment, metadata-only attachment, or action-only attachment is valid when the product needs it:
Attachment::new()
.media(AttachmentMedia::new().child(Icon::new(IconName::FileText)));
Attachment::new().content(
AttachmentContent::new()
.title(AttachmentTitle::new("notes.txt"))
.description(AttachmentDescription::new("TXT · 12 KB")),
)The default state is:
| Property | Default | Meaning |
|---|---|---|
| Status | Complete | The item is ready. |
| Size | Medium | Uses the standard conversation density. |
| Axis | Horizontal | Media, metadata, and actions share one row. |
| Media/content/actions | absent | Add only the slots the item needs. |
| Surface | background and foreground | The card surface, separated by the border like shadcn’s bg-card. |
| Radius | radius_tokens().lg (md for XSmall) | Shared semantic radius. |
| Geometry | 56 px tall, 232 px wide with content, 38 px media (Medium) | The composer chip; see Sizes and axes. |
Attachment never owns a product-level file model. Keep the file ID and state
in the parent view, then render the current record into this element.
#Anatomy and basic usage — 2 of 2
// The optional slots: media only, metadata only, and metadata with an
// action. A control without a message is drawn disabled, which is how the
// action here reads until the caller supplies one.
let optional = column![
Attachment::new("")
.icon(glyph("IMG"))
.show_meta(false)
.width(120.0),
Attachment::new("metadata-only.txt")
.description("Text · 1 KB")
.show_media(false)
.width(232.0),
Attachment::new("ready-for-review.pdf")
.description("Awaiting review")
.show_media(false)
.action("Open")
.on_action(Message::Open)
.width(232.0),
]
.spacing(theme::SPACING_MD);#Media and image previews
// A vertical card: the preview is the square above the metadata. An
// application passes an image element; this demo passes a tinted panel.
let thumbnail = Attachment::new("sdk-preview.svg")
.description("SVG · 1280 × 720")
.axis(Axis::Vertical)
.preview(preview(Color::from_rgb(0.30, 0.42, 0.85), "preview"))
.on_remove(Message::Remove);Use children for an icon-style media slot and src(...) for an image preview:
Attachment::new()
.media(
AttachmentMedia::new()
.src("https://example.com/previews/sdk.svg")
.overlay(Icon::new(IconName::Download)),
)
.content(
AttachmentContent::new()
.title(AttachmentTitle::new("sdk-preview.svg"))
.description(AttachmentDescription::new("SVG · 1280 × 720")),
)Attachment {
name: "sdk-preview.svg";
size: "SVG · 1280 × 720";
axis: AttachmentAxis.vertical;
preview: root.preview;
}The image is rendered with ObjectFit::Cover inside the media bounds. Children
and overlay(...) are painted above the image. overlay(...) centers an
element over the whole media area, which is useful for a spinner, play icon, or
preview action:
Attachment::new()
.status(AttachmentStatus::Uploading)
.axis(Axis::Vertical)
.media(
AttachmentMedia::new()
.src(preview_url)
.overlay(Spinner::new().small()),
)The slot draws the lifecycle status itself. A source image keeps its colors
and takes a scrim: a translucent dark layer with a white spinner while
Uploading or Processing, a darker one with the retry control (see
Remove and retry controls) or an alert glyph once
Failed. Custom overlays are painted above the scrim. With no source, the
media slot is a themed muted area that shows a spinner in the primary color
while in progress and, once failed, the destructive semantic surface and
foreground with an alert glyph; its children come back with Complete.
An image tile — a vertical attachment without content — is a square the media fills edge to edge, its corners one border width tighter than the card’s so the two stay concentric.
AttachmentMedia is independently styleable. Use with_size(...) to override
the inherited media size, or use normal GPUI refinements for a custom preview
ratio and surface:
AttachmentMedia::new()
.with_size(Size::Large)
.aspect_ratio(16. / 9.)
.rounded(cx.theme().radius_lg)
.child(Icon::new(IconName::Image))An explicit media size takes precedence over the attachment size. A vertical attachment makes the media full width and square by default; the media’s own style can replace that geometry when the application has a different preview design.
#Media and image previews — 2 of 2
// The loading ring over the dimmed preview while the piece is in progress.
let overlays = Attachment::new("preview.svg")
.description("Uploading · 72%")
.axis(Axis::Vertical)
.status(Status::Uploading)
.progress(72.0)
.preview(preview(Color::from_rgb(0.85, 0.45, 0.35), "preview"))
.overlay(true)
.phase(app.phase())
.on_remove(Message::Remove);#Lifecycle states
// The five states in the Slint example's order: pending, uploading with a
// progress bar, processing with the loading ring, failed with a Retry
// action, and complete.
let states = column![
Attachment::new("meeting-notes.pdf")
.description("Ready to upload")
.status(Status::Pending)
.icon(glyph("PDF"))
.width(272.0),
Attachment::new("design-assets.zip")
.description("Uploading · 68%")
.status(Status::Uploading)
.progress(68.0)
.icon(glyph("ZIP"))
.on_remove(Message::Remove)
.width(272.0),
Attachment::new("transcript.pdf")
.description("Processing document")
.status(Status::Processing)
.overlay(true)
.phase(app.phase())
.icon(glyph("PDF"))
.width(272.0),
Attachment::new("archive.zip")
.description("Upload failed")
.status(Status::Failed)
.icon(glyph("ZIP"))
.action("Retry")
.on_action(Message::Retry)
.on_remove(Message::Remove)
.width(272.0),
Attachment::new("published-report.pdf")
.description("Uploaded · 1.8 MB")
.status(Status::Complete)
.icon(check_glyph())
.width(272.0),
]
.spacing(theme::SPACING_MD);AttachmentStatus has five explicit states. The parent status is passed to the
typed title, description, media, and action layout during rendering:
| State | Surface/layout behavior | Recommended content |
|---|---|---|
Pending | Dashed border; preview is not dimmed. | “Ready to upload” and a start action. |
Uploading | Media shows a spinner, or a progress ring with progress(...) (over a scrim on an image); a chip draws a bar along its bottom edge; typed title shimmers. | A description such as “Uploading”; the percentage is appended for you. |
Processing | Media shows a spinner (over a scrim on an image); typed title shimmers. | “Processing…” and a non-destructive wait state. |
Failed | Destructive border and description; media shows the retry control or alert glyph with on_retry, the ban glyph without one (a rejection). | Error reason plus on_retry, or tooltip(...) with the reason and on_remove. |
Complete | Ready surface; preview is full opacity. | File metadata and normal actions. |
Attachment::new()
.status(AttachmentStatus::Uploading)
.media(AttachmentMedia::new().child(Icon::new(IconName::FileText)))
.content(
AttachmentContent::new()
.title(AttachmentTitle::new("design-assets.zip"))
.description(AttachmentDescription::new("Uploading · 68%"))
.child(Progress::new("attachment-progress").value(68.)),
)
.actions(
AttachmentActions::new()
.child(Button::new("cancel-upload").ghost().xsmall().label("Cancel")),
)Attachment {
name: "meeting-notes.pdf";
size: "Ready to upload";
status: AttachmentStatus.pending;
closable: false;
}
Attachment {
name: "design-assets.zip";
size: "Uploading · 68%";
status: AttachmentStatus.uploading;
progress: 68;
}
Attachment {
name: "transcript.pdf";
size: "Processing document";
status: AttachmentStatus.processing;
shimmer-styled: true;
closable: false;
}
Attachment {
name: "archive.zip";
size: "Upload failed";
status: AttachmentStatus.failed;
action: "Retry";
remove-icon: "trash";
remove-variant: ButtonVariant.danger;
}
Attachment {
name: "published-report.pdf";
size: "Uploaded · 1.8 MB";
status: AttachmentStatus.complete;
icon: "circle-check";
icon-color: Theme.success;
}The status helpers are useful when application state maps to presentation:
match status {
AttachmentStatus::Pending => "Ready to upload",
AttachmentStatus::Uploading => "Uploading…",
AttachmentStatus::Processing => "Processing…",
AttachmentStatus::Failed => "Upload failed",
AttachmentStatus::Complete => "Ready",
}is_pending(), is_uploading(), is_processing(), is_failed(),
is_complete(), and is_in_progress() are pure readers. They do not update
the attachment or the application upload task.
#Status inheritance and overrides
// The attachment's own status decides the title, the description, the border
// and the media slot together: an `Uploading` piece draws its title in the
// loading colour. This port has no per-child status override, which is what
// Slint's separate title and description statuses are; the module names the
// gap rather than faking it.
let inheritance = column![
Attachment::new("inherited-title.pdf")
.description("Inherited loading appearance")
.status(Status::Uploading)
.icon(glyph("PDF"))
.width(272.0),
Attachment::new("stable-title.pdf")
.description("An explicit status leaves the title at rest")
.status(Status::Complete)
.icon(glyph("PDF"))
.width(272.0),
]
.spacing(theme::SPACING_MD);Titles and descriptions added through their typed builders inherit the parent status. An explicit child status wins over the inherited value:
Attachment::new()
.status(AttachmentStatus::Failed)
.content(
AttachmentContent::new()
.title(AttachmentTitle::new("archive.zip"))
.description(
AttachmentDescription::new("Previous upload completed")
.status(AttachmentStatus::Complete),
),
)Attachment {
name: "inherited-title.pdf";
size: "Inherited loading appearance";
status: AttachmentStatus.uploading;
closable: false;
}
Attachment {
name: "stable-title.pdf";
size: "Explicit title status disables its shimmer";
status: AttachmentStatus.uploading;
shimmer: false;
closable: false;
}Use typed .title(...) and .description(...) whenever loading shimmer or
failure coloring should follow the attachment. The generic .child(...) form
still accepts arbitrary elements, but it cannot inspect the erased child’s
status and therefore does not inherit automatically:
AttachmentContent::new()
.title(AttachmentTitle::new("status-aware-title"))
.description(AttachmentDescription::new("status-aware-description"))
.child(custom_metadata_element)Customize an in-progress title with a reusable shimmer style:
AttachmentTitle::new("transcript.pdf")
.with_shimmer_style(
ShimmerStyle::new()
.duration(std::time::Duration::from_secs(3))
.spread(0.45)
.reverse(true)
.once(false),
)AttachmentDescription uses the destructive semantic color only for an
explicit or inherited Failed status. The words in the description should
still state what happened; color is a supporting cue.
#Sizes and axes
// The four named sizes — 28, 32, 40 and 48 pixel media boxes — and a small
// chip whose media box is overridden to the large one.
let sizes = column![
Attachment::new("large.pdf")
.description("Large · PDF · 3.1 MB")
.density(ControlSize::Large)
.icon(glyph("PDF"))
.width(320.0),
Attachment::new("medium.pdf")
.description("Medium · PDF · 2.4 MB")
.icon(glyph("PDF"))
.width(272.0),
Attachment::new("small.csv")
.description("Small · CSV · 840 KB")
.density(ControlSize::Small)
.icon(glyph("CSV"))
.width(232.0),
Attachment::new("xsmall.txt")
.density(ControlSize::XSmall)
.icon(glyph("TXT"))
.width(176.0),
Attachment::new("custom-media.pdf")
.description("Large media in a small attachment")
.density(ControlSize::Small)
.media_box(48.0)
.icon(glyph("PDF"))
.width(232.0),
]
.spacing(theme::SPACING_MD);Attachment implements Sizable. The convenience builders map to Size:
Attachment::new().xsmall();
Attachment::new().small();
Attachment::new(); // medium (default)
Attachment::new().large();
Attachment::new().w_auto() // let the chip hug its content instead of the fixed widthAttachment {
name: "large.pdf";
size: "Large · PDF · 3.1 MB";
density: ControlSize.large;
closable: false;
}
Attachment {
name: "medium.pdf";
size: "Medium · PDF · 2.4 MB";
closable: false;
}
Attachment {
name: "small.csv";
size: "Small · CSV · 840 KB";
density: ControlSize.small;
closable: false;
}
Attachment {
name: "xsmall.txt";
density: ControlSize.xsmall;
closable: false;
}
Attachment {
name: "custom-media.pdf";
size: "Large media in a small attachment";
density: ControlSize.small;
media-box: 48px;
closable: false;
}The named sizes set the whole geometry as one scale, in rems so it follows the root font size:
| Size | Chip height | Chip width | Media | Title |
|---|---|---|---|---|
XSmall | 40 px | 176 px | 28 px | 11 px |
Small | 48 px | 200 px | 32 px | 12 px |
Medium | 56 px | 232 px | 38 px | 13 px |
Large | 64 px | 272 px | 44 px | 14 px |
A horizontal card takes the fixed width only when it carries content, so a row
of chips lines up and long names truncate instead of stretching the card. An
image tile is a square with the chip’s height. Size::Size(...) scales the
Medium geometry from a custom base value. Refine with the normal GPUI width
methods (w_auto(), w_full(), w(...)) when the product needs another
measure; named sizes are preferable for a coherent theme.
Horizontal is the default and keeps the media, metadata, and actions in one row. Vertical moves the preview above the metadata and places actions over the preview’s upper trailing corner:
Attachment::new()
.axis(Axis::Vertical)
.large()
.media(AttachmentMedia::new().src(preview_url))
.content(
AttachmentContent::new()
.title(AttachmentTitle::new("presentation.png"))
.description(AttachmentDescription::new("PNG · 1920 × 1080")),
)
.actions(
AttachmentActions::new()
.child(Button::new("remove-presentation").ghost().xsmall().label("Remove")),
)The vertical default is square media. Set a media aspect ratio or size when
the content needs a landscape preview. AttachmentContent and
AttachmentActions remain independent slots, so an application can omit one
or place additional controls in either.
#Sizes and axes — 2 of 3
// Horizontal keeps media, metadata and actions in one row; vertical puts the
// media above the metadata. A vertical chip with no preview draws the muted
// empty media box.
let orientation = row![
Attachment::new("horizontal.pdf")
.description("Horizontal layout")
.icon(glyph("PDF"))
.width(232.0),
Attachment::new("vertical.pdf")
.description("Vertical layout")
.axis(Axis::Vertical),
]
.spacing(theme::SPACING_MD)
.align_y(Alignment::Start);#Sizes and axes — 3 of 3
// A 288px card holding a long name: iced 0.14 text has no ellipsis, so the
// title is clipped by the card rather than elided.
let long_names =
Attachment::new("accessibility-review-and-keyboard-navigation-findings.pdf")
.description("Final report · reviewed by the desktop experience team")
.icon(glyph("PDF"))
.width(288.0);#Content and actions
// The trigger a composer puts before the chips. The icon is the caller's
// element, so a text glyph stands in for the file mark here.
let trigger = row![Button::new("Add files…")
.icon(text("+").size(theme::FONT_SIZE_MD))
.outline()
.on_press(Message::Open)]
.align_y(Alignment::Center);AttachmentContent keeps titles and descriptions in a vertical metadata stack.
It also accepts custom children for progress, badges, or a second line:
AttachmentContent::new()
.title(AttachmentTitle::new("report.pdf"))
.description(AttachmentDescription::new("PDF · 2.4 MB"))
.child(Badge::new().count(3))Use AttachmentActions for one or more existing semantic controls:
AttachmentActions::new()
.child(Button::new("download").ghost().xsmall().label("Download"))
.child(Button::new("remove").danger().xsmall().label("Remove"))HorizontalLayout {
alignment: start;
Button {
text: "Add files…";
icon: "file-text";
outline: true;
horizontal-stretch: 0;
}
}AttachmentActions only supplies layout and does not make its children
focusable, clickable, or disabled. A tooltip is supplemental; the current
Button implementation derives its accessibility label from .label(...), so
use a visible label when an action must have a named accessible control. An
icon-only button with only .tooltip(...) is not a substitute for that label.
#Whole-card click
// The card activates as one target and the remove control rides its corner
// as a second; the notice under it is the application's own state.
let whole_card = column![
Attachment::new("design-mockups.png")
.description("PNG · 1.8 MB")
.icon(glyph("IMG"))
.on_press(Message::Open)
.on_remove(Message::RemoveMockups)
.width(272.0),
muted(if app.notice.is_empty() {
"Click the card to open it; the remove control is its own target.".to_string()
} else {
app.notice.clone()
}),
]
.spacing(theme::SPACING_SM);Set .id(...) and .on_click(...) to make the whole card activate, e.g. to
open a preview. The click layer is painted below AttachmentActions, so action
buttons stay independently clickable:
Attachment::new()
.id("design-attachment")
.on_click(|_, window, cx| {
// Open the preview.
})
.content(
AttachmentContent::new()
.title(AttachmentTitle::new("design-mockups.png"))
.description(AttachmentDescription::new("PNG · 1.8 MB")),
)
.actions(
AttachmentActions::new()
.child(Button::new("remove").ghost().xsmall().icon(IconName::Close)),
)Attachment {
name: "design-mockups.png";
size: "PNG · 1.8 MB";
clickable: true;
clicked => {
root.notice = "Opening design-mockups.png…";
}
removed => {
root.notice = "Removed design-mockups.png";
}
}
if root.notice != "": Text {
text: root.notice;
color: Theme.muted-foreground;
font-size: Theme.font-size-sm;
wrap: word-wrap;
}The handler takes effect only together with .id(...); click state needs that
stable identity. A clickable card shows a muted hover surface so it reads as
interactive. What activation means — a dialog, a browser, a file viewer, or a
selection — stays with the application. Keep destructive and secondary commands
in AttachmentActions so they never depend on the card’s primary activation,
and offer the card’s primary action as a Button or Link somewhere reachable
from the keyboard: the click layer itself is a pointer convenience and takes no
focus.
#Remove and retry controls
A composer removes attachments and retries failed uploads. Both controls are built in, so they look the same in every product and need no wrapper:
Attachment::new()
.id(("attachment", item.id))
.status(item.status)
.on_remove(cx.listener(move |this, _, _, cx| this.remove(item.id, cx)))
.on_retry(cx.listener(move |this, _, _, cx| this.retry(item.id, cx)))
.axis(Axis::Vertical)
.media(AttachmentMedia::new().src(thumbnail))on_remove rides a small surface-colored disc with a hairline border on the
card’s upper trailing corner, the way a card’s close control usually looks. It appears on hover on
desktop and stays visible on touch platforms; the card reserves the overhang,
so a row of cards keeps its alignment. on_retry takes effect only while the
status is Failed: an image preview gets a round button in its scrim, and a
typed description gets a localized “Retry” link after its text. A failed
attachment without on_retry reads as a rejection and shows the ban glyph
instead. All of these key their element state on .id(...), so they take
effect only together with it. What removing or retrying means stays with the
application.
Two more builders complete the composer picture:
Attachment::new()
.id(("attachment", item.id))
.status(AttachmentStatus::Uploading)
.progress(item.percent) // 0..=100
.content(
AttachmentContent::new()
.title(AttachmentTitle::new("Q3 statement.pdf"))
.description(AttachmentDescription::new("Uploading")),
);
Attachment::new()
.id(("attachment", item.id))
.status(AttachmentStatus::Failed)
.tooltip("Image exceeds 20 MB limit · Remove to send")
.on_remove(cx.listener(move |this, _, _, cx| this.remove(item.id, cx)))
.axis(Axis::Vertical)
.media(AttachmentMedia::new().src(thumbnail))progress(percent) turns the uploading spinner into a determinate ring, draws
a thin primary bar along a horizontal card’s bottom edge, and appends
“· 62%” to a typed description; it is ignored in every other status.
tooltip(text) shows the text while the card is hovered, which is where the
reason for a failure or a rejection belongs.
#Groups
// One horizontally scrolling row, twelve pixels apart.
let group = Group::new()
.push(
Attachment::new("default.pdf")
.description("PDF · 2.4 MB")
.icon(glyph("PDF"))
.width(180.0),
)
.push(
Attachment::new("small.csv")
.description("CSV · 840 KB")
.density(ControlSize::Small)
.icon(glyph("CSV"))
.width(160.0),
)
.push(
Attachment::new("compact.txt")
.density(ControlSize::XSmall)
.icon(glyph("TXT"))
.width(140.0),
);AttachmentGroup provides a horizontally scrollable row with the shared group
gap. Its ID is required because it owns GPUI’s element-local scroll state:
AttachmentGroup::new("message-attachments")
.child(first_attachment)
.child(second_attachment)
.child(third_attachment)AttachmentGroup {
Attachment {
name: "default.pdf";
size: "PDF · 2.4 MB";
closable: false;
}
Attachment {
name: "small.csv";
size: "CSV · 840 KB";
density: ControlSize.small;
closable: false;
}
Attachment {
name: "compact.txt";
density: ControlSize.xsmall;
closable: false;
}
}The group is w_full(), min_w_0(), and uses horizontal scrolling. It does not
provide selection, snapping, reorder handles, a “+N more” overflow label, or a
preview dialog. Compose those behaviors in an application-owned wrapper. Keep
the ID stable for the lifetime of the conversation row.
Two builders help a composer or message row that overflows:
AttachmentGroup::new("composer-attachments")
// Fade each edge into the surface behind the row while it hides content.
.with_edge_fade(cx.theme().background)
// Drive the scrolling yourself, e.g. from paging buttons.
.track_scroll(&self.attachments_scroll)
.children(attachments)with_edge_fade(color) draws a short gradient at an edge only while more
attachments continue past it; a row that fits shows none. The fades sit above
the attachments and take no pointer events. track_scroll(&handle) replaces
the group’s own scroll state with the caller’s ScrollHandle, so the
application can move the row and read its offset.
#Custom styling and theme tokens
// Slint refines this surface from `Theme.accent` and `Theme.primary`. This
// component takes concrete colours rather than a style closure, so the
// override is a palette the caller owns; a chip beside it resolves its
// colours from the active theme when the surface draws.
let custom = Attachment::new("custom-theme.json")
.description("JSON · 16 KB")
.icon(glyph("JSON"))
.fill(Color::from_rgb(0.20, 0.24, 0.62))
.border_color(Color::from_rgb(0.33, 0.40, 0.85))
.title_color(Color::WHITE)
.icon_color(Color::from_rgb(0.80, 0.84, 1.0))
.media_background(Color::from_rgb(0.30, 0.36, 0.82))
.width(272.0);Attachment, AttachmentGroup, and every named slot implement Styled.
Refinements are applied after component defaults, which gives developers control
over the surface, spacing, media geometry, typography, and action layout:
Attachment::new()
.w_full()
.rounded(cx.theme().radius_lg)
.bg(cx.theme().group_box)
.border_color(cx.theme().ring)
.media(
AttachmentMedia::new()
.rounded(cx.theme().radius_lg)
.bg(cx.theme().primary.opacity(0.12))
.text_color(cx.theme().primary)
.child(Icon::new(IconName::FileText)),
)
.content(
AttachmentContent::new()
.title(AttachmentTitle::new("custom-theme.json").text_color(cx.theme().primary))
.description(AttachmentDescription::new("JSON · 16 KB")),
)Attachment {
name: "custom-theme.json";
size: "JSON · 16 KB";
closable: false;
fill: Theme.accent;
border-color: Theme.accent.with-alpha(0.5);
title-color: Theme.primary;
icon-color: Theme.primary;
media-background: Theme.primary.with-alpha(0.12);
}Prefer semantic roles from cx.theme() (background, muted, border,
destructive, foreground, and their foreground counterparts) to raw colors.
The component’s default radii, spacing, and typography follow the shared design
scale; application-specific density can be expressed with Size and typed
style refinements at the composition boundary.
Use AttachmentContent::title(...) and .description(...) for status-aware
metadata, .child(...) for arbitrary custom content, child .status(...) for
an explicit override, AttachmentTitle::with_shimmer_style(...) for loading
motion, and AttachmentMedia::overlay(...) for controls above an image.
#Accessibility and state guidance
- Include the file name and useful type/size information in text. An icon-only media preview is not enough to identify the attachment.
- Put upload, retry, remove, download, and preview actions in semantic
ButtonorLinkcontrols. A tooltip is supplemental; for the currentButtonAPI, use.label(...)when the action needs an accessible name. - Describe
Pending,Uploading,Processing, andFailedin text or a control state. The dashed border, opacity, shimmer, and destructive color are supporting cues. - Keep progress determinate when the application knows a byte or item count;
use
Progressas a child rather than duplicating progress semantics inAttachment. - Loading shimmer is disabled by
ShimmerTextwhen reduced motion is enabled. Keep a readable title and description visible in that mode. - Ensure a vertical overlay action remains reachable from the keyboard; it must not be available only through image hover.
#Component boundaries
These boundaries are deliberate:
- Use
Buttondirectly instead of an attachment-specific action component. This preserves Button variants, sizes, loading, disabled behavior, focus, and event handling. - Use
Progressdirectly instead of an attachment-specific progress wrapper. - Use
.id(...)with.on_click(...)for whole-card activation. The card only reports the click; whether that opens a dialog, a browser, a file viewer, or toggles a selection stays with the application. - Use
AttachmentGrouponly for the shared horizontal row and overflow. Use an application-owned container for selection, reordering, snapping, or custom scroll controls.
#API reference
#Attachment
| Method | Default | Purpose |
|---|---|---|
new() | Complete, Medium, Horizontal, no slots | Create an attachment. |
id(ElementId) | none | Stable identity for the built-in controls: click layer, remove, retry. |
on_click(handler) | none | Whole-card activation; requires id(...) and stays below the actions. |
on_remove(handler) | none | Corner remove control; requires id(...). |
on_retry(handler) | none | Retry control while Failed; requires id(...). |
progress(percent) | none | Determinate ring, bottom bar and “· 62%” while Uploading. |
tooltip(text) | none | Hover tooltip, e.g. the failure reason; requires id(...). |
status(AttachmentStatus) | Complete | Set lifecycle styling. |
axis(Axis) | Horizontal | Choose horizontal or vertical layout. |
with_size(Size) | Medium | Set a named or custom size. |
xsmall() / small() / large() | — | Sizable shortcuts. |
media(AttachmentMedia) | none | Add a preview slot. |
content(AttachmentContent) | none | Add metadata. |
actions(AttachmentActions) | none | Add action controls. |
#AttachmentMedia
| Method | Default | Purpose |
|---|---|---|
new() | no source, no children | Create a media slot. |
src(ImageSource) | none | Render an image preview. |
with_size(Size) | inherited attachment size | Override media density. |
overlay(element) | none | Center an element over the media, above the status treatment. |
child(element) | — | Add an icon or custom content above the preview. |
Styled methods | themed muted media | Refine geometry, radius, background, and typography. |
#AttachmentContent, AttachmentTitle, and AttachmentDescription
| Method | Default | Purpose |
|---|---|---|
AttachmentContent::new() | empty vertical metadata stack | Create content. |
.title(AttachmentTitle) | — | Add a status-aware single-line title. |
.description(AttachmentDescription) | — | Add a status-aware single-line description. |
AttachmentTitle::new(text) | no explicit child status | Create a title. |
AttachmentTitle::status(status) | inherits parent | Override title loading state. |
AttachmentTitle::with_shimmer_style(style) | default shimmer | Customize title animation. |
AttachmentDescription::new(text) | no explicit child status | Create a description. |
AttachmentDescription::status(status) | inherits parent | Override description color state. |
.child(element) | — | Add progress, badges, or custom metadata. |
#AttachmentActions and AttachmentGroup
| Method | Default | Purpose |
|---|---|---|
AttachmentActions::new() | empty action layout | Create the action slot. |
.child(element) | — | Add Button, Link, or another control. |
AttachmentGroup::new(id) | stable ID required | Create a horizontal scrolling group. |
AttachmentGroup::track_scroll(&ScrollHandle) | own scroll state | Scroll the row through the caller’s handle. |
AttachmentGroup::with_edge_fade(color) | none | Fade an edge into color while it hides attachments. |
AttachmentGroup::child(element) | — | Add attachments to the group. |
#Related types
- [
AttachmentStatus] —Pending,Uploading,Processing,Failed, andComplete. - [
Size] —XSmall,Small,Medium,Large, or a customPixelsvalue. - [
Axis] —HorizontalorVerticalfrom GPUI. - [
ShimmerStyle] — shared loading animation configuration.
#AttachmentStatus
pending, uploading, processing, failed, complete
#AttachmentAxis
horizontal, vertical
#AttachmentGroup
A horizontal row of attachments that scrolls when they do not fit.
#Attachment
| Name | Kind | Type | Default | Description |
|---|---|---|---|---|
name | in | string | ||
size | in | string | ||
progress | in | float | 100 | |
status | in | AttachmentStatus | AttachmentStatus.complete | |
density | in | ControlSize | ControlSize.medium | |
axis | in | AttachmentAxis | AttachmentAxis.horizontal | |
icon | in | string | "file-text" | |
preview | in | image | ||
show-media | in | bool | true | |
show-meta | in | bool | true | |
closable | in | bool | true | |
overlay | in | bool | ||
clickable | in | bool | ||
truncate | in | bool | Kept so callers can ask for ellipsis. Titles always elide: the preferred width stays the full string, and a narrower card shows the ellipsis. | |
shimmer | in | bool | true | Set false to keep a stable title while the attachment is still loading. |
shimmer-styled | in | bool | ||
action | in | string | ||
action-size | in | ControlSize | ControlSize.xsmall | |
remove-icon | in | string | "x" | |
remove-variant | in | ButtonVariant | ButtonVariant.ghost | |
media-box | in | length | 0 uses the density. A positive length overrides the media box. | |
fill | in | color | Theme.background | |
title-color | in | color | Theme.foreground | |
icon-color | in | color | root.failed && root.preview.width == 0 ? Theme.destructive : Theme.foreground | |
media-background | in | color | root.failed && root.preview.width == 0 ? Theme.destructive.with-alpha(0.1) : Theme.muted | |
description-color | in | color | root.failed ? Theme.destructive.with-alpha(0.8) : Theme.muted-foreground | |
removed | callback | () | ||
clicked | callback | () | ||
action-clicked | callback | () |