authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-03 13:30:07-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 13:30:07-04:00
log21b9dda5afb00a33e228a68d2ccc885bba7285dc
treead20a620ba7321e0e702f239e4a23e767d615c8d
parentd4ba2a1f11498eae9683f6dc95a08b1c17c29765
signature Signed by PGP key B5690EEEBB952194

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

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

4 files changed, 39 insertions(+), 45 deletions(-)

apps/web/src/components/pullRequest/PullRequestGhosts.tsx+16-10
......@@ -3,11 +3,9 @@
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 */
1210import { cn } from "~/lib/utils";
1311
......@@ -32,7 +30,7 @@ export function PullRequestListGhost({
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() {
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() {
160158/** People-shaped: an avatar and a name, in the reviewer picker's own row height. */
161159export 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 }) {
174176/** The timeline's own shape: dots on the rail, a line and a date to each. */
175177export 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 }) {
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";
33function 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";
2323import { ScrollArea } from "../ui/scroll-area";
2424import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
2525import { SidebarInset } from "../ui/sidebar";
26import { Skeleton } from "../ui/skeleton";
2627import { Toggle, ToggleGroup } from "../ui/toggle-group";
2728import {
2829 WorkspaceBreadcrumb,
......@@ -585,8 +586,9 @@ function UsageDeviceStrip({
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 */
591593function UsageSkeleton() {
592594 return (
......@@ -594,29 +596,29 @@ function UsageSkeleton() {
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() {
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() {
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
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
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;