History

fix(web): unify skeleton loading animations on one pulse (#9448)

maria committed 2 months ago

Commit 21b9dda1 parent d4ba2a14 files · +39 −45Browse files

Unified · Split

4 files
  1. apps/web/src/components/pullRequest/PullRequestGhosts.tsx +16 −10
  2. apps/web/src/components/ui/skeleton.tsx +1 −4
  3. apps/web/src/components/usage/UsagePage.tsx +17 −15
  4. apps/web/src/index.css +5 −16
apps/web/src/components/pullRequest/PullRequestGhosts.tsx+16−10
@@ -3,11 +3,9 @@ · 2 unmodified lines
33 * and a detail panel opening — use bars in the geometry of the content they stand for, pulsing
44 * on one composited layer. Diff loading uses the shared diff-panel skeleton instead.
55 *
6− * Deliberately not the app's shimmer skeleton. The sweep is a `transform` animation per bar —
7− * compositor-safe, but a layer for every bar on screen — and its white highlight over the
8− * near-white `muted` base all but disappears in light mode. Here one `animate-ghost-pulse` on the
9− * container is a single opacity animation however many bars sit under it, and the bars take
10− * their tone from `muted-foreground` at low alpha, which reads on both themes.
6+ * The bars share the app-wide `Skeleton` tone (`muted-foreground` at low alpha, which reads on
7+ * both themes) and the single `animate-skeleton` pulse, applied once on the container so any
8+ * number of bars costs one opacity animation.
119 */
1210 import { cn } from "~/lib/utils";
1311
@@ -32,7 +30,7 @@ export function PullRequestListGhost({ · 18 unmodified lines
3230 <div
3331 role="status"
3432 aria-label={caption ?? "Loading pull requests"}
35− className="animate-ghost-pulse space-y-0.5"
33+ className="motion-safe:animate-skeleton space-y-0.5"
3634 >
3735 {caption ? (
3836 <p className="px-3 pb-1 text-xs font-medium text-muted-foreground/70">{caption}</p>
@@ -67,7 +65,7 @@ export function PullRequestDetailGhost() { · 28 unmodified lines
6765 <div
6866 role="status"
6967 aria-label="Loading pull request"
70− className="animate-ghost-pulse flex h-full min-h-0 flex-col overflow-hidden bg-background"
68+ className="motion-safe:animate-skeleton flex h-full min-h-0 flex-col overflow-hidden bg-background"
7169 >
7270 <div className="shrink-0 border-b border-border/60">
7371 <div className="flex h-7 items-center justify-between gap-3 px-4">
@@ -160,7 +158,11 @@ export function PullRequestDetailGhost() { · 86 unmodified lines
160158 /** People-shaped: an avatar and a name, in the reviewer picker's own row height. */
161159 export function PullRequestPeopleGhost({ rows = 4 }: { rows?: number }) {
162160 return (
163− <div role="status" aria-label="Loading people" className="animate-ghost-pulse space-y-1 p-1">
161+ <div
162+ role="status"
163+ aria-label="Loading people"
164+ className="motion-safe:animate-skeleton space-y-1 p-1"
165+ >
164166 {Array.from({ length: rows }, (_, index) => (
165167 <div key={index} className="flex h-7 items-center gap-2 rounded-md px-2">
166168 <GhostBar className="size-4 rounded-full" />
@@ -174,7 +176,11 @@ export function PullRequestPeopleGhost({ rows = 4 }: { rows?: number }) { · 7 unmodified lines
174176 /** The timeline's own shape: dots on the rail, a line and a date to each. */
175177 export function PullRequestTimelineGhost({ rows = 6 }: { rows?: number }) {
176178 return (
177− <div role="status" aria-label="Loading timeline" className="animate-ghost-pulse px-4 py-5">
179+ <div
180+ role="status"
181+ aria-label="Loading timeline"
182+ className="motion-safe:animate-skeleton px-4 py-5"
183+ >
178184 <div className="relative ml-2 border-l border-border/70 pl-5">
179185 {Array.from({ length: rows }, (_, index) => (
180186 <div key={index} className="relative pb-5">
@@ -194,7 +200,7 @@ export function PullRequestConversationGhost({ rows = 3 }: { rows?: number }) { · 13 unmodified lines
194200 <div
195201 role="status"
196202 aria-label="Loading pull request conversation"
197− className="animate-ghost-pulse space-y-4 py-2"
203+ className="motion-safe:animate-skeleton space-y-4 py-2"
198204 >
199205 {Array.from({ length: rows }, (_, index) => (
200206 <div key={index} className="flex items-start gap-2">
apps/web/src/components/ui/skeleton.tsx+1−4
@@ -3,10 +3,7 @@ import { cn } from "~/lib/utils"; · 2 unmodified lines
33 function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
44 return (
55 <div
6− className={cn(
7− "relative overflow-hidden rounded-sm bg-muted [--skeleton-highlight:--alpha(var(--color-white)/64%)] after:absolute after:inset-0 after:animate-skeleton after:bg-[linear-gradient(120deg,transparent_40%,var(--skeleton-highlight),transparent_60%)] motion-reduce:after:content-none dark:[--skeleton-highlight:--alpha(var(--color-white)/4%)]",
8− className,
9− )}
6+ className={cn("rounded-sm bg-muted-foreground/15 motion-safe:animate-skeleton", className)}
107 data-slot="skeleton"
118 {...props}
129 />
apps/web/src/components/usage/UsagePage.tsx+17−15
@@ -23,6 +23,7 @@ import { Button } from "../ui/button"; · 22 unmodified lines
2323 import { ScrollArea } from "../ui/scroll-area";
2424 import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
2525 import { SidebarInset } from "../ui/sidebar";
26+import { Skeleton } from "../ui/skeleton";
2627 import { Toggle, ToggleGroup } from "../ui/toggle-group";
2728 import {
2829 WorkspaceBreadcrumb,
@@ -585,8 +586,9 @@ function UsageDeviceStrip({ · 556 unmodified lines
585586 }
586587
587588 /**
588− * Static stand-in with the loaded page's shape. No shimmer; blocks fill in
589− * exactly once when the last device answers.
589+ * Stand-in with the loaded page's shape, using the shared `Skeleton` bars so it
590+ * breathes with the same `animate-skeleton` pulse as every other loading state.
591+ * Blocks fill in exactly once when the last device answers.
590592 */
591593 function UsageSkeleton() {
592594 return (
@@ -594,29 +596,29 @@ function UsageSkeleton() { · 1 unmodified line
594596 <section className="grid gap-6 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]">
595597 <div className="flex flex-col gap-5">
596598 <div className="flex flex-col gap-1">
597− <div className="h-10 w-36 rounded-sm bg-muted" />
598− <div className="h-4 w-32 rounded-sm bg-muted" />
599+ <Skeleton className="h-10 w-36" />
600+ <Skeleton className="h-4 w-32" />
599601 </div>
600602 {PROVIDER_ORDER.map((provider) => (
601603 <div key={provider} className="flex flex-col gap-1">
602604 <div className="flex min-h-5 items-center justify-between gap-4">
603605 <span className="flex items-center gap-2">
604− <span className="size-2 shrink-0 rounded-full bg-muted" />
605− <span className="size-4 shrink-0 rounded-full bg-muted" />
606− <div className="h-3.5 w-20 rounded-sm bg-muted" />
606+ <Skeleton className="size-2 shrink-0 rounded-full" />
607+ <Skeleton className="size-4 shrink-0 rounded-full" />
608+ <Skeleton className="h-3.5 w-20" />
607609 </span>
608− <div className="h-3.5 w-14 rounded-sm bg-muted" />
610+ <Skeleton className="h-3.5 w-14" />
609611 </div>
610− <div className="h-4 w-36 rounded-sm bg-muted" />
612+ <Skeleton className="h-4 w-36" />
611613 </div>
612614 ))}
613615 </div>
614616
615617 <div className="flex flex-col gap-3">
616− <div className="h-5 w-24 rounded-sm bg-muted" />
618+ <Skeleton className="h-5 w-24" />
617619 <div className="flex flex-col gap-1">
618− <div className="ml-16 h-56 rounded-sm bg-muted/35" />
619− <div className="ml-16 h-4 rounded-sm bg-muted/35" />
620+ <Skeleton className="ml-16 h-56 bg-muted-foreground/10" />
621+ <Skeleton className="ml-16 h-4 bg-muted-foreground/10" />
620622 </div>
621623 </div>
622624 </section>
@@ -628,7 +630,7 @@ function UsageSkeleton() { · 5 unmodified lines
628630 (label) => (
629631 <div key={label} className="flex flex-col gap-0.5">
630632 <span className="text-xs text-muted-foreground">{label}</span>
631− <div className="h-6 w-16 rounded-sm bg-muted" />
633+ <Skeleton className="h-6 w-16" />
632634 </div>
633635 ),
634636 )}
@@ -638,9 +640,9 @@ function UsageSkeleton() { · 3 unmodified lines
638640 <section className="flex flex-col gap-3">
639641 <div className="flex items-center justify-between gap-3">
640642 <h2 className="text-sm font-medium text-foreground">Breakdown</h2>
641− <div className="h-7 w-28 rounded-lg bg-input/40" />
643+ <Skeleton className="h-7 w-28 rounded-lg" />
642644 </div>
643− <div className="h-44 rounded-sm bg-muted/35" />
645+ <Skeleton className="h-44 bg-muted-foreground/10" />
644646 </section>
645647 </>
646648 );
apps/web/src/index.css+5−16
@@ -145,11 +145,10 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil · 144 unmodified lines
145145
146146 @theme inline {
147147 --color-zinc-25: oklch(99.2% 0 0);
148− --animate-skeleton: skeleton 2s infinite linear;
148+ --animate-skeleton: skeleton 2.4s infinite;
149149 /* Duty-cycled indicator animations: long holds with stepped ramps, so the
150150 compositor updates discrete frames instead of every vsync. */
151151 --animate-status-pulse: status-pulse 2s infinite;
152− --animate-ghost-pulse: ghost-pulse 2.4s infinite;
153152 --animate-status-ping: status-ping 2s infinite;
154153 --color-warning-foreground: var(--warning-foreground);
155154 --color-warning: var(--warning);
@@ -207,20 +206,10 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil · 51 unmodified lines
207206 --radius-2xl: calc(var(--radius) + 8px);
208207 --radius-3xl: calc(var(--radius) + 12px);
209208 @keyframes skeleton {
210− /* Transform-only so the highlight sweep stays on the compositor, then a
211− long hold with the band parked off-screen instead of a constant shimmer. */
212− 0% {
213− transform: translateX(-100%);
214− }
215− 60%,
216− 100% {
217− transform: translateX(100%);
218− }
219− }
220− @keyframes ghost-pulse {
221− /* The loading ghosts' breath. Stepped like the status indicators, so however many bars a
222− ghost holds, the compositor draws a handful of discrete frames per cycle rather than one
223− per vsync — which on a 120Hz display is the difference between ~14 and ~288 updates. */
209+ /* The single loading-bar breath used by every skeleton: one opacity pulse
210+ per container, stepped so however many bars sit under it, the compositor
211+ draws a handful of discrete frames per cycle rather than one per vsync —
212+ which on a 120Hz display is the difference between ~14 and ~288 updates. */
224213 0%,
225214 42% {
226215 opacity: 1;