Add v2 Avatar component
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é <emile@probo.com>
This commit is contained in:
1
package-lock.json
generated
1
package-lock.json
generated
@@ -21462,6 +21462,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ariakit/react": "^0.4.17",
|
"@ariakit/react": "^0.4.17",
|
||||||
|
"@base-ui/react": "^1.6.0",
|
||||||
"@floating-ui/react": "^0.27.19",
|
"@floating-ui/react": "^0.27.19",
|
||||||
"@fontsource-variable/inter": "^5.2.8",
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ariakit/react": "^0.4.17",
|
"@ariakit/react": "^0.4.17",
|
||||||
|
"@base-ui/react": "^1.6.0",
|
||||||
"@floating-ui/react": "^0.27.19",
|
"@floating-ui/react": "^0.27.19",
|
||||||
"@fontsource-variable/inter": "^5.2.8",
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
|
|||||||
106
packages/ui/src/v2/Avatar/Avatar.stories.tsx
Normal file
106
packages/ui/src/v2/Avatar/Avatar.stories.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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<typeof Avatar>;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof Avatar>;
|
||||||
|
|
||||||
|
export const Playground: Story = {};
|
||||||
|
|
||||||
|
export const Sizes: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
{sizes.map(size => (
|
||||||
|
<Avatar key={size} size={size} fallback="AB" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Variants: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{(["soft", "solid"] as const).map(variant => (
|
||||||
|
<div key={variant} className="flex items-center gap-3">
|
||||||
|
{colors.map(color => (
|
||||||
|
<Avatar key={color} variant={variant} color={color} size={4} fallback="AB" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HighContrast: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{colors.map(color => (
|
||||||
|
<Avatar key={color} variant="solid" color={color} size={4} fallback="AB" highContrast />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Radius: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{radii.map(radius => (
|
||||||
|
<Avatar key={radius} radius={radius} size={5} fallback="AB" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Fallback: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar size={5} fallback="AB" />
|
||||||
|
<Avatar size={5} color="gold" fallback={<UserIcon weight="fill" />} />
|
||||||
|
<Avatar size={5} src={image} alt="User" fallback="AB" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Skeleton: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<AvatarSkeleton size={3} />
|
||||||
|
<AvatarSkeleton size={5} />
|
||||||
|
<AvatarSkeleton size={6} radius="full" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
47
packages/ui/src/v2/Avatar/Avatar.tsx
Normal file
47
packages/ui/src/v2/Avatar/Avatar.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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<ComponentProps<typeof BaseAvatar.Root>, "color" | "className">
|
||||||
|
& VariantProps<typeof avatar>
|
||||||
|
& {
|
||||||
|
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 (
|
||||||
|
<BaseAvatar.Root className={slots.root({ className })} {...rest}>
|
||||||
|
{src != null && (
|
||||||
|
<BaseAvatar.Image src={src} alt={alt} className={slots.image()} />
|
||||||
|
)}
|
||||||
|
<BaseAvatar.Fallback className={slots.fallback()}>{fallback}</BaseAvatar.Fallback>
|
||||||
|
</BaseAvatar.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
packages/ui/src/v2/Avatar/AvatarSkeleton.tsx
Normal file
28
packages/ui/src/v2/Avatar/AvatarSkeleton.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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<ComponentProps<"span">, "children"> & VariantProps<typeof avatarSkeleton>;
|
||||||
|
|
||||||
|
// 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 <span aria-hidden className={avatarSkeleton({ size, radius, className })} {...rest} />;
|
||||||
|
}
|
||||||
129
packages/ui/src/v2/Avatar/variants.ts
Normal file
129
packages/ui/src/v2/Avatar/variants.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user