diff --git a/.changeset/skeleton-members-badge-loading.md b/.changeset/skeleton-members-badge-loading.md new file mode 100644 index 00000000000..2f926bbd1e7 --- /dev/null +++ b/.changeset/skeleton-members-badge-loading.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': minor +--- + +Add a `Skeleton` loading placeholder with a subtle shimmer, targetable via the new `skeleton` appearance descriptor. The Organization Profile members tabs now reserve space for their notification count badges while loading, so the tabs no longer shift when the counts appear. diff --git a/packages/ui/src/common/NotificationCountBadge.tsx b/packages/ui/src/common/NotificationCountBadge.tsx index 7fb99e121af..a1349ae797f 100644 --- a/packages/ui/src/common/NotificationCountBadge.tsx +++ b/packages/ui/src/common/NotificationCountBadge.tsx @@ -1,6 +1,7 @@ import { formatToCompactNumber } from '@/utils/intl'; import { Flex, localizationKeys, NotificationBadge, useLocalizations } from '../customizables'; +import { Skeleton } from '../elements/Skeleton'; import { usePrefersReducedMotion } from '../hooks'; import type { PropsOfComponent, ThemableCssProp } from '../styledSystem'; import { animations } from '../styledSystem'; @@ -9,10 +10,11 @@ type NotificationCountBadgeProps = PropsOfComponent & notificationCount: number; containerSx?: ThemableCssProp; shouldAnimate?: boolean; + isLoading?: boolean; }; export const NotificationCountBadge = (props: NotificationCountBadgeProps) => { - const { notificationCount, containerSx, shouldAnimate = true, ...restProps } = props; + const { notificationCount, containerSx, shouldAnimate = true, isLoading = false, ...restProps } = props; const prefersReducedMotion = usePrefersReducedMotion(); const { t } = useLocalizations(); const localeKey = t(localizationKeys('locale')); @@ -37,12 +39,22 @@ export const NotificationCountBadge = (props: NotificationCountBadgeProps) => { containerSx, ]} > - - {formattedNotificationCount} - + {isLoading ? ( + ({ + height: t.space.$4, + width: t.space.$5, + borderRadius: t.radii.$lg, + })} + /> + ) : ( + + {formattedNotificationCount} + + )} ); }; diff --git a/packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx b/packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx index 0b103b559e0..06631727ba5 100644 --- a/packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx +++ b/packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx @@ -84,37 +84,36 @@ export const OrganizationMembers = withCardStateProvider(() => { ({ gap: t.space.$2 })}> {canReadMemberships && ( - {!!memberships?.count && ( - - )} + )} {canManageMemberships && ( - {invitations?.data && !invitations.isLoading && ( - - )} + )} {canManageMemberships && isDomainsEnabled && ( - {membershipRequests?.data && !membershipRequests.isLoading && ( - - )} + )} diff --git a/packages/ui/src/customizables/elementDescriptors.ts b/packages/ui/src/customizables/elementDescriptors.ts index 0e8f3dc9d92..13720d53ea4 100644 --- a/packages/ui/src/customizables/elementDescriptors.ts +++ b/packages/ui/src/customizables/elementDescriptors.ts @@ -510,6 +510,7 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([ 'notificationBadge', 'buttonArrowIcon', 'spinner', + 'skeleton', 'apiKeys', 'apiKeysHeader', diff --git a/packages/ui/src/elements/Skeleton.tsx b/packages/ui/src/elements/Skeleton.tsx new file mode 100644 index 00000000000..d51aaf81b2c --- /dev/null +++ b/packages/ui/src/elements/Skeleton.tsx @@ -0,0 +1,59 @@ +import { Box, descriptors } from '../customizables'; +import { usePrefersReducedMotion } from '../hooks'; +import type { PropsOfComponent } from '../styledSystem'; +import { animations } from '../styledSystem'; + +type SkeletonProps = PropsOfComponent & { + /** When false, render children as-is instead of the loading placeholder. Defaults to true. */ + show?: boolean; +}; + +/** + * Decorative placeholder for loading states with a subtle shimmer. Two modes: + * + * - **Block** — `` reserves space with a plain bar. + * - **Content** — `