diff --git a/assets/icons/building-2.svg b/assets/icons/building-2.svg
new file mode 100644
index 00000000..a5b21833
--- /dev/null
+++ b/assets/icons/building-2.svg
@@ -0,0 +1,16 @@
+
diff --git a/assets/icons/user.svg b/assets/icons/user.svg
new file mode 100644
index 00000000..aabc54fb
--- /dev/null
+++ b/assets/icons/user.svg
@@ -0,0 +1,16 @@
+
diff --git a/crates/story/src/avatar_story.rs b/crates/story/src/avatar_story.rs
new file mode 100644
index 00000000..0559d592
--- /dev/null
+++ b/crates/story/src/avatar_story.rs
@@ -0,0 +1,154 @@
+use gpui::{
+ px, App, AppContext, Context, Entity, FocusHandle, Focusable, IntoElement, ParentElement,
+ Render, Styled, Window,
+};
+use gpui_component::{
+ avatar::{Avatar, AvatarGroup},
+ dock::PanelControl,
+ v_flex, ActiveTheme, IconName, Sizable as _, StyledExt,
+};
+
+use crate::section;
+
+pub struct AvatarStory {
+ focus_handle: gpui::FocusHandle,
+}
+
+impl AvatarStory {
+ fn new(_: &mut Window, cx: &mut Context) -> Self {
+ Self {
+ focus_handle: cx.focus_handle(),
+ }
+ }
+
+ pub fn view(window: &mut Window, cx: &mut App) -> Entity {
+ cx.new(|cx| Self::new(window, cx))
+ }
+}
+
+impl super::Story for AvatarStory {
+ fn title() -> &'static str {
+ "Avatar"
+ }
+
+ fn description() -> &'static str {
+ "Avatar is an image that represents a user or organization."
+ }
+
+ fn new_view(window: &mut Window, cx: &mut App) -> Entity {
+ Self::view(window, cx)
+ }
+
+ fn zoomable() -> Option {
+ None
+ }
+}
+
+impl Focusable for AvatarStory {
+ fn focus_handle(&self, _: &App) -> FocusHandle {
+ self.focus_handle.clone()
+ }
+}
+
+impl Render for AvatarStory {
+ fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement {
+ v_flex()
+ .gap_4()
+ .child(
+ section("Avatar with image")
+ .max_w_md()
+ .child(
+ Avatar::new()
+ .name("Jason lee")
+ .src("https://i.pravatar.cc/200?u=a")
+ .with_size(px(100.)),
+ )
+ .child(Avatar::new().src("https://i.pravatar.cc/200?u=b").large())
+ .child(Avatar::new().src("https://i.pravatar.cc/200?u=c"))
+ .child(Avatar::new().src("https://i.pravatar.cc/200?u=d").small())
+ .child(Avatar::new().src("https://i.pravatar.cc/200?u=e").xsmall()),
+ )
+ .child(
+ section("Avatar with text")
+ .max_w_md()
+ .child(Avatar::new().name("Jason Lee").large())
+ .child(Avatar::new().name("Floyd Wang"))
+ .child(Avatar::new().name("xda").small())
+ .child(Avatar::new().name("ihavecoke").xsmall()),
+ )
+ .child(
+ section("Placeholder")
+ .max_w_md()
+ .child(Avatar::new().large())
+ .child(Avatar::new())
+ .child(Avatar::new().small())
+ .child(Avatar::new().xsmall())
+ .child(Avatar::new().placeholder(IconName::Building2)),
+ )
+ .child(
+ section("Avatar Group")
+ .v_flex()
+ .max_w_md()
+ .child(
+ AvatarGroup::new()
+ .child(Avatar::new().src("https://i.pravatar.cc/200?u=a"))
+ .child(Avatar::new().src("https://i.pravatar.cc/200?u=b"))
+ .child(Avatar::new().src("https://i.pravatar.cc/200?u=c"))
+ .child(Avatar::new().src("https://i.pravatar.cc/200?u=d"))
+ .child(Avatar::new().src("https://i.pravatar.cc/200?u=e"))
+ .child(Avatar::new().src("https://i.pravatar.cc/200?u=f")),
+ )
+ .child(
+ AvatarGroup::new()
+ .small()
+ .limit(5)
+ .child(Avatar::new().src("https://i.pravatar.cc/200?u=a"))
+ .child(Avatar::new().src("https://i.pravatar.cc/200?u=b"))
+ .child(Avatar::new().src("https://i.pravatar.cc/200?u=c"))
+ .child(Avatar::new().src("https://i.pravatar.cc/200?u=d"))
+ .child(Avatar::new().src("https://i.pravatar.cc/200?u=e"))
+ .child(Avatar::new().src("https://i.pravatar.cc/200?u=f"))
+ .child(Avatar::new().src("https://i.pravatar.cc/200?u=g"))
+ .child(Avatar::new().src("https://i.pravatar.cc/200?u=h"))
+ .child(Avatar::new().src("https://i.pravatar.cc/200?u=i"))
+ .child(Avatar::new().src("https://i.pravatar.cc/200?u=j"))
+ .child(Avatar::new().src("https://i.pravatar.cc/200?u=k")),
+ )
+ .child(
+ AvatarGroup::new()
+ .xsmall()
+ .limit(6)
+ .ellipsis()
+ .child(Avatar::new().src("https://i.pravatar.cc/200?u=a"))
+ .child(Avatar::new().src("https://i.pravatar.cc/200?u=b"))
+ .child(Avatar::new().src("https://i.pravatar.cc/200?u=c"))
+ .child(Avatar::new().src("https://i.pravatar.cc/200?u=d"))
+ .child(Avatar::new().src("https://i.pravatar.cc/200?u=e"))
+ .child(Avatar::new().src("https://i.pravatar.cc/200?u=f"))
+ .child(Avatar::new().src("https://i.pravatar.cc/200?u=g"))
+ .child(Avatar::new().src("https://i.pravatar.cc/200?u=h"))
+ .child(Avatar::new().src("https://i.pravatar.cc/200?u=i"))
+ .child(Avatar::new().src("https://i.pravatar.cc/200?u=j"))
+ .child(Avatar::new().src("https://i.pravatar.cc/200?u=k")),
+ ),
+ )
+ .child(
+ section("Custom rounded").child(
+ Avatar::new()
+ .src("https://i.pravatar.cc/200?u=a")
+ .with_size(px(100.))
+ .rounded(px(20.)),
+ ),
+ )
+ .child(
+ section("Custom Style").child(
+ Avatar::new()
+ .src("https://i.pravatar.cc/200?u=c")
+ .with_size(px(100.))
+ .border_3()
+ .border_color(cx.theme().foreground)
+ .shadow_sm(),
+ ),
+ )
+ }
+}
diff --git a/crates/story/src/lib.rs b/crates/story/src/lib.rs
index 4af91e85..8cdc4380 100644
--- a/crates/story/src/lib.rs
+++ b/crates/story/src/lib.rs
@@ -1,6 +1,7 @@
mod accordion_story;
mod alert_story;
mod assets;
+mod avatar_story;
mod badge_story;
mod button_story;
mod calendar_story;
@@ -52,6 +53,7 @@ use gpui::{
pub use accordion_story::AccordionStory;
pub use alert_story::AlertStory;
+pub use avatar_story::AvatarStory;
pub use badge_story::BadgeStory;
pub use button_story::ButtonStory;
pub use calendar_story::CalendarStory;
diff --git a/crates/story/src/main.rs b/crates/story/src/main.rs
index ac2e11ba..4cbf3b86 100644
--- a/crates/story/src/main.rs
+++ b/crates/story/src/main.rs
@@ -39,6 +39,7 @@ impl Gallery {
vec![
StoryContainer::panel::(window, cx),
StoryContainer::panel::(window, cx),
+ StoryContainer::panel::(window, cx),
StoryContainer::panel::(window, cx),
StoryContainer::panel::(window, cx),
StoryContainer::panel::(window, cx),
diff --git a/crates/ui/src/avatar/avatar.rs b/crates/ui/src/avatar/avatar.rs
new file mode 100644
index 00000000..66578feb
--- /dev/null
+++ b/crates/ui/src/avatar/avatar.rs
@@ -0,0 +1,172 @@
+use std::sync::LazyLock;
+
+use gpui::{
+ div, img, prelude::FluentBuilder, App, Div, Hsla, ImageSource, InteractiveElement,
+ Interactivity, IntoElement, ParentElement as _, RenderOnce, SharedString, StyleRefinement,
+ Styled, Window,
+};
+
+use crate::{
+ avatar::{avatar_size, AvatarSized as _},
+ ActiveTheme, Icon, IconName, Sizable, Size, StyledExt,
+};
+
+static AVATAR_COLORS: LazyLock<[Hsla; 17]> = LazyLock::new(|| {
+ [
+ crate::red_500(),
+ crate::orange_500(),
+ crate::amber_500(),
+ crate::yellow_500(),
+ crate::lime_500(),
+ crate::green_500(),
+ crate::emerald_500(),
+ crate::teal_500(),
+ crate::cyan_500(),
+ crate::sky_500(),
+ crate::blue_500(),
+ crate::indigo_500(),
+ crate::violet_500(),
+ crate::fuchsia_500(),
+ crate::purple_500(),
+ crate::pink_500(),
+ crate::rose_500(),
+ ]
+});
+
+/// User avatar element.
+///
+/// We can use [`Sizable`] trait to set the size of the avatar (see also: [`avatar_size`] about the size in pixels).
+#[derive(IntoElement)]
+pub struct Avatar {
+ base: Div,
+ style: StyleRefinement,
+ src: Option,
+ name: Option,
+ short_name: SharedString,
+ placeholder: Icon,
+ size: Size,
+}
+
+impl Avatar {
+ pub fn new() -> Self {
+ Self {
+ base: div(),
+ style: StyleRefinement::default(),
+ src: None,
+ name: None,
+ short_name: SharedString::default(),
+ placeholder: Icon::new(IconName::User),
+ size: Size::Medium,
+ }
+ }
+
+ /// Set to use image source for the avatar.
+ pub fn src(mut self, source: impl Into) -> Self {
+ self.src = Some(source.into());
+ self
+ }
+
+ /// Set name of the avatar user, if `src` is none, will use this name as placeholder.
+ pub fn name(mut self, name: impl Into) -> Self {
+ let name: SharedString = name.into();
+ let short: SharedString = extract_text_initials(&name).into();
+
+ self.name = Some(name);
+ self.short_name = short;
+ self
+ }
+
+ /// Set placeholder icon, default: [`IconName::User`]
+ pub fn placeholder(mut self, icon: impl Into) -> Self {
+ self.placeholder = icon.into();
+ self
+ }
+}
+impl Sizable for Avatar {
+ fn with_size(mut self, size: impl Into) -> Self {
+ self.size = size.into();
+ self
+ }
+}
+impl Styled for Avatar {
+ fn style(&mut self) -> &mut StyleRefinement {
+ &mut self.style
+ }
+}
+
+impl InteractiveElement for Avatar {
+ fn interactivity(&mut self) -> &mut Interactivity {
+ self.base.interactivity()
+ }
+}
+
+impl RenderOnce for Avatar {
+ fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
+ let corner_radii = self.style.corner_radii.clone();
+ let mut inner_style = StyleRefinement::default();
+ inner_style.corner_radii = corner_radii;
+
+ const BG_OPACITY: f32 = 0.2;
+
+ self.base
+ .avatar_size(self.size)
+ .flex()
+ .items_center()
+ .justify_center()
+ .flex_shrink_0()
+ .rounded_full()
+ .overflow_hidden()
+ .bg(cx.theme().secondary)
+ .text_color(cx.theme().background)
+ .border_1()
+ .border_color(cx.theme().background)
+ .when(self.name.is_none() && self.src.is_none(), |this| {
+ this.text_size(avatar_size(self.size) * 0.6)
+ .child(self.placeholder)
+ })
+ .map(|this| match self.src {
+ None => this.when(self.name.is_some(), |this| {
+ let color_ix = gpui::hash(&self.short_name) % AVATAR_COLORS.len() as u64;
+ let color = AVATAR_COLORS[color_ix as usize];
+
+ this.bg(color.opacity(BG_OPACITY))
+ .text_color(color)
+ .child(div().avatar_text_size(self.size).child(self.short_name))
+ }),
+ Some(src) => this.child(
+ img(src)
+ .avatar_size(self.size)
+ .rounded_full()
+ .refine_style(&inner_style),
+ ),
+ })
+ .refine_style(&self.style)
+ }
+}
+
+fn extract_text_initials(text: &str) -> String {
+ let mut result = text
+ .split(" ")
+ .flat_map(|word| word.chars().next().map(|c| c.to_string()))
+ .take(2)
+ .collect::>()
+ .join("");
+
+ if result.len() == 1 {
+ result = text.chars().take(2).collect::();
+ }
+
+ result.to_uppercase()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_avatar_text_initials() {
+ assert_eq!(extract_text_initials(&"Jason Lee"), "JL".to_string());
+ assert_eq!(extract_text_initials(&"Foo Bar Dar"), "FB".to_string());
+ assert_eq!(extract_text_initials(&"huacnlee"), "HU".to_string());
+ }
+}
diff --git a/crates/ui/src/avatar/avatar_group.rs b/crates/ui/src/avatar/avatar_group.rs
new file mode 100644
index 00000000..59db9ab7
--- /dev/null
+++ b/crates/ui/src/avatar/avatar_group.rs
@@ -0,0 +1,108 @@
+use gpui::{
+ div, prelude::FluentBuilder as _, Div, InteractiveElement, Interactivity, IntoElement,
+ ParentElement as _, RenderOnce, StyleRefinement, Styled,
+};
+
+use crate::{avatar::Avatar, ActiveTheme, Sizable, Size, StyledExt as _};
+
+/// A grouped avatars to display in a compact layout.
+#[derive(IntoElement)]
+pub struct AvatarGroup {
+ base: Div,
+ style: StyleRefinement,
+ avatars: Vec,
+ size: Size,
+ limit: usize,
+ ellipsis: bool,
+}
+
+impl AvatarGroup {
+ pub fn new() -> Self {
+ Self {
+ base: div(),
+ style: StyleRefinement::default(),
+ avatars: Vec::new(),
+ size: Size::default(),
+ limit: 3,
+ ellipsis: false,
+ }
+ }
+
+ /// Add a child avatar to the group.
+ pub fn child(mut self, avatar: Avatar) -> Self {
+ self.avatars.push(avatar);
+ self
+ }
+
+ /// Add multiple child avatars to the group.
+ pub fn children(mut self, avatars: impl IntoIterator- ) -> Self {
+ self.avatars.extend(avatars);
+ self
+ }
+
+ /// Set the maximum number of avatars to display before showing a "more" avatar.
+ pub fn limit(mut self, limit: usize) -> Self {
+ self.limit = limit;
+ self
+ }
+
+ /// Set whether to show an ellipsis when the limit is reached, default: false
+ pub fn ellipsis(mut self) -> Self {
+ self.ellipsis = true;
+ self
+ }
+}
+
+impl Sizable for AvatarGroup {
+ fn with_size(mut self, size: impl Into) -> Self {
+ self.size = size.into();
+ self
+ }
+}
+
+impl Styled for AvatarGroup {
+ fn style(&mut self) -> &mut StyleRefinement {
+ &mut self.style
+ }
+}
+
+impl InteractiveElement for AvatarGroup {
+ fn interactivity(&mut self) -> &mut Interactivity {
+ self.base.interactivity()
+ }
+}
+
+impl RenderOnce for AvatarGroup {
+ fn render(self, _: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement {
+ let item_ml = -super::avatar_size(self.size) * 0.3;
+ let avatars_len = self.avatars.len();
+
+ self.base
+ .h_flex()
+ .flex_row_reverse()
+ .refine_style(&self.style)
+ .children(if self.ellipsis && avatars_len > self.limit {
+ Some(
+ Avatar::new()
+ .name("⋯")
+ .bg(cx.theme().secondary)
+ .text_color(cx.theme().muted_foreground)
+ .with_size(self.size)
+ .ml_1(),
+ )
+ } else {
+ None
+ })
+ .children(
+ self.avatars
+ .into_iter()
+ .take(self.limit)
+ .enumerate()
+ .rev()
+ .map(|(ix, item)| {
+ item.with_size(self.size)
+ .when(ix > 0, |this| this.ml(item_ml))
+ }),
+ )
+ }
+}
diff --git a/crates/ui/src/avatar/mod.rs b/crates/ui/src/avatar/mod.rs
new file mode 100644
index 00000000..2db67785
--- /dev/null
+++ b/crates/ui/src/avatar/mod.rs
@@ -0,0 +1,39 @@
+mod avatar;
+mod avatar_group;
+
+pub use avatar::*;
+pub use avatar_group::*;
+
+use crate::{Icon, Size, StyledExt as _};
+use gpui::{px, rems, Div, Img, IntoElement, Pixels, Styled};
+
+/// Returns the size of the avatar based on the given [`Size`].
+pub(super) fn avatar_size(size: Size) -> Pixels {
+ match size {
+ Size::Large => px(80.),
+ Size::Medium => px(48.),
+ Size::Small => px(24.),
+ Size::XSmall => px(20.),
+ Size::Size(size) => size,
+ }
+}
+
+/// Extension for add `avatar_size` method to `IntoElement` to apply avatar size to element.
+pub(super) trait AvatarSized: IntoElement + Styled {
+ fn avatar_size(self, size: Size) -> Self {
+ self.size(avatar_size(size))
+ }
+
+ fn avatar_text_size(self, size: Size) -> Self {
+ match size {
+ Size::Large => self.text_3xl().font_semibold(),
+ Size::Medium => self.text_sm(),
+ Size::Small => self.text_xs(),
+ Size::XSmall => self.text_size(rems(0.65)),
+ Size::Size(size) => self.size(size * 0.5),
+ }
+ }
+}
+impl AvatarSized for Div {}
+impl AvatarSized for Icon {}
+impl AvatarSized for Img {}
diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs
index 06b02674..e4e0b499 100644
--- a/crates/ui/src/icon.rs
+++ b/crates/ui/src/icon.rs
@@ -16,6 +16,7 @@ pub enum IconName {
Bell,
BookOpen,
Bot,
+ Building2,
Calendar,
ChartPie,
Check,
@@ -77,6 +78,7 @@ pub enum IconName {
ThumbsDown,
ThumbsUp,
TriangleAlert,
+ User,
WindowClose,
WindowMaximize,
WindowMinimize,
@@ -95,6 +97,7 @@ impl IconName {
Self::Bell => "icons/bell.svg",
Self::BookOpen => "icons/book-open.svg",
Self::Bot => "icons/bot.svg",
+ Self::Building2 => "icons/building-2.svg",
Self::Calendar => "icons/calendar.svg",
Self::ChartPie => "icons/chart-pie.svg",
Self::Check => "icons/check.svg",
@@ -156,6 +159,7 @@ impl IconName {
Self::ThumbsDown => "icons/thumbs-down.svg",
Self::ThumbsUp => "icons/thumbs-up.svg",
Self::TriangleAlert => "icons/triangle-alert.svg",
+ Self::User => "icons/user.svg",
Self::WindowClose => "icons/window-close.svg",
Self::WindowMaximize => "icons/window-maximize.svg",
Self::WindowMinimize => "icons/window-minimize.svg",
diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs
index 69d3d404..84e2dcd6 100644
--- a/crates/ui/src/lib.rs
+++ b/crates/ui/src/lib.rs
@@ -19,6 +19,7 @@ pub(crate) mod actions;
pub mod accordion;
pub mod alert;
pub mod animation;
+pub mod avatar;
pub mod badge;
pub mod breadcrumb;
pub mod button;