Empty
Empty presents missing content, empty results, and first-use states. Its named
slots provide the layout and visual hierarchy; the application decides when to
show it and owns the state and actions of its children.
The component is stateless and lives entirely in GPUI Component, using its theme and native controls.
#Import
use gpui_kit::{ParentElement as _, Styled as _, rems};
use gpui_kit::assets::IconName;
use gpui_kit::component::{
ActiveTheme as _, Icon, Sizable as _,
avatar::{Avatar, AvatarGroup},
button::{Button, ButtonVariants as _},
empty::{
Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia,
EmptyMediaVariant, EmptyTitle,
},
input::{Input, InputState},
link::Link,
};// Installed in ui/components. That directory is on the Slint include path, so import by file name:
import { EmptyMediaVariant, EmptyAlign, Empty } from "empty.slint";use iced::widget::{column, container, mouse_area, row, scrollable, svg, text};
use iced::{Alignment, Element, Length, Task, Theme};Import the component through gpui_kit::component::empty; GPUI’s own
gpui_kit::Empty is a separate element that renders nothing.
#Basic usage
Empty::new()
.header(
EmptyHeader::new()
.media(
EmptyMedia::new()
.with_variant(EmptyMediaVariant::Icon)
.child(Icon::new(IconName::Folder)),
)
.title(EmptyTitle::new().child("No projects yet"))
.description(
EmptyDescription::new()
.child("Create your first project to get started."),
),
)
.content(
EmptyContent::new()
.flex_row()
.flex_wrap()
.justify_center()
.gap_2()
.child(Button::new("create-project").label("Create project…"))
.child(
Button::new("import-project")
.outline()
.label("Import project…"),
),
)
.child(
Link::new("empty-help")
.href("https://gpui-kit.com/starter")
.text_sm()
.child("Learn more"),
)Empty {
height: 260px;
icon: "folder";
title: "No projects yet";
description: "You haven't created any projects yet. Get started by creating your first project.";
action: "Create project";
secondary-action: "Import sample";
Text {
text: "Learn more";
color: Theme.primary;
font-size: Theme.font-size-sm;
horizontal-alignment: center;
}
}// The page's first documented sample and the Slint example's first
// `GroupBox`: a framed icon, a title, a description and two actions. Slint
// keeps its "Learn more" line in the panel's free children slot; this
// `Empty` ends at the action row, so the line is a sibling beneath it.
let basic = body_box(
column![
Empty::new()
.media_slot(mark(FOLDER, 16.0))
.title("No projects yet")
.description("You haven't created any projects yet. Get started by creating your first project.")
.action(
Button::new("Create project")
.variant(Variant::Primary)
.on_press(Message::Nothing),
)
.secondary_action(
Button::new("Import sample")
.outline()
.on_press(Message::Nothing),
),
container(
text("Learn more")
.size(theme::FONT_SIZE_SM)
.style(|theme: &Theme| text::Style {
color: Some(Tokens::of(theme).primary),
}),
)
.width(Length::Fill)
.center_x(Length::Fill),
]
.spacing(theme::SPACING_SM),
);Attach normal Button callbacks for application actions. The extra root child
appears after EmptyContent, so a help link can remain separate from the
primary content group.
#Anatomy
| Part | Composition | Purpose |
|---|---|---|
Empty | .header(EmptyHeader), .content(EmptyContent), .child(...) | Overall alignment and spacing |
EmptyHeader | .media(EmptyMedia), .title(EmptyTitle), .description(EmptyDescription) | Media and explanatory content |
EmptyMedia | .with_variant(...), .child(...) | Icon, image, avatar, or arbitrary media |
EmptyTitle | .child(...) | Title text or custom content |
EmptyDescription | .child(...) | Wrapping text or rich supporting content |
EmptyContent | .child(...) | Actions, inputs, or other controls |
All parts have new() and Default constructors and implement Styled.
All parts except EmptyHeader implement ParentElement.
Named slots are optional and have replacement semantics: calling .header(...)
twice keeps the second header. Rendering always places the header before the
content, and media before title before description, regardless of the order in
which those setters are called. Direct root children are appended after both
named slots, in their own insertion order; they are not inserted into the content
slot. Replacing a slot leaves the other slots and extra root children intact.
#Outline
// The panel's one-pixel border in the theme's border colour. Slint cannot
// draw a dashed border and uses the solid one; iced keeps Slint's answer,
// which is also what GPUI draws.
let outline = body_box(
Empty::new()
.bordered(true)
.media_slot(mark(BOX, 16.0))
.title("Cloud storage is empty")
.description("Upload files to your cloud storage to access them anywhere.")
.secondary_action(
Button::new("Upload files…")
.outline()
.on_press(Message::Nothing),
),
);The default Empty has a transparent background and no visible border. Add a
border through Styled; its default border style is dashed.
Empty::new()
.border_1()
.header(
EmptyHeader::new()
.title(EmptyTitle::new().child("Cloud storage is empty"))
.description(
EmptyDescription::new()
.child("Upload files to access them anywhere."),
),
)Empty {
height: 240px;
bordered: true;
icon: "box";
title: "Cloud storage is empty";
description: "Upload files to your cloud storage to access them anywhere.";
secondary-action: "Upload files…";
}Use .border_color(...) to refine its semantic color.
#Background
// A semantic surface on the panel itself: the Slint example's
// `Theme.muted.with-alpha(0.35)`, named from the theme the application
// booted with rather than written by hand.
let background = body_box(
Empty::new()
.background(app.tokens.muted.scale_alpha(0.35))
.media_slot(mark(BELL, 16.0))
.title("No notifications")
.description("You're all caught up. New notifications will appear here.")
.secondary_action(Button::new("Refresh").outline().on_press(Message::Nothing)),
);Apply a semantic surface directly, without adding a component variant:
Empty::new()
.bg(cx.theme().muted.opacity(0.3))
.header(
EmptyHeader::new()
.title(EmptyTitle::new().child("No notifications"))
.description(
EmptyDescription::new()
.child("New notifications will appear here."),
),
)Empty {
height: 240px;
background: Theme.muted.with-alpha(0.35);
icon: "bell";
title: "No notifications";
description: "You're all caught up. New notifications will appear here.";
secondary-action: "Refresh";
}#Avatar
// One avatar in the bare media slot: Slint's `avatars: ["AM"]`, which
// replaces the framed icon with an avatar at its own size.
let avatar = body_box(
Empty::new()
.media(Media::Plain)
.media_slot(initials("AM"))
.title("Alex is offline")
.description("You can leave a message for Alex to read when they're back.")
.action(
Button::new("Leave a message…")
.variant(Variant::Primary)
.on_press(Message::Nothing),
),
);The default media variant adds no frame, background, or fixed size. An existing Avatar retains its own image, fallback, size, and appearance.
EmptyHeader::new()
.media(
EmptyMedia::new().child(
Avatar::new()
.name("Alex Morgan")
.src("https://avatars.githubusercontent.com/u/5518?v=4"),
),
)
.title(EmptyTitle::new().child("Alex is offline"))
.description(
EmptyDescription::new()
.child("Leave a message for Alex to read when they're back."),
)Empty {
height: 240px;
avatars: [
"AM"
];
title: "Alex is offline";
description: "You can leave a message for Alex to read when they're back.";
action: "Leave a message…";
}#Avatar group
// Several avatars in the same slot, which the group owns the overlap and
// size of. Slint's `AvatarGroup` overlaps with negative spacing; iced has
// none, so the group is a spaced row.
let group = body_box(
Empty::new()
.media(Media::Plain)
.media_slot(
row![initials("AM"), initials("TL"), initials("SC")]
.spacing(theme::SPACING_XS)
.align_y(Alignment::Center),
)
.title("No team members")
.description("Invite your team to collaborate on this project.")
.action(
Button::new("Invite members…")
.variant(Variant::Primary)
.on_press(Message::Nothing),
),
);Multiple avatars use the same media slot. The group owns avatar overlap and size; Empty does not inspect or modify its children.
EmptyHeader::new()
.media(
EmptyMedia::new().child(
AvatarGroup::new()
.child(Avatar::new().name("Alex Morgan"))
.child(Avatar::new().name("Taylor Lee"))
.child(Avatar::new().name("Sam Chen")),
),
)
.title(EmptyTitle::new().child("No team members"))
.description(
EmptyDescription::new()
.child("Invite your team to collaborate on this project."),
)Empty {
height: 240px;
avatars: [
"AM",
"TL",
"SC"
];
title: "No team members";
description: "Invite your team to collaborate on this project.";
action: "Invite members…";
}#Inputs and custom content
// The field and its hint in the row this `Empty` draws under the
// description, which is where Slint's free children go. The application
// owns the value and takes every edit back through `on_input`.
let inputs = body_box(
Empty::new()
.media(Media::None)
.title("Looking for a project?")
.description(
"The page you're looking for doesn't exist. Try searching for another page.",
)
.action(
column![
Input::new("empty-search")
.placeholder("Search projects")
.prefix(mark(SEARCH, 16.0))
.value(app.search.clone())
.width(Length::Fill)
.on_input(Message::Search),
container(muted("Search by name or try a different keyword."))
.width(Length::Fill)
.center_x(Length::Fill),
]
.spacing(theme::SPACING_SM)
.width(Length::Fixed(320.0)),
),
);Retain an Entity<InputState> in the owning view, then compose the existing
Input in EmptyContent:
EmptyContent::new()
.child(
Input::new(&self.search)
.prefix(Icon::new(IconName::Search).size_4())
.cleanable(true),
)
.child(
EmptyDescription::new()
.child("Search by name or try a different keyword."),
)Empty {
height: 280px;
media-variant: EmptyMediaVariant.none;
title: "Looking for a project?";
description: "The page you're looking for doesn't exist. Try searching for another page.";
Input {
text <=> root.search;
placeholder: "Search projects";
prefix-icon: "search";
cleanable: true;
}
Text {
text: "Search by name or try a different keyword.";
color: Theme.muted-foreground;
font-size: Theme.font-size-xs;
horizontal-alignment: center;
wrap: word-wrap;
}
}The application handles input events and switches between results and Empty. Each rendered input retains its own state entity and focus. Empty has no input state, validation, submission, or loading policy.
#Constrained layouts
// A 340px panel at the tighter 16px inset, leading-aligned with a 300px
// text column: the Slint example's `preferred-width`, `align: start` and
// `text-max-width` refined on the root and the header together.
let constrained = body_box(
container(
Empty::new()
.bordered(true)
.padding(16.0)
.text_max_width(300.0)
.align(Align::Start)
.media_slot(mark(INBOX, 16.0))
.title("No shared files")
.description("Add files so your team can review and edit them together.")
.secondary_action(
Button::new("Add files…")
.outline()
.on_press(Message::Nothing),
),
)
.width(Length::Fixed(340.0)),
);Refine the root and the individual slots together to build a compact, leading-aligned empty state:
Empty::new()
.max_w(rems(20.))
.p_4()
.items_start()
.text_left()
.header(
EmptyHeader::new()
.items_start()
.title(EmptyTitle::new().child("No shared files"))
.description(
EmptyDescription::new()
.child("Add files so your team can review and edit them together."),
),
)
.content(
EmptyContent::new()
.items_start()
.child(Button::new("add-files").outline().label("Add files…")),
)HorizontalLayout {
alignment: start;
Empty {
preferred-width: 340px;
horizontal-stretch: 0;
height: 240px;
bordered: true;
content-padding: 16px;
text-max-width: 300px;
align: EmptyAlign.start;
icon: "inbox";
title: "No shared files";
description: "Add files so your team can review and edit them together.";
secondary-action: "Add files…";
}
}The root fills the available width and can grow within a flex layout. Header
and content use the available width up to 24 rem. Text wraps naturally, and
Empty does not clip its children or own a scroll region. The parent supplies
the viewport and any required scrolling. Custom media should fit its container;
action rows can use .flex_wrap() when space is constrained.
#Styling defaults
| Part | Default |
|---|---|
| Root | Centered column, p_6(), gap_4(), theme radius_tokens().xl |
| Header | gap_2(), centered items, maximum width 24 rem |
| Media | Centered column sized to its content, mb_2(), does not shrink |
| Icon media | size_8(), muted background, foreground text, theme radius_tokens().lg |
| Title | text_sm(), medium weight |
| Description | text_sm(), line height 1.625, muted foreground |
| Content | Centered column, gap_2p5(), text_sm(), maximum width 24 rem |
Instance styles override defaults and media-variant styles. Icon media supplies
a one-rem font size that an unsized GPUI Component Icon inherits; an explicit
icon size is preserved. Arbitrary SVG/image children keep their own sizing.
Typography uses the application’s font and rem scale. GPUI’s native wrapping
and letter spacing apply; CSS text-balance and tracking-tight are not
reimplemented by this component.
Empty does not create focus targets or automatically announce itself as an alert or live status. Its Button and Input children retain their normal focus and keyboard behavior. Choose application commands as Buttons and external resources as Links.