@discourser/design-system 0.3.1 → 0.4.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.
@@ -0,0 +1,726 @@
1
+ # Skeleton
2
+
3
+ **Purpose:** Loading placeholder component that displays a temporary gray placeholder while content is loading, providing visual feedback and reducing perceived loading time following Material Design 3 patterns.
4
+
5
+ ## Import
6
+
7
+ ```typescript
8
+ import {
9
+ Skeleton,
10
+ SkeletonCircle,
11
+ SkeletonText,
12
+ } from '@discourser/design-system';
13
+ ```
14
+
15
+ ## Component Variants
16
+
17
+ The Skeleton system provides three main components for different use cases:
18
+
19
+ | Component | Purpose | When to Use |
20
+ | ---------------- | ----------------------------- | ----------------------------------------- |
21
+ | `Skeleton` | Basic rectangular placeholder | General content blocks, images, cards |
22
+ | `SkeletonCircle` | Circular placeholder | Avatars, profile pictures, circular icons |
23
+ | `SkeletonText` | Multi-line text placeholder | Paragraphs, descriptions, text content |
24
+
25
+ ## Animation Variants
26
+
27
+ | Variant | Visual Effect | Usage | When to Use |
28
+ | ------- | ---------------------- | ------------------------- | ---------------------------------------------- |
29
+ | `pulse` | Gentle opacity pulsing | Default loading state | Most loading scenarios, subtle feedback |
30
+ | `shine` | Shimmer/wave effect | Enhanced loading feedback | Premium feel, prominent loading states |
31
+ | `none` | No animation | Static placeholder | Reduced motion preference, minimal distraction |
32
+
33
+ ### Visual Characteristics
34
+
35
+ - **pulse**: Smooth opacity fade in/out at 1.2s duration
36
+ - **shine**: Gradient wave moving across at 5s duration
37
+ - **none**: Static gray background without animation
38
+
39
+ ## Props
40
+
41
+ ### Skeleton
42
+
43
+ | Prop | Type | Default | Description |
44
+ | ----------- | ------------------------------ | --------- | ------------------------------------------ |
45
+ | `loading` | `boolean` | `true` | Whether to show skeleton or reveal content |
46
+ | `variant` | `'pulse' \| 'shine' \| 'none'` | `'pulse'` | Animation style |
47
+ | `width` | `string \| number` | - | Width of skeleton (CSS value) |
48
+ | `height` | `string \| number` | - | Height of skeleton (CSS value) |
49
+ | `circle` | `boolean` | `false` | Make skeleton circular |
50
+ | `className` | `string` | - | Additional CSS classes |
51
+
52
+ ### SkeletonCircle
53
+
54
+ | Prop | Type | Default | Description |
55
+ | --------- | ------------------------------ | --------- | ------------------------------------------ |
56
+ | `loading` | `boolean` | `true` | Whether to show skeleton or reveal content |
57
+ | `variant` | `'pulse' \| 'shine' \| 'none'` | `'pulse'` | Animation style |
58
+ | `size` | `string \| number` | - | Circle size (width and height) |
59
+
60
+ **Note:** SkeletonCircle automatically sets `circle={true}` and renders as a circular skeleton.
61
+
62
+ ### SkeletonText
63
+
64
+ | Prop | Type | Default | Description |
65
+ | ----------- | ------------------------------ | --------- | ------------------------------------------ |
66
+ | `loading` | `boolean` | `true` | Whether to show skeleton or reveal content |
67
+ | `variant` | `'pulse' \| 'shine' \| 'none'` | `'pulse'` | Animation style |
68
+ | `noOfLines` | `number` | `3` | Number of text lines to display |
69
+ | `gap` | `string` | - | Space between lines |
70
+ | `rootProps` | `StackProps` | - | Props for the Stack container |
71
+
72
+ **Note:** Last line is automatically set to 80% width (100% if only one line).
73
+
74
+ ## Examples
75
+
76
+ ### Basic Skeleton
77
+
78
+ ```typescript
79
+ // Simple rectangular skeleton
80
+ <Skeleton width="200px" height="20px" />
81
+
82
+ // Custom dimensions
83
+ <Skeleton width="100%" height="300px" />
84
+
85
+ // With explicit loading state
86
+ <Skeleton loading={isLoading} width="full" height="40px">
87
+ <Text>Loaded content appears here</Text>
88
+ </Skeleton>
89
+ ```
90
+
91
+ ### Skeleton Circle (Avatars)
92
+
93
+ ```typescript
94
+ // Basic avatar skeleton
95
+ <SkeletonCircle size="40px" />
96
+
97
+ // Large profile picture
98
+ <SkeletonCircle size="120px" />
99
+
100
+ // With content reveal
101
+ <SkeletonCircle loading={isLoading} size="48px">
102
+ <Avatar src={user.avatar} />
103
+ </SkeletonCircle>
104
+
105
+ // Multiple avatar sizes
106
+ <HStack gap="4">
107
+ <SkeletonCircle size="32px" />
108
+ <SkeletonCircle size="48px" />
109
+ <SkeletonCircle size="64px" />
110
+ </HStack>
111
+ ```
112
+
113
+ ### Skeleton Text (Paragraphs)
114
+
115
+ ```typescript
116
+ // Default 3 lines
117
+ <SkeletonText />
118
+
119
+ // Custom number of lines
120
+ <SkeletonText noOfLines={5} />
121
+
122
+ // Single line
123
+ <SkeletonText noOfLines={1} />
124
+
125
+ // Custom gap between lines
126
+ <SkeletonText noOfLines={4} gap="3" />
127
+
128
+ // With content reveal
129
+ <SkeletonText loading={isLoading} noOfLines={3}>
130
+ <Text>{article.content}</Text>
131
+ </SkeletonText>
132
+
133
+ // Custom container props
134
+ <SkeletonText
135
+ noOfLines={3}
136
+ rootProps={{
137
+ maxWidth: '600px',
138
+ padding: '4',
139
+ }}
140
+ />
141
+ ```
142
+
143
+ ### Animation Variants
144
+
145
+ ```typescript
146
+ // Pulse animation (default)
147
+ <Skeleton variant="pulse" width="200px" height="20px" />
148
+
149
+ // Shine animation
150
+ <Skeleton variant="shine" width="200px" height="20px" />
151
+
152
+ // No animation
153
+ <Skeleton variant="none" width="200px" height="20px" />
154
+
155
+ // Respect reduced motion preference
156
+ <Skeleton
157
+ variant={prefersReducedMotion ? 'none' : 'shine'}
158
+ width="full"
159
+ height="40px"
160
+ />
161
+ ```
162
+
163
+ ### Loading State Control
164
+
165
+ ```typescript
166
+ const [isLoading, setIsLoading] = useState(true);
167
+
168
+ useEffect(() => {
169
+ fetchData().then(() => setIsLoading(false));
170
+ }, []);
171
+
172
+ // Skeleton disappears when loading is false
173
+ <Skeleton loading={isLoading} width="full" height="200px">
174
+ <Image src={data.imageUrl} alt={data.title} />
175
+ </Skeleton>
176
+ ```
177
+
178
+ ## Common Patterns
179
+
180
+ ### User Profile Card
181
+
182
+ ```typescript
183
+ const [isLoading, setIsLoading] = useState(true);
184
+
185
+ <Card>
186
+ <HStack gap="4" align="start">
187
+ <SkeletonCircle loading={isLoading} size="64px">
188
+ <Avatar src={user.avatar} size="lg" />
189
+ </SkeletonCircle>
190
+
191
+ <Stack flex="1" gap="2">
192
+ <Skeleton loading={isLoading} width="150px" height="20px">
193
+ <Heading size="md">{user.name}</Heading>
194
+ </Skeleton>
195
+
196
+ <Skeleton loading={isLoading} width="200px" height="16px">
197
+ <Text color="fg.muted">{user.email}</Text>
198
+ </Skeleton>
199
+
200
+ <SkeletonText loading={isLoading} noOfLines={2}>
201
+ <Text>{user.bio}</Text>
202
+ </SkeletonText>
203
+ </Stack>
204
+ </HStack>
205
+ </Card>
206
+ ```
207
+
208
+ ### Article List
209
+
210
+ ```typescript
211
+ function ArticleListSkeleton() {
212
+ return (
213
+ <Stack gap="6">
214
+ {[...Array(3)].map((_, index) => (
215
+ <Card key={index}>
216
+ <Stack gap="3">
217
+ {/* Cover image */}
218
+ <Skeleton width="full" height="200px" />
219
+
220
+ {/* Title */}
221
+ <Skeleton width="80%" height="24px" />
222
+
223
+ {/* Meta info */}
224
+ <HStack gap="3">
225
+ <SkeletonCircle size="32px" />
226
+ <Stack gap="2" flex="1">
227
+ <Skeleton width="120px" height="14px" />
228
+ <Skeleton width="80px" height="12px" />
229
+ </Stack>
230
+ </HStack>
231
+
232
+ {/* Description */}
233
+ <SkeletonText noOfLines={3} />
234
+ </Stack>
235
+ </Card>
236
+ ))}
237
+ </Stack>
238
+ );
239
+ }
240
+
241
+ function ArticleList() {
242
+ const { data, isLoading } = useArticles();
243
+
244
+ if (isLoading) return <ArticleListSkeleton />;
245
+
246
+ return (
247
+ <Stack gap="6">
248
+ {data.map((article) => (
249
+ <ArticleCard key={article.id} article={article} />
250
+ ))}
251
+ </Stack>
252
+ );
253
+ }
254
+ ```
255
+
256
+ ### Data Table
257
+
258
+ ```typescript
259
+ function TableSkeleton({ rows = 5, columns = 4 }) {
260
+ return (
261
+ <Table>
262
+ <TableHead>
263
+ <TableRow>
264
+ {[...Array(columns)].map((_, i) => (
265
+ <TableHeader key={i}>
266
+ <Skeleton width="80px" height="16px" />
267
+ </TableHeader>
268
+ ))}
269
+ </TableRow>
270
+ </TableHead>
271
+ <TableBody>
272
+ {[...Array(rows)].map((_, rowIndex) => (
273
+ <TableRow key={rowIndex}>
274
+ {[...Array(columns)].map((_, colIndex) => (
275
+ <TableCell key={colIndex}>
276
+ <Skeleton
277
+ width={colIndex === 0 ? '120px' : '80px'}
278
+ height="16px"
279
+ />
280
+ </TableCell>
281
+ ))}
282
+ </TableRow>
283
+ ))}
284
+ </TableBody>
285
+ </Table>
286
+ );
287
+ }
288
+ ```
289
+
290
+ ### Comment Thread
291
+
292
+ ```typescript
293
+ function CommentSkeleton() {
294
+ return (
295
+ <Stack gap="4">
296
+ {[...Array(3)].map((_, index) => (
297
+ <HStack key={index} gap="3" align="start">
298
+ <SkeletonCircle size="40px" />
299
+
300
+ <Stack flex="1" gap="2">
301
+ <HStack gap="2">
302
+ <Skeleton width="100px" height="16px" />
303
+ <Skeleton width="60px" height="14px" />
304
+ </HStack>
305
+
306
+ <SkeletonText noOfLines={2} />
307
+
308
+ <HStack gap="4">
309
+ <Skeleton width="40px" height="14px" />
310
+ <Skeleton width="40px" height="14px" />
311
+ </HStack>
312
+ </Stack>
313
+ </HStack>
314
+ ))}
315
+ </Stack>
316
+ );
317
+ }
318
+ ```
319
+
320
+ ### Dashboard Statistics
321
+
322
+ ```typescript
323
+ function StatsSkeleton() {
324
+ return (
325
+ <Grid columns={{ base: 1, md: 2, lg: 4 }} gap="4">
326
+ {[...Array(4)].map((_, index) => (
327
+ <Card key={index}>
328
+ <Stack gap="3">
329
+ <HStack justify="space-between">
330
+ <Skeleton width="80px" height="14px" />
331
+ <SkeletonCircle size="24px" />
332
+ </HStack>
333
+
334
+ <Skeleton width="100px" height="32px" />
335
+
336
+ <Skeleton width="120px" height="12px" />
337
+ </Stack>
338
+ </Card>
339
+ ))}
340
+ </Grid>
341
+ );
342
+ }
343
+ ```
344
+
345
+ ### Product Grid
346
+
347
+ ```typescript
348
+ function ProductGridSkeleton({ count = 8 }) {
349
+ return (
350
+ <Grid columns={{ base: 1, sm: 2, md: 3, lg: 4 }} gap="6">
351
+ {[...Array(count)].map((_, index) => (
352
+ <Card key={index}>
353
+ <Stack gap="3">
354
+ {/* Product image */}
355
+ <Skeleton width="full" height="200px" />
356
+
357
+ {/* Product name */}
358
+ <Skeleton width="90%" height="20px" />
359
+
360
+ {/* Price */}
361
+ <Skeleton width="60px" height="24px" />
362
+
363
+ {/* Rating */}
364
+ <HStack gap="1">
365
+ {[...Array(5)].map((_, i) => (
366
+ <Skeleton key={i} width="16px" height="16px" />
367
+ ))}
368
+ </HStack>
369
+ </Stack>
370
+ </Card>
371
+ ))}
372
+ </Grid>
373
+ );
374
+ }
375
+ ```
376
+
377
+ ### Chat Messages
378
+
379
+ ```typescript
380
+ function ChatSkeleton() {
381
+ return (
382
+ <Stack gap="4">
383
+ {[...Array(5)].map((_, index) => {
384
+ const isOwnMessage = index % 2 === 0;
385
+ return (
386
+ <HStack
387
+ key={index}
388
+ gap="3"
389
+ justify={isOwnMessage ? 'flex-end' : 'flex-start'}
390
+ >
391
+ {!isOwnMessage && <SkeletonCircle size="32px" />}
392
+
393
+ <Stack gap="1" maxW="70%">
394
+ <Skeleton width="200px" height="16px" />
395
+ <Skeleton width="150px" height="12px" />
396
+ </Stack>
397
+
398
+ {isOwnMessage && <SkeletonCircle size="32px" />}
399
+ </HStack>
400
+ );
401
+ })}
402
+ </Stack>
403
+ );
404
+ }
405
+ ```
406
+
407
+ ### Form Fields
408
+
409
+ ```typescript
410
+ function FormSkeleton() {
411
+ return (
412
+ <Stack gap="4">
413
+ {[...Array(4)].map((_, index) => (
414
+ <Stack key={index} gap="2">
415
+ <Skeleton width="100px" height="16px" />
416
+ <Skeleton width="full" height="40px" />
417
+ </Stack>
418
+ ))}
419
+
420
+ <HStack gap="3" justify="flex-end">
421
+ <Skeleton width="80px" height="40px" />
422
+ <Skeleton width="100px" height="40px" />
423
+ </HStack>
424
+ </Stack>
425
+ );
426
+ }
427
+ ```
428
+
429
+ ## DO NOT
430
+
431
+ ```typescript
432
+ // ❌ Don't use skeleton for instant content
433
+ <Skeleton loading={true} width="200px" height="20px">
434
+ <Text>Static text that loads instantly</Text>
435
+ </Skeleton> // No need for skeleton if content is immediate
436
+
437
+ // ❌ Don't forget to set dimensions
438
+ <Skeleton /> // No width or height specified
439
+
440
+ // ❌ Don't use skeleton for interactive elements
441
+ <Skeleton width="100px" height="40px">
442
+ <Button>Click me</Button>
443
+ </Skeleton> // Use disabled state instead
444
+
445
+ // ❌ Don't overuse animations
446
+ <div>
447
+ {[...Array(100)].map((_, i) => (
448
+ <Skeleton key={i} variant="shine" />
449
+ ))} // Too many animations cause performance issues
450
+ </div>
451
+
452
+ // ❌ Don't use skeleton for error states
453
+ {error && <Skeleton width="full" height="200px" />}
454
+ // Show error message instead
455
+
456
+ // ❌ Don't mix skeleton with partial content
457
+ <Card>
458
+ <Skeleton width="200px" height="20px" />
459
+ <Text>This text is loaded</Text> // Inconsistent loading state
460
+ </Card>
461
+
462
+ // ✅ Always set dimensions for predictable layout
463
+ <Skeleton width="200px" height="20px" />
464
+
465
+ // ✅ Use loading state from data fetching
466
+ <Skeleton loading={isLoading} width="200px" height="20px">
467
+ <Text>{data.title}</Text>
468
+ </Skeleton>
469
+
470
+ // ✅ Show entire section as loading or loaded
471
+ {isLoading ? (
472
+ <CardSkeleton />
473
+ ) : (
474
+ <Card>{content}</Card>
475
+ )}
476
+
477
+ // ✅ Use appropriate animation for context
478
+ <Skeleton
479
+ variant={prefersReducedMotion ? 'none' : 'pulse'}
480
+ width="full"
481
+ height="200px"
482
+ />
483
+
484
+ // ✅ Show error state explicitly
485
+ {error ? (
486
+ <Alert variant="error">{error.message}</Alert>
487
+ ) : isLoading ? (
488
+ <Skeleton width="full" height="200px" />
489
+ ) : (
490
+ <Content data={data} />
491
+ )}
492
+ ```
493
+
494
+ ## Accessibility
495
+
496
+ The Skeleton component follows WCAG 2.1 Level AA standards:
497
+
498
+ - **Hidden Content**: Uses `visibility: hidden` for skeleton content to prevent screen reader announcement
499
+ - **Semantic Structure**: Maintains layout structure while loading
500
+ - **Reduced Motion**: Respects `prefers-reduced-motion` user preference
501
+ - **Color Independence**: Does not rely on color alone (uses animation)
502
+ - **No Focus Trap**: Skeleton elements are not focusable
503
+ - **Transparent Text**: Uses `color: transparent` to hide text during loading
504
+
505
+ ### Accessibility Best Practices
506
+
507
+ ```typescript
508
+ // ✅ Provide loading announcement
509
+ <div role="status" aria-live="polite" aria-busy={isLoading}>
510
+ {isLoading ? (
511
+ <SkeletonText noOfLines={3} />
512
+ ) : (
513
+ <Text>{content}</Text>
514
+ )}
515
+ </div>
516
+
517
+ // ✅ Use aria-label for loading regions
518
+ <section aria-label="Loading articles" aria-busy={isLoading}>
519
+ {isLoading ? (
520
+ <ArticleListSkeleton />
521
+ ) : (
522
+ <ArticleList articles={data} />
523
+ )}
524
+ </section>
525
+
526
+ // ✅ Respect reduced motion preference
527
+ const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
528
+
529
+ <Skeleton
530
+ variant={prefersReducedMotion ? 'none' : 'pulse'}
531
+ width="200px"
532
+ height="20px"
533
+ />
534
+
535
+ // ✅ Announce loading state changes
536
+ <div aria-live="polite">
537
+ {isLoading && <span className="sr-only">Loading content...</span>}
538
+ </div>
539
+
540
+ // ✅ Maintain focus management
541
+ function ContentSection() {
542
+ const [isLoading, setIsLoading] = useState(true);
543
+ const contentRef = useRef<HTMLDivElement>(null);
544
+
545
+ useEffect(() => {
546
+ if (!isLoading && contentRef.current) {
547
+ // Focus first interactive element after load
548
+ const firstButton = contentRef.current.querySelector('button');
549
+ firstButton?.focus();
550
+ }
551
+ }, [isLoading]);
552
+
553
+ return (
554
+ <div ref={contentRef}>
555
+ {isLoading ? <ContentSkeleton /> : <Content />}
556
+ </div>
557
+ );
558
+ }
559
+ ```
560
+
561
+ ## Animation Selection Guide
562
+
563
+ | Scenario | Recommended Variant | Reasoning |
564
+ | --------------------------- | ------------------- | -------------------------------------------- |
565
+ | General content loading | `pulse` | Subtle, not distracting, good for most cases |
566
+ | Premium/prominent content | `shine` | Enhanced visual feedback, modern feel |
567
+ | User prefers reduced motion | `none` | Respects accessibility preferences |
568
+ | Many simultaneous skeletons | `pulse` | Better performance than shine |
569
+ | Quick loading (under 500ms) | `none` | Prevents animation flash |
570
+ | Long loading (over 2s) | `shine` | Indicates activity, reduces perceived wait |
571
+ | Background/ambient loading | `pulse` | Less attention-grabbing |
572
+
573
+ ## Performance Considerations
574
+
575
+ ```typescript
576
+ // ✅ Use pulse for multiple skeletons (better performance)
577
+ <Grid columns={4}>
578
+ {[...Array(20)].map((_, i) => (
579
+ <Skeleton key={i} variant="pulse" width="full" height="200px" />
580
+ ))}
581
+ </Grid>
582
+
583
+ // ⚠️ Be cautious with shine for many elements
584
+ <Grid columns={4}>
585
+ {[...Array(20)].map((_, i) => (
586
+ <Skeleton key={i} variant="shine" width="full" height="200px" />
587
+ ))} // Can cause performance issues
588
+ </Grid>
589
+
590
+ // ✅ Use memo for skeleton components
591
+ const ProductSkeleton = memo(() => (
592
+ <Card>
593
+ <Stack gap="3">
594
+ <Skeleton width="full" height="200px" />
595
+ <Skeleton width="90%" height="20px" />
596
+ <Skeleton width="60px" height="24px" />
597
+ </Stack>
598
+ </Card>
599
+ ));
600
+
601
+ // ✅ Virtualize long lists with skeletons
602
+ function VirtualizedList() {
603
+ return (
604
+ <VirtualList
605
+ items={isLoading ? Array(50).fill(null) : data}
606
+ renderItem={(item) =>
607
+ item === null ? <ItemSkeleton /> : <Item data={item} />
608
+ }
609
+ />
610
+ );
611
+ }
612
+ ```
613
+
614
+ ## Responsive Considerations
615
+
616
+ ```typescript
617
+ // Responsive skeleton dimensions
618
+ <Skeleton
619
+ width={{ base: 'full', md: '300px' }}
620
+ height={{ base: '150px', md: '200px' }}
621
+ />
622
+
623
+ // Responsive text lines
624
+ <SkeletonText
625
+ noOfLines={{ base: 2, md: 3 }}
626
+ />
627
+
628
+ // Responsive grid with skeletons
629
+ <Grid
630
+ columns={{ base: 1, sm: 2, md: 3, lg: 4 }}
631
+ gap="4"
632
+ >
633
+ {[...Array(8)].map((_, i) => (
634
+ <ProductSkeleton key={i} />
635
+ ))}
636
+ </Grid>
637
+
638
+ // Mobile-optimized avatar size
639
+ <SkeletonCircle size={{ base: '48px', md: '64px' }} />
640
+ ```
641
+
642
+ ## Testing
643
+
644
+ When testing Skeleton components:
645
+
646
+ ```typescript
647
+ import { render, screen } from '@testing-library/react';
648
+ import { Skeleton, SkeletonText, SkeletonCircle } from '@discourser/design-system';
649
+
650
+ test('shows skeleton when loading', () => {
651
+ render(
652
+ <Skeleton loading={true} width="200px" height="20px">
653
+ <Text>Content</Text>
654
+ </Skeleton>
655
+ );
656
+
657
+ // Skeleton should be visible
658
+ const skeleton = screen.getByTestId('skeleton'); // Add data-testid if needed
659
+ expect(skeleton).toBeInTheDocument();
660
+
661
+ // Content should be hidden
662
+ expect(screen.queryByText('Content')).not.toBeVisible();
663
+ });
664
+
665
+ test('shows content when not loading', () => {
666
+ render(
667
+ <Skeleton loading={false} width="200px" height="20px">
668
+ <Text>Content</Text>
669
+ </Skeleton>
670
+ );
671
+
672
+ // Content should be visible
673
+ expect(screen.getByText('Content')).toBeVisible();
674
+ });
675
+
676
+ test('renders correct number of lines for SkeletonText', () => {
677
+ const { container } = render(<SkeletonText noOfLines={5} />);
678
+
679
+ // Should render 5 skeleton lines
680
+ const skeletonLines = container.querySelectorAll('[class*="skeleton"]');
681
+ expect(skeletonLines).toHaveLength(5);
682
+ });
683
+
684
+ test('SkeletonCircle renders as circular', () => {
685
+ const { container } = render(<SkeletonCircle size="48px" />);
686
+
687
+ const circle = container.firstChild;
688
+ expect(circle).toHaveStyle({
689
+ borderRadius: '9999px',
690
+ });
691
+ });
692
+
693
+ test('respects loading state changes', () => {
694
+ const { rerender } = render(
695
+ <Skeleton loading={true} width="200px" height="20px">
696
+ <Text>Content</Text>
697
+ </Skeleton>
698
+ );
699
+
700
+ expect(screen.queryByText('Content')).not.toBeVisible();
701
+
702
+ rerender(
703
+ <Skeleton loading={false} width="200px" height="20px">
704
+ <Text>Content</Text>
705
+ </Skeleton>
706
+ );
707
+
708
+ expect(screen.getByText('Content')).toBeVisible();
709
+ });
710
+
711
+ test('applies correct animation variant', () => {
712
+ const { container } = render(
713
+ <Skeleton variant="shine" width="200px" height="20px" />
714
+ );
715
+
716
+ const skeleton = container.firstChild;
717
+ expect(skeleton).toHaveAttribute('data-variant', 'shine');
718
+ });
719
+ ```
720
+
721
+ ## Related Components
722
+
723
+ - **Spinner**: For small, inline loading indicators
724
+ - **Progress**: For determinate loading with progress indication
725
+ - **Toast**: For notifying users when content finishes loading
726
+ - **EmptyState**: For when no content is available after loading