Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions app/components/Skeleton/Skeleton.module.css
Original file line number Diff line number Diff line change
@@ -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; }
}
56 changes: 56 additions & 0 deletions app/components/Skeleton/Skeleton.tsx
Original file line number Diff line number Diff line change
@@ -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
* <Skeleton width={180} height={20} />
* <Skeleton variant="circle" width={40} />
* <Skeleton variant="rect" height={120} radius={12} />
*/
export function Skeleton({
width = "100%",
height = "1rem",
variant = "text",
radius,
className,
}: SkeletonProps) {
return (
<span
className={[styles.skeleton, styles[variant], className]
.filter(Boolean)
.join(" ")}
style={{
width: toCssSize(width),
// A circle should be a perfect round, so height tracks width.
height: variant === "circle" ? toCssSize(width) : toCssSize(height),
...(radius != null ? { borderRadius: toCssSize(radius) } : {}),
}}
// Purely decorative: keep it out of the accessibility tree.
aria-hidden="true"
/>
);
}

export default Skeleton;
Loading