Add shared skeleton loader primitives (#998)

This commit is contained in:
klopez4212
2026-06-15 20:43:36 +01:00
committed by GitHub
parent 9ee5aeebd9
commit 7a2e35521c
3 changed files with 238 additions and 15 deletions

View File

@@ -2,6 +2,16 @@
@import "tw-animate-css"; @import "tw-animate-css";
@config "../../../tailwind.config.js"; @config "../../../tailwind.config.js";
.sprout-arc-spinner {
animation: sprout-arc-spinner-spin 500ms linear infinite;
}
@keyframes sprout-arc-spinner-spin {
to {
transform: rotate(360deg);
}
}
.buzz-emoji-mart { .buzz-emoji-mart {
align-items: stretch; align-items: stretch;
display: flex; display: flex;
@@ -837,6 +847,121 @@
} }
} }
:root {
--skel-pulse-dur: 1000ms;
--skel-pulse-count: infinite;
--skel-pulse-min: 0.5;
--skel-reveal-dur: 400ms;
--skel-reveal-blur: 2px;
--skel-reveal-ease: ease-in-out;
}
.t-skel {
position: relative;
}
.t-skel[data-layout="flow"] {
display: grid;
}
.t-skel-skeleton,
.t-skel-content {
inset: 0;
position: absolute;
}
.t-skel[data-layout="flow"] > .t-skel-skeleton,
.t-skel[data-layout="flow"] > .t-skel-content {
grid-area: 1 / 1;
width: 100%;
}
.t-skel[data-layout="flow"] > .t-skel-skeleton {
inset: auto;
position: relative;
}
.t-skel[data-layout="flow"] > .t-skel-content,
.t-skel[data-layout="flow"].is-revealed > .t-skel-skeleton {
inset: 0;
position: absolute;
}
.t-skel[data-layout="flow"].is-revealed > .t-skel-content {
inset: auto;
position: relative;
}
.t-skel-skeleton {
filter: blur(0);
opacity: 1;
transition:
opacity var(--skel-reveal-dur) var(--skel-reveal-ease),
filter var(--skel-reveal-dur) var(--skel-reveal-ease);
z-index: 1;
}
.t-skel-content {
filter: blur(var(--skel-reveal-blur));
opacity: 0;
pointer-events: none;
transition:
opacity var(--skel-reveal-dur) var(--skel-reveal-ease),
filter var(--skel-reveal-dur) var(--skel-reveal-ease);
z-index: 2;
}
.t-skel.is-revealed .t-skel-skeleton {
filter: blur(var(--skel-reveal-blur));
opacity: 0;
pointer-events: none;
}
.t-skel.is-revealed .t-skel-content {
filter: blur(0);
opacity: 1;
pointer-events: auto;
}
.t-skel.is-resetting .t-skel-skeleton,
.t-skel.is-resetting .t-skel-content {
transition: none;
}
.t-skel-bar.is-pulsing,
.t-skel-skeleton.is-pulsing > :not(:has(.t-skel-bar)) {
animation: t-skel-pulse var(--skel-pulse-dur) ease-in-out
var(--skel-pulse-count);
}
.t-skel.is-revealed .t-skel-bar.is-pulsing,
.t-skel.is-revealed .t-skel-skeleton.is-pulsing > :not(:has(.t-skel-bar)) {
animation: none;
}
@keyframes t-skel-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: var(--skel-pulse-min);
}
}
@media (prefers-reduced-motion: reduce) {
.t-skel-skeleton,
.t-skel-content {
transition: none;
}
.t-skel-bar.is-pulsing,
.t-skel-skeleton.is-pulsing > :not(:has(.t-skel-bar)) {
animation: none;
}
}
@layer components { @layer components {
.buzz-startup-shell { .buzz-startup-shell {
min-height: 100dvh; min-height: 100dvh;

View File

@@ -1,15 +1,99 @@
import * as React from "react";
import { cn } from "@/shared/lib/cn"; import { cn } from "@/shared/lib/cn";
function Skeleton({ type SkeletonProps = React.HTMLAttributes<HTMLDivElement> & {
className, pulsing?: boolean;
...props };
}: React.HTMLAttributes<HTMLDivElement>) {
function Skeleton({ className, pulsing = true, ...props }: SkeletonProps) {
return ( return (
<div <div
className={cn("animate-pulse rounded-md bg-primary/10", className)} aria-hidden="true"
className={cn(
"t-skel-bar rounded-md bg-primary/10",
pulsing && "is-pulsing",
className,
)}
{...props} {...props}
/> />
); );
} }
export { Skeleton }; type SkeletonRevealProps = React.HTMLAttributes<HTMLDivElement> & {
contentClassName?: string;
layout?: "absolute" | "flow";
loading: boolean;
skeleton: React.ReactNode;
skeletonClassName?: string;
};
function SkeletonReveal({
children,
className,
contentClassName,
layout = "flow",
loading,
skeleton,
skeletonClassName,
...props
}: SkeletonRevealProps) {
const rootRef = React.useRef<HTMLDivElement>(null);
const previousLoadingRef = React.useRef(loading);
const [isResetting, setIsResetting] = React.useState(false);
React.useLayoutEffect(() => {
const wasLoading = previousLoadingRef.current;
previousLoadingRef.current = loading;
if (!loading || wasLoading) return;
setIsResetting(true);
rootRef.current?.getBoundingClientRect();
const reset = () => setIsResetting(false);
const frameId = globalThis.requestAnimationFrame
? globalThis.requestAnimationFrame(reset)
: globalThis.setTimeout(reset, 0);
return () => {
if (typeof frameId === "number") {
if (globalThis.cancelAnimationFrame) {
globalThis.cancelAnimationFrame(frameId);
} else {
globalThis.clearTimeout(frameId);
}
}
};
}, [loading]);
return (
<div
className={cn(
"t-skel",
!loading && "is-revealed",
isResetting && "is-resetting",
className,
)}
data-layout={layout}
data-state={loading ? "loading" : "loaded"}
ref={rootRef}
{...props}
>
<div
aria-hidden="true"
className={cn("t-skel-skeleton is-pulsing", skeletonClassName)}
>
{skeleton}
</div>
<div
aria-hidden={loading}
className={cn("t-skel-content", contentClassName)}
>
{children}
</div>
</div>
);
}
export { Skeleton, SkeletonReveal };

View File

@@ -1,26 +1,40 @@
import { Loader2 } from "lucide-react"; import type * as React from "react";
import { cn } from "@/shared/lib/cn"; import { cn } from "@/shared/lib/cn";
type SpinnerProps = React.ComponentPropsWithoutRef<"svg"> & { type SpinnerProps = React.ComponentPropsWithoutRef<"span"> & {
className?: string; className?: string;
size?: number; size?: number | string;
}; };
export function Spinner({ export function Spinner({
children,
className, className,
size, size,
role = "status", role = "status",
"aria-label": ariaLabel = "Loading", "aria-label": ariaLabel = "Loading",
"aria-hidden": ariaHidden,
style,
...rest ...rest
}: SpinnerProps) { }: SpinnerProps) {
const isDecorative = ariaHidden === true || ariaHidden === "true";
return ( return (
<Loader2 <span
className={cn("animate-spin", className)} aria-hidden={ariaHidden}
size={size} className={cn(
role={role} "sprout-arc-spinner inline-block h-6 w-6 shrink-0 rounded-full border-4 border-current/10 border-t-current",
aria-label={ariaLabel} className,
)}
role={isDecorative ? undefined : role}
style={{
...(size === undefined ? null : { height: size, width: size }),
...style,
}}
{...rest} {...rest}
/> >
{children}
{isDecorative ? null : <span className="sr-only">{ariaLabel}</span>}
</span>
); );
} }