From 2063cfc2ff08febdc2c7e1a9ce5ac14081411e53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Wed, 24 Jun 2026 13:47:35 +0200 Subject: [PATCH] Add v2 Avatar component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the Avatar component from the Probo Radix UI Figma on top of Base UI's avatar primitive (image load + fallback). It supports size 1-9, solid/soft variants across the sand/gold/red/green/amber/ sky scales with highContrast, a radius scale, and a fallback node for initials or an icon, plus a paired AvatarSkeleton. Styling uses tailwind-variants/lite slots (root/image/fallback). Add @base-ui/react to @probo/ui — the first v2 component built on a Base UI primitive. Signed-off-by: Émile Ré --- package-lock.json | 1 + packages/ui/package.json | 1 + packages/ui/src/v2/Avatar/Avatar.stories.tsx | 106 +++++++++++++++ packages/ui/src/v2/Avatar/Avatar.tsx | 47 +++++++ packages/ui/src/v2/Avatar/AvatarSkeleton.tsx | 28 ++++ packages/ui/src/v2/Avatar/variants.ts | 129 +++++++++++++++++++ 6 files changed, 312 insertions(+) create mode 100644 packages/ui/src/v2/Avatar/Avatar.stories.tsx create mode 100644 packages/ui/src/v2/Avatar/Avatar.tsx create mode 100644 packages/ui/src/v2/Avatar/AvatarSkeleton.tsx create mode 100644 packages/ui/src/v2/Avatar/variants.ts diff --git a/package-lock.json b/package-lock.json index 5dff894a4..c063dc29d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21462,6 +21462,7 @@ "version": "1.0.0", "dependencies": { "@ariakit/react": "^0.4.17", + "@base-ui/react": "^1.6.0", "@floating-ui/react": "^0.27.19", "@fontsource-variable/inter": "^5.2.8", "@phosphor-icons/react": "^2.1.10", diff --git a/packages/ui/package.json b/packages/ui/package.json index 53703fa4b..ee8ab0cd3 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@ariakit/react": "^0.4.17", + "@base-ui/react": "^1.6.0", "@floating-ui/react": "^0.27.19", "@fontsource-variable/inter": "^5.2.8", "@phosphor-icons/react": "^2.1.10", diff --git a/packages/ui/src/v2/Avatar/Avatar.stories.tsx b/packages/ui/src/v2/Avatar/Avatar.stories.tsx new file mode 100644 index 000000000..6cc579af7 --- /dev/null +++ b/packages/ui/src/v2/Avatar/Avatar.stories.tsx @@ -0,0 +1,106 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { UserIcon } from "@phosphor-icons/react"; +import type { Meta, StoryObj } from "@storybook/react"; + +import { Avatar } from "./Avatar"; +import { AvatarSkeleton } from "./AvatarSkeleton"; + +const image = "https://i.pravatar.cc/240?img=12"; + +const sizes = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const; +const colors = ["neutral", "gold", "red", "green", "amber", "sky"] as const; +const radii = ["none", "small", "medium", "large", "full"] as const; + +export default { + title: "v2/Avatar", + component: Avatar, + args: { + fallback: "AB", + size: 3, + variant: "soft", + color: "neutral", + highContrast: false, + radius: "medium", + }, +} satisfies Meta; + +type Story = StoryObj; + +export const Playground: Story = {}; + +export const Sizes: Story = { + render: () => ( +
+ {sizes.map(size => ( + + ))} +
+ ), +}; + +export const Variants: Story = { + render: () => ( +
+ {(["soft", "solid"] as const).map(variant => ( +
+ {colors.map(color => ( + + ))} +
+ ))} +
+ ), +}; + +export const HighContrast: Story = { + render: () => ( +
+ {colors.map(color => ( + + ))} +
+ ), +}; + +export const Radius: Story = { + render: () => ( +
+ {radii.map(radius => ( + + ))} +
+ ), +}; + +export const Fallback: Story = { + render: () => ( +
+ + } /> + +
+ ), +}; + +export const Skeleton: Story = { + render: () => ( +
+ + + +
+ ), +}; diff --git a/packages/ui/src/v2/Avatar/Avatar.tsx b/packages/ui/src/v2/Avatar/Avatar.tsx new file mode 100644 index 000000000..d72424cb7 --- /dev/null +++ b/packages/ui/src/v2/Avatar/Avatar.tsx @@ -0,0 +1,47 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { Avatar as BaseAvatar } from "@base-ui/react/avatar"; +import type { ComponentProps, ReactNode } from "react"; +import type { VariantProps } from "tailwind-variants/lite"; + +import { avatar } from "./variants"; + +export type AvatarProps + = Omit, "color" | "className"> + & VariantProps + & { + className?: string; + // Profile image URL. When absent or it fails to load, `fallback` shows. + src?: string; + alt?: string; + // Shown until/unless the image loads: user initials (string) or an icon. + fallback: ReactNode; + }; + +// Foundational avatar primitive (Radix "Avatar") over Base UI's image-loading +// behavior. See contrib/claude/ui.md. +export function Avatar(props: AvatarProps) { + const { size, variant, color, highContrast, radius, className, src, alt, fallback, ...rest } = props; + const slots = avatar({ size, variant, color, highContrast, radius }); + + return ( + + {src != null && ( + + )} + {fallback} + + ); +} diff --git a/packages/ui/src/v2/Avatar/AvatarSkeleton.tsx b/packages/ui/src/v2/Avatar/AvatarSkeleton.tsx new file mode 100644 index 000000000..39d9ae3e3 --- /dev/null +++ b/packages/ui/src/v2/Avatar/AvatarSkeleton.tsx @@ -0,0 +1,28 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import type { ComponentProps } from "react"; +import type { VariantProps } from "tailwind-variants/lite"; + +import { avatarSkeleton } from "./variants"; + +export type AvatarSkeletonProps = Omit, "children"> & VariantProps; + +// Loading placeholder paired with Avatar: a pulse block matching its size and +// radius. +export function AvatarSkeleton(props: AvatarSkeletonProps) { + const { size, radius, className, ...rest } = props; + + return ; +} diff --git a/packages/ui/src/v2/Avatar/variants.ts b/packages/ui/src/v2/Avatar/variants.ts new file mode 100644 index 000000000..b7cf95fdd --- /dev/null +++ b/packages/ui/src/v2/Avatar/variants.ts @@ -0,0 +1,129 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { tv } from "tailwind-variants/lite"; + +// Profile picture / initials / fallback icon (Radix "Avatar"). +// root dimensions + radius + the fallback surface color +// image fills the root, cropped to cover +// fallback initials/icon shown until the image loads +export const avatar = tv({ + slots: { + root: "inline-flex shrink-0 items-center justify-center overflow-hidden align-middle select-none", + image: "size-full object-cover", + fallback: "flex size-full items-center justify-center font-medium leading-none", + }, + variants: { + size: { + 1: { root: "size-6", fallback: "text-1" }, + 2: { root: "size-8", fallback: "text-2" }, + 3: { root: "size-10", fallback: "text-3" }, + 4: { root: "size-12", fallback: "text-3" }, + 5: { root: "size-16", fallback: "text-5" }, + 6: { root: "size-20", fallback: "text-6" }, + 7: { root: "size-24", fallback: "text-7" }, + 8: { root: "size-32", fallback: "text-8" }, + 9: { root: "size-40", fallback: "text-9" }, + }, + radius: { + none: { root: "rounded-none" }, + small: { root: "rounded-2" }, + medium: { root: "rounded-3" }, + large: { root: "rounded-4" }, + full: { root: "rounded-full" }, + }, + // Surface treatment + hue resolve together in the compound variants below. + variant: { + solid: {}, + soft: {}, + }, + color: { + neutral: {}, + gold: {}, + red: {}, + green: {}, + amber: {}, + sky: {}, + }, + highContrast: { + true: {}, + false: {}, + }, + }, + compoundVariants: [ + // Soft: tinted background (step 3), hue text (step 11 / 12 high-contrast). + { variant: "soft", color: "neutral", highContrast: false, class: { fallback: "bg-sand-3 text-sand-11" } }, + { variant: "soft", color: "neutral", highContrast: true, class: { fallback: "bg-sand-3 text-sand-12" } }, + { variant: "soft", color: "gold", highContrast: false, class: { fallback: "bg-gold-3 text-gold-11" } }, + { variant: "soft", color: "gold", highContrast: true, class: { fallback: "bg-gold-3 text-gold-12" } }, + { variant: "soft", color: "red", highContrast: false, class: { fallback: "bg-red-3 text-red-11" } }, + { variant: "soft", color: "red", highContrast: true, class: { fallback: "bg-red-3 text-red-12" } }, + { variant: "soft", color: "green", highContrast: false, class: { fallback: "bg-green-3 text-green-11" } }, + { variant: "soft", color: "green", highContrast: true, class: { fallback: "bg-green-3 text-green-12" } }, + { variant: "soft", color: "amber", highContrast: false, class: { fallback: "bg-amber-3 text-amber-11" } }, + { variant: "soft", color: "amber", highContrast: true, class: { fallback: "bg-amber-3 text-amber-12" } }, + { variant: "soft", color: "sky", highContrast: false, class: { fallback: "bg-sky-3 text-sky-11" } }, + { variant: "soft", color: "sky", highContrast: true, class: { fallback: "bg-sky-3 text-sky-12" } }, + + // Solid: filled background (step 9, or 10 high-contrast). Most hues take + // white text; amber/sky steps 9-10 are light and take dark text. + { variant: "solid", color: "neutral", highContrast: false, class: { fallback: "bg-sand-9 text-white" } }, + { variant: "solid", color: "neutral", highContrast: true, class: { fallback: "bg-sand-10 text-white" } }, + { variant: "solid", color: "gold", highContrast: false, class: { fallback: "bg-gold-9 text-white" } }, + { variant: "solid", color: "gold", highContrast: true, class: { fallback: "bg-gold-10 text-white" } }, + { variant: "solid", color: "red", highContrast: false, class: { fallback: "bg-red-9 text-white" } }, + { variant: "solid", color: "red", highContrast: true, class: { fallback: "bg-red-10 text-white" } }, + { variant: "solid", color: "green", highContrast: false, class: { fallback: "bg-green-9 text-white" } }, + { variant: "solid", color: "green", highContrast: true, class: { fallback: "bg-green-10 text-white" } }, + { variant: "solid", color: "amber", highContrast: false, class: { fallback: "bg-amber-9 text-amber-12" } }, + { variant: "solid", color: "amber", highContrast: true, class: { fallback: "bg-amber-10 text-amber-12" } }, + { variant: "solid", color: "sky", highContrast: false, class: { fallback: "bg-sky-9 text-sky-12" } }, + { variant: "solid", color: "sky", highContrast: true, class: { fallback: "bg-sky-10 text-sky-12" } }, + ], + defaultVariants: { + size: 3, + variant: "soft", + color: "neutral", + highContrast: false, + radius: "medium", + }, +}); + +export const avatarSkeleton = tv({ + base: "inline-block shrink-0 animate-pulse bg-sand-3 align-middle", + variants: { + size: { + 1: "size-6", + 2: "size-8", + 3: "size-10", + 4: "size-12", + 5: "size-16", + 6: "size-20", + 7: "size-24", + 8: "size-32", + 9: "size-40", + }, + radius: { + none: "rounded-none", + small: "rounded-2", + medium: "rounded-3", + large: "rounded-4", + full: "rounded-full", + }, + }, + defaultVariants: { + size: 3, + radius: "medium", + }, +});