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 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
@@ -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 };
@@ -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":";;;;;;;;AAAA,IAAM,MAAA,GAAS,YAAA;AAEf,IAAM,WAAA,GAAc;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,CAAA;AA8Bb,SAAS,oBAAA,GAA6B;AAC3C,EAAA,IAAI,OAAO,aAAa,WAAA,EAAa;AACrC,EAAA,IAAI,QAAA,CAAS,cAAA,CAAe,MAAM,CAAA,EAAG;AAErC,EAAA,MAAM,KAAA,GAAQ,QAAA,CAAS,aAAA,CAAc,OAAO,CAAA;AAC5C,EAAA,KAAA,CAAM,EAAA,GAAK,MAAA;AACX,EAAA,KAAA,CAAM,WAAA,GAAc,WAAA;AACpB,EAAA,QAAA,CAAS,IAAA,CAAK,YAAY,KAAK,CAAA;AACjC;AClCO,SAAS,IAAA,CAAK;AAAA,EACnB,KAAA;AAAA,EACA,MAAA;AAAA,EACA,MAAA,GAAS,KAAA;AAAA,EACT,OAAA,GAAU,KAAA;AAAA,EACV,SAAA;AAAA,EACA,KAAA;AAAA,EACA,KAAA,GAAQ,CAAA;AAAA,EACR,MAAA,GAAS;AACX,CAAA,EAAc;AACZ,EAAAA,eAAA,CAAU,MAAM;AACd,IAAA,oBAAA,EAAqB;AAAA,EACvB,CAAA,EAAG,EAAE,CAAA;AAEL,EAAA,MAAM,YAAA,GACJ,MAAA,GAAS,KAAA,GAAQ,OAAA,GAAU,QAAA,GAAW,MAAA;AAExC,EAAA,MAAM,SAAA,GAAiC;AAAA,IACrC,OAAO,KAAA,IAAA,IAAA,GAAA,KAAA,GAAS,MAAA;AAAA,IAChB,QAAQ,MAAA,IAAA,IAAA,GAAA,MAAA,GAAU,KAAA;AAAA,IAClB,YAAA;AAAA,IACA,OAAA,EAAS,SAAS,cAAA,GAAiB,OAAA;AAAA,IACnC,GAAG;AAAA,GACL;AAEA,EAAA,IAAI,SAAS,CAAA,EAAG;AACd,IAAA,uBACEC,cAAA;AAAA,MAAC,MAAA;AAAA,MAAA;AAAA,QACC,eAAA,EAAc,EAAA;AAAA,QACd,SAAA;AAAA,QACA,KAAA,EAAO,SAAA;AAAA,QACP,aAAA,EAAY;AAAA;AAAA,KACd;AAAA,EAEJ;AAEA,EAAA,uBACEA,cAAA,CAACC,cAAA,EAAA,EACE,QAAA,EAAA,KAAA,CAAM,IAAA,CAAK,EAAE,QAAQ,KAAA,EAAM,EAAG,CAAC,CAAA,EAAG,CAAA,qBACjCD,cAAA;AAAA,IAAC,MAAA;AAAA,IAAA;AAAA,MAEC,eAAA,EAAc,EAAA;AAAA,MACd,SAAA;AAAA,MACA,KAAA,EAAO;AAAA,QACL,GAAG,SAAA;AAAA,QACH,YAAA,EAAc,CAAA,GAAI,KAAA,GAAQ,CAAA,GAAI,OAAA,GAAU;AAAA,OAC1C;AAAA,MACA,aAAA,EAAY;AAAA,KAAA;AAAA,IAPP;AAAA,GASR,CAAA,EACH,CAAA;AAEJ;ACrDA,IAAM,KAAA,GAAQ,OAAA,CAAQ,GAAA,CAAI,QAAA,KAAa,aAAA;AAEvC,SAAS,eAAA,CAAgB,SAAkB,SAAA,EAAmB;AAC5D,EAAA,MAAM,YAAA,GAAeE,aAAuB,IAAI,CAAA;AAChD,EAAA,MAAM,cAAA,GAAiBA,aAAO,CAAC,CAAA;AAC/B,EAAA,MAAM,SAAA,GAAYA,aAAO,KAAK,CAAA;AAE9B,EAAAH,gBAAU,MAAM;AACd,IAAA,IAAI,CAAC,OAAA,IAAW,CAAC,YAAA,CAAa,OAAA,EAAS;AAEvC,IAAA,MAAM,KAAK,YAAA,CAAa,OAAA;AACxB,IAAA,cAAA,CAAe,OAAA,GAAU,EAAA,CAAG,qBAAA,EAAsB,CAAE,MAAA;AAEpD,IAAA,MAAM,QAAA,GAAW,IAAI,cAAA,CAAe,MAAM;AACxC,MAAA,IAAI,UAAU,OAAA,EAAS;AAEvB,MAAA,MAAM,OAAA,GAAU,EAAA,CAAG,qBAAA,EAAsB,CAAE,MAAA;AAC3C,MAAA,MAAM,WAAW,cAAA,CAAe,OAAA;AAEhC,MAAA,IAAI,QAAA,KAAa,CAAA,IAAK,OAAA,KAAY,QAAA,EAAU;AAE5C,MAAA,MAAM,IAAA,GAAO,IAAA,CAAK,GAAA,CAAI,OAAA,GAAU,QAAQ,CAAA,GAAI,QAAA;AAC5C,MAAA,IAAI,OAAO,SAAA,EAAW;AACpB,QAAA,SAAA,CAAU,OAAA,GAAU,IAAA;AACpB,QAAA,OAAA,CAAQ,IAAA;AAAA,UACN,CAAA;AAAA,oBAAA,EACyB,IAAA,CAAK,KAAA,CAAM,QAAQ,CAAC,CAAA;AAAA,oBAAA,EACpB,IAAA,CAAK,KAAA,CAAM,OAAO,CAAC,CAAA;AAAA,oBAAA,EACnB,IAAA,CAAK,KAAA,CAAM,IAAA,GAAO,GAAG,CAAC,iBAAiB,IAAA,CAAK,KAAA,CAAM,SAAA,GAAY,GAAG,CAAC,CAAA;AAAA,gFAAA;AAAA,SAE7F;AAAA,MACF;AAAA,IACF,CAAC,CAAA;AAED,IAAA,QAAA,CAAS,QAAQ,EAAE,CAAA;AACnB,IAAA,OAAO,MAAM,SAAS,UAAA,EAAW;AAAA,EACnC,CAAA,EAAG,CAAC,OAAA,EAAS,SAAS,CAAC,CAAA;AAEvB,EAAA,OAAO,YAAA;AACT;AAEO,SAAS,gBAAA,CAAiB;AAAA,EAC/B,QAAA;AAAA,EACA,QAAA;AAAA,EACA,YAAA,GAAe;AACjB,CAAA,EAA0B;AACxB,EAAA,MAAM,YAAA,GAAe,eAAA,CAAgB,KAAA,EAAO,YAAY,CAAA;AAExD,EAAA,IAAI,KAAA,EAAO;AACT,IAAA,uBACEC,cAAAA,CAAC,KAAA,EAAA,EAAI,GAAA,EAAK,YAAA,EAAc,mBAAA,EAAkB,EAAA,EACxC,QAAA,kBAAAA,cAAAA,CAACG,cAAA,EAAA,EAAS,QAAA,EAAqB,QAAA,EAAS,CAAA,EAC1C,CAAA;AAAA,EAEJ;AAEA,EAAA,uBAAOH,cAAAA,CAACG,cAAA,EAAA,EAAS,QAAA,EAAqB,QAAA,EAAS,CAAA;AACjD;ACzDO,IAAM,YAAA,GAA8B;AAAA,EACzC,KAAA,EAAO,SAAA;AAAA,EACP,SAAA,EAAW,SAAA;AAAA,EACX,YAAA,EAAc,CAAA;AAAA,EACd,QAAA,EAAU,GAAA;AAAA,EACV,kBAAA,EAAoB,KAAA;AAAA,EACpB,eAAA,EAAiB;AACnB;AAEA,IAAM,eAAA,GAAkBC,oBAA6B,YAAY,CAAA;AAE1D,SAAS,gBAAA,GAAkC;AAChD,EAAA,OAAOC,iBAAW,eAAe,CAAA;AACnC;AAEO,SAAS,gBAAA,CAAiB,EAAE,KAAA,EAAO,QAAA,EAAS,EAA0B;AAC3E,EAAA,MAAM,MAAA,GAAwB,EAAE,GAAG,YAAA,EAAc,GAAG,KAAA,EAAM;AAE1D,EAAA,uBACEL,cAAAA,CAAC,eAAA,CAAgB,UAAhB,EAAyB,KAAA,EAAO,QAC/B,QAAA,kBAAAA,cAAAA;AAAA,IAAC,KAAA;AAAA,IAAA;AAAA,MACC,mBAAA,EAAkB,EAAA;AAAA,MAClB,sBAAoB,MAAA,CAAO,kBAAA;AAAA,MAC1B,GAAI,CAAC,MAAA,CAAO,eAAA,GAAkB,EAAE,uBAAA,EAAyB,EAAA,KAAO,EAAC;AAAA,MAClE,KAAA,EACE;AAAA,QACE,eAAe,MAAA,CAAO,KAAA;AAAA,QACtB,mBAAmB,MAAA,CAAO,SAAA;AAAA,QAC1B,gBAAA,EAAkB,CAAA,EAAG,MAAA,CAAO,QAAQ,CAAA,CAAA;AAAA,OACtC;AAAA,MAGD;AAAA;AAAA,GACH,EACF,CAAA;AAEJ;;;AC5BO,SAAS,cAAA,CACd,WACA,MAAA,EACI;AAhBN,EAAA,IAAA,EAAA,EAAA,EAAA;AAiBE,EAAA,MAAM,eAAc,EAAA,GAAA,CAAA,EAAA,GAAA,SAAA,CAAU,WAAA,KAAV,IAAA,GAAA,EAAA,GAAyB,SAAA,CAAU,SAAnC,IAAA,GAAA,EAAA,GAA2C,WAAA;AAE/D,EAAA,SAAS,iBAAA,GAAoB;AAC3B,IAAA,OAAO,MAAA,EAAO;AAAA,EAChB;AAEA,EAAA,iBAAA,CAAkB,WAAA,GAAc,GAAG,WAAW,CAAA,QAAA,CAAA;AAC9C,EAAA,OAAO,iBAAA;AACT","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
+ }