From 9c84df9b9a6a7c922a23b6bf47fae9a8aedc3a15 Mon Sep 17 00:00:00 2001 From: Yusef Habib Fernandez Date: Thu, 1 Oct 2026 08:48:14 +0200 Subject: [PATCH 1/3] ux(loading): reveal skeletons after a delay and keep card heights stable Move the 120ms anti flicker delay into the `.skeleton` class, so every skeleton holds its space at once and reveals itself late. Remove `useDelayedFlag` and `LOADING_INDICATOR_DELAY`. Mark skeleton bars `aria-hidden` and add `SkeletonScreen`, which announces one "loading" for each region. Stop `animate-pulse` under `prefers-reduced-motion`. Fix the card skeletons that hid data already in hand or changed the card height when they left. --- .../src/app/styles/main.css | 59 +++++++++++++++++++ .../src/common/components/QueryStates.tsx | 19 ++++-- .../src/common/components/Skeleton.tsx | 14 ++++- .../components/skeletons/SkeletonScreen.tsx | 19 ++++++ .../components/skeletons/SkeletonText.tsx | 35 +++++++++++ .../src/common/constants/extra.ts | 4 -- .../src/common/hooks/useDelayedFlag.ts | 34 ----------- .../account/components/AccountCard.tsx | 2 +- .../accounts/components/AccountsCard.tsx | 8 +-- .../accounts/components/AccountsListItem.tsx | 2 +- .../accounts/components/AccountsTotalCard.tsx | 6 +- .../dashboard/components/DailyRewardsCard.tsx | 24 ++++---- .../dashboard/components/IcpPriceCard.tsx | 29 +++++---- .../dashboard/components/TotalStakedCard.tsx | 43 +++++++------- .../features/stakes/components/StakedCard.tsx | 4 +- .../components/summaryCards/CapitalCard.tsx | 29 +++++---- .../components/summaryCards/EarningsCard.tsx | 2 +- .../summaryCards/EngagementCard.tsx | 4 +- .../voting/components/FollowedNeuronCard.tsx | 2 +- 19 files changed, 219 insertions(+), 120 deletions(-) create mode 100644 src/governance-app-frontend/src/common/components/skeletons/SkeletonScreen.tsx create mode 100644 src/governance-app-frontend/src/common/components/skeletons/SkeletonText.tsx delete mode 100644 src/governance-app-frontend/src/common/hooks/useDelayedFlag.ts diff --git a/src/governance-app-frontend/src/app/styles/main.css b/src/governance-app-frontend/src/app/styles/main.css index 292779331..88f64a89e 100644 --- a/src/governance-app-frontend/src/app/styles/main.css +++ b/src/governance-app-frontend/src/app/styles/main.css @@ -61,6 +61,11 @@ --sidebar-ring: var(--icp-accent-strong); --main-content: var(--icp-bg); --staking-ratio: var(--icp-section-teal); + /* Skeleton bars pulse between these two. The pulse animates a color, which + leaves `opacity` free for the delayed reveal. An ink wash on the parchment, + not a tint of the rust accent: a placeholder must not read as content. */ + --skeleton-base: rgba(26, 26, 26, 0.07); + --skeleton-peak: rgba(26, 26, 26, 0.035); } .dark { @@ -111,6 +116,8 @@ --sidebar-ring: var(--icp-accent-strong); --main-content: var(--icp-bg); --staking-ratio: #3fafaa; + --skeleton-base: rgba(162, 154, 141, 0.2); + --skeleton-peak: rgba(162, 154, 141, 0.1); } @theme inline { @@ -204,6 +211,58 @@ } @layer utilities { + /* + * Skeleton bars. + * + * A bar claims its space on the first frame, so the content lands without a + * jump. It stays invisible for `--skeleton-delay` first, so a fast query + * resolves into content with no grey flash on the way. + * + * The reveal animates `opacity` and the pulse animates `background-color`. + * Two animations on one property fight, and the pulse is declared last, so + * it would win and cancel the delay. + */ + @keyframes skeleton-reveal { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + @keyframes skeleton-pulse { + 0%, + 100% { + background-color: var(--skeleton-base); + } + 50% { + background-color: var(--skeleton-peak); + } + } + + .skeleton { + --skeleton-delay: 120ms; + background-color: var(--skeleton-base); + animation: + skeleton-reveal 150ms ease-out var(--skeleton-delay) both, + skeleton-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) var(--skeleton-delay) infinite; + } + + /* + * Keep the delayed reveal, drop the pulse. Spinners keep turning: they are + * the only sign that a mutation is still running. + */ + @media (prefers-reduced-motion: reduce) { + .skeleton { + animation: skeleton-reveal 0s linear var(--skeleton-delay) both; + } + + .animate-pulse { + animation: none; + } + } + .text-hero-responsive { font-size: clamp(2rem, 5vh, 4rem); line-height: 1.1; diff --git a/src/governance-app-frontend/src/common/components/QueryStates.tsx b/src/governance-app-frontend/src/common/components/QueryStates.tsx index 48bcf259b..c61a0217d 100644 --- a/src/governance-app-frontend/src/common/components/QueryStates.tsx +++ b/src/governance-app-frontend/src/common/components/QueryStates.tsx @@ -1,10 +1,9 @@ import { InfiniteData, UseInfiniteQueryResult, UseQueryResult } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import { useDelayedFlag } from '@hooks/useDelayedFlag'; - import { EmptyMessage } from './EmptyMessage'; -import { MultipleSkeletons } from './MultipleSkeletons'; +import { SkeletonScreen } from './skeletons/SkeletonScreen'; +import { SkeletonText } from './skeletons/SkeletonText'; import { WarningMessage } from './WarningMessage'; type InfiniteQueryData = Partial>; @@ -42,11 +41,19 @@ export const QueryStates = ({ }: Props) => { const { t } = useTranslation(); const q = query || infiniteQuery; - const showLoading = useDelayedFlag(q.isLoading); if (q.isLoading) { - // Hold the frame empty until the delay elapses rather than flashing a skeleton. - return showLoading ? loadingComponent || : null; + // The skeleton holds the space at once and reveals itself after a short + // delay, so a fast query needs no hold-back here. See `.skeleton` in + // `main.css`. Pass a `loadingComponent` built from `Skeleton` to keep that, + // and wrap it in `SkeletonScreen` so screen readers hear "loading". + return ( + loadingComponent || ( + + + + ) + ); } if (q.error) { diff --git a/src/governance-app-frontend/src/common/components/Skeleton.tsx b/src/governance-app-frontend/src/common/components/Skeleton.tsx index ed5c6a156..0d128f338 100644 --- a/src/governance-app-frontend/src/common/components/Skeleton.tsx +++ b/src/governance-app-frontend/src/common/components/Skeleton.tsx @@ -1,11 +1,23 @@ import { cn } from '@common/utils/shadcn'; +/** + * A placeholder bar. + * + * The look and the timing live in the `.skeleton` class in `main.css`: the bar + * holds its space from the first frame and stays invisible for a short delay, + * so a fast query resolves into content without a grey flash and without a + * jump. Pass size classes only; the colour comes from the theme. + * + * The bar is decorative. Wrap a group of them in `SkeletonScreen` so screen + * readers hear one "loading" for the region instead of one per bar. + */ function Skeleton({ className, ...props }: React.ComponentProps<'div'>) { return (
); } diff --git a/src/governance-app-frontend/src/common/components/skeletons/SkeletonScreen.tsx b/src/governance-app-frontend/src/common/components/skeletons/SkeletonScreen.tsx new file mode 100644 index 000000000..0d89eafde --- /dev/null +++ b/src/governance-app-frontend/src/common/components/skeletons/SkeletonScreen.tsx @@ -0,0 +1,19 @@ +import { useTranslation } from 'react-i18next'; + +type Props = React.ComponentProps<'div'>; + +/** + * Announces one loading region. + * + * The bars inside are `aria-hidden`, so without this wrapper a screen reader + * hears nothing at all while a page loads. + */ +export const SkeletonScreen = ({ className, children, ...props }: Props) => { + const { t } = useTranslation(); + + return ( +
$.common.loading)} className={className} {...props}> + {children} +
+ ); +}; diff --git a/src/governance-app-frontend/src/common/components/skeletons/SkeletonText.tsx b/src/governance-app-frontend/src/common/components/skeletons/SkeletonText.tsx new file mode 100644 index 000000000..60840cb6a --- /dev/null +++ b/src/governance-app-frontend/src/common/components/skeletons/SkeletonText.tsx @@ -0,0 +1,35 @@ +import { Skeleton } from '@components/Skeleton'; +import { cn } from '@utils/shadcn'; + +type Size = 'sm' | 'base' | 'lg'; + +type Props = { + /** Number of bars. The last one is short, the way a paragraph ends. */ + lines?: number; + /** Matches the text scale the bars stand in for. */ + size?: Size; + className?: string; +}; + +const heights: Record = { + sm: 'h-3', + base: 'h-3.5', + lg: 'h-4', +}; + +const gaps: Record = { + sm: 'gap-2', + base: 'gap-2.5', + lg: 'gap-3', +}; + +export const SkeletonText = ({ lines = 3, size = 'base', className }: Props) => ( +
+ {Array.from({ length: lines }).map((_, index) => ( + 1 ? 'w-3/5' : 'w-full')} + /> + ))} +
+); diff --git a/src/governance-app-frontend/src/common/constants/extra.ts b/src/governance-app-frontend/src/common/constants/extra.ts index 39cd2b133..8425675c4 100644 --- a/src/governance-app-frontend/src/common/constants/extra.ts +++ b/src/governance-app-frontend/src/common/constants/extra.ts @@ -14,10 +14,6 @@ export const PAGINATION_LIMIT_PROPOSALS = 25; // Somehow this one has a different limit in case of a certified request. export const PAGINATION_LIMIT_TRANSACTIONS = 50; -// Loading indicators are held back for this long before appearing, so a fast -// response resolves into content instead of flashing a skeleton on the way. -export const LOADING_INDICATOR_DELAY = 120; - export const E8S_PRECISION = 8; export const E8S = 10 ** E8S_PRECISION; export const E8Sn = BigInt(E8S); diff --git a/src/governance-app-frontend/src/common/hooks/useDelayedFlag.ts b/src/governance-app-frontend/src/common/hooks/useDelayedFlag.ts deleted file mode 100644 index ae1aa6f6f..000000000 --- a/src/governance-app-frontend/src/common/hooks/useDelayedFlag.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { useEffect, useState } from 'react'; - -import { LOADING_INDICATOR_DELAY } from '@constants/extra'; - -/** - * Returns `true` only once `active` has stayed true for `delayMs`. - * - * Used to hold back loading indicators. Queries resolve at whatever speed the - * network allows — a warm IC query call comes back in well under 100ms — and - * painting a skeleton for a handful of frames on the way to content reads as a - * flicker. Delaying the *indicator* keeps that away without delaying the data. - */ -export const useDelayedFlag = (active: boolean, delayMs: number = LOADING_INDICATOR_DELAY) => { - const [elapsed, setElapsed] = useState(false); - const [previousActive, setPreviousActive] = useState(active); - - // Rearm on any change of `active`, so the next active spell waits the delay - // out again. Adjusting during render rather than in an effect: React reruns - // this component before committing, so no extra frame is shown. - if (previousActive !== active) { - setPreviousActive(active); - setElapsed(false); - } - - useEffect(() => { - if (!active) return; - - const timeout = window.setTimeout(() => setElapsed(true), delayMs); - - return () => window.clearTimeout(timeout); - }, [active, delayMs]); - - return active && elapsed; -}; diff --git a/src/governance-app-frontend/src/features/account/components/AccountCard.tsx b/src/governance-app-frontend/src/features/account/components/AccountCard.tsx index ee1fb4cb1..66eb5c252 100644 --- a/src/governance-app-frontend/src/features/account/components/AccountCard.tsx +++ b/src/governance-app-frontend/src/features/account/components/AccountCard.tsx @@ -77,7 +77,7 @@ export function AccountCard() { )} {balanceQuery.isLoading || tickersQuery.isLoading ? ( - + ) : (

diff --git a/src/governance-app-frontend/src/features/accounts/components/AccountsCard.tsx b/src/governance-app-frontend/src/features/accounts/components/AccountsCard.tsx index dd3e226c1..e4daad7b9 100644 --- a/src/governance-app-frontend/src/features/accounts/components/AccountsCard.tsx +++ b/src/governance-app-frontend/src/features/accounts/components/AccountsCard.tsx @@ -41,7 +41,7 @@ export const AccountsCard = () => { {t(($) => $.accounts.title)}

{isLoading ? ( - + ) : ( {count} )} @@ -59,7 +59,7 @@ export const AccountsCard = () => { )} {isLoading || tickersQuery.isLoading ? ( - + ) : (

@@ -75,8 +75,8 @@ export const AccountsCard = () => {

{Array.from({ length: 2 }).map((_, i) => (
- - + +
))}
diff --git a/src/governance-app-frontend/src/features/accounts/components/AccountsListItem.tsx b/src/governance-app-frontend/src/features/accounts/components/AccountsListItem.tsx index 4d1a7f924..f323d0843 100644 --- a/src/governance-app-frontend/src/features/accounts/components/AccountsListItem.tsx +++ b/src/governance-app-frontend/src/features/accounts/components/AccountsListItem.tsx @@ -116,7 +116,7 @@ function AccountBalance({ return (
- +
); } diff --git a/src/governance-app-frontend/src/features/accounts/components/AccountsTotalCard.tsx b/src/governance-app-frontend/src/features/accounts/components/AccountsTotalCard.tsx index 2681d8be5..1286641b4 100644 --- a/src/governance-app-frontend/src/features/accounts/components/AccountsTotalCard.tsx +++ b/src/governance-app-frontend/src/features/accounts/components/AccountsTotalCard.tsx @@ -64,7 +64,7 @@ export const AccountsTotalCard = () => {

)} {isLoading || tickersQuery.isLoading ? ( - + ) : (

@@ -96,8 +96,8 @@ export const AccountsTotalCard = () => { {isLoading ? (

- - + +
) : (
diff --git a/src/governance-app-frontend/src/features/dashboard/components/DailyRewardsCard.tsx b/src/governance-app-frontend/src/features/dashboard/components/DailyRewardsCard.tsx index b96a484ee..4fc5a2d6d 100644 --- a/src/governance-app-frontend/src/features/dashboard/components/DailyRewardsCard.tsx +++ b/src/governance-app-frontend/src/features/dashboard/components/DailyRewardsCard.tsx @@ -57,17 +57,19 @@ export const DailyRewardsCard = () => { )}

)} - {isRewardEventLoading ? ( - - ) : ( - nonNullish(lastRewardTimestamp) && ( -

- {t(($) => $.home.lastRewardEvent, { - date: secondsToDate(Number(lastRewardTimestamp)), - })} -

- ) - )} +
+ {isRewardEventLoading ? ( + + ) : ( + nonNullish(lastRewardTimestamp) && ( +

+ {t(($) => $.home.lastRewardEvent, { + date: secondsToDate(Number(lastRewardTimestamp)), + })} +

+ ) + )} +
); diff --git a/src/governance-app-frontend/src/features/dashboard/components/IcpPriceCard.tsx b/src/governance-app-frontend/src/features/dashboard/components/IcpPriceCard.tsx index 095d2c036..bb1c8811c 100644 --- a/src/governance-app-frontend/src/features/dashboard/components/IcpPriceCard.tsx +++ b/src/governance-app-frontend/src/features/dashboard/components/IcpPriceCard.tsx @@ -28,25 +28,28 @@ export const IcpPriceCard = () => { {t(($) => $.home.icpPrice)}

{tickersQuery.isLoading ? ( - <> - - - + ) : ( - <> -

- {icpPriceUsd ? `$${icpPriceUsd}` : '—'} -

- {nonNullish(change) && ( +

+ {icpPriceUsd ? `$${icpPriceUsd}` : '—'} +

+ )} + {/* The row keeps its height with or without a change, so the card does + not shrink when the skeleton leaves. */} +
+ {tickersQuery.isLoading ? ( + + ) : ( + nonNullish(change) && (

{isPositive ? '▲' : '▼'} {formatPercentage(Math.abs(change))} ({t(($) => $.home.icpPrice24h)})

- )} - - )} + ) + )} +
); diff --git a/src/governance-app-frontend/src/features/dashboard/components/TotalStakedCard.tsx b/src/governance-app-frontend/src/features/dashboard/components/TotalStakedCard.tsx index bc6adb192..5e9cfdccb 100644 --- a/src/governance-app-frontend/src/features/dashboard/components/TotalStakedCard.tsx +++ b/src/governance-app-frontend/src/features/dashboard/components/TotalStakedCard.tsx @@ -35,32 +35,33 @@ export const TotalStakedCard = () => { {t(($) => $.home.totalStaked)}

{isLoading ? ( - <> - - - + ) : ( - <> -

- {nonNullish(tvl) ? ( - - ) : ( - '—' - )} -

- {nonNullish(stakedPercentage) && ( -

+

+ {nonNullish(tvl) ? ( + + ) : ( + '—' + )} +

+ )} +
+ {isLoading ? ( + + ) : ( + nonNullish(stakedPercentage) && ( +

{t(($) => $.home.ofTotalSupply, { value: formatNumber(stakedPercentage, { minFraction: 1, maxFraction: 1 }), })}

- )} - - )} + ) + )} +
); diff --git a/src/governance-app-frontend/src/features/stakes/components/StakedCard.tsx b/src/governance-app-frontend/src/features/stakes/components/StakedCard.tsx index 7aa33910f..7902b3d8f 100644 --- a/src/governance-app-frontend/src/features/stakes/components/StakedCard.tsx +++ b/src/governance-app-frontend/src/features/stakes/components/StakedCard.tsx @@ -91,7 +91,7 @@ export function StakedCard() { {stakingRewards.apy.cur < stakingRewards.apy.max && } ) : ( - + )}
@@ -108,7 +108,7 @@ export function StakedCard() { )} {neuronsQuery.isLoading || tickersQuery.isLoading ? ( - + ) : (

diff --git a/src/governance-app-frontend/src/features/stakes/components/summaryCards/CapitalCard.tsx b/src/governance-app-frontend/src/features/stakes/components/summaryCards/CapitalCard.tsx index ecc22e392..bb5891be7 100644 --- a/src/governance-app-frontend/src/features/stakes/components/summaryCards/CapitalCard.tsx +++ b/src/governance-app-frontend/src/features/stakes/components/summaryCards/CapitalCard.tsx @@ -30,19 +30,18 @@ export function CapitalCard({ neurons }: CapitalCardProps) {

{t(($) => $.neuron.summary.capital)}

- {tickersQuery.isLoading ? ( - <> - - - - ) : ( - <> -

- - {t(($) => $.common.inIcp, { value: formatNumber(totalStaked) })} - -

-

+ {/* The staked total arrives with the neurons, so it never waits on the + ticker price. Only the USD line below does. */} +

+ + {t(($) => $.common.inIcp, { value: formatNumber(totalStaked) })} + +

+
+ {tickersQuery.isLoading ? ( + + ) : ( +

{nonNullish(usdValue) ? ( {t(($) => $.account.approxUsd, { @@ -53,8 +52,8 @@ export function CapitalCard({ neurons }: CapitalCardProps) { '—' )}

- - )} + )} +
); diff --git a/src/governance-app-frontend/src/features/stakes/components/summaryCards/EarningsCard.tsx b/src/governance-app-frontend/src/features/stakes/components/summaryCards/EarningsCard.tsx index 629f00638..b85a1bfed 100644 --- a/src/governance-app-frontend/src/features/stakes/components/summaryCards/EarningsCard.tsx +++ b/src/governance-app-frontend/src/features/stakes/components/summaryCards/EarningsCard.tsx @@ -53,7 +53,7 @@ export function EarningsCard({ neurons }: EarningsCardProps) { ) : stakingRewardsError ? (

—

) : ( - + )} diff --git a/src/governance-app-frontend/src/features/stakes/components/summaryCards/EngagementCard.tsx b/src/governance-app-frontend/src/features/stakes/components/summaryCards/EngagementCard.tsx index fb3262c24..363f48929 100644 --- a/src/governance-app-frontend/src/features/stakes/components/summaryCards/EngagementCard.tsx +++ b/src/governance-app-frontend/src/features/stakes/components/summaryCards/EngagementCard.tsx @@ -38,8 +38,8 @@ export function EngagementCard({ neurons }: EngagementCardProps) { {isLoading ? ( <> - - + + ) : ( <> diff --git a/src/governance-app-frontend/src/features/voting/components/FollowedNeuronCard.tsx b/src/governance-app-frontend/src/features/voting/components/FollowedNeuronCard.tsx index debd41496..6af583902 100644 --- a/src/governance-app-frontend/src/features/voting/components/FollowedNeuronCard.tsx +++ b/src/governance-app-frontend/src/features/voting/components/FollowedNeuronCard.tsx @@ -72,7 +72,7 @@ export const FollowedNeuronCard = ({ neuron }: Props) => {
- {knownNeuronsQuery.isLoading ? : renderContent()} + {knownNeuronsQuery.isLoading ? : renderContent()}