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
+125
View File
@@ -2,6 +2,16 @@
@import "tw-animate-css";
@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 {
align-items: stretch;
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 {
.buzz-startup-shell {
min-height: 100dvh;
+90 -6
View File
@@ -1,15 +1,99 @@
import * as React from "react";
import { cn } from "@/shared/lib/cn";
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
type SkeletonProps = React.HTMLAttributes<HTMLDivElement> & {
pulsing?: boolean;
};
function Skeleton({ className, pulsing = true, ...props }: SkeletonProps) {
return (
<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}
/>
);
}
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 };
+23 -9
View File
@@ -1,26 +1,40 @@
import { Loader2 } from "lucide-react";
import type * as React from "react";
import { cn } from "@/shared/lib/cn";
type SpinnerProps = React.ComponentPropsWithoutRef<"svg"> & {
type SpinnerProps = React.ComponentPropsWithoutRef<"span"> & {
className?: string;
size?: number;
size?: number | string;
};
export function Spinner({
children,
className,
size,
role = "status",
"aria-label": ariaLabel = "Loading",
"aria-hidden": ariaHidden,
style,
...rest
}: SpinnerProps) {
const isDecorative = ariaHidden === true || ariaHidden === "true";
return (
<Loader2
className={cn("animate-spin", className)}
size={size}
role={role}
aria-label={ariaLabel}
<span
aria-hidden={ariaHidden}
className={cn(
"sprout-arc-spinner inline-block h-6 w-6 shrink-0 rounded-full border-4 border-current/10 border-t-current",
className,
)}
role={isDecorative ? undefined : role}
style={{
...(size === undefined ? null : { height: size, width: size }),
...style,
}}
{...rest}
/>
>
{children}
{isDecorative ? null : <span className="sr-only">{ariaLabel}</span>}
</span>
);
}