Avatar
An image element with a fallback for representing the user.
Usage
Image & fallback
The fallback shows while the image loads, and stays if it fails.
Avatar do
AvatarImage(src: "https://avatars.githubusercontent.com/u/246692?v=4", alt: "joeldrapper")
AvatarFallback { "JD" }
end
Only fallback
Avatar do
AvatarFallback { "JD" }
end
Sizes
sm, default and lg match shadcn; xs and xl are RubyUI extras. :md still works as an alias of :default.
div(class: "flex items-center gap-4") do
[:xs, :sm, :default, :lg, :xl].each do |size|
Avatar(size: size) do
AvatarImage(src: "https://avatars.githubusercontent.com/u/246692?v=4", alt: "joeldrapper")
AvatarFallback { "JD" }
end
end
end
Sizes (only fallback)
div(class: "flex items-center gap-4") do
[:xs, :sm, :default, :lg, :xl].each do |size|
Avatar(size: size) do
AvatarFallback { "JD" }
end
end
end
Badge
AvatarBadge sits on the bottom-right corner, e.g. an online status.
Avatar do
AvatarImage(src: "https://avatars.githubusercontent.com/u/246692?v=4", alt: "joeldrapper")
AvatarFallback { "JD" }
AvatarBadge(class: "bg-green-600 dark:bg-green-800")
end
Group
AvatarGroup overlaps the avatars; AvatarGroupCount shows how many are left.
AvatarGroup do
Avatar do
AvatarImage(src: "https://avatars.githubusercontent.com/u/246692?v=4", alt: "joeldrapper")
AvatarFallback { "JD" }
end
Avatar do
AvatarFallback { "CN" }
end
Avatar do
AvatarFallback { "RU" }
end
AvatarGroupCount { "+3" }
end
Fallback delay
delay: (ms) waits before showing the fallback, so a fast image doesn't flash the initials first. The avatar dispatches ruby-ui--avatar:status with detail.status (loading, loaded or error) on every change. The delay needs JavaScript: without it, a delayed fallback stays hidden.
Avatar do
AvatarImage(src: "https://avatars.githubusercontent.com/u/246692?v=4", alt: "joeldrapper")
AvatarFallback(delay: 600) { "JD" }
end
Installation
Using RubyUI CLI
Run the install command
rails g ruby_ui:component Avatar
Manual installation
1
Add RubyUI::Avatar to app/components/ruby_ui/avatar/avatar.rb
# frozen_string_literal: true
module RubyUI
# A round image with a text fallback, like shadcn's Avatar. The size is
# exposed as data-size so the parts (fallback text, badge) follow it.
class Avatar < Base
SIZES = {
xs: "size-4",
sm: "size-6",
default: "size-8",
lg: "size-10",
xl: "size-20"
}.freeze
# :md was the default before RubyUI matched shadcn's size names.
ALIASES = {md: :default}.freeze
def initialize(size: :default, **attrs)
@size = size.to_s.to_sym
@size = ALIASES.fetch(@size, @size)
@size = :default unless SIZES.key?(@size) # nil or unknown: default size
super(**attrs)
end
def view_template(&)
span(**attrs, &)
end
private
def default_attrs
{
data: {
slot: "avatar",
size: @size,
controller: "ruby-ui--avatar"
},
class: [
"group/avatar relative flex shrink-0 select-none rounded-full",
"after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken dark:after:mix-blend-lighten",
# The root doesn't clip (the badge sits on its edge), so a plain <img>
# child (instead of AvatarImage) is sized and rounded here.
"*:[img]:size-full *:[img]:rounded-full *:[img]:object-cover",
SIZES.fetch(@size)
]
}
end
end
end
2
Add RubyUI::AvatarBadge to app/components/ruby_ui/avatar/avatar_badge.rb
# frozen_string_literal: true
module RubyUI
# A dot (or a small icon) on the avatar's bottom-right corner, e.g. an
# online status. Its size follows the avatar's size; on :xs and :sm the
# icon is hidden and only the dot shows.
class AvatarBadge < Base
def view_template(&)
span(**attrs, &)
end
private
def default_attrs
{
data: {slot: "avatar-badge"},
class: [
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none",
"bg-primary text-primary-foreground ring-background",
"group-data-[size=xs]/avatar:size-1.5 group-data-[size=xs]/avatar:[&>svg]:hidden",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
"group-data-[size=xl]/avatar:size-5 group-data-[size=xl]/avatar:[&>svg]:size-3"
]
}
end
end
end
3
Add RubyUI::AvatarDocs to app/components/ruby_ui/avatar/avatar_docs.rb
# frozen_string_literal: true
class Views::Docs::Avatar < Views::Base
def view_template
component = "Avatar"
div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
render Docs::Header.new(title: "Avatar", description: "An image element with a fallback for representing the user.")
Heading(level: 2) { "Usage" }
render Docs::VisualCodeExample.new(title: "Image & fallback", description: "The fallback shows while the image loads, and stays if it fails.", context: self) do
<<~RUBY
Avatar do
AvatarImage(src: "https://avatars.githubusercontent.com/u/246692?v=4", alt: "joeldrapper")
AvatarFallback { "JD" }
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Only fallback", context: self) do
<<~RUBY
Avatar do
AvatarFallback { "JD" }
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Sizes", description: "sm, default and lg match shadcn; xs and xl are RubyUI extras. :md still works as an alias of :default.", context: self) do
<<~RUBY
div(class: "flex items-center gap-4") do
[:xs, :sm, :default, :lg, :xl].each do |size|
Avatar(size: size) do
AvatarImage(src: "https://avatars.githubusercontent.com/u/246692?v=4", alt: "joeldrapper")
AvatarFallback { "JD" }
end
end
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Sizes (only fallback)", context: self) do
<<~RUBY
div(class: "flex items-center gap-4") do
[:xs, :sm, :default, :lg, :xl].each do |size|
Avatar(size: size) do
AvatarFallback { "JD" }
end
end
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Badge", description: "AvatarBadge sits on the bottom-right corner, e.g. an online status.", context: self) do
<<~RUBY
Avatar do
AvatarImage(src: "https://avatars.githubusercontent.com/u/246692?v=4", alt: "joeldrapper")
AvatarFallback { "JD" }
AvatarBadge(class: "bg-green-600 dark:bg-green-800")
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Group", description: "AvatarGroup overlaps the avatars; AvatarGroupCount shows how many are left.", context: self) do
<<~RUBY
AvatarGroup do
Avatar do
AvatarImage(src: "https://avatars.githubusercontent.com/u/246692?v=4", alt: "joeldrapper")
AvatarFallback { "JD" }
end
Avatar do
AvatarFallback { "CN" }
end
Avatar do
AvatarFallback { "RU" }
end
AvatarGroupCount { "+3" }
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Fallback delay", description: "delay: (ms) waits before showing the fallback, so a fast image doesn't flash the initials first. The avatar dispatches ruby-ui--avatar:status with detail.status (loading, loaded or error) on every change. The delay needs JavaScript: without it, a delayed fallback stays hidden.", context: self) do
<<~RUBY
Avatar do
AvatarImage(src: "https://avatars.githubusercontent.com/u/246692?v=4", alt: "joeldrapper")
AvatarFallback(delay: 600) { "JD" }
end
RUBY
end
render Components::ComponentSetup::Tabs.new(component_name: component)
render Docs::ComponentsTable.new(component_files(component))
end
end
end
4
Add RubyUI::AvatarFallback to app/components/ruby_ui/avatar/avatar_fallback.rb
# frozen_string_literal: true
module RubyUI
# Shown while the image loads, when it fails, or when there is no image.
# delay: (ms) waits before showing it, so a fast image doesn't flash the
# initials first (Base UI's Avatar.Fallback delay).
class AvatarFallback < Base
def initialize(delay: nil, **attrs)
@delay = delay
super(**attrs)
end
def view_template(&)
span(**attrs, &)
end
private
def delay? = @delay.to_i.positive?
def default_attrs
{
data: {
slot: "avatar-fallback",
ruby_ui__avatar_target: "fallback",
delay: @delay
},
# With a delay, start hidden so it can't paint before the controller
# connects (the controller removes the attribute and runs the timer).
hidden: delay?,
class: [
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground",
"group-data-[size=xs]/avatar:text-[0.5rem] group-data-[size=sm]/avatar:text-xs group-data-[size=xl]/avatar:text-base",
# Before the controller connects (or without JS) an image that is
# still visible wins: the fallback stays out of the way.
"group-has-[[data-slot=avatar-image]:not(.hidden)]/avatar:hidden"
]
}
end
end
end
5
Add RubyUI::AvatarGroup to app/components/ruby_ui/avatar/avatar_group.rb
# frozen_string_literal: true
module RubyUI
# Overlapping avatars, each ringed in the background color. End it with an
# AvatarGroupCount for the "+N" bubble.
class AvatarGroup < Base
def view_template(&)
div(**attrs, &)
end
private
def default_attrs
{
data: {slot: "avatar-group"},
class: "group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background"
}
end
end
end
6
Add RubyUI::AvatarGroupCount to app/components/ruby_ui/avatar/avatar_group_count.rb
# frozen_string_literal: true
module RubyUI
# The "+N" bubble at the end of an AvatarGroup. It takes the size of the
# group's avatars.
class AvatarGroupCount < Base
def view_template(&)
div(**attrs, &)
end
private
def default_attrs
{
data: {slot: "avatar-group-count"},
class: [
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background [&>svg]:size-4",
"group-has-data-[size=xs]/avatar-group:size-4 group-has-data-[size=xs]/avatar-group:text-[0.5rem] group-has-data-[size=xs]/avatar-group:[&>svg]:size-2",
"group-has-data-[size=sm]/avatar-group:size-6 group-has-data-[size=sm]/avatar-group:text-xs group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
"group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5",
"group-has-data-[size=xl]/avatar-group:size-20 group-has-data-[size=xl]/avatar-group:text-base group-has-data-[size=xl]/avatar-group:[&>svg]:size-8"
]
}
end
end
end
7
Add RubyUI::AvatarImage to app/components/ruby_ui/avatar/avatar_image.rb
# frozen_string_literal: true
module RubyUI
class AvatarImage < Base
def initialize(src:, alt: "", **attrs)
@src = src
@alt = alt
super(**attrs)
end
def view_template
img(**attrs)
end
private
def default_attrs
{
# NB: do not set loading: "lazy" here. avatar_controller hides a not-yet-loaded
# image with `display:none` (the `hidden` class) so the fallback shows. The
# browser never fetches a `loading="lazy"` image that generates no box, so its
# `load` event never fires and the image stays hidden forever (#415). shadcn/radix
# do not lazy-load the avatar image either.
data: {
slot: "avatar-image",
ruby_ui__avatar_target: "image",
action: "load->ruby-ui--avatar#showImage error->ruby-ui--avatar#showFallback"
},
class: "aspect-square size-full rounded-full object-cover",
alt: @alt,
src: @src
}
end
end
end
8
Add avatar_controller.js to app/javascript/controllers/ruby_ui/avatar_controller.js
import { Controller } from "@hotwired/stimulus";
// Tracks the image's loading status like Base UI's Avatar: the image shows
// once it has loaded, the fallback shows otherwise (no image, still loading,
// or failed), after the fallback's optional data-delay (ms).
//
// Every status change dispatches `ruby-ui--avatar:status` with
// `detail: { status }`, where status is "loading", "loaded" or "error"
// (Base UI's onLoadingStatusChange).
export default class extends Controller {
static targets = ["image", "fallback"];
connect() {
this.status = null;
this.fallbackReady = false;
// A delayed fallback is rendered with the hidden attribute; from here on
// the controller drives its visibility with the hidden class.
this.fallbackTargets.forEach((fallback) => (fallback.hidden = false));
this.startFallbackDelay();
if (!this.hasImageTarget) {
this.render();
return;
}
const image = this.imageTarget;
if (image.complete) {
this.setStatus(image.naturalWidth > 0 ? "loaded" : "error");
} else {
this.setStatus("loading");
}
}
disconnect() {
clearTimeout(this.delayTimer);
this.delayTimer = null;
}
// load->ruby-ui--avatar#showImage
showImage() {
this.setStatus("loaded");
}
// error->ruby-ui--avatar#showFallback
showFallback() {
this.setStatus("error");
}
setStatus(status) {
if (status !== this.status) {
this.status = status;
this.dispatch("status", { detail: { status } });
}
this.render();
}
startFallbackDelay() {
clearTimeout(this.delayTimer);
const delay = this.hasFallbackTarget
? Number(this.fallbackTarget.dataset.delay) || 0
: 0;
if (delay <= 0) {
this.fallbackReady = true;
return;
}
this.delayTimer = setTimeout(() => {
this.delayTimer = null;
this.fallbackReady = true;
this.render();
}, delay);
}
render() {
const loaded = this.status === "loaded";
this.imageTargets.forEach((image) =>
image.classList.toggle("hidden", !loaded),
);
this.fallbackTargets.forEach((fallback) =>
fallback.classList.toggle("hidden", loaded || !this.fallbackReady),
);
}
}
9
Update the Stimulus controllers manifest file
rake stimulus:manifest:updateComponents
| Component | Built using | Source |
|---|---|---|
Avatar | Phlex | |
AvatarBadge | Phlex | |
AvatarDocs | Phlex | |
AvatarFallback | Phlex | |
AvatarGroup | Phlex | |
AvatarGroupCount | Phlex | |
AvatarImage | Phlex | |
AvatarController | Stimulus JS |