react-loaded
v0.2.0
Published
Zero-layout-shift skeleton screens for React
Maintainers
Readme
React Loaded
Loading should feel loaded before it actually loads.
Smart skeleton screens that mirror your actual components. No layout shift. No visual jumps.
The Problem
Traditional skeleton screens create a disconnect between loading and loaded states:
- Generic gray boxes do not match your actual UI
- Lists show arbitrary counts (3 skeletons -> 47 items = jarring jump)
- Building custom skeletons for every component is tedious and fragile
The Solution
React Loaded renders your real components in "skeleton mode" using CSS masking. The skeleton is your component, just with content hidden. This guarantees:
- Zero layout shift between loading and loaded states
- Pixel-perfect structure that matches the final render
- Persistent list counts that remember how many items to show
Installation
pnpm add react-loadedRequired: import the stylesheet once in your app:
import "react-loaded/style.css";Quick Start
Single Component
import { SmartSkeleton } from "react-loaded";
function UserProfile({ userId }) {
const { data: user, isLoading } = useQuery(["user", userId], fetchUser);
return (
<SmartSkeleton
loading={isLoading}
element={<ProfileCard user={{ name: "Loading...", avatar: "" }} />}
>
<ProfileCard user={user} />
</SmartSkeleton>
);
}Or with conditional rendering:
if (isLoading) {
return (
<SmartSkeleton
loading
element={<ProfileCard user={{ name: "Loading...", avatar: "" }} />}
/>
);
}
return <ProfileCard user={user} />;Lists with Persistence
import { SmartSkeletonList } from "react-loaded";
function ProductList() {
const { data: products, isLoading } = useQuery(["products"], fetchProducts);
return (
<SmartSkeletonList
loading={isLoading}
items={products}
storageKey="product-list"
defaultCount={6}
renderItem={(product) => <ProductCard product={product} />}
renderSkeleton={(index) => (
<ProductCard product={{ id: index, name: "Product", price: 0 }} />
)}
keyExtractor={(product) => product.id}
/>
);
}How persistence works:
- First visit: shows
defaultCountskeletons (6) - Data loads: renders 42 products, saves count to localStorage
- Next visit: shows 42 skeletons -> loads 42 products -> no jump
API Reference
<SmartSkeleton>
Wraps a single component to display it in skeleton mode while loading.
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| element | ReactElement | required | The skeleton version with mock or placeholder data |
| children | ReactElement | - | The real content when loaded. Returns null if omitted |
| loading | boolean | false | Whether to show the skeleton |
| animate | boolean | true | Enable shimmer animation |
| variant | "filled" \| "ghost" | "filled" | Skeleton background style (ghost disables wrapper/card background) |
| className | string | - | Additional CSS classes |
| seed | string \| number | - | Stable seed for text width randomness |
| suppressRefWarning | boolean | false | Suppress console warning when auto-wrapper is needed |
<SmartSkeletonList>
Renders a list with skeleton placeholders and optional count persistence.
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| items | T[] | undefined | required | Array of items, undefined while loading |
| renderItem | (item: T, index: number) => ReactElement | required | Render function for loaded items |
| renderSkeleton | (index: number) => ReactElement | required | Render function for skeleton placeholders |
| loading | boolean | false | Whether to show skeletons |
| storageKey | string | - | localStorage key for count persistence |
| defaultCount | number | 3 | Initial skeleton count |
| minCount | number | 1 | Minimum skeletons to display |
| maxCount | number | - | Maximum skeletons to display |
| animate | boolean | true | Enable shimmer animation |
| variant | "filled" \| "ghost" | "filled" | Skeleton background style for each list placeholder |
| seed | string \| number | - | Stable seed for text width randomness |
| suppressRefWarning | boolean | false | Suppress console warning when auto-wrapper is needed |
| keyExtractor | (item: T, index: number) => string | number | index | Extract unique key for each item |
useIsSkeletonMode()
Hook to detect if a component is rendered inside a skeleton.
import { useIsSkeletonMode } from "react-loaded";
function Avatar({ src }) {
const isSkeleton = useIsSkeletonMode();
// Skip expensive operations during skeleton render
if (isSkeleton) {
return <div className="avatar-placeholder" />;
}
return <img src={src} onLoad={trackAnalytics} />;
}usePersistedCount()
Low-level hook for custom persistence logic.
import { usePersistedCount } from "react-loaded";
const count = usePersistedCount({
storageKey: "my-list",
defaultCount: 5,
currentCount: items?.length,
loading: isLoading,
minCount: 1,
maxCount: 20,
});Customization
Override CSS custom properties to match your design system:
:root {
--loaded-bg-wrapper: rgba(229, 231, 235, 1); /* Skeleton background */
--loaded-bg-content: rgba(156, 163, 175, 0.6); /* Content block color */
--loaded-border-radius: 4px; /* Border radius */
--loaded-text-inset: 0.3em; /* Text bar vertical padding */
}Dark Mode Example
@media (prefers-color-scheme: dark) {
:root {
--loaded-bg-wrapper: rgba(55, 65, 81, 1);
--loaded-bg-content: rgba(107, 114, 128, 0.6);
}
}How It Works
- Render phase: Your component renders with mock data
- CSS masking: Text becomes transparent, backgrounds neutralized
- Visual overlay: Skeleton bars appear over text, media gets placeholder backgrounds
- Transition: When
loadingbecomesfalse, your real component renders in place
SSR note: React Loaded is primarily designed for client-side loading states (navigation/refetch).
If you render skeletons during SSR, the full overlay (text widths, media/content classes) is applied on the client via refs.
For best SSR results, ensure your skeleton element forwards className and ref to a DOM node.
The skeleton preserves:
- Exact dimensions and spacing
- Text alignment (left, center, right)
- Responsive behavior
- Component hierarchy
Ref Handling
React Loaded supports both React ref models:
- React 19+:
refcan be passed as a regular prop. - React 18: function components should use
forwardRef.
For best rendering, your skeleton element should expose a DOM ref. If it does not, React Loaded automatically wraps it in a div and logs a development warning.
To suppress the warning:
<SmartSkeleton
suppressRefWarning
element={<ThirdPartyComponent />}
/>Or better, wrap third-party components so a DOM ref is always available:
const WrappedComponent = forwardRef((props, ref) => (
<div ref={ref}>
<ThirdPartyComponent {...props} />
</div>
));Stable Text Widths with seed
By default, skeleton text bars have slightly randomized widths to look more natural. If you need consistent widths across renders (useful for tests or SSR hydration), pass a seed:
<SmartSkeleton
loading={isLoading}
seed="user-profile"
element={<ProfileCard user={mockUser} />}
>
<ProfileCard user={user} />
</SmartSkeleton>The same seed always produces the same text widths, making skeleton output deterministic.
Notes
- React 18 and 19 are supported.
- Persistence uses
localStorageunder the root keyreact-loadedwith a versioned schema. - In skeleton mode, the library applies CSS classes on the subtree. Components should accept
classNameand expose a usable ref (React 19refprop or React 18forwardRef). - Dev warnings are enabled when
NODE_ENV !== "production". If your environment doesn’t injectNODE_ENV, you can force them withglobalThis.__REACT_LOADED_DEV__ = true. - SSR: the library uses an isomorphic layout effect to avoid server warnings and keep hydration stable.
- JSR/Deno: CSS module imports aren’t supported. For Node/bundlers, import
react-loaded/style.css. For Deno, you’ll need to copy the CSS into your app (or recreate the styles).
License
MIT
