From 1ebb2983ee7b1b03562b6b60aead4b63249bfe6c Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 1 Oct 2026 16:33:18 -0600 Subject: [PATCH 01/14] feat(mosaic): add section skeletons with a page-wide loading wave Co-Authored-By: Claude Opus 5.5 --- .changeset/section-skeletons.md | 2 + .../src/components/section/section.styles.ts | 10 +- .../mosaic/src/components/section/section.tsx | 93 +++++++++++++------ ...er-profile-active-devices-section.view.tsx | 36 +++++++ .../user-profile-active-devices.messages.ts | 1 + .../user-profile-security-icon.tsx | 6 +- packages/mosaic/src/hooks/useSkeletonWave.ts | 27 ++++++ packages/mosaic/src/utils/skeleton.styles.ts | 35 +++++++ .../swingset/src/components/StoryEmbed.tsx | 8 +- .../swingset/src/components/StoryPreview.tsx | 3 +- packages/swingset/src/lib/utils.ts | 6 ++ packages/swingset/src/stories/section.mdx | 13 +++ .../swingset/src/stories/section.stories.tsx | 73 +++++++++++++++ .../user-profile-active-devices-section.mdx | 7 ++ ...profile-active-devices-section.stories.tsx | 9 +- 15 files changed, 293 insertions(+), 36 deletions(-) create mode 100644 .changeset/section-skeletons.md create mode 100644 packages/mosaic/src/hooks/useSkeletonWave.ts create mode 100644 packages/mosaic/src/utils/skeleton.styles.ts diff --git a/.changeset/section-skeletons.md b/.changeset/section-skeletons.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/section-skeletons.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/components/section/section.styles.ts b/packages/mosaic/src/components/section/section.styles.ts index 3cfef0569b5..8bdbe06a3d6 100644 --- a/packages/mosaic/src/components/section/section.styles.ts +++ b/packages/mosaic/src/components/section/section.styles.ts @@ -135,7 +135,6 @@ export const styles = stylex.create({ flexDirection: 'column', flexGrow: 1, justifyContent: 'center', - rowGap: space['0.5'], minWidth: 0, }, label: { @@ -144,6 +143,15 @@ export const styles = stylex.create({ fontWeight: fontWeightVars['--cl-font-medium'], lineHeight: typeScaleVars['--cl-text-sm-leading'], }, + titleSkeleton: { + width: space['32'], + }, + labelSkeleton: { + width: space['24'], + }, + descriptionSkeleton: { + width: space['40'], + }, description: { color: colorVars['--cl-color-foreground-secondary'], fontSize: typeScaleVars['--cl-text-sm-size'], diff --git a/packages/mosaic/src/components/section/section.tsx b/packages/mosaic/src/components/section/section.tsx index 4baeeca3ee7..226d91d15db 100644 --- a/packages/mosaic/src/components/section/section.tsx +++ b/packages/mosaic/src/components/section/section.tsx @@ -1,7 +1,9 @@ import { useSafeLayoutEffect } from '@clerk/shared/react'; +import { useMergeRefs } from '@floating-ui/react'; import * as stylex from '@stylexjs/stylex'; import React from 'react'; +import { useSkeletonWave } from '../../hooks/useSkeletonWave'; import { useTransition } from '../../primitives/hooks/use-transition'; import { useRender } from '../../primitives/utils'; import type { MosaicComponentProps } from '../../props'; @@ -9,6 +11,7 @@ import { mergeStyleProps, themeProps } from '../../props'; import { FeedbackBody, hasMessage, useHeldMessage, useMessageHeight } from '../../utils/feedback'; import { feedbackHeight, feedbackStyles } from '../../utils/feedback.styles'; import { reset } from '../../utils/reset.styles'; +import { skeletonStyles } from '../../utils/skeleton.styles'; import { sizes as typographySizes, styles as typographyStyles } from '../../utils/typography.styles'; import type { HeadingProps } from '../heading'; import { Heading, useHeadingLevel } from '../heading'; @@ -16,18 +19,18 @@ import { sectionHeaderDescriptionMarker, sectionHeaderMarker, sectionNestedItemM import { styles } from './section.styles'; export type SectionRootProps = Omit, 'title'>; -export type SectionGroupProps = MosaicComponentProps<'div'>; +export type SectionGroupProps = MosaicComponentProps<'div'> & { skeleton?: boolean }; export type SectionHeaderProps = MosaicComponentProps<'div'>; -export type SectionTitleProps = Omit; +export type SectionTitleProps = Omit & { skeleton?: boolean }; export type SectionBodyProps = MosaicComponentProps<'div'>; export type SectionRowProps = MosaicComponentProps<'div'>; export type SectionItemsProps = MosaicComponentProps<'ul'>; export type SectionItemProps = MosaicComponentProps<'div'> & { wrap?: boolean }; export type SectionMediaSize = 'sm' | 'md' | 'lg' | 'xl'; -export type SectionMediaProps = MosaicComponentProps<'div'> & { size?: SectionMediaSize }; +export type SectionMediaProps = MosaicComponentProps<'div'> & { size?: SectionMediaSize; skeleton?: boolean }; export type SectionContentProps = MosaicComponentProps<'div'>; -export type SectionLabelProps = MosaicComponentProps<'div'>; -export type SectionDescriptionProps = MosaicComponentProps<'div'>; +export type SectionLabelProps = MosaicComponentProps<'div'> & { skeleton?: boolean }; +export type SectionDescriptionProps = MosaicComponentProps<'div'> & { skeleton?: boolean }; export type SectionActionsProps = MosaicComponentProps<'div'>; export type SectionErrorProps = MosaicComponentProps<'p'>; @@ -53,7 +56,7 @@ const Root = React.forwardRef(function SectionRoo }); const Group = React.forwardRef(function SectionGroup( - { render, xstyle, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, ...rest }, + { skeleton = false, render, xstyle, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, ...rest }, ref, ) { const [titleId, setTitleId] = React.useState(); @@ -62,12 +65,21 @@ const Group = React.forwardRef(function Secti defaultTagName: 'div', render, ref, - props: { - role: 'group', - ...mergeStyleProps(themeProps('section-group'), stylex.props(reset.base, styles.group, xstyle), rest), - 'aria-label': ariaLabel, - 'aria-labelledby': ariaLabelledBy ?? (ariaLabel ? undefined : titleId), - }, + props: skeleton + ? { + 'aria-hidden': true, + ...mergeStyleProps( + themeProps('section-group', { skeleton }), + stylex.props(reset.base, styles.group, xstyle), + rest, + ), + } + : { + role: 'group', + ...mergeStyleProps(themeProps('section-group'), stylex.props(reset.base, styles.group, xstyle), rest), + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy ?? (ariaLabel ? undefined : titleId), + }, }); return {element}; @@ -94,13 +106,15 @@ const Header = React.forwardRef(function Sec }); const Title = React.forwardRef(function SectionTitle( - { id: idProp, xstyle, ...rest }, + { id: idProp, skeleton = false, xstyle, ...rest }, ref, ) { const setTitleId = React.useContext(SectionGroupContext); const generatedId = React.useId(); const level = useHeadingLevel(); - const id = idProp ?? (setTitleId ? `cl-section-${generatedId}-title` : undefined); + const wave = useSkeletonWave(skeleton); + const mergedRef = useMergeRefs([ref, wave]); + const id = skeleton ? undefined : (idProp ?? (setTitleId ? `cl-section-${generatedId}-title` : undefined)); useSafeLayoutEffect(() => { if (!id || !setTitleId) { @@ -113,12 +127,18 @@ const Title = React.forwardRef(function S return ( ); }); @@ -183,16 +203,18 @@ const Item = React.forwardRef(function Section }); const Media = React.forwardRef(function SectionMedia( - { size = 'md', render, xstyle, ...rest }, + { size = 'md', skeleton = false, render, xstyle, ...rest }, ref, ) { + const wave = useSkeletonWave(skeleton); + return useRender({ defaultTagName: 'div', render, - ref, + ref: [ref, wave], props: mergeStyleProps( - themeProps('section-media', { size }), - stylex.props(reset.base, styles.mediaBase, mediaSizes[size], xstyle), + themeProps('section-media', { size, skeleton }), + stylex.props(reset.base, styles.mediaBase, mediaSizes[size], skeleton && skeletonStyles.bone, xstyle), rest, ), }); @@ -217,34 +239,51 @@ const Content = React.forwardRef(function S }); const Label = React.forwardRef(function SectionLabel( - { render, xstyle, ...rest }, + { skeleton = false, render, xstyle, ...rest }, ref, ) { + const wave = useSkeletonWave(skeleton); + return useRender({ defaultTagName: 'div', render, - ref, - props: mergeStyleProps(themeProps('section-label'), stylex.props(reset.base, styles.label, xstyle), rest), + ref: [ref, wave], + props: mergeStyleProps( + themeProps('section-label', { skeleton }), + stylex.props( + reset.base, + styles.label, + skeleton && skeletonStyles.bone, + skeleton && skeletonStyles.line, + skeleton && styles.labelSkeleton, + xstyle, + ), + rest, + ), }); }); const Description = React.forwardRef(function SectionDescription( - { render, xstyle, ...rest }, + { skeleton = false, render, xstyle, ...rest }, ref, ) { const inHeader = React.useContext(SectionHeaderContext); + const wave = useSkeletonWave(skeleton); return useRender({ defaultTagName: 'div', render, - ref, + ref: [ref, wave], props: mergeStyleProps( - themeProps('section-description'), + themeProps('section-description', { skeleton }), stylex.props( reset.base, styles.description, inHeader && styles.headerDescription, inHeader && sectionHeaderDescriptionMarker, + skeleton && skeletonStyles.bone, + skeleton && skeletonStyles.line, + skeleton && styles.descriptionSkeleton, xstyle, ), rest, diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx index f2f3325ca58..3e743879fda 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx @@ -9,6 +9,7 @@ import { Badge } from '../../components/badge'; import { Button } from '../../components/button'; import { Dialog } from '../../components/dialog'; import { Section } from '../../components/section'; +import { VisuallyHidden } from '../../components/visually-hidden'; import { useListRemovalFocus } from '../../hooks/useListRemovalFocus'; import type { MosaicMessages } from '../../localization'; import { fill, plural, useLocale, useMessages } from '../../localization'; @@ -174,6 +175,41 @@ export function UserProfileActiveDevicesSectionView({ ); } +const SKELETON_ITEMS = [0, 1, 2]; + +export function UserProfileActiveDevicesSectionSkeleton() { + const m = useMessages('userProfileActiveDevices'); + + return ( +
+ {m.loading} + + + + + + + + {SKELETON_ITEMS.map(item => ( + + + + + + + + ))} + + + + +
+ ); +} + function deviceBadges(device: UserProfileDevice, m: MosaicMessages['userProfileActiveDevices']): string[] { const labels: (string | null)[] = [ device.isCurrent ? m.thisDevice : null, diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices.messages.ts index ed29beef3b4..9320e03cec5 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices.messages.ts @@ -1,5 +1,6 @@ export const userProfileActiveDevicesMessages = { title: 'Active devices', + loading: 'Loading active devices', emptyCurrent: 'No current device available', signOutAll: 'Sign out of all devices', signOutAllError: 'Something went wrong signing these devices out. Please try again.', diff --git a/packages/mosaic/src/features/user-profile/user-profile-security-icon.tsx b/packages/mosaic/src/features/user-profile/user-profile-security-icon.tsx index e7013d95d4c..c6938eb382d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-security-icon.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-security-icon.tsx @@ -1,9 +1,5 @@ -import * as stylex from '@stylexjs/stylex'; - import { Icon } from '../../components/icon'; import { Section } from '../../components/section'; -import { mergeStyleProps } from '../../props'; -import { space } from '../../tokens.stylex'; import { styles } from './user-profile-security-panel.styles'; export type UserProfileSecurityIconName = 'authenticator' | 'backup-codes' | 'desktop' | 'mobile' | 'passkey' | 'sms'; @@ -21,7 +17,7 @@ export function UserProfileSecurityIcon({ name }: { name: UserProfileSecurityIco return ( (enabled: boolean) { + const ref = useRef(null); + + useSafeLayoutEffect(() => { + const element = ref.current; + if (!enabled || !element) { + return; + } + const now = Number(document.timeline?.currentTime ?? performance.now()); + const top = element.getBoundingClientRect().top + window.scrollY; + const phase = (((now - top * MS_PER_PX) % PERIOD_MS) + PERIOD_MS) % PERIOD_MS; + element.style.setProperty('--_cl-skeleton-delay', `${-phase}ms`); + element.setAttribute('data-skeleton-wave', ''); + return () => { + element.style.removeProperty('--_cl-skeleton-delay'); + element.removeAttribute('data-skeleton-wave'); + }; + }, [enabled]); + + return ref; +} diff --git a/packages/mosaic/src/utils/skeleton.styles.ts b/packages/mosaic/src/utils/skeleton.styles.ts new file mode 100644 index 00000000000..2a23ecdff16 --- /dev/null +++ b/packages/mosaic/src/utils/skeleton.styles.ts @@ -0,0 +1,35 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, radiusVars } from '../tokens.stylex'; + +const wave = stylex.keyframes({ + '0%': { opacity: 1 }, + '37.5%': { opacity: 0.6 }, + '75%': { opacity: 1 }, + '100%': { opacity: 1 }, +}); + +export const skeletonStyles = stylex.create({ + bone: { + borderRadius: radiusVars['--cl-radius-md'], + animationDelay: 'var(--_cl-skeleton-delay, 0ms)', + animationDuration: '1.2s', + animationFillMode: 'both', + animationIterationCount: 'infinite', + animationName: { + default: 'none', + ':where([data-skeleton-wave])': wave, + '@media (prefers-reduced-motion: reduce)': { default: 'none', ':where([data-skeleton-wave])': 'none' }, + }, + animationTimingFunction: 'cubic-bezier(0.645, 0.045, 0.355, 1)', + backgroundColor: colorVars['--cl-color-neutral-alpha-200'], + userSelect: 'none', + }, + line: { + borderRadius: radiusVars['--cl-radius-sm'], + marginBlock: '0.2lh', + display: 'block', + height: '0.6lh', + maxWidth: '100%', + }, +}); diff --git a/packages/swingset/src/components/StoryEmbed.tsx b/packages/swingset/src/components/StoryEmbed.tsx index c7f4401bc44..0724bb7b9a5 100644 --- a/packages/swingset/src/components/StoryEmbed.tsx +++ b/packages/swingset/src/components/StoryEmbed.tsx @@ -9,6 +9,7 @@ import { toUsageSnippet } from '@/lib/exampleSnippet'; import { extractStorySource } from '@/lib/extractStorySource'; import { generateKnobs, initKnobValues } from '@/lib/generateKnobs'; import type { StoryModule } from '@/lib/types'; +import { cn, fillsFrame } from '@/lib/utils'; import { StoryMosaicProvider } from './ChaosProvider'; import { CodeFooter } from './CodeFooter'; @@ -62,7 +63,12 @@ export function StoryEmbed({ name, storyModule, showCode = true, composition }: return (
-
+
*]:w-full', + )} + > diff --git a/packages/swingset/src/components/StoryPreview.tsx b/packages/swingset/src/components/StoryPreview.tsx index 8ba03946298..6f6333ca48e 100644 --- a/packages/swingset/src/components/StoryPreview.tsx +++ b/packages/swingset/src/components/StoryPreview.tsx @@ -6,6 +6,7 @@ import { useEffect, useState } from 'react'; import { generateKnobs, initKnobValues } from '@/lib/generateKnobs'; import type { StoryModule } from '@/lib/types'; +import { cn, fillsFrame } from '@/lib/utils'; import { StoryMosaicProvider } from './ChaosProvider'; import { usePlayground } from './PlaygroundContext'; @@ -49,7 +50,7 @@ export function StoryPreview({ name, storyModule }: StoryPreviewProps) {
-
+
*]:w-full')}> {mounted && ( diff --git a/packages/swingset/src/lib/utils.ts b/packages/swingset/src/lib/utils.ts index 9ad0df4269e..fd78a50df81 100644 --- a/packages/swingset/src/lib/utils.ts +++ b/packages/swingset/src/lib/utils.ts @@ -1,6 +1,12 @@ import { type ClassValue, clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; +import type { StoryModule } from './types'; + export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +export function fillsFrame(storyModule: StoryModule) { + return storyModule.meta.navigation?.category === 'Sections'; +} diff --git a/packages/swingset/src/stories/section.mdx b/packages/swingset/src/stories/section.mdx index 7559e7e7399..0c6cfb266d5 100644 --- a/packages/swingset/src/stories/section.mdx +++ b/packages/swingset/src/stories/section.mdx @@ -46,6 +46,12 @@ and `xl` sizes. than `26rem`. `sectionCompactStyles.hidden` and `sectionCompactStyles.only` toggle inline content at the same width. +`Section.Group`, `Section.Title`, `Section.Media`, `Section.Label`, and `Section.Description` take +`skeleton` for a loading state. A skeleton part renders empty as a placeholder the size of the +content it stands in for, and a skeleton group is hidden from assistive technology, so pair it with +a visually hidden status message. Placeholders pulse in one wave that travels down the page, across +every skeleton on it. When to show the skeleton, and for how long, is up to the caller. + `Section.Title` renders at the level from the nearest `HeadingLevelProvider`, or `h2` outside one. On a page, put the sections in `Panel.Sections` so the card titles sit one level below the page title. See [Panel](/components/panel) and [Heading](/components/heading). @@ -134,3 +140,10 @@ External CSS can move the card from `.cl-section-group` to `.cl-section-body`; t name='Flat' storyModule={SectionStories} /> + +### Loading + + diff --git a/packages/swingset/src/stories/section.stories.tsx b/packages/swingset/src/stories/section.stories.tsx index 452c1002280..20ce039154a 100644 --- a/packages/swingset/src/stories/section.stories.tsx +++ b/packages/swingset/src/stories/section.stories.tsx @@ -5,6 +5,7 @@ import { Icon, IconFrame } from '@clerk/mosaic/components/icon'; import { Section } from '@clerk/mosaic/components/section'; import { space } from '@clerk/mosaic/tokens.stylex'; import * as stylex from '@stylexjs/stylex'; +import { useEffect, useState } from 'react'; import type { StoryMeta } from '@/lib/types'; @@ -14,6 +15,14 @@ const styles = stylex.create({ rootMax: { maxWidth: 560, }, + loadingStack: { + alignItems: 'flex-start', + display: 'flex', + flexDirection: 'column', + gap: space['4'], + maxWidth: 560, + width: '100%', + }, descriptionFlex: { alignItems: 'center', display: 'flex', @@ -525,3 +534,67 @@ export function Flat() { ); } + +const loadingItems = [ + { icon: 'device-laptop', label: 'Chrome on macOS', description: 'San Francisco, US · Active now' }, + { icon: 'device-phone', label: 'Safari on iOS', description: 'San Francisco, US · 2 hours ago' }, + { icon: 'device-laptop', label: 'Firefox on Windows', description: 'Denver, US · 3 days ago' }, +] as const; + +export function Loading() { + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!loading) { + return; + } + const timer = setTimeout(() => setLoading(false), 2000); + return () => clearTimeout(timer); + }, [loading]); + + return ( +
+ + + + + {loading ? null : 'Active devices'} + + + + {loadingItems.map(item => ( + + + {loading ? null : ( + + + + )} + + + {loading ? null : item.label} + {loading ? null : item.description} + + + ))} + + + + +
+ ); +} diff --git a/packages/swingset/src/stories/user-profile-active-devices-section.mdx b/packages/swingset/src/stories/user-profile-active-devices-section.mdx index f1a04716f56..7809efcbf87 100644 --- a/packages/swingset/src/stories/user-profile-active-devices-section.mdx +++ b/packages/swingset/src/stories/user-profile-active-devices-section.mdx @@ -31,3 +31,10 @@ The first attempt of each kind fails, second attempt succeeds. name='Impersonation' storyModule={Stories} /> + +## Loading + + diff --git a/packages/swingset/src/stories/user-profile-active-devices-section.stories.tsx b/packages/swingset/src/stories/user-profile-active-devices-section.stories.tsx index 5127d7e25a1..f4d1f6874d0 100644 --- a/packages/swingset/src/stories/user-profile-active-devices-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-active-devices-section.stories.tsx @@ -1,4 +1,7 @@ -import { UserProfileActiveDevicesSectionView } from '@clerk/mosaic/features/user-profile/user-profile-active-devices-section.view'; +import { + UserProfileActiveDevicesSectionSkeleton, + UserProfileActiveDevicesSectionView, +} from '@clerk/mosaic/features/user-profile/user-profile-active-devices-section.view'; import type { StoryMeta } from '@/lib/types'; @@ -42,3 +45,7 @@ export function Impersonation() { /> ); } + +export function Loading() { + return ; +} From c95f4f760be6bd063f761cc7da04b2e0b53ba410 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 1 Oct 2026 17:21:44 -0600 Subject: [PATCH 02/14] feat(mosaic): move pulses onto the skeleton wave and add panel title skeletons Co-Authored-By: Claude Opus 5.5 --- .claude/skills/mosaic/references/motion.md | 41 +++++++++++++++---- .../src/components/avatar/avatar.styles.ts | 15 ------- .../mosaic/src/components/avatar/avatar.tsx | 19 ++++----- .../src/components/panel/panel.styles.ts | 3 ++ .../mosaic/src/components/panel/panel.tsx | 23 +++++++++-- .../src/components/section/section.styles.ts | 3 ++ .../mosaic/src/components/section/section.tsx | 27 +++++++++--- ...rofile-active-devices-section.skeleton.tsx | 41 +++++++++++++++++++ ...er-profile-active-devices-section.view.tsx | 36 ---------------- .../user-profile-backup-codes.styles.ts | 16 +------- .../user-profile-backup-codes.view.tsx | 19 +++++++-- packages/mosaic/src/hooks/useSkeletonWave.ts | 4 +- packages/mosaic/src/tokens.stylex.ts | 2 +- packages/mosaic/src/utils/skeleton.styles.ts | 16 ++++---- .../swingset/src/stories/panel.component.mdx | 3 ++ packages/swingset/src/stories/section.mdx | 2 + ...profile-active-devices-section.stories.tsx | 6 +-- 17 files changed, 165 insertions(+), 111 deletions(-) create mode 100644 packages/mosaic/src/features/user-profile/user-profile-active-devices-section.skeleton.tsx diff --git a/.claude/skills/mosaic/references/motion.md b/.claude/skills/mosaic/references/motion.md index 3f2e1d7fdca..5cdf884accd 100644 --- a/.claude/skills/mosaic/references/motion.md +++ b/.claude/skills/mosaic/references/motion.md @@ -1,4 +1,4 @@ -# Motion: entrances, exits, and pulses +# Motion: entrances, exits, and loading Token semantics live in `packages/mosaic/src/tokens.stylex.ts`, above `durationDefaults` / `easingDefaults` — read those comments first. This file is the @@ -15,7 +15,7 @@ rather than eyeball it. | `--cl-ease-default` | `cubic-bezier(0.175, 0.885, 0.32, 1.1)` | things ARRIVING (Swift Out) | | `--cl-ease-enter` | `cubic-bezier(0, 0, 0.2, 1)` | arrivals that must not overshoot | | `--cl-ease-exit` | `cubic-bezier(0.55, 0.085, 0.68, 0.53)` | things LEAVING (In Quad) | -| `--cl-ease-pulse` | `cubic-bezier(0.4, 0, 0.6, 1)` | repeating opacity pulses | +| `--cl-ease-in-out` | `cubic-bezier(0.645, 0.045, 0.355, 1)` | pulses and swaps (In-Out Cubic) | Named curves come from [easing.dev](https://www.easing.dev) (Lochie Axon's Easing Graphs). Take one from there rather than inventing a bezier, so the catalog stays @@ -52,12 +52,37 @@ For entrances, opacity takes `--cl-ease-enter`: there is nothing past `1` to overshoot into, so the pass is clamped away and only its cost — the slower approach to full opacity — is left. -## Repeating pulses - -Use `--cl-ease-pulse` for repeating opacity fades such as loading skeletons. Its -symmetric curve slows at both ends of each fade, keeping the reversal smooth. -Keep the pulse duration on the component and disable the animation under -`prefers-reduced-motion: reduce`. +## Loading skeletons: one wave down the page + +Every loading placeholder pulses through one shared wave, never a local pulse of its +own. The pieces: + +- `skeletonStyles.bone` (fill and radius) and `skeletonStyles.line` (a bar `0.6lh` + tall with `0.2lh` margins, so it fills exactly one line of the text it replaces) + in `utils/skeleton.styles.ts`. +- `skeletonStyles.wave`: the keyframes (opacity `1 → 0.32 → 1` over the first 56% + of a 2s cycle, then a hold), on `--cl-ease-in-out`. A component with its own + fill and shape, such as `Avatar.Fallback`, takes `wave` alone. +- `useSkeletonWave(enabled)` in `hooks/`: before first paint it sets a negative + `animation-delay` from the element's page position, `-(now − y × 2.1ms/px) mod +2000ms`, and marks it `data-skeleton-wave`. Every bone shares the document clock + and lags it by its height on the page, so skeletons that mount separately read + as a single wave, and two lines in one row sit on different parts of it. + +`wave` only animates once `data-skeleton-wave` is set. Without that gate, a +server-rendered skeleton pulses in unison before hydration and then jumps into +phase. The keyframe duration and `PERIOD_MS` in the hook must match. + +Tuning, all relative to each other: the hook's px rate sets how fast the wave +travels; the cycle over that rate sets the spacing between crests (~950px, about +one crest per panel); the keyframe's dip width sets how wide a crest is. Shorten +the cycle alone and crests crowd together; widen the dip alone and the hold +disappears. + +Use the `skeleton` prop on `Section` parts and `Panel.Title` rather than composing +bones by hand. Any new skeleton must measure the same as the content it replaces: +`Section.Actions skeleton` exists because a 28px menu trigger outgrew a 20px line. +Off under `prefers-reduced-motion: reduce`. ## A curve has a direction — don't run the entrance curve backwards diff --git a/packages/mosaic/src/components/avatar/avatar.styles.ts b/packages/mosaic/src/components/avatar/avatar.styles.ts index 96e6a2c5306..f6090d3bc97 100644 --- a/packages/mosaic/src/components/avatar/avatar.styles.ts +++ b/packages/mosaic/src/components/avatar/avatar.styles.ts @@ -2,11 +2,6 @@ import * as stylex from '@stylexjs/stylex'; import { colorVars, fontFamilyVars, fontWeightVars, radiusVars, space } from '../../tokens.stylex'; -// Timed to match `skeleton.tsx`'s pulse, so the two generations of placeholder read as one thing. -const pulse = stylex.keyframes({ - '50%': { opacity: 0.5 }, -}); - export const styles = stylex.create({ // root — sizes and positions its parts; fill comes from the image or fallback base: { @@ -71,16 +66,6 @@ export const styles = stylex.create({ visibility: 'hidden', }, - fallbackPending: { - animationDuration: '2s', - animationIterationCount: 'infinite', - animationName: { - default: pulse, - '@media (prefers-reduced-motion: reduce)': 'none', - }, - animationTimingFunction: 'cubic-bezier(0.4, 0, 0.6, 1)', - }, - icon: { borderColor: colorVars['--cl-color-border'], borderRadius: radiusVars['--cl-radius-full'], diff --git a/packages/mosaic/src/components/avatar/avatar.tsx b/packages/mosaic/src/components/avatar/avatar.tsx index 7c093c03236..479be1a937b 100644 --- a/packages/mosaic/src/components/avatar/avatar.tsx +++ b/packages/mosaic/src/components/avatar/avatar.tsx @@ -1,12 +1,15 @@ import { useSafeLayoutEffect } from '@clerk/shared/react'; +import { useMergeRefs } from '@floating-ui/react'; import * as stylex from '@stylexjs/stylex'; import React from 'react'; +import { useSkeletonWave } from '../../hooks/useSkeletonWave'; import { useRender } from '../../primitives/utils'; import type { MosaicComponentProps, MosaicElementProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { focusOutline } from '../../utils/focus-outline.styles'; import { reset } from '../../utils/reset.styles'; +import { skeletonStyles } from '../../utils/skeleton.styles'; import { shapes, sizes, styles } from './avatar.styles'; type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error'; @@ -145,24 +148,20 @@ const AvatarFallback = React.forwardRef(fu return () => clearTimeout(timer); }, [delayMs]); + const pending = canRender && status === 'loading'; + const wave = useSkeletonWave(pending); + const mergedRef = useMergeRefs([ref, wave]); + if (!canRender || status === 'loaded') { return null; } - const pending = status === 'loading'; - return ( diff --git a/packages/mosaic/src/components/panel/panel.styles.ts b/packages/mosaic/src/components/panel/panel.styles.ts index c9e74458738..f39fdf9453b 100644 --- a/packages/mosaic/src/components/panel/panel.styles.ts +++ b/packages/mosaic/src/components/panel/panel.styles.ts @@ -12,6 +12,9 @@ export const styles = stylex.create({ title: { display: 'block', }, + titleSkeleton: { + width: space['24'], + }, /** * The headline as a button: the heading's own type, inline so the caret can align to its diff --git a/packages/mosaic/src/components/panel/panel.tsx b/packages/mosaic/src/components/panel/panel.tsx index 2cb11c725ce..0187e86ef3c 100644 --- a/packages/mosaic/src/components/panel/panel.tsx +++ b/packages/mosaic/src/components/panel/panel.tsx @@ -1,18 +1,20 @@ import * as stylex from '@stylexjs/stylex'; import React from 'react'; +import { useSkeletonWave } from '../../hooks/useSkeletonWave'; import { useRender } from '../../primitives/utils'; import type { MosaicComponentProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { focusOutline } from '../../utils/focus-outline.styles'; import { reset } from '../../utils/reset.styles'; +import { skeletonStyles } from '../../utils/skeleton.styles'; import { Heading, HeadingLevelProvider, useHeadingLevel } from '../heading'; import { Icon } from '../icon'; import { ContentPanelContext, ProfileContext } from '../profile/profile.context'; import { styles } from './panel.styles'; export type PanelRootProps = MosaicComponentProps<'div'>; -export type PanelTitleProps = MosaicComponentProps<'div'>; +export type PanelTitleProps = MosaicComponentProps<'div'> & { skeleton?: boolean }; export type PanelSectionsProps = MosaicComponentProps<'div'>; const Root = React.forwardRef(function PanelRoot({ render, xstyle, ...rest }, ref) { @@ -34,9 +36,10 @@ const Root = React.forwardRef(function PanelRoot * line box. That needs an inline formatting context, so the button is `display: inline`. */ const Title = React.forwardRef(function PanelTitle( - { children, render, xstyle, ...rest }, + { skeleton = false, children, render, xstyle, ...rest }, ref, ) { + const wave = useSkeletonWave(skeleton); const profile = React.useContext(ProfileContext); const panel = React.useContext(ContentPanelContext); const registerPageTitle = profile?.registerPageTitle; @@ -56,8 +59,20 @@ const Title = React.forwardRef(function PanelTi render, ref, props: { - ...mergeStyleProps(themeProps('panel-title'), stylex.props(reset.base, styles.title, xstyle), rest), - children: ( + ...mergeStyleProps( + themeProps('panel-title', { skeleton }), + stylex.props(reset.base, styles.title, xstyle), + skeleton ? { 'aria-hidden': true } : {}, + rest, + ), + children: skeleton ? ( + + ) : ( & { size?: SectionMe export type SectionContentProps = MosaicComponentProps<'div'>; export type SectionLabelProps = MosaicComponentProps<'div'> & { skeleton?: boolean }; export type SectionDescriptionProps = MosaicComponentProps<'div'> & { skeleton?: boolean }; -export type SectionActionsProps = MosaicComponentProps<'div'>; +export type SectionActionsProps = MosaicComponentProps<'div'> & { skeleton?: boolean }; export type SectionErrorProps = MosaicComponentProps<'p'>; const mediaSizes = { @@ -134,6 +134,7 @@ const Title = React.forwardRef(function S xstyle={[ styles.title, skeleton && skeletonStyles.bone, + skeleton && skeletonStyles.wave, skeleton && skeletonStyles.line, skeleton && styles.titleSkeleton, xstyle, @@ -214,7 +215,14 @@ const Media = React.forwardRef(function Secti ref: [ref, wave], props: mergeStyleProps( themeProps('section-media', { size, skeleton }), - stylex.props(reset.base, styles.mediaBase, mediaSizes[size], skeleton && skeletonStyles.bone, xstyle), + stylex.props( + reset.base, + styles.mediaBase, + mediaSizes[size], + skeleton && skeletonStyles.bone, + skeleton && skeletonStyles.wave, + xstyle, + ), rest, ), }); @@ -254,6 +262,7 @@ const Label = React.forwardRef(function Secti reset.base, styles.label, skeleton && skeletonStyles.bone, + skeleton && skeletonStyles.wave, skeleton && skeletonStyles.line, skeleton && styles.labelSkeleton, xstyle, @@ -282,6 +291,7 @@ const Description = React.forwardRef(fu inHeader && styles.headerDescription, inHeader && sectionHeaderDescriptionMarker, skeleton && skeletonStyles.bone, + skeleton && skeletonStyles.wave, skeleton && skeletonStyles.line, skeleton && styles.descriptionSkeleton, xstyle, @@ -292,7 +302,7 @@ const Description = React.forwardRef(fu }); const Actions = React.forwardRef(function SectionActions( - { render, xstyle, ...rest }, + { skeleton = false, render, xstyle, ...rest }, ref, ) { const wrap = React.useContext(SectionItemWrapContext); @@ -303,8 +313,15 @@ const Actions = React.forwardRef(function S render, ref, props: mergeStyleProps( - themeProps('section-actions'), - stylex.props(reset.base, styles.actions, wrap && styles.actionsWrap, inHeader && styles.headerActions, xstyle), + themeProps('section-actions', { skeleton }), + stylex.props( + reset.base, + styles.actions, + wrap && styles.actionsWrap, + inHeader && styles.headerActions, + skeleton && styles.actionsSkeleton, + xstyle, + ), rest, ), }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.skeleton.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.skeleton.tsx new file mode 100644 index 00000000000..c66dc7ffa11 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.skeleton.tsx @@ -0,0 +1,41 @@ +import * as stylex from '@stylexjs/stylex'; + +import { Section } from '../../components/section'; +import { VisuallyHidden } from '../../components/visually-hidden'; +import { useMessages } from '../../localization'; +import { styles } from './user-profile-security-panel.styles'; + +const SKELETON_ITEMS = [0, 1, 2]; + +export function UserProfileActiveDevicesSectionSkeleton() { + const m = useMessages('userProfileActiveDevices'); + + return ( +
+ {m.loading} + + + + + + + + {SKELETON_ITEMS.map(item => ( + + + + + + + + ))} + + + + +
+ ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx index 3e743879fda..f2f3325ca58 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx @@ -9,7 +9,6 @@ import { Badge } from '../../components/badge'; import { Button } from '../../components/button'; import { Dialog } from '../../components/dialog'; import { Section } from '../../components/section'; -import { VisuallyHidden } from '../../components/visually-hidden'; import { useListRemovalFocus } from '../../hooks/useListRemovalFocus'; import type { MosaicMessages } from '../../localization'; import { fill, plural, useLocale, useMessages } from '../../localization'; @@ -175,41 +174,6 @@ export function UserProfileActiveDevicesSectionView({ ); } -const SKELETON_ITEMS = [0, 1, 2]; - -export function UserProfileActiveDevicesSectionSkeleton() { - const m = useMessages('userProfileActiveDevices'); - - return ( -
- {m.loading} - - - - - - - - {SKELETON_ITEMS.map(item => ( - - - - - - - - ))} - - - - -
- ); -} - function deviceBadges(device: UserProfileDevice, m: MosaicMessages['userProfileActiveDevices']): string[] { const labels: (string | null)[] = [ device.isCurrent ? m.thisDevice : null, diff --git a/packages/mosaic/src/features/user-profile/user-profile-backup-codes.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-backup-codes.styles.ts index de07661a95f..13c3d03822f 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-backup-codes.styles.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-backup-codes.styles.ts @@ -1,23 +1,9 @@ import * as stylex from '@stylexjs/stylex'; -import { colorVars, easingVars, radiusVars, space } from '../../tokens.stylex'; - -const pulse = stylex.keyframes({ - '50%': { opacity: 0.5 }, -}); +import { colorVars, radiusVars, space } from '../../tokens.stylex'; export const styles = stylex.create({ skeleton: { - borderRadius: radiusVars['--cl-radius-sm'], - animationDuration: '2s', - animationIterationCount: 'infinite', - animationName: { - default: pulse, - '@media (prefers-reduced-motion: reduce)': 'none', - }, - animationTimingFunction: easingVars['--cl-ease-pulse'], - backgroundColor: colorVars['--cl-color-neutral-alpha-200'], - height: '1lh', width: space['16'], }, codes: { diff --git a/packages/mosaic/src/features/user-profile/user-profile-backup-codes.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-backup-codes.view.tsx index e425aa8b340..3938fde9f04 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-backup-codes.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-backup-codes.view.tsx @@ -6,9 +6,11 @@ import { Card } from '../../components/card'; import { useFlowAutoFocus } from '../../components/flow'; import { Icon } from '../../components/icon'; import { Text } from '../../components/text'; +import { useSkeletonWave } from '../../hooks/useSkeletonWave'; import { useMessages } from '../../localization'; import { mergeStyleProps, themeProps } from '../../props'; import { reset } from '../../utils/reset.styles'; +import { skeletonStyles } from '../../utils/skeleton.styles'; import { styles } from './user-profile-backup-codes.styles'; export interface UserProfileBackupCodesViewProps { @@ -83,10 +85,7 @@ export function UserProfileBackupCodesView({ aria-hidden='true' {...stylex.props(reset.base, styles.cell)} > - } - xstyle={styles.skeleton} - /> +
))}
@@ -155,3 +154,15 @@ export function UserProfileBackupCodesView({ ); } + +function BackupCodeSkeleton() { + const wave = useSkeletonWave(true); + + return ( + } + xstyle={[skeletonStyles.bone, skeletonStyles.wave, skeletonStyles.line, styles.skeleton]} + /> + ); +} diff --git a/packages/mosaic/src/hooks/useSkeletonWave.ts b/packages/mosaic/src/hooks/useSkeletonWave.ts index e8d5d8a6fcf..a2ea399bc95 100644 --- a/packages/mosaic/src/hooks/useSkeletonWave.ts +++ b/packages/mosaic/src/hooks/useSkeletonWave.ts @@ -1,8 +1,8 @@ import { useSafeLayoutEffect } from '@clerk/shared/react'; import { useRef } from 'react'; -const PERIOD_MS = 1200; -const MS_PER_PX = 2.5; +const PERIOD_MS = 2000; +const MS_PER_PX = 2.1; export function useSkeletonWave(enabled: boolean) { const ref = useRef(null); diff --git a/packages/mosaic/src/tokens.stylex.ts b/packages/mosaic/src/tokens.stylex.ts index 256a7957d35..4ed41c21e15 100644 --- a/packages/mosaic/src/tokens.stylex.ts +++ b/packages/mosaic/src/tokens.stylex.ts @@ -404,7 +404,7 @@ const easingDefaults = { '--cl-ease-default': 'cubic-bezier(0.175, 0.885, 0.32, 1.1)', '--cl-ease-enter': 'cubic-bezier(0, 0, 0.2, 1)', '--cl-ease-exit': 'cubic-bezier(0.55, 0.085, 0.68, 0.53)', - '--cl-ease-pulse': 'cubic-bezier(0.4, 0, 0.6, 1)', + '--cl-ease-in-out': 'cubic-bezier(0.645, 0.045, 0.355, 1)', } as const; export const easingVars = stylex.defineVars(easingDefaults); diff --git a/packages/mosaic/src/utils/skeleton.styles.ts b/packages/mosaic/src/utils/skeleton.styles.ts index 2a23ecdff16..c8b72874cf7 100644 --- a/packages/mosaic/src/utils/skeleton.styles.ts +++ b/packages/mosaic/src/utils/skeleton.styles.ts @@ -1,19 +1,18 @@ import * as stylex from '@stylexjs/stylex'; -import { colorVars, radiusVars } from '../tokens.stylex'; +import { colorVars, easingVars, radiusVars } from '../tokens.stylex'; const wave = stylex.keyframes({ '0%': { opacity: 1 }, - '37.5%': { opacity: 0.6 }, - '75%': { opacity: 1 }, + '28%': { opacity: 0.32 }, + '56%': { opacity: 1 }, '100%': { opacity: 1 }, }); export const skeletonStyles = stylex.create({ - bone: { - borderRadius: radiusVars['--cl-radius-md'], + wave: { animationDelay: 'var(--_cl-skeleton-delay, 0ms)', - animationDuration: '1.2s', + animationDuration: '2s', animationFillMode: 'both', animationIterationCount: 'infinite', animationName: { @@ -21,7 +20,10 @@ export const skeletonStyles = stylex.create({ ':where([data-skeleton-wave])': wave, '@media (prefers-reduced-motion: reduce)': { default: 'none', ':where([data-skeleton-wave])': 'none' }, }, - animationTimingFunction: 'cubic-bezier(0.645, 0.045, 0.355, 1)', + animationTimingFunction: easingVars['--cl-ease-in-out'], + }, + bone: { + borderRadius: radiusVars['--cl-radius-md'], backgroundColor: colorVars['--cl-color-neutral-alpha-200'], userSelect: 'none', }, diff --git a/packages/swingset/src/stories/panel.component.mdx b/packages/swingset/src/stories/panel.component.mdx index fe7fd875c85..a0f72b7ad9a 100644 --- a/packages/swingset/src/stories/panel.component.mdx +++ b/packages/swingset/src/stories/panel.component.mdx @@ -7,6 +7,9 @@ children, `Panel.Title` renders the page's heading, and `Panel.Sections` stacks [Section](/components/section)s one heading level below the title. Inside a [Profile](/components/profile) page that has gone compact, `Panel.Title` opens the navigation sheet. +`Panel.Title` takes `skeleton` for a loading state: the heading renders empty as a placeholder in +the same wave as [Section](/components/section) skeletons, and is hidden from assistive technology. + ## Example