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 @@...@@ -3,11 +3,9 @@
3 * and a detail panel opening — use bars in the geometry of the content they stand for, pulsing3 * and a detail panel opening — use bars in the geometry of the content they stand for, pulsing
4 * on one composited layer. Diff loading uses the shared diff-panel skeleton instead.4 * on one composited layer. Diff loading uses the shared diff-panel skeleton instead.
5 *5 *
6 * Deliberately not the app's shimmer skeleton. The sweep is a `transform` animation per bar —6 * The bars share the app-wide `Skeleton` tone (`muted-foreground` at low alpha, which reads on
7 * compositor-safe, but a layer for every bar on screen — and its white highlight over the7 * both themes) and the single `animate-skeleton` pulse, applied once on the container so any
8 * near-white `muted` base all but disappears in light mode. Here one `animate-ghost-pulse` on the8 * number of bars costs one opacity animation.
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.
11 */9 */
12import { cn } from "~/lib/utils";10import { cn } from "~/lib/utils";
1311
...@@ -32,7 +30,7 @@ export function PullRequestListGhost({...@@ -32,7 +30,7 @@ export function PullRequestListGhost({
32 <div30 <div
33 role="status"31 role="status"
34 aria-label={caption ?? "Loading pull requests"}32 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"
36 >34 >
37 {caption ? (35 {caption ? (
38 <p className="px-3 pb-1 text-xs font-medium text-muted-foreground/70">{caption}</p>36 <p className="px-3 pb-1 text-xs font-medium text-muted-foreground/70">{caption}</p>
...@@ -67,7 +65,7 @@ export function PullRequestDetailGhost() {...@@ -67,7 +65,7 @@ export function PullRequestDetailGhost() {
67 <div65 <div
68 role="status"66 role="status"
69 aria-label="Loading pull request"67 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"
71 >69 >
72 <div className="shrink-0 border-b border-border/60">70 <div className="shrink-0 border-b border-border/60">
73 <div className="flex h-7 items-center justify-between gap-3 px-4">71 <div className="flex h-7 items-center justify-between gap-3 px-4">
...@@ -160,7 +158,11 @@ export function PullRequestDetailGhost() {...@@ -160,7 +158,11 @@ export function PullRequestDetailGhost() {
160/** People-shaped: an avatar and a name, in the reviewer picker's own row height. */158/** People-shaped: an avatar and a name, in the reviewer picker's own row height. */
161export function PullRequestPeopleGhost({ rows = 4 }: { rows?: number }) {159export function PullRequestPeopleGhost({ rows = 4 }: { rows?: number }) {
162 return (160 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 >
164 {Array.from({ length: rows }, (_, index) => (166 {Array.from({ length: rows }, (_, index) => (
165 <div key={index} className="flex h-7 items-center gap-2 rounded-md px-2">167 <div key={index} className="flex h-7 items-center gap-2 rounded-md px-2">
166 <GhostBar className="size-4 rounded-full" />168 <GhostBar className="size-4 rounded-full" />
...@@ -174,7 +176,11 @@ export function PullRequestPeopleGhost({ rows = 4 }: { rows?: number }) {...@@ -174,7 +176,11 @@ export function PullRequestPeopleGhost({ rows = 4 }: { rows?: number }) {
174/** The timeline's own shape: dots on the rail, a line and a date to each. */176/** The timeline's own shape: dots on the rail, a line and a date to each. */
175export function PullRequestTimelineGhost({ rows = 6 }: { rows?: number }) {177export function PullRequestTimelineGhost({ rows = 6 }: { rows?: number }) {
176 return (178 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 >
178 <div className="relative ml-2 border-l border-border/70 pl-5">184 <div className="relative ml-2 border-l border-border/70 pl-5">
179 {Array.from({ length: rows }, (_, index) => (185 {Array.from({ length: rows }, (_, index) => (
180 <div key={index} className="relative pb-5">186 <div key={index} className="relative pb-5">
...@@ -194,7 +200,7 @@ export function PullRequestConversationGhost({ rows = 3 }: { rows?: number }) {...@@ -194,7 +200,7 @@ export function PullRequestConversationGhost({ rows = 3 }: { rows?: number }) {
194 <div200 <div
195 role="status"201 role="status"
196 aria-label="Loading pull request conversation"202 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"
198 >204 >
199 {Array.from({ length: rows }, (_, index) => (205 {Array.from({ length: rows }, (_, index) => (
200 <div key={index} className="flex items-start gap-2">206 <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";...@@ -3,10 +3,7 @@ import { cn } from "~/lib/utils";
3function Skeleton({ className, ...props }: React.ComponentProps<"div">) {3function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
4 return (4 return (
5 <div5 <div
6 className={cn(6 className={cn("rounded-sm bg-muted-foreground/15 motion-safe:animate-skeleton", className)}
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 )}
10 data-slot="skeleton"7 data-slot="skeleton"
11 {...props}8 {...props}
12 />9 />
apps/web/src/components/usage/UsagePage.tsx+17-15
...@@ -23,6 +23,7 @@ import { Button } from "../ui/button";...@@ -23,6 +23,7 @@ import { Button } from "../ui/button";
23import { ScrollArea } from "../ui/scroll-area";23import { ScrollArea } from "../ui/scroll-area";
24import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";24import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
25import { SidebarInset } from "../ui/sidebar";25import { SidebarInset } from "../ui/sidebar";
26import { Skeleton } from "../ui/skeleton";
26import { Toggle, ToggleGroup } from "../ui/toggle-group";27import { Toggle, ToggleGroup } from "../ui/toggle-group";
27import {28import {
28 WorkspaceBreadcrumb,29 WorkspaceBreadcrumb,
...@@ -585,8 +586,9 @@ function UsageDeviceStrip({...@@ -585,8 +586,9 @@ function UsageDeviceStrip({
585}586}
586587
587/**588/**
588 * Static stand-in with the loaded page's shape. No shimmer; blocks fill in589 * Stand-in with the loaded page's shape, using the shared `Skeleton` bars so it
589 * exactly once when the last device answers.590 * breathes with the same `animate-skeleton` pulse as every other loading state.
591 * Blocks fill in exactly once when the last device answers.
590 */592 */
591function UsageSkeleton() {593function UsageSkeleton() {
592 return (594 return (
...@@ -594,29 +596,29 @@ function UsageSkeleton() {...@@ -594,29 +596,29 @@ function UsageSkeleton() {
594 <section className="grid gap-6 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]">596 <section className="grid gap-6 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]">
595 <div className="flex flex-col gap-5">597 <div className="flex flex-col gap-5">
596 <div className="flex flex-col gap-1">598 <div className="flex flex-col gap-1">
597 <div className="h-10 w-36 rounded-sm bg-muted" />599 <Skeleton className="h-10 w-36" />
598 <div className="h-4 w-32 rounded-sm bg-muted" />600 <Skeleton className="h-4 w-32" />
599 </div>601 </div>
600 {PROVIDER_ORDER.map((provider) => (602 {PROVIDER_ORDER.map((provider) => (
601 <div key={provider} className="flex flex-col gap-1">603 <div key={provider} className="flex flex-col gap-1">
602 <div className="flex min-h-5 items-center justify-between gap-4">604 <div className="flex min-h-5 items-center justify-between gap-4">
603 <span className="flex items-center gap-2">605 <span className="flex items-center gap-2">
604 <span className="size-2 shrink-0 rounded-full bg-muted" />606 <Skeleton className="size-2 shrink-0 rounded-full" />
605 <span className="size-4 shrink-0 rounded-full bg-muted" />607 <Skeleton className="size-4 shrink-0 rounded-full" />
606 <div className="h-3.5 w-20 rounded-sm bg-muted" />608 <Skeleton className="h-3.5 w-20" />
607 </span>609 </span>
608 <div className="h-3.5 w-14 rounded-sm bg-muted" />610 <Skeleton className="h-3.5 w-14" />
609 </div>611 </div>
610 <div className="h-4 w-36 rounded-sm bg-muted" />612 <Skeleton className="h-4 w-36" />
611 </div>613 </div>
612 ))}614 ))}
613 </div>615 </div>
614616
615 <div className="flex flex-col gap-3">617 <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" />
617 <div className="flex flex-col gap-1">619 <div className="flex flex-col gap-1">
618 <div className="ml-16 h-56 rounded-sm bg-muted/35" />620 <Skeleton className="ml-16 h-56 bg-muted-foreground/10" />
619 <div className="ml-16 h-4 rounded-sm bg-muted/35" />621 <Skeleton className="ml-16 h-4 bg-muted-foreground/10" />
620 </div>622 </div>
621 </div>623 </div>
622 </section>624 </section>
...@@ -628,7 +630,7 @@ function UsageSkeleton() {...@@ -628,7 +630,7 @@ function UsageSkeleton() {
628 (label) => (630 (label) => (
629 <div key={label} className="flex flex-col gap-0.5">631 <div key={label} className="flex flex-col gap-0.5">
630 <span className="text-xs text-muted-foreground">{label}</span>632 <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" />
632 </div>634 </div>
633 ),635 ),
634 )}636 )}
...@@ -638,9 +640,9 @@ function UsageSkeleton() {...@@ -638,9 +640,9 @@ function UsageSkeleton() {
638 <section className="flex flex-col gap-3">640 <section className="flex flex-col gap-3">
639 <div className="flex items-center justify-between gap-3">641 <div className="flex items-center justify-between gap-3">
640 <h2 className="text-sm font-medium text-foreground">Breakdown</h2>642 <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" />
642 </div>644 </div>
643 <div className="h-44 rounded-sm bg-muted/35" />645 <Skeleton className="h-44 bg-muted-foreground/10" />
644 </section>646 </section>
645 </>647 </>
646 );648 );
apps/web/src/index.css+5-16
...@@ -145,11 +145,10 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -145,11 +145,10 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
145145
146@theme inline {146@theme inline {
147 --color-zinc-25: oklch(99.2% 0 0);147 --color-zinc-25: oklch(99.2% 0 0);
148 --animate-skeleton: skeleton 2s infinite linear;148 --animate-skeleton: skeleton 2.4s infinite;
149 /* Duty-cycled indicator animations: long holds with stepped ramps, so the149 /* Duty-cycled indicator animations: long holds with stepped ramps, so the
150 compositor updates discrete frames instead of every vsync. */150 compositor updates discrete frames instead of every vsync. */
151 --animate-status-pulse: status-pulse 2s infinite;151 --animate-status-pulse: status-pulse 2s infinite;
152 --animate-ghost-pulse: ghost-pulse 2.4s infinite;
153 --animate-status-ping: status-ping 2s infinite;152 --animate-status-ping: status-ping 2s infinite;
154 --color-warning-foreground: var(--warning-foreground);153 --color-warning-foreground: var(--warning-foreground);
155 --color-warning: var(--warning);154 --color-warning: var(--warning);
...@@ -207,20 +206,10 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -207,20 +206,10 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
207 --radius-2xl: calc(var(--radius) + 8px);206 --radius-2xl: calc(var(--radius) + 8px);
208 --radius-3xl: calc(var(--radius) + 12px);207 --radius-3xl: calc(var(--radius) + 12px);
209 @keyframes skeleton {208 @keyframes skeleton {
210 /* Transform-only so the highlight sweep stays on the compositor, then a209 /* The single loading-bar breath used by every skeleton: one opacity pulse
211 long hold with the band parked off-screen instead of a constant shimmer. */210 per container, stepped so however many bars sit under it, the compositor
212 0% {211 draws a handful of discrete frames per cycle rather than one per vsync —
213 transform: translateX(-100%);212 which on a 120Hz display is the difference between ~14 and ~288 updates. */
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. */
224 0%,213 0%,
225 42% {214 42% {
226 opacity: 1;215 opacity: 1;