react-streaming-skeletons 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +248 -0
- package/dist/index.d.mts +54 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.js +184 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +177 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +63 -0
package/README.md
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# react-streaming-skeletons
|
|
2
|
+
|
|
3
|
+
Zero-layout-shift skeleton components for React Suspense streaming and Next.js App Router.
|
|
4
|
+
|
|
5
|
+
## The Problem
|
|
6
|
+
|
|
7
|
+
When you stream Server Components with `<Suspense>`, a mismatched fallback causes the page to **jump** when content loads — hurting your Core Web Vitals CLS score.
|
|
8
|
+
|
|
9
|
+
```tsx
|
|
10
|
+
// Before — causes layout shift
|
|
11
|
+
<Suspense fallback={<div className="h-4 bg-gray-200" />}> {/* 16px */}
|
|
12
|
+
<UserProfile /> {/* 340px */}
|
|
13
|
+
</Suspense>
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
This library gives you primitives to build dimension-matched skeletons, plus a **dev-mode warning** when your skeleton and real content heights diverge.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install react-streaming-skeletons
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
React 18+ and react-dom 18+ are required as peer dependencies.
|
|
25
|
+
|
|
26
|
+
## Quick Start
|
|
27
|
+
|
|
28
|
+
```tsx
|
|
29
|
+
import { Bone, SkeletonBoundary, defineSkeleton } from 'react-streaming-skeletons'
|
|
30
|
+
|
|
31
|
+
// 1. Define a skeleton co-located with your real component
|
|
32
|
+
export function UserCard({ user }) {
|
|
33
|
+
return (
|
|
34
|
+
<div className="flex gap-3 p-4">
|
|
35
|
+
<img src={user.avatar} className="w-10 h-10 rounded-full" />
|
|
36
|
+
<div>
|
|
37
|
+
<h2>{user.name}</h2>
|
|
38
|
+
<p>{user.bio}</p>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const UserCardSkeleton = defineSkeleton(UserCard, () => (
|
|
45
|
+
<div className="flex gap-3 p-4">
|
|
46
|
+
<Bone circle width={40} height={40} />
|
|
47
|
+
<div>
|
|
48
|
+
<Bone width={120} height={20} />
|
|
49
|
+
<Bone width={200} height={16} style={{ marginTop: 6 }} />
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
))
|
|
53
|
+
|
|
54
|
+
// 2. Use SkeletonBoundary instead of raw Suspense
|
|
55
|
+
export default function Page() {
|
|
56
|
+
return (
|
|
57
|
+
<SkeletonBoundary fallback={<UserCardSkeleton />}>
|
|
58
|
+
<UserCard />
|
|
59
|
+
</SkeletonBoundary>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Next.js App Router
|
|
65
|
+
|
|
66
|
+
`SkeletonBoundary` works directly in Server Component pages. Your async Server Component is passed as `children` — it retains its server nature.
|
|
67
|
+
|
|
68
|
+
```tsx
|
|
69
|
+
// app/dashboard/page.tsx (Server Component)
|
|
70
|
+
import { SkeletonBoundary } from 'react-streaming-skeletons'
|
|
71
|
+
import { StatsCard, StatsCardSkeleton } from '@/components/StatsCard'
|
|
72
|
+
|
|
73
|
+
export default function DashboardPage() {
|
|
74
|
+
return (
|
|
75
|
+
<main>
|
|
76
|
+
<h1>Dashboard</h1>
|
|
77
|
+
|
|
78
|
+
<SkeletonBoundary fallback={<StatsCardSkeleton />}>
|
|
79
|
+
<StatsCard /> {/* async Server Component — fetches from DB */}
|
|
80
|
+
</SkeletonBoundary>
|
|
81
|
+
</main>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```tsx
|
|
87
|
+
// components/StatsCard.tsx (async Server Component)
|
|
88
|
+
async function StatsCard() {
|
|
89
|
+
const stats = await fetchStats() // streamed from server
|
|
90
|
+
return <div>{stats.revenue}</div>
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## API
|
|
95
|
+
|
|
96
|
+
### `<Bone>`
|
|
97
|
+
|
|
98
|
+
The core animated skeleton element.
|
|
99
|
+
|
|
100
|
+
```tsx
|
|
101
|
+
<Bone
|
|
102
|
+
width={200} // number (px) or string ("60%", "10rem"). Default: "100%"
|
|
103
|
+
height={20} // number (px) or string. Default: "1em"
|
|
104
|
+
circle // renders as a circle (border-radius: 50%)
|
|
105
|
+
rounded // renders as a pill (border-radius: 9999px)
|
|
106
|
+
count={3} // renders N stacked bones
|
|
107
|
+
inline // display: inline-block instead of block
|
|
108
|
+
className="..." // forwarded to the element
|
|
109
|
+
style={{}} // merged into inline styles
|
|
110
|
+
/>
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**Examples**
|
|
114
|
+
|
|
115
|
+
```tsx
|
|
116
|
+
// Avatar placeholder
|
|
117
|
+
<Bone circle width={48} height={48} />
|
|
118
|
+
|
|
119
|
+
// Text line
|
|
120
|
+
<Bone width="70%" height={16} />
|
|
121
|
+
|
|
122
|
+
// Paragraph (3 lines)
|
|
123
|
+
<Bone count={3} height={14} />
|
|
124
|
+
|
|
125
|
+
// Badge
|
|
126
|
+
<Bone rounded width={80} height={24} />
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
### `<SkeletonBoundary>`
|
|
132
|
+
|
|
133
|
+
A `<Suspense>` wrapper that shows `fallback` while children stream in.
|
|
134
|
+
|
|
135
|
+
```tsx
|
|
136
|
+
<SkeletonBoundary
|
|
137
|
+
fallback={<MySkeleton />}
|
|
138
|
+
clsThreshold={0.1} // dev-only: warn when height shifts > 10%. Default: 0.1
|
|
139
|
+
>
|
|
140
|
+
<AsyncServerComponent />
|
|
141
|
+
</SkeletonBoundary>
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
In **development mode**, `SkeletonBoundary` wraps its content in a `<div>` and uses `ResizeObserver` to detect when the resolved content height differs from the skeleton height by more than `clsThreshold`. A `console.warn` is printed with the exact pixel values so you can fix the mismatch. The wrapper div is **not rendered in production**.
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
### `<SkeletonProvider>`
|
|
149
|
+
|
|
150
|
+
Set a global theme for all `<Bone>` elements in the tree.
|
|
151
|
+
|
|
152
|
+
```tsx
|
|
153
|
+
<SkeletonProvider
|
|
154
|
+
theme={{
|
|
155
|
+
color: '#e2e8f0', // base bone colour. Default: "#e2e8f0"
|
|
156
|
+
highlight: '#f8fafc', // shimmer highlight colour. Default: "#f8fafc"
|
|
157
|
+
borderRadius: 4, // default radius (px or string). Default: 4
|
|
158
|
+
duration: 1.5, // shimmer animation duration in seconds. Default: 1.5
|
|
159
|
+
animationDirection: 'ltr', // "ltr" | "rtl". Default: "ltr"
|
|
160
|
+
enableAnimation: true, // set false to disable shimmer (e.g. prefers-reduced-motion). Default: true
|
|
161
|
+
}}
|
|
162
|
+
>
|
|
163
|
+
<App />
|
|
164
|
+
</SkeletonProvider>
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Theming is implemented with CSS custom properties (`--rss-color`, `--rss-highlight`, `--rss-duration`) so it has zero runtime overhead and respects nested overrides.
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
### `defineSkeleton(Component, renderFn)`
|
|
172
|
+
|
|
173
|
+
Links a skeleton to its real component so they stay co-located in the same file.
|
|
174
|
+
|
|
175
|
+
```tsx
|
|
176
|
+
export const UserCardSkeleton = defineSkeleton(UserCard, () => (
|
|
177
|
+
<div>
|
|
178
|
+
<Bone circle width={40} height={40} />
|
|
179
|
+
<Bone width="60%" height={20} />
|
|
180
|
+
</div>
|
|
181
|
+
))
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The returned component gets a `displayName` of `"<ComponentName>Skeleton"`, which makes it easy to identify in React DevTools.
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
### `useSkeletonTheme()`
|
|
189
|
+
|
|
190
|
+
Read the current theme values in a custom skeleton component.
|
|
191
|
+
|
|
192
|
+
```tsx
|
|
193
|
+
import { useSkeletonTheme } from 'react-streaming-skeletons'
|
|
194
|
+
|
|
195
|
+
function CustomBone() {
|
|
196
|
+
const { color, duration } = useSkeletonTheme()
|
|
197
|
+
// ...
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## Dark Mode
|
|
202
|
+
|
|
203
|
+
Wrap `SkeletonProvider` inside your theme toggle to switch bone colours:
|
|
204
|
+
|
|
205
|
+
```tsx
|
|
206
|
+
<SkeletonProvider
|
|
207
|
+
theme={{
|
|
208
|
+
color: isDark ? '#374151' : '#e5e7eb',
|
|
209
|
+
highlight: isDark ? '#4b5563' : '#f9fafb',
|
|
210
|
+
}}
|
|
211
|
+
>
|
|
212
|
+
{children}
|
|
213
|
+
</SkeletonProvider>
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## Accessibility
|
|
217
|
+
|
|
218
|
+
All `<Bone>` elements render with `aria-hidden="true"` so they are invisible to screen readers. Users who prefer reduced motion should disable the shimmer animation:
|
|
219
|
+
|
|
220
|
+
```tsx
|
|
221
|
+
const prefersReduced =
|
|
222
|
+
typeof window !== 'undefined' &&
|
|
223
|
+
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
224
|
+
|
|
225
|
+
<SkeletonProvider theme={{ enableAnimation: !prefersReduced }}>
|
|
226
|
+
<App />
|
|
227
|
+
</SkeletonProvider>
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## How the CLS Warning Works
|
|
231
|
+
|
|
232
|
+
In development, every `<SkeletonBoundary>` observes its container with `ResizeObserver`. The first measured height is treated as the skeleton height. When Suspense resolves and the container resizes, the new height is compared against the baseline. If the shift exceeds `clsThreshold` (default 10%), you'll see:
|
|
233
|
+
|
|
234
|
+
```
|
|
235
|
+
[react-streaming-skeletons] CLS risk detected!
|
|
236
|
+
Skeleton height : 32px
|
|
237
|
+
Content height : 280px
|
|
238
|
+
Shift : 775% (threshold: 10%)
|
|
239
|
+
Fix: match your <Bone height={...}> values to the resolved content dimensions.
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
## Bundle Size
|
|
243
|
+
|
|
244
|
+
~3 KB gzipped. Zero runtime dependencies.
|
|
245
|
+
|
|
246
|
+
## License
|
|
247
|
+
|
|
248
|
+
MIT
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
import { CSSProperties, ReactNode, ComponentType, ReactElement, FC } from 'react';
|
|
3
|
+
|
|
4
|
+
interface SkeletonTheme {
|
|
5
|
+
color: string;
|
|
6
|
+
highlight: string;
|
|
7
|
+
borderRadius: number | string;
|
|
8
|
+
duration: number;
|
|
9
|
+
animationDirection: 'ltr' | 'rtl';
|
|
10
|
+
enableAnimation: boolean;
|
|
11
|
+
}
|
|
12
|
+
interface BoneProps {
|
|
13
|
+
width?: number | string;
|
|
14
|
+
height?: number | string;
|
|
15
|
+
circle?: boolean;
|
|
16
|
+
rounded?: boolean;
|
|
17
|
+
className?: string;
|
|
18
|
+
style?: CSSProperties;
|
|
19
|
+
count?: number;
|
|
20
|
+
inline?: boolean;
|
|
21
|
+
}
|
|
22
|
+
interface SkeletonProviderProps {
|
|
23
|
+
theme?: Partial<SkeletonTheme>;
|
|
24
|
+
children: ReactNode;
|
|
25
|
+
}
|
|
26
|
+
interface SkeletonBoundaryProps {
|
|
27
|
+
fallback: ReactNode;
|
|
28
|
+
children: ReactNode;
|
|
29
|
+
/** Fraction (0–1) of height change that triggers a dev-mode CLS warning. Default: 0.1 */
|
|
30
|
+
clsThreshold?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
declare function Bone({ width, height, circle, rounded, className, style, count, inline, }: BoneProps): react_jsx_runtime.JSX.Element;
|
|
34
|
+
|
|
35
|
+
declare function SkeletonBoundary({ fallback, children, clsThreshold, }: SkeletonBoundaryProps): react_jsx_runtime.JSX.Element;
|
|
36
|
+
|
|
37
|
+
declare const defaultTheme: SkeletonTheme;
|
|
38
|
+
declare function useSkeletonTheme(): SkeletonTheme;
|
|
39
|
+
declare function SkeletonProvider({ theme, children }: SkeletonProviderProps): react_jsx_runtime.JSX.Element;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Co-locate a skeleton with its real component so the two stay in sync.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* export const UserCardSkeleton = defineSkeleton(UserCard, () => (
|
|
46
|
+
* <div>
|
|
47
|
+
* <Bone circle width={40} height={40} />
|
|
48
|
+
* <Bone width="60%" height={20} />
|
|
49
|
+
* </div>
|
|
50
|
+
* ))
|
|
51
|
+
*/
|
|
52
|
+
declare function defineSkeleton<P>(Component: ComponentType<P>, render: () => ReactElement): FC;
|
|
53
|
+
|
|
54
|
+
export { Bone, type BoneProps, SkeletonBoundary, type SkeletonBoundaryProps, SkeletonProvider, type SkeletonProviderProps, type SkeletonTheme, defaultTheme, defineSkeleton, useSkeletonTheme };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
import { CSSProperties, ReactNode, ComponentType, ReactElement, FC } from 'react';
|
|
3
|
+
|
|
4
|
+
interface SkeletonTheme {
|
|
5
|
+
color: string;
|
|
6
|
+
highlight: string;
|
|
7
|
+
borderRadius: number | string;
|
|
8
|
+
duration: number;
|
|
9
|
+
animationDirection: 'ltr' | 'rtl';
|
|
10
|
+
enableAnimation: boolean;
|
|
11
|
+
}
|
|
12
|
+
interface BoneProps {
|
|
13
|
+
width?: number | string;
|
|
14
|
+
height?: number | string;
|
|
15
|
+
circle?: boolean;
|
|
16
|
+
rounded?: boolean;
|
|
17
|
+
className?: string;
|
|
18
|
+
style?: CSSProperties;
|
|
19
|
+
count?: number;
|
|
20
|
+
inline?: boolean;
|
|
21
|
+
}
|
|
22
|
+
interface SkeletonProviderProps {
|
|
23
|
+
theme?: Partial<SkeletonTheme>;
|
|
24
|
+
children: ReactNode;
|
|
25
|
+
}
|
|
26
|
+
interface SkeletonBoundaryProps {
|
|
27
|
+
fallback: ReactNode;
|
|
28
|
+
children: ReactNode;
|
|
29
|
+
/** Fraction (0–1) of height change that triggers a dev-mode CLS warning. Default: 0.1 */
|
|
30
|
+
clsThreshold?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
declare function Bone({ width, height, circle, rounded, className, style, count, inline, }: BoneProps): react_jsx_runtime.JSX.Element;
|
|
34
|
+
|
|
35
|
+
declare function SkeletonBoundary({ fallback, children, clsThreshold, }: SkeletonBoundaryProps): react_jsx_runtime.JSX.Element;
|
|
36
|
+
|
|
37
|
+
declare const defaultTheme: SkeletonTheme;
|
|
38
|
+
declare function useSkeletonTheme(): SkeletonTheme;
|
|
39
|
+
declare function SkeletonProvider({ theme, children }: SkeletonProviderProps): react_jsx_runtime.JSX.Element;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Co-locate a skeleton with its real component so the two stay in sync.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* export const UserCardSkeleton = defineSkeleton(UserCard, () => (
|
|
46
|
+
* <div>
|
|
47
|
+
* <Bone circle width={40} height={40} />
|
|
48
|
+
* <Bone width="60%" height={20} />
|
|
49
|
+
* </div>
|
|
50
|
+
* ))
|
|
51
|
+
*/
|
|
52
|
+
declare function defineSkeleton<P>(Component: ComponentType<P>, render: () => ReactElement): FC;
|
|
53
|
+
|
|
54
|
+
export { Bone, type BoneProps, SkeletonBoundary, type SkeletonBoundaryProps, SkeletonProvider, type SkeletonProviderProps, type SkeletonTheme, defaultTheme, defineSkeleton, useSkeletonTheme };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
var react = require('react');
|
|
5
|
+
var jsxRuntime = require('react/jsx-runtime');
|
|
6
|
+
|
|
7
|
+
// src/components/Bone.tsx
|
|
8
|
+
|
|
9
|
+
// src/utils/injectStyles.ts
|
|
10
|
+
var CSS_ID = "rss-styles";
|
|
11
|
+
var SHIMMER_CSS = `
|
|
12
|
+
@keyframes rss-shimmer-ltr {
|
|
13
|
+
0% { background-position: -200px 0; }
|
|
14
|
+
100% { background-position: calc(200px + 100%) 0; }
|
|
15
|
+
}
|
|
16
|
+
@keyframes rss-shimmer-rtl {
|
|
17
|
+
0% { background-position: calc(200px + 100%) 0; }
|
|
18
|
+
100% { background-position: -200px 0; }
|
|
19
|
+
}
|
|
20
|
+
[data-rss-bone] {
|
|
21
|
+
display: inline-block;
|
|
22
|
+
line-height: 1;
|
|
23
|
+
background: linear-gradient(
|
|
24
|
+
90deg,
|
|
25
|
+
var(--rss-color, #e2e8f0) 25%,
|
|
26
|
+
var(--rss-highlight, #f8fafc) 50%,
|
|
27
|
+
var(--rss-color, #e2e8f0) 75%
|
|
28
|
+
);
|
|
29
|
+
background-size: 200px 100%;
|
|
30
|
+
animation: rss-shimmer-ltr var(--rss-duration, 1.5s) infinite linear;
|
|
31
|
+
}
|
|
32
|
+
[data-rss-direction="rtl"] [data-rss-bone] {
|
|
33
|
+
animation-name: rss-shimmer-rtl;
|
|
34
|
+
}
|
|
35
|
+
[data-rss-no-animation] [data-rss-bone] {
|
|
36
|
+
animation: none;
|
|
37
|
+
background: var(--rss-color, #e2e8f0);
|
|
38
|
+
}
|
|
39
|
+
`;
|
|
40
|
+
function ensureStylesInjected() {
|
|
41
|
+
if (typeof document === "undefined") return;
|
|
42
|
+
if (document.getElementById(CSS_ID)) return;
|
|
43
|
+
const style = document.createElement("style");
|
|
44
|
+
style.id = CSS_ID;
|
|
45
|
+
style.textContent = SHIMMER_CSS;
|
|
46
|
+
document.head.appendChild(style);
|
|
47
|
+
}
|
|
48
|
+
function Bone({
|
|
49
|
+
width,
|
|
50
|
+
height,
|
|
51
|
+
circle = false,
|
|
52
|
+
rounded = false,
|
|
53
|
+
className,
|
|
54
|
+
style,
|
|
55
|
+
count = 1,
|
|
56
|
+
inline = false
|
|
57
|
+
}) {
|
|
58
|
+
react.useEffect(() => {
|
|
59
|
+
ensureStylesInjected();
|
|
60
|
+
}, []);
|
|
61
|
+
const borderRadius = circle ? "50%" : rounded ? "9999px" : void 0;
|
|
62
|
+
const baseStyle = {
|
|
63
|
+
width: width != null ? width : "100%",
|
|
64
|
+
height: height != null ? height : "1em",
|
|
65
|
+
borderRadius,
|
|
66
|
+
display: inline ? "inline-block" : "block",
|
|
67
|
+
...style
|
|
68
|
+
};
|
|
69
|
+
if (count <= 1) {
|
|
70
|
+
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
71
|
+
"span",
|
|
72
|
+
{
|
|
73
|
+
"data-rss-bone": "",
|
|
74
|
+
className,
|
|
75
|
+
style: baseStyle,
|
|
76
|
+
"aria-hidden": "true"
|
|
77
|
+
}
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return /* @__PURE__ */ jsxRuntime.jsx(react.Fragment, { children: Array.from({ length: count }, (_, i) => /* @__PURE__ */ jsxRuntime.jsx(
|
|
81
|
+
"span",
|
|
82
|
+
{
|
|
83
|
+
"data-rss-bone": "",
|
|
84
|
+
className,
|
|
85
|
+
style: {
|
|
86
|
+
...baseStyle,
|
|
87
|
+
marginBottom: i < count - 1 ? "0.5em" : void 0
|
|
88
|
+
},
|
|
89
|
+
"aria-hidden": "true"
|
|
90
|
+
},
|
|
91
|
+
i
|
|
92
|
+
)) });
|
|
93
|
+
}
|
|
94
|
+
var isDev = process.env.NODE_ENV === "development";
|
|
95
|
+
function useCLSDetection(enabled, threshold) {
|
|
96
|
+
const containerRef = react.useRef(null);
|
|
97
|
+
const firstHeightRef = react.useRef(0);
|
|
98
|
+
const warnedRef = react.useRef(false);
|
|
99
|
+
react.useEffect(() => {
|
|
100
|
+
if (!enabled || !containerRef.current) return;
|
|
101
|
+
const el = containerRef.current;
|
|
102
|
+
firstHeightRef.current = el.getBoundingClientRect().height;
|
|
103
|
+
const observer = new ResizeObserver(() => {
|
|
104
|
+
if (warnedRef.current) return;
|
|
105
|
+
const current = el.getBoundingClientRect().height;
|
|
106
|
+
const baseline = firstHeightRef.current;
|
|
107
|
+
if (baseline === 0 || current === baseline) return;
|
|
108
|
+
const diff = Math.abs(current - baseline) / baseline;
|
|
109
|
+
if (diff > threshold) {
|
|
110
|
+
warnedRef.current = true;
|
|
111
|
+
console.warn(
|
|
112
|
+
`[react-streaming-skeletons] CLS risk detected!
|
|
113
|
+
Skeleton height : ${Math.round(baseline)}px
|
|
114
|
+
Content height : ${Math.round(current)}px
|
|
115
|
+
Shift : ${Math.round(diff * 100)}% (threshold: ${Math.round(threshold * 100)}%)
|
|
116
|
+
Fix: match your <Bone height={...}> values to the resolved content dimensions.`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
observer.observe(el);
|
|
121
|
+
return () => observer.disconnect();
|
|
122
|
+
}, [enabled, threshold]);
|
|
123
|
+
return containerRef;
|
|
124
|
+
}
|
|
125
|
+
function SkeletonBoundary({
|
|
126
|
+
fallback,
|
|
127
|
+
children,
|
|
128
|
+
clsThreshold = 0.1
|
|
129
|
+
}) {
|
|
130
|
+
const containerRef = useCLSDetection(isDev, clsThreshold);
|
|
131
|
+
if (isDev) {
|
|
132
|
+
return /* @__PURE__ */ jsxRuntime.jsx("div", { ref: containerRef, "data-rss-boundary": "", children: /* @__PURE__ */ jsxRuntime.jsx(react.Suspense, { fallback, children }) });
|
|
133
|
+
}
|
|
134
|
+
return /* @__PURE__ */ jsxRuntime.jsx(react.Suspense, { fallback, children });
|
|
135
|
+
}
|
|
136
|
+
var defaultTheme = {
|
|
137
|
+
color: "#e2e8f0",
|
|
138
|
+
highlight: "#f8fafc",
|
|
139
|
+
borderRadius: 4,
|
|
140
|
+
duration: 1.5,
|
|
141
|
+
animationDirection: "ltr",
|
|
142
|
+
enableAnimation: true
|
|
143
|
+
};
|
|
144
|
+
var SkeletonContext = react.createContext(defaultTheme);
|
|
145
|
+
function useSkeletonTheme() {
|
|
146
|
+
return react.useContext(SkeletonContext);
|
|
147
|
+
}
|
|
148
|
+
function SkeletonProvider({ theme, children }) {
|
|
149
|
+
const merged = { ...defaultTheme, ...theme };
|
|
150
|
+
return /* @__PURE__ */ jsxRuntime.jsx(SkeletonContext.Provider, { value: merged, children: /* @__PURE__ */ jsxRuntime.jsx(
|
|
151
|
+
"div",
|
|
152
|
+
{
|
|
153
|
+
"data-rss-provider": "",
|
|
154
|
+
"data-rss-direction": merged.animationDirection,
|
|
155
|
+
...!merged.enableAnimation ? { "data-rss-no-animation": "" } : {},
|
|
156
|
+
style: {
|
|
157
|
+
"--rss-color": merged.color,
|
|
158
|
+
"--rss-highlight": merged.highlight,
|
|
159
|
+
"--rss-duration": `${merged.duration}s`
|
|
160
|
+
},
|
|
161
|
+
children
|
|
162
|
+
}
|
|
163
|
+
) });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// src/utils/defineSkeleton.ts
|
|
167
|
+
function defineSkeleton(Component, render) {
|
|
168
|
+
var _a, _b;
|
|
169
|
+
const displayName = (_b = (_a = Component.displayName) != null ? _a : Component.name) != null ? _b : "Component";
|
|
170
|
+
function SkeletonComponent() {
|
|
171
|
+
return render();
|
|
172
|
+
}
|
|
173
|
+
SkeletonComponent.displayName = `${displayName}Skeleton`;
|
|
174
|
+
return SkeletonComponent;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
exports.Bone = Bone;
|
|
178
|
+
exports.SkeletonBoundary = SkeletonBoundary;
|
|
179
|
+
exports.SkeletonProvider = SkeletonProvider;
|
|
180
|
+
exports.defaultTheme = defaultTheme;
|
|
181
|
+
exports.defineSkeleton = defineSkeleton;
|
|
182
|
+
exports.useSkeletonTheme = useSkeletonTheme;
|
|
183
|
+
//# sourceMappingURL=index.js.map
|
|
184
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/utils/injectStyles.ts","../src/components/Bone.tsx","../src/components/SkeletonBoundary.tsx","../src/components/SkeletonProvider.tsx","../src/utils/defineSkeleton.ts"],"names":["useEffect","jsx","Fragment","useRef","Suspense","createContext","useContext"],"mappings":"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","file":"index.js","sourcesContent":["const CSS_ID = 'rss-styles'\n\nconst SHIMMER_CSS = `\n@keyframes rss-shimmer-ltr {\n 0% { background-position: -200px 0; }\n 100% { background-position: calc(200px + 100%) 0; }\n}\n@keyframes rss-shimmer-rtl {\n 0% { background-position: calc(200px + 100%) 0; }\n 100% { background-position: -200px 0; }\n}\n[data-rss-bone] {\n display: inline-block;\n line-height: 1;\n background: linear-gradient(\n 90deg,\n var(--rss-color, #e2e8f0) 25%,\n var(--rss-highlight, #f8fafc) 50%,\n var(--rss-color, #e2e8f0) 75%\n );\n background-size: 200px 100%;\n animation: rss-shimmer-ltr var(--rss-duration, 1.5s) infinite linear;\n}\n[data-rss-direction=\"rtl\"] [data-rss-bone] {\n animation-name: rss-shimmer-rtl;\n}\n[data-rss-no-animation] [data-rss-bone] {\n animation: none;\n background: var(--rss-color, #e2e8f0);\n}\n`\n\nexport function ensureStylesInjected(): void {\n if (typeof document === 'undefined') return\n if (document.getElementById(CSS_ID)) return\n\n const style = document.createElement('style')\n style.id = CSS_ID\n style.textContent = SHIMMER_CSS\n document.head.appendChild(style)\n}\n","'use client'\n\nimport React, { useEffect, Fragment } from 'react'\nimport { ensureStylesInjected } from '../utils/injectStyles'\nimport type { BoneProps } from '../types'\n\nexport function Bone({\n width,\n height,\n circle = false,\n rounded = false,\n className,\n style,\n count = 1,\n inline = false,\n}: BoneProps) {\n useEffect(() => {\n ensureStylesInjected()\n }, [])\n\n const borderRadius: string | undefined =\n circle ? '50%' : rounded ? '9999px' : undefined\n\n const baseStyle: React.CSSProperties = {\n width: width ?? '100%',\n height: height ?? '1em',\n borderRadius,\n display: inline ? 'inline-block' : 'block',\n ...style,\n }\n\n if (count <= 1) {\n return (\n <span\n data-rss-bone=\"\"\n className={className}\n style={baseStyle}\n aria-hidden=\"true\"\n />\n )\n }\n\n return (\n <Fragment>\n {Array.from({ length: count }, (_, i) => (\n <span\n key={i}\n data-rss-bone=\"\"\n className={className}\n style={{\n ...baseStyle,\n marginBottom: i < count - 1 ? '0.5em' : undefined,\n }}\n aria-hidden=\"true\"\n />\n ))}\n </Fragment>\n )\n}\n","'use client'\n\nimport React, { Suspense, useRef, useEffect } from 'react'\nimport type { SkeletonBoundaryProps } from '../types'\n\nconst isDev = process.env.NODE_ENV === 'development'\n\nfunction useCLSDetection(enabled: boolean, threshold: number) {\n const containerRef = useRef<HTMLDivElement>(null)\n const firstHeightRef = useRef(0)\n const warnedRef = useRef(false)\n\n useEffect(() => {\n if (!enabled || !containerRef.current) return\n\n const el = containerRef.current\n firstHeightRef.current = el.getBoundingClientRect().height\n\n const observer = new ResizeObserver(() => {\n if (warnedRef.current) return\n\n const current = el.getBoundingClientRect().height\n const baseline = firstHeightRef.current\n\n if (baseline === 0 || current === baseline) return\n\n const diff = Math.abs(current - baseline) / baseline\n if (diff > threshold) {\n warnedRef.current = true\n console.warn(\n `[react-streaming-skeletons] CLS risk detected!\\n` +\n ` Skeleton height : ${Math.round(baseline)}px\\n` +\n ` Content height : ${Math.round(current)}px\\n` +\n ` Shift : ${Math.round(diff * 100)}% (threshold: ${Math.round(threshold * 100)}%)\\n` +\n ` Fix: match your <Bone height={...}> values to the resolved content dimensions.`,\n )\n }\n })\n\n observer.observe(el)\n return () => observer.disconnect()\n }, [enabled, threshold])\n\n return containerRef\n}\n\nexport function SkeletonBoundary({\n fallback,\n children,\n clsThreshold = 0.1,\n}: SkeletonBoundaryProps) {\n const containerRef = useCLSDetection(isDev, clsThreshold)\n\n if (isDev) {\n return (\n <div ref={containerRef} data-rss-boundary=\"\">\n <Suspense fallback={fallback}>{children}</Suspense>\n </div>\n )\n }\n\n return <Suspense fallback={fallback}>{children}</Suspense>\n}\n","'use client'\n\nimport React, { createContext, useContext } from 'react'\nimport type { SkeletonTheme, SkeletonProviderProps } from '../types'\n\nexport const defaultTheme: SkeletonTheme = {\n color: '#e2e8f0',\n highlight: '#f8fafc',\n borderRadius: 4,\n duration: 1.5,\n animationDirection: 'ltr',\n enableAnimation: true,\n}\n\nconst SkeletonContext = createContext<SkeletonTheme>(defaultTheme)\n\nexport function useSkeletonTheme(): SkeletonTheme {\n return useContext(SkeletonContext)\n}\n\nexport function SkeletonProvider({ theme, children }: SkeletonProviderProps) {\n const merged: SkeletonTheme = { ...defaultTheme, ...theme }\n\n return (\n <SkeletonContext.Provider value={merged}>\n <div\n data-rss-provider=\"\"\n data-rss-direction={merged.animationDirection}\n {...(!merged.enableAnimation ? { 'data-rss-no-animation': '' } : {})}\n style={\n {\n '--rss-color': merged.color,\n '--rss-highlight': merged.highlight,\n '--rss-duration': `${merged.duration}s`,\n } as React.CSSProperties\n }\n >\n {children}\n </div>\n </SkeletonContext.Provider>\n )\n}\n","import type { ComponentType, FC, ReactElement } from 'react'\n\n/**\n * Co-locate a skeleton with its real component so the two stay in sync.\n *\n * @example\n * export const UserCardSkeleton = defineSkeleton(UserCard, () => (\n * <div>\n * <Bone circle width={40} height={40} />\n * <Bone width=\"60%\" height={20} />\n * </div>\n * ))\n */\nexport function defineSkeleton<P>(\n Component: ComponentType<P>,\n render: () => ReactElement,\n): FC {\n const displayName = Component.displayName ?? Component.name ?? 'Component'\n\n function SkeletonComponent() {\n return render()\n }\n\n SkeletonComponent.displayName = `${displayName}Skeleton`\n return SkeletonComponent\n}\n"]}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { createContext, useEffect, Fragment, Suspense, useContext, useRef } from 'react';
|
|
3
|
+
import { jsx } from 'react/jsx-runtime';
|
|
4
|
+
|
|
5
|
+
// src/components/Bone.tsx
|
|
6
|
+
|
|
7
|
+
// src/utils/injectStyles.ts
|
|
8
|
+
var CSS_ID = "rss-styles";
|
|
9
|
+
var SHIMMER_CSS = `
|
|
10
|
+
@keyframes rss-shimmer-ltr {
|
|
11
|
+
0% { background-position: -200px 0; }
|
|
12
|
+
100% { background-position: calc(200px + 100%) 0; }
|
|
13
|
+
}
|
|
14
|
+
@keyframes rss-shimmer-rtl {
|
|
15
|
+
0% { background-position: calc(200px + 100%) 0; }
|
|
16
|
+
100% { background-position: -200px 0; }
|
|
17
|
+
}
|
|
18
|
+
[data-rss-bone] {
|
|
19
|
+
display: inline-block;
|
|
20
|
+
line-height: 1;
|
|
21
|
+
background: linear-gradient(
|
|
22
|
+
90deg,
|
|
23
|
+
var(--rss-color, #e2e8f0) 25%,
|
|
24
|
+
var(--rss-highlight, #f8fafc) 50%,
|
|
25
|
+
var(--rss-color, #e2e8f0) 75%
|
|
26
|
+
);
|
|
27
|
+
background-size: 200px 100%;
|
|
28
|
+
animation: rss-shimmer-ltr var(--rss-duration, 1.5s) infinite linear;
|
|
29
|
+
}
|
|
30
|
+
[data-rss-direction="rtl"] [data-rss-bone] {
|
|
31
|
+
animation-name: rss-shimmer-rtl;
|
|
32
|
+
}
|
|
33
|
+
[data-rss-no-animation] [data-rss-bone] {
|
|
34
|
+
animation: none;
|
|
35
|
+
background: var(--rss-color, #e2e8f0);
|
|
36
|
+
}
|
|
37
|
+
`;
|
|
38
|
+
function ensureStylesInjected() {
|
|
39
|
+
if (typeof document === "undefined") return;
|
|
40
|
+
if (document.getElementById(CSS_ID)) return;
|
|
41
|
+
const style = document.createElement("style");
|
|
42
|
+
style.id = CSS_ID;
|
|
43
|
+
style.textContent = SHIMMER_CSS;
|
|
44
|
+
document.head.appendChild(style);
|
|
45
|
+
}
|
|
46
|
+
function Bone({
|
|
47
|
+
width,
|
|
48
|
+
height,
|
|
49
|
+
circle = false,
|
|
50
|
+
rounded = false,
|
|
51
|
+
className,
|
|
52
|
+
style,
|
|
53
|
+
count = 1,
|
|
54
|
+
inline = false
|
|
55
|
+
}) {
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
ensureStylesInjected();
|
|
58
|
+
}, []);
|
|
59
|
+
const borderRadius = circle ? "50%" : rounded ? "9999px" : void 0;
|
|
60
|
+
const baseStyle = {
|
|
61
|
+
width: width != null ? width : "100%",
|
|
62
|
+
height: height != null ? height : "1em",
|
|
63
|
+
borderRadius,
|
|
64
|
+
display: inline ? "inline-block" : "block",
|
|
65
|
+
...style
|
|
66
|
+
};
|
|
67
|
+
if (count <= 1) {
|
|
68
|
+
return /* @__PURE__ */ jsx(
|
|
69
|
+
"span",
|
|
70
|
+
{
|
|
71
|
+
"data-rss-bone": "",
|
|
72
|
+
className,
|
|
73
|
+
style: baseStyle,
|
|
74
|
+
"aria-hidden": "true"
|
|
75
|
+
}
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
return /* @__PURE__ */ jsx(Fragment, { children: Array.from({ length: count }, (_, i) => /* @__PURE__ */ jsx(
|
|
79
|
+
"span",
|
|
80
|
+
{
|
|
81
|
+
"data-rss-bone": "",
|
|
82
|
+
className,
|
|
83
|
+
style: {
|
|
84
|
+
...baseStyle,
|
|
85
|
+
marginBottom: i < count - 1 ? "0.5em" : void 0
|
|
86
|
+
},
|
|
87
|
+
"aria-hidden": "true"
|
|
88
|
+
},
|
|
89
|
+
i
|
|
90
|
+
)) });
|
|
91
|
+
}
|
|
92
|
+
var isDev = process.env.NODE_ENV === "development";
|
|
93
|
+
function useCLSDetection(enabled, threshold) {
|
|
94
|
+
const containerRef = useRef(null);
|
|
95
|
+
const firstHeightRef = useRef(0);
|
|
96
|
+
const warnedRef = useRef(false);
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (!enabled || !containerRef.current) return;
|
|
99
|
+
const el = containerRef.current;
|
|
100
|
+
firstHeightRef.current = el.getBoundingClientRect().height;
|
|
101
|
+
const observer = new ResizeObserver(() => {
|
|
102
|
+
if (warnedRef.current) return;
|
|
103
|
+
const current = el.getBoundingClientRect().height;
|
|
104
|
+
const baseline = firstHeightRef.current;
|
|
105
|
+
if (baseline === 0 || current === baseline) return;
|
|
106
|
+
const diff = Math.abs(current - baseline) / baseline;
|
|
107
|
+
if (diff > threshold) {
|
|
108
|
+
warnedRef.current = true;
|
|
109
|
+
console.warn(
|
|
110
|
+
`[react-streaming-skeletons] CLS risk detected!
|
|
111
|
+
Skeleton height : ${Math.round(baseline)}px
|
|
112
|
+
Content height : ${Math.round(current)}px
|
|
113
|
+
Shift : ${Math.round(diff * 100)}% (threshold: ${Math.round(threshold * 100)}%)
|
|
114
|
+
Fix: match your <Bone height={...}> values to the resolved content dimensions.`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
observer.observe(el);
|
|
119
|
+
return () => observer.disconnect();
|
|
120
|
+
}, [enabled, threshold]);
|
|
121
|
+
return containerRef;
|
|
122
|
+
}
|
|
123
|
+
function SkeletonBoundary({
|
|
124
|
+
fallback,
|
|
125
|
+
children,
|
|
126
|
+
clsThreshold = 0.1
|
|
127
|
+
}) {
|
|
128
|
+
const containerRef = useCLSDetection(isDev, clsThreshold);
|
|
129
|
+
if (isDev) {
|
|
130
|
+
return /* @__PURE__ */ jsx("div", { ref: containerRef, "data-rss-boundary": "", children: /* @__PURE__ */ jsx(Suspense, { fallback, children }) });
|
|
131
|
+
}
|
|
132
|
+
return /* @__PURE__ */ jsx(Suspense, { fallback, children });
|
|
133
|
+
}
|
|
134
|
+
var defaultTheme = {
|
|
135
|
+
color: "#e2e8f0",
|
|
136
|
+
highlight: "#f8fafc",
|
|
137
|
+
borderRadius: 4,
|
|
138
|
+
duration: 1.5,
|
|
139
|
+
animationDirection: "ltr",
|
|
140
|
+
enableAnimation: true
|
|
141
|
+
};
|
|
142
|
+
var SkeletonContext = createContext(defaultTheme);
|
|
143
|
+
function useSkeletonTheme() {
|
|
144
|
+
return useContext(SkeletonContext);
|
|
145
|
+
}
|
|
146
|
+
function SkeletonProvider({ theme, children }) {
|
|
147
|
+
const merged = { ...defaultTheme, ...theme };
|
|
148
|
+
return /* @__PURE__ */ jsx(SkeletonContext.Provider, { value: merged, children: /* @__PURE__ */ jsx(
|
|
149
|
+
"div",
|
|
150
|
+
{
|
|
151
|
+
"data-rss-provider": "",
|
|
152
|
+
"data-rss-direction": merged.animationDirection,
|
|
153
|
+
...!merged.enableAnimation ? { "data-rss-no-animation": "" } : {},
|
|
154
|
+
style: {
|
|
155
|
+
"--rss-color": merged.color,
|
|
156
|
+
"--rss-highlight": merged.highlight,
|
|
157
|
+
"--rss-duration": `${merged.duration}s`
|
|
158
|
+
},
|
|
159
|
+
children
|
|
160
|
+
}
|
|
161
|
+
) });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// src/utils/defineSkeleton.ts
|
|
165
|
+
function defineSkeleton(Component, render) {
|
|
166
|
+
var _a, _b;
|
|
167
|
+
const displayName = (_b = (_a = Component.displayName) != null ? _a : Component.name) != null ? _b : "Component";
|
|
168
|
+
function SkeletonComponent() {
|
|
169
|
+
return render();
|
|
170
|
+
}
|
|
171
|
+
SkeletonComponent.displayName = `${displayName}Skeleton`;
|
|
172
|
+
return SkeletonComponent;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export { Bone, SkeletonBoundary, SkeletonProvider, defaultTheme, defineSkeleton, useSkeletonTheme };
|
|
176
|
+
//# sourceMappingURL=index.mjs.map
|
|
177
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/utils/injectStyles.ts","../src/components/Bone.tsx","../src/components/SkeletonBoundary.tsx","../src/components/SkeletonProvider.tsx","../src/utils/defineSkeleton.ts"],"names":["useEffect","jsx"],"mappings":"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","file":"index.mjs","sourcesContent":["const CSS_ID = 'rss-styles'\n\nconst SHIMMER_CSS = `\n@keyframes rss-shimmer-ltr {\n 0% { background-position: -200px 0; }\n 100% { background-position: calc(200px + 100%) 0; }\n}\n@keyframes rss-shimmer-rtl {\n 0% { background-position: calc(200px + 100%) 0; }\n 100% { background-position: -200px 0; }\n}\n[data-rss-bone] {\n display: inline-block;\n line-height: 1;\n background: linear-gradient(\n 90deg,\n var(--rss-color, #e2e8f0) 25%,\n var(--rss-highlight, #f8fafc) 50%,\n var(--rss-color, #e2e8f0) 75%\n );\n background-size: 200px 100%;\n animation: rss-shimmer-ltr var(--rss-duration, 1.5s) infinite linear;\n}\n[data-rss-direction=\"rtl\"] [data-rss-bone] {\n animation-name: rss-shimmer-rtl;\n}\n[data-rss-no-animation] [data-rss-bone] {\n animation: none;\n background: var(--rss-color, #e2e8f0);\n}\n`\n\nexport function ensureStylesInjected(): void {\n if (typeof document === 'undefined') return\n if (document.getElementById(CSS_ID)) return\n\n const style = document.createElement('style')\n style.id = CSS_ID\n style.textContent = SHIMMER_CSS\n document.head.appendChild(style)\n}\n","'use client'\n\nimport React, { useEffect, Fragment } from 'react'\nimport { ensureStylesInjected } from '../utils/injectStyles'\nimport type { BoneProps } from '../types'\n\nexport function Bone({\n width,\n height,\n circle = false,\n rounded = false,\n className,\n style,\n count = 1,\n inline = false,\n}: BoneProps) {\n useEffect(() => {\n ensureStylesInjected()\n }, [])\n\n const borderRadius: string | undefined =\n circle ? '50%' : rounded ? '9999px' : undefined\n\n const baseStyle: React.CSSProperties = {\n width: width ?? '100%',\n height: height ?? '1em',\n borderRadius,\n display: inline ? 'inline-block' : 'block',\n ...style,\n }\n\n if (count <= 1) {\n return (\n <span\n data-rss-bone=\"\"\n className={className}\n style={baseStyle}\n aria-hidden=\"true\"\n />\n )\n }\n\n return (\n <Fragment>\n {Array.from({ length: count }, (_, i) => (\n <span\n key={i}\n data-rss-bone=\"\"\n className={className}\n style={{\n ...baseStyle,\n marginBottom: i < count - 1 ? '0.5em' : undefined,\n }}\n aria-hidden=\"true\"\n />\n ))}\n </Fragment>\n )\n}\n","'use client'\n\nimport React, { Suspense, useRef, useEffect } from 'react'\nimport type { SkeletonBoundaryProps } from '../types'\n\nconst isDev = process.env.NODE_ENV === 'development'\n\nfunction useCLSDetection(enabled: boolean, threshold: number) {\n const containerRef = useRef<HTMLDivElement>(null)\n const firstHeightRef = useRef(0)\n const warnedRef = useRef(false)\n\n useEffect(() => {\n if (!enabled || !containerRef.current) return\n\n const el = containerRef.current\n firstHeightRef.current = el.getBoundingClientRect().height\n\n const observer = new ResizeObserver(() => {\n if (warnedRef.current) return\n\n const current = el.getBoundingClientRect().height\n const baseline = firstHeightRef.current\n\n if (baseline === 0 || current === baseline) return\n\n const diff = Math.abs(current - baseline) / baseline\n if (diff > threshold) {\n warnedRef.current = true\n console.warn(\n `[react-streaming-skeletons] CLS risk detected!\\n` +\n ` Skeleton height : ${Math.round(baseline)}px\\n` +\n ` Content height : ${Math.round(current)}px\\n` +\n ` Shift : ${Math.round(diff * 100)}% (threshold: ${Math.round(threshold * 100)}%)\\n` +\n ` Fix: match your <Bone height={...}> values to the resolved content dimensions.`,\n )\n }\n })\n\n observer.observe(el)\n return () => observer.disconnect()\n }, [enabled, threshold])\n\n return containerRef\n}\n\nexport function SkeletonBoundary({\n fallback,\n children,\n clsThreshold = 0.1,\n}: SkeletonBoundaryProps) {\n const containerRef = useCLSDetection(isDev, clsThreshold)\n\n if (isDev) {\n return (\n <div ref={containerRef} data-rss-boundary=\"\">\n <Suspense fallback={fallback}>{children}</Suspense>\n </div>\n )\n }\n\n return <Suspense fallback={fallback}>{children}</Suspense>\n}\n","'use client'\n\nimport React, { createContext, useContext } from 'react'\nimport type { SkeletonTheme, SkeletonProviderProps } from '../types'\n\nexport const defaultTheme: SkeletonTheme = {\n color: '#e2e8f0',\n highlight: '#f8fafc',\n borderRadius: 4,\n duration: 1.5,\n animationDirection: 'ltr',\n enableAnimation: true,\n}\n\nconst SkeletonContext = createContext<SkeletonTheme>(defaultTheme)\n\nexport function useSkeletonTheme(): SkeletonTheme {\n return useContext(SkeletonContext)\n}\n\nexport function SkeletonProvider({ theme, children }: SkeletonProviderProps) {\n const merged: SkeletonTheme = { ...defaultTheme, ...theme }\n\n return (\n <SkeletonContext.Provider value={merged}>\n <div\n data-rss-provider=\"\"\n data-rss-direction={merged.animationDirection}\n {...(!merged.enableAnimation ? { 'data-rss-no-animation': '' } : {})}\n style={\n {\n '--rss-color': merged.color,\n '--rss-highlight': merged.highlight,\n '--rss-duration': `${merged.duration}s`,\n } as React.CSSProperties\n }\n >\n {children}\n </div>\n </SkeletonContext.Provider>\n )\n}\n","import type { ComponentType, FC, ReactElement } from 'react'\n\n/**\n * Co-locate a skeleton with its real component so the two stay in sync.\n *\n * @example\n * export const UserCardSkeleton = defineSkeleton(UserCard, () => (\n * <div>\n * <Bone circle width={40} height={40} />\n * <Bone width=\"60%\" height={20} />\n * </div>\n * ))\n */\nexport function defineSkeleton<P>(\n Component: ComponentType<P>,\n render: () => ReactElement,\n): FC {\n const displayName = Component.displayName ?? Component.name ?? 'Component'\n\n function SkeletonComponent() {\n return render()\n }\n\n SkeletonComponent.displayName = `${displayName}Skeleton`\n return SkeletonComponent\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "react-streaming-skeletons",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Zero-layout-shift skeleton components for React Suspense streaming and Next.js App Router",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"module": "./dist/index.mjs",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.mjs",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"sideEffects": false,
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsup",
|
|
21
|
+
"dev": "tsup --watch",
|
|
22
|
+
"test": "jest",
|
|
23
|
+
"typecheck": "tsc --noEmit",
|
|
24
|
+
"prepublishOnly": "npm run build"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"react": ">=18.0.0",
|
|
28
|
+
"react-dom": ">=18.0.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@testing-library/jest-dom": "^6.4.2",
|
|
32
|
+
"@testing-library/react": "^15.0.0",
|
|
33
|
+
"@types/jest": "^29.5.12",
|
|
34
|
+
"@types/node": "^25.9.0",
|
|
35
|
+
"@types/react": "^18.3.0",
|
|
36
|
+
"@types/react-dom": "^18.3.0",
|
|
37
|
+
"jest": "^29.7.0",
|
|
38
|
+
"jest-environment-jsdom": "^29.7.0",
|
|
39
|
+
"react": "^18.3.0",
|
|
40
|
+
"react-dom": "^18.3.0",
|
|
41
|
+
"ts-jest": "^29.2.0",
|
|
42
|
+
"tsup": "^8.1.0",
|
|
43
|
+
"typescript": "^5.4.5"
|
|
44
|
+
},
|
|
45
|
+
"keywords": [
|
|
46
|
+
"react",
|
|
47
|
+
"skeleton",
|
|
48
|
+
"loading",
|
|
49
|
+
"streaming",
|
|
50
|
+
"suspense",
|
|
51
|
+
"nextjs",
|
|
52
|
+
"app-router",
|
|
53
|
+
"cls",
|
|
54
|
+
"layout-shift",
|
|
55
|
+
"server-components"
|
|
56
|
+
],
|
|
57
|
+
"author": "Mehulbirare",
|
|
58
|
+
"license": "MIT",
|
|
59
|
+
"repository": {
|
|
60
|
+
"type": "git",
|
|
61
|
+
"url": "https://github.com/Mehulbirare/react-streaming-skeletons"
|
|
62
|
+
}
|
|
63
|
+
}
|