[Feature] Avatar: match shadcn (Base UI + vega)
Merged
CI
3d ago+568+568+012345678901234567890123456789-128-128-012345678901234567890123456789
Restyles Avatar after shadcn's Base UI + vega Avatar and adds AvatarBadge, AvatarGroup and AvatarGroupCount. With these changes the shadcn audit reports no Look, HTML, Props or ARIA differences.
![]()
![]()
The main row has no badge or group because those parts didn't exist before this PR.
Changes
- Sizes follow vega: default 32px,
sm24px,lg40px.xs(16px) andxl(80px) stay as RubyUI extras. size: :defaultis the default, as in shadcn, and:mdstill works as an alias.nilor an unknown size renders as:default. Before this PR,size: :defaultrendered with no width or height.- The root gets vega's 1px border ring (an
::afterwithmix-blend-darken,lightenin dark mode),select-none,data-slotanddata-size. It no longer clips withoverflow-hidden, so the badge can sit on the edge. A plain<img>child still comes out sized, round and cropped. - AvatarImage uses
object-coverandrounded-full. Non-square photos were stretched before. - AvatarFallback uses
text-sminmuted-foreground(12px onsm). Before, it inherited the text color and a font size set on the root. - AvatarFallback takes
delay:(ms), like Base UI: it waits before showing the initials, so a fast image doesn't flash them first. With a delay it renders hidden until the controller connects. - The controller tracks the image status (
loading,loaded,error) and dispatchesruby-ui--avatar:statuswithdetail.statuson every change, the counterpart of Base UI'sonLoadingStatusChange. - New parts: AvatarBadge (bottom-right dot, sized per avatar size), AvatarGroup (overlapping avatars ringed in the background color) and AvatarGroupCount (the "+N" bubble).
- Every part has a
data-slot. - The docs page adds Badge, Group and Fallback delay examples, and the Introduction page uses AvatarImage.
- Avatar is now part of the shadcn audit (
audit/component_map.yml,audit/fixtures/avatar/).
Possible breaking changes for apps that copied the component:
- The default avatar shrinks from 40px to 32px and
lgfrom 56px to 40px. Passclass: "size-10"to keep the old size. - Initials are smaller and gray. Pass
class:on AvatarFallback for the old look. - The root no longer clips its children. Content other than an image or the fallback that relied on
overflow-hiddenneeds its own rounding.
Test steps
cd gem && bundle exec rakecd docs && bin/dev, open/docs/avatar: check the sizes, badge, group and fallback delay examples in light and dark.- On the same page, block the image URL in DevTools (Network → Block request URL) and reload: the initials show in place of the photo.
shadcn audit, main
shadcn audit ruby-ui/ruby_ui
base 8221400d head 8221400d shadcn c0d63c33
⚠️ Environment font: "Inter Variable", "system-ui", -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "sans-serif" vs "Inter Variable", sans-serif (light)
⚠️ Environment font: "Inter Variable", "system-ui", -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "sans-serif" vs "Inter Variable", sans-serif (dark)
⚠️ Environment RubyUI preview: date gem bumped 3.5.1 -> 3.6.0 in the throwaway checkout's Gemfile.lock (local rdoc rubygems plugin activates date 3.6.0); no effect on rendering.
✅ Theme neutral tokens match shadcn
Avatar (ref: bases/base/ui/avatar.tsx + style-vega.css @ c0d63c33)
❌ Look border-bottom-left-radius: 0px; border-bottom-right-radius: 0px; border-top-left-radius: 0px; border-top-right-radius: 0px
vega: border-bottom-left-radius: 3.35544e+07px; border-bottom-right-radius: 3.35544e+07px; border-top-left-radius: 3.35544e+07px; border-top-right-radius: 3.35544e+07px
AvatarImage — closed/light, closed/dark. RubyUI AvatarImage has no rounded-full (class `aspect-square h-full w-full`) vs vega `aspect-square size-full object-cover rounded-full`; it still looks round because the root clips with overflow-hidden. Also no object-cover: RubyUI computes object-fit: fill, so a non-square photo is stretched instead of cropped.
↳ computed styles (shadcn-audit collect)
❌ Look color: oklch(0.145 0 0); color: oklch(0.985 0 0)
vega: color: oklch(0.556 0 0); color: oklch(0.708 0 0)
AvatarFallback — closed/light, closed/dark. RubyUI AvatarFallback `flex h-full w-full items-center justify-center rounded-full bg-muted` has no text color (inherits foreground) vs vega `bg-muted text-muted-foreground rounded-full`.
↳ computed styles (shadcn-audit collect)
⏸ Look properties: font-family, font-size, height, line-height, width
Avatar, AvatarImage, AvatarFallback — explained by environment diff: font. Settled by the DOM: only font-family is environmental (both load Inter Variable; only the fallback stack differs). width/height/font-size/line-height are real size differences, graded on the Avatar size and AvatarFallback text lines below.
✅ ARIA Avatar, AvatarImage, AvatarFallback: accessibility tree matches
↳ accessibility tree (shadcn-audit collect)
❌ Look default (:md) h-10 w-10 = 40px; :lg h-14 w-14 = 56px; :sm h-6 w-6 = 24px
vega: size-8 = 32px; data-[size=lg]:size-10 = 40px; data-[size=sm]:size-6 = 24px
Avatar size, light and dark (computed width/height). sm matches; default and lg are bigger in RubyUI. Also AvatarImage inherits the same 40px vs 32px.
↳ style-vega.css:71
❌ Look text size on the root: default text-base (16px/24px), :lg text-xl (20px/28px), :sm text-xs (12px/16px); fallback inherits it
vega: AvatarFallback text-sm (14px/20px), group-data-[size=sm]/avatar:text-xs (12px/16px); nothing on the root
AvatarFallback text, light and dark (computed font-size/line-height). sm matches; default and lg differ.
↳ bases/base/ui/avatar.tsx:47
❌ Look no ::after ring (content: none); overflow-hidden; user-select: auto
vega: after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken dark:after:mix-blend-lighten; select-none; overflow visible
Avatar root, read with getComputedStyle(el, '::after') in the browser: shadcn draws a 1px oklch(0.922 0 0) border ring blended over the image/fallback; RubyUI has none. collect does not read pseudo-elements, so this line is by hand.
↳ bases/base/ui/avatar.tsx:19
✅ HTML span > img + span (tags)
Same tags and nesting in the rendered fixture. Base UI mounts only one of image/fallback (fallback until the image loads, then the img); RubyUI renders both and hides one with the `hidden` class. Display:none keeps the hidden one out of layout and the a11y tree, so the rendered result matches.
↳ bases/base/ui/avatar.tsx:15
❌ HTML no data-slot on any part, no data-size on the root
shadcn: data-slot=avatar|avatar-image|avatar-fallback, data-size=default|sm|lg
data-size is the styling hook shadcn's group-data-[size=*] classes rely on (fallback text, badge).
↳ bases/base/ui/avatar.tsx:16
❌ HTML no AvatarBadge, AvatarGroup or AvatarGroupCount
shadcn: AvatarBadge (span, absolute bottom-right dot), AvatarGroup (div, -space-x-2, ring-2 ring-background on each avatar), AvatarGroupCount (div, +N bubble)
Not in the fixture (RubyUI has no counterpart to render). Add them to the map and fixture once implemented.
↳ bases/base/ui/avatar.tsx:56-104
❌ Props size: :xs | :sm | :md (default) | :lg | :xl; size: :default not in SIZES, renders no size classes
shadcn: size?: "default" | "sm" | "lg" (default "default")
size is mapped, but the shadcn value `default` has no RubyUI key: Avatar(size: :default) yields SIZES[:default] = nil, so the avatar has no width/height (gem/lib/ruby_ui/avatar/avatar.rb:13). RubyUI extras :xs, :md, :xl are a note, not a failure.
↳ bases/base/ui/avatar.tsx:9-12
⏸ Props no delay on AvatarFallback, no onLoadingStatusChange on AvatarImage
shadcn: Base UI primitive props: Fallback delay?: number; Image onLoadingStatusChange
Inherited from @base-ui/react 1.9.0 (avatar/fallback/AvatarFallback.d.ts:18, avatar/image/AvatarImage.d.ts:22), not declared in avatar.tsx, so not in the map. Map gap if they should be audited.
✅ Behavior loaded image: img shown, fallback display:none; undecodable image: img display:none, fallback shown
Observed in the browser with the fixture's data-URI image and broken data URI (no network). Not exercised: the instant before Stimulus connects (RubyUI SSR shows both img and fallback until connect) and Base UI's fallback delay.
↳ bases/base/ui/avatar.tsx:28-52
✅ Animation none
No transition/animation classes on either side.
↳ style-vega.css:69-88
✅ Native Stimulus controller toggles `hidden` on img load/error
HTML has no native fallback content for a failed <img> (alt text only); no entry in native-html.md fits, so the small controller is not a native candidate.
↳ https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#image_loading_errors
✅ Spec img alt from alt: (default "")
Both render alt on the img ("@shadcn"). RubyUI's default alt="" marks the image decorative, which is valid; in that case the avatar has no accessible name, same as shadcn without alt.
↳ https://html.spec.whatwg.org/multipage/images.html#alt
shadcn audit, this PR
shadcn audit ruby-ui/ruby_ui
base 674e56b3 head de123e78 shadcn c0d63c33
⚠️ Environment font: "Inter Variable", "system-ui", -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "sans-serif" vs "Inter Variable", sans-serif (light)
⚠️ Environment font: "Inter Variable", "system-ui", -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "sans-serif" vs "Inter Variable", sans-serif (dark)
⚠️ Environment Audited a local snapshot commit (de123e78, not on any branch) of the uncommitted fix; RubyUI preview Gemfile.lock: date 3.5.1 -> 3.6.0 (local rdoc plugin), no effect on rendering.
✅ Theme neutral tokens match shadcn
Avatar (ref: bases/base/ui/avatar.tsx + style-vega.css @ c0d63c33)
⏸ Look properties: font-family
Avatar, AvatarImage, AvatarFallback, AvatarBadge, AvatarGroup, AvatarGroupCount — explained by environment diff: font. Only font-family differs: both pages load Inter Variable; only the fallback stack differs (environment). Every other computed property matches on all six parts, light and dark.
✅ ARIA Avatar, AvatarImage, AvatarFallback, AvatarBadge, AvatarGroup, AvatarGroupCount: accessibility tree matches
↳ accessibility tree (shadcn-audit collect)
✅ Look ::after 1px oklch(0.922 0 0) border, mix-blend darken; select-none; overflow visible
Avatar root pseudo-element read with getComputedStyle(el, '::after') in the browser (collect doesn't read pseudo-elements).
↳ bases/base/ui/avatar.tsx:19
✅ HTML span[data-slot=avatar][data-size] > img[data-slot=avatar-image] + span[data-slot=avatar-fallback]; span[data-slot=avatar-badge]; div[data-slot=avatar-group] > … + div[data-slot=avatar-group-count]
RubyUI keeps the hidden image/fallback in the DOM with display:none; Base UI unmounts it. Same rendered result and a11y tree.
↳ bases/base/ui/avatar.tsx:15-104
✅ Props size: :default (default) | :sm | :lg, plus :xs/:xl; :md alias of :default; unknown/nil → :default. AvatarFallback delay:
RubyUI extras: :xs, :xl, :md alias. onLoadingStatusChange maps to the ruby-ui--avatar:status event.
↳ bases/base/ui/avatar.tsx:9-12
✅ Behavior loaded image shows, fallback hidden; failed image: fallback; delay 400ms: fallback hidden at 50ms, shown at 550ms; events loading → error
Observed in the browser. Without the controller (before connect / no JS) a visible image hides the fallback via group-has CSS.
↳ bases/base/ui/avatar.tsx:28-52
✅ Animation none
↳ style-vega.css:69-88
✅ Native Stimulus controller tracks img load/error
No native fallback content for a failed <img>; no native-html.md entry fits.
↳ https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#image_loading_errors
✅ Spec img alt from alt: (default "")
↳ https://html.spec.whatwg.org/multipage/images.html#alt
🤖 Generated with Claude Code
Summary by cubic
Restyles Avatar to match shadcn's Base UI + vega look and adds AvatarBadge, AvatarGroup and AvatarGroupCount. The shadcn audit now reports no Look, HTML, Props or ARIA differences.
Changes
- Sizes follow vega: default 32px,
sm24px,lg40px;xsandxlremain RubyUI extras, and:mdstill works as an alias of:default. size: :defaultis now the default and renders 32px, where before it rendered with no width or height.- Root gets a 1px border ring,
select-none,data-slotanddata-size; AvatarImage crops non-square photos withobject-cover, AvatarFallback usestext-sminmuted-foreground. - AvatarFallback takes
delay:(ms) and the controller dispatchesruby-ui--avatar:statuswithdetail.status(loading,loaded,error), mirroring Base UI'sonLoadingStatusChange. A delayed fallback needs JavaScript: without the controller it stays hidden. - Avatar is now part of the shadcn audit.
Breaking changes
- The default avatar shrinks from 40px to 32px and
lgfrom 56px to 40px. - Initials are smaller and gray, and the root no longer clips its children.