diff --git a/app/components/Skeleton/Skeleton.module.css b/app/components/Skeleton/Skeleton.module.css new file mode 100644 index 0000000..c17a8f7 --- /dev/null +++ b/app/components/Skeleton/Skeleton.module.css @@ -0,0 +1,63 @@ +.skeleton { + display: block; + position: relative; + overflow: hidden; + background-color: var(--skeleton-base, rgba(15, 23, 42, 0.08)); + border-radius: var(--radius-sm, 6px); +} + +.skeleton::after { + content: ""; + position: absolute; + inset: 0; + transform: translateX(-100%); + background: linear-gradient( + 90deg, + transparent, + var(--skeleton-sheen, rgba(255, 255, 255, 0.45)), + transparent + ); + animation: skeleton-shimmer 1.4s ease-in-out infinite; +} + +.text { border-radius: var(--radius-sm, 6px); } +.rect { border-radius: var(--radius-md, 10px); } +.circle { border-radius: 50%; } + +@keyframes skeleton-shimmer { + 100% { transform: translateX(100%); } +} + +:global([data-theme="dark"]) .skeleton { + background-color: var(--skeleton-base-dark, rgba(255, 255, 255, 0.09)); +} +:global([data-theme="dark"]) .skeleton::after { + background: linear-gradient( + 90deg, + transparent, + var(--skeleton-sheen-dark, rgba(255, 255, 255, 0.13)), + transparent + ); +} + +@media (prefers-color-scheme: dark) { + :global(:root:not([data-theme="light"])) .skeleton { + background-color: var(--skeleton-base-dark, rgba(255, 255, 255, 0.09)); + } + :global(:root:not([data-theme="light"])) .skeleton::after { + background: linear-gradient( + 90deg, transparent, + var(--skeleton-sheen-dark, rgba(255, 255, 255, 0.13)), transparent + ); + } +} + +@media (prefers-reduced-motion: reduce) { + .skeleton::after { animation: none; } + .skeleton { animation: skeleton-pulse 1.6s ease-in-out infinite; } +} + +@keyframes skeleton-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.55; } +} diff --git a/app/components/Skeleton/Skeleton.tsx b/app/components/Skeleton/Skeleton.tsx new file mode 100644 index 0000000..710b679 --- /dev/null +++ b/app/components/Skeleton/Skeleton.tsx @@ -0,0 +1,56 @@ +import styles from "./Skeleton.module.css"; + +type SkeletonVariant = "text" | "rect" | "circle"; + +type SkeletonProps = { + /** Width as a CSS value. Numbers are treated as px. Defaults to 100%. */ + width?: string | number; + /** Height as a CSS value. Numbers are treated as px. Defaults to 1rem. */ + height?: string | number; + /** Visual shape of the placeholder. */ + variant?: SkeletonVariant; + /** Optional border-radius override (CSS value / px number). */ + radius?: string | number; + /** Extra class names to merge with the component styles. */ + className?: string; +}; + +const toCssSize = (value?: string | number) => + typeof value === "number" ? `${value}px` : value; + +/** + * Skeleton — a lightweight, theme-aware loading placeholder. + * + * Use it while data is loading to reduce layout shift and improve the + * perceived performance of data-heavy views (tables, cards, charts). + * + * @example + * + * + * + */ +export function Skeleton({ + width = "100%", + height = "1rem", + variant = "text", + radius, + className, +}: SkeletonProps) { + return ( +