@discourser/design-system 0.3.1 → 0.5.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.
Files changed (36) hide show
  1. package/README.md +12 -4
  2. package/dist/styles.css +5126 -0
  3. package/guidelines/Guidelines.md +92 -41
  4. package/guidelines/components/accordion.md +732 -0
  5. package/guidelines/components/avatar.md +1015 -0
  6. package/guidelines/components/badge.md +728 -0
  7. package/guidelines/components/button.md +75 -40
  8. package/guidelines/components/card.md +84 -25
  9. package/guidelines/components/checkbox.md +671 -0
  10. package/guidelines/components/dialog.md +619 -31
  11. package/guidelines/components/drawer.md +1616 -0
  12. package/guidelines/components/heading.md +576 -0
  13. package/guidelines/components/icon-button.md +92 -37
  14. package/guidelines/components/input-addon.md +685 -0
  15. package/guidelines/components/input-group.md +830 -0
  16. package/guidelines/components/input.md +92 -37
  17. package/guidelines/components/popover.md +1271 -0
  18. package/guidelines/components/progress.md +836 -0
  19. package/guidelines/components/radio-group.md +852 -0
  20. package/guidelines/components/select.md +1662 -0
  21. package/guidelines/components/skeleton.md +802 -0
  22. package/guidelines/components/slider.md +911 -0
  23. package/guidelines/components/spinner.md +783 -0
  24. package/guidelines/components/switch.md +105 -38
  25. package/guidelines/components/tabs.md +1488 -0
  26. package/guidelines/components/textarea.md +495 -0
  27. package/guidelines/components/toast.md +784 -0
  28. package/guidelines/components/tooltip.md +912 -0
  29. package/guidelines/design-tokens/colors.md +309 -72
  30. package/guidelines/design-tokens/elevation.md +615 -45
  31. package/guidelines/design-tokens/spacing.md +654 -74
  32. package/guidelines/design-tokens/typography.md +432 -50
  33. package/guidelines/overview-components.md +60 -8
  34. package/guidelines/overview-imports.md +314 -0
  35. package/guidelines/overview-patterns.md +3852 -0
  36. package/package.json +4 -2
@@ -0,0 +1,783 @@
1
+ # Spinner
2
+
3
+ **Purpose:** Loading indicator that provides visual feedback during asynchronous operations following Material Design 3 patterns.
4
+
5
+ ## When to Use This Component
6
+
7
+ Use Spinner when you need to **indicate loading or processing states** to users during async operations (data fetching, form submission, page loading).
8
+
9
+ **Decision Tree:**
10
+
11
+ | Scenario | Use This | Why |
12
+ | ------------------------------------------- | -------------------- | ------------------------------------- |
13
+ | Loading data from API | Spinner ✅ | Indicates async operation in progress |
14
+ | Button action in progress (submitting form) | Spinner in Button ✅ | Shows action is processing |
15
+ | Page/section loading | Spinner ✅ | Feedback while content loads |
16
+ | Progress with known duration/percentage | ProgressBar | Shows specific progress amount |
17
+ | Multi-step process with defined steps | Stepper | Shows progress through steps |
18
+ | Indefinite wait (unknown duration) | Spinner ✅ | Best for unknown duration |
19
+ | Background process (non-blocking) | Toast or Badge | Don't block user interaction |
20
+
21
+ **Component Comparison:**
22
+
23
+ ```typescript
24
+ // ✅ Use Spinner for loading data
25
+ const [isLoading, setIsLoading] = useState(true);
26
+
27
+ {isLoading ? (
28
+ <div className={css({ display: 'flex', justifyContent: 'center', py: 'lg' })}>
29
+ <Spinner size="lg" />
30
+ </div>
31
+ ) : (
32
+ <DataTable data={data} />
33
+ )}
34
+
35
+ // ✅ Use Spinner in buttons during submission
36
+ <Button disabled={isSubmitting}>
37
+ {isSubmitting && <Spinner size="sm" />}
38
+ {isSubmitting ? 'Submitting...' : 'Submit'}
39
+ </Button>
40
+
41
+ // ✅ Use Spinner inline with text
42
+ <div className={css({ display: 'flex', alignItems: 'center', gap: 'sm' })}>
43
+ <Spinner size="sm" />
44
+ <span>Loading your data...</span>
45
+ </div>
46
+
47
+ // ❌ Don't use Spinner for progress with percentage
48
+ <Spinner size="md" />
49
+ <p>Loading... 45%</p> // Wrong - should show progress bar
50
+
51
+ <ProgressBar value={45} max={100}>
52
+ <ProgressBar.Label>Loading... 45%</ProgressBar.Label>
53
+ <ProgressBar.Track>
54
+ <ProgressBar.Range />
55
+ </ProgressBar.Track>
56
+ </ProgressBar> // Correct
57
+
58
+ // ❌ Don't use Spinner for multi-step processes
59
+ <Spinner size="md" />
60
+ <p>Step 2 of 5</p> // Wrong - should show stepper
61
+
62
+ <Stepper currentStep={2} totalSteps={5}>
63
+ {/* Steps */}
64
+ </Stepper> // Correct
65
+
66
+ // ❌ Don't use Spinner alone without context
67
+ <Spinner size="md" /> // Wrong - user doesn't know what's loading
68
+
69
+ <div>
70
+ <Spinner size="md" />
71
+ <p>Loading products...</p> // Correct - provides context
72
+ </div>
73
+ ```
74
+
75
+ ## Import
76
+
77
+ ```typescript
78
+ import { Spinner } from '@discourser/design-system';
79
+ ```
80
+
81
+ ## Sizes
82
+
83
+ | Size | Dimension | Usage |
84
+ | --------- | --------- | ------------------------------------------- |
85
+ | `inherit` | 1em | Inherits parent font size, inline with text |
86
+ | `xs` | 12px | Extra small, inline icons |
87
+ | `sm` | 16px | Small buttons, compact UI |
88
+ | `md` | 20px | Default, most use cases |
89
+ | `lg` | 24px | Larger buttons, sections |
90
+ | `xl` | 28px | Prominent loading states |
91
+ | `2xl` | 32px | Full page loading, hero sections |
92
+
93
+ **Recommendation:** Use `md` for most cases. Use `sm` for buttons. Use `lg` or larger for full-page loading.
94
+
95
+ ## Props
96
+
97
+ | Prop | Type | Default | Description |
98
+ | ----------- | ------------------------------------------------------------ | ------- | ---------------------- |
99
+ | `size` | `'inherit' \| 'xs' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| '2xl'` | `'md'` | Spinner size |
100
+ | `className` | `string` | - | Additional CSS classes |
101
+
102
+ **Note:** Spinner extends `HTMLAttributes<HTMLSpanElement>`, so all standard HTML span attributes are supported. The spinner inherits `currentColor` for its color.
103
+
104
+ ## Examples
105
+
106
+ ### Basic Usage
107
+
108
+ ```typescript
109
+ import { Spinner } from '@discourser/design-system';
110
+
111
+ // Default spinner
112
+ <Spinner />
113
+
114
+ // Medium spinner (explicit)
115
+ <Spinner size="md" />
116
+
117
+ // With accessible label
118
+ <Spinner role="status" aria-label="Loading" />
119
+ ```
120
+
121
+ ### Different Sizes
122
+
123
+ ```typescript
124
+ // Inherit size (matches text size)
125
+ <p className={css({ fontSize: '24px' })}>
126
+ Loading <Spinner size="inherit" />
127
+ </p>
128
+
129
+ // Extra small
130
+ <Spinner size="xs" />
131
+
132
+ // Small
133
+ <Spinner size="sm" />
134
+
135
+ // Medium (default)
136
+ <Spinner size="md" />
137
+
138
+ // Large
139
+ <Spinner size="lg" />
140
+
141
+ // Extra large
142
+ <Spinner size="xl" />
143
+
144
+ // 2X Large
145
+ <Spinner size="2xl" />
146
+ ```
147
+
148
+ ### In Buttons
149
+
150
+ ```typescript
151
+ import { Button, Spinner } from '@discourser/design-system';
152
+
153
+ const [isLoading, setIsLoading] = useState(false);
154
+
155
+ // Loading state in button
156
+ <Button disabled={isLoading}>
157
+ {isLoading && <Spinner size="sm" />}
158
+ {isLoading ? 'Loading...' : 'Load Data'}
159
+ </Button>
160
+
161
+ // Spinner only (icon button style)
162
+ <Button disabled={isLoading} aria-label={isLoading ? 'Loading' : 'Submit'}>
163
+ {isLoading ? <Spinner size="sm" /> : <SendIcon />}
164
+ </Button>
165
+
166
+ // With left icon slot
167
+ <Button leftIcon={isLoading ? <Spinner size="sm" /> : null} disabled={isLoading}>
168
+ {isLoading ? 'Submitting...' : 'Submit Form'}
169
+ </Button>
170
+ ```
171
+
172
+ ### Loading States
173
+
174
+ ```typescript
175
+ // Page loading
176
+ const [isLoading, setIsLoading] = useState(true);
177
+
178
+ {isLoading ? (
179
+ <div className={css({
180
+ display: 'flex',
181
+ justifyContent: 'center',
182
+ alignItems: 'center',
183
+ height: '400px'
184
+ })}>
185
+ <Spinner size="xl" />
186
+ </div>
187
+ ) : (
188
+ <Content />
189
+ )}
190
+
191
+ // Section loading
192
+ {isLoadingSection ? (
193
+ <div className={css({ display: 'flex', justifyContent: 'center', py: 'lg' })}>
194
+ <Spinner size="lg" />
195
+ </div>
196
+ ) : (
197
+ <Section />
198
+ )}
199
+
200
+ // Inline loading
201
+ <div className={css({ display: 'flex', alignItems: 'center', gap: 'sm' })}>
202
+ <Spinner size="sm" />
203
+ <span>Loading your messages...</span>
204
+ </div>
205
+ ```
206
+
207
+ ### With Context Message
208
+
209
+ ```typescript
210
+ // Centered with message
211
+ <div className={css({
212
+ display: 'flex',
213
+ flexDirection: 'column',
214
+ alignItems: 'center',
215
+ gap: 'md',
216
+ py: 'xl'
217
+ })}>
218
+ <Spinner size="xl" />
219
+ <p className={css({ color: 'fg.muted' })}>Loading your data...</p>
220
+ </div>
221
+
222
+ // Inline with message
223
+ <div className={css({ display: 'flex', alignItems: 'center', gap: 'sm' })}>
224
+ <Spinner size="md" />
225
+ <span>Fetching products...</span>
226
+ </div>
227
+ ```
228
+
229
+ ### Color Variations
230
+
231
+ ```typescript
232
+ // Inherits text color (default behavior)
233
+ <div className={css({ color: 'primary' })}>
234
+ <Spinner size="md" />
235
+ </div>
236
+
237
+ // With custom color
238
+ <div className={css({ color: 'success' })}>
239
+ <Spinner size="md" />
240
+ <span>Success! Loading...</span>
241
+ </div>
242
+
243
+ // Error state
244
+ <div className={css({ color: 'error' })}>
245
+ <Spinner size="md" />
246
+ <span>Retrying...</span>
247
+ </div>
248
+ ```
249
+
250
+ ### In Cards
251
+
252
+ ```typescript
253
+ import { Card } from '@discourser/design-system';
254
+
255
+ <Card.Root>
256
+ <Card.Header>
257
+ <Card.Title>User Statistics</Card.Title>
258
+ </Card.Header>
259
+ <Card.Body>
260
+ {isLoading ? (
261
+ <div className={css({
262
+ display: 'flex',
263
+ justifyContent: 'center',
264
+ alignItems: 'center',
265
+ minHeight: '200px'
266
+ })}>
267
+ <Spinner size="lg" />
268
+ </div>
269
+ ) : (
270
+ <Statistics data={data} />
271
+ )}
272
+ </Card.Body>
273
+ </Card.Root>
274
+ ```
275
+
276
+ ### In Modals/Dialogs
277
+
278
+ ```typescript
279
+ import { Dialog, Spinner } from '@discourser/design-system';
280
+
281
+ <Dialog.Root open={isOpen}>
282
+ <Dialog.Content>
283
+ <Dialog.Header>
284
+ <Dialog.Title>Loading Data</Dialog.Title>
285
+ </Dialog.Header>
286
+ <Dialog.Body>
287
+ <div className={css({
288
+ display: 'flex',
289
+ flexDirection: 'column',
290
+ alignItems: 'center',
291
+ gap: 'md',
292
+ py: 'xl'
293
+ })}>
294
+ <Spinner size="xl" />
295
+ <p>Please wait while we fetch your information...</p>
296
+ </div>
297
+ </Dialog.Body>
298
+ </Dialog.Content>
299
+ </Dialog.Root>
300
+ ```
301
+
302
+ ### In Input Groups
303
+
304
+ ```typescript
305
+ import { InputGroup, Input, Spinner } from '@discourser/design-system';
306
+
307
+ const [isSearching, setIsSearching] = useState(false);
308
+
309
+ <InputGroup.Root size="md">
310
+ <InputGroup.Element>
311
+ {isSearching ? (
312
+ <Spinner size="sm" />
313
+ ) : (
314
+ <SearchIcon />
315
+ )}
316
+ </InputGroup.Element>
317
+ <Input placeholder="Search..." />
318
+ </InputGroup.Root>
319
+ ```
320
+
321
+ ### In Lists
322
+
323
+ ```typescript
324
+ // Loading list item
325
+ <ul className={css({ display: 'flex', flexDirection: 'column', gap: 'sm' })}>
326
+ <li>Item 1</li>
327
+ <li>Item 2</li>
328
+ {isLoadingMore && (
329
+ <li className={css({ display: 'flex', justifyContent: 'center', py: 'md' })}>
330
+ <Spinner size="md" />
331
+ </li>
332
+ )}
333
+ </ul>
334
+ ```
335
+
336
+ ### Full Page Loading
337
+
338
+ ```typescript
339
+ // Overlay loading screen
340
+ {isLoadingPage && (
341
+ <div className={css({
342
+ position: 'fixed',
343
+ inset: 0,
344
+ display: 'flex',
345
+ flexDirection: 'column',
346
+ justifyContent: 'center',
347
+ alignItems: 'center',
348
+ gap: 'lg',
349
+ bg: 'rgba(0, 0, 0, 0.5)',
350
+ zIndex: 9999
351
+ })}>
352
+ <Spinner size="2xl" className={css({ color: 'white' })} />
353
+ <p className={css({ color: 'white', fontSize: 'lg' })}>Loading application...</p>
354
+ </div>
355
+ )}
356
+ ```
357
+
358
+ ## Common Patterns
359
+
360
+ ### Async Data Fetching
361
+
362
+ ```typescript
363
+ const [data, setData] = useState(null);
364
+ const [isLoading, setIsLoading] = useState(true);
365
+ const [error, setError] = useState(null);
366
+
367
+ useEffect(() => {
368
+ async function fetchData() {
369
+ try {
370
+ setIsLoading(true);
371
+ const response = await fetch('/api/data');
372
+ const result = await response.json();
373
+ setData(result);
374
+ } catch (err) {
375
+ setError(err.message);
376
+ } finally {
377
+ setIsLoading(false);
378
+ }
379
+ }
380
+
381
+ fetchData();
382
+ }, []);
383
+
384
+ return (
385
+ <div>
386
+ {isLoading && (
387
+ <div className={css({ display: 'flex', justifyContent: 'center', py: 'xl' })}>
388
+ <Spinner size="lg" />
389
+ </div>
390
+ )}
391
+ {error && <p className={css({ color: 'error' })}>Error: {error}</p>}
392
+ {data && <DataDisplay data={data} />}
393
+ </div>
394
+ );
395
+ ```
396
+
397
+ ### Form Submission
398
+
399
+ ```typescript
400
+ const [isSubmitting, setIsSubmitting] = useState(false);
401
+
402
+ const handleSubmit = async (e: FormEvent) => {
403
+ e.preventDefault();
404
+ setIsSubmitting(true);
405
+
406
+ try {
407
+ await submitForm(formData);
408
+ toast.success('Form submitted successfully!');
409
+ } catch (error) {
410
+ toast.error('Failed to submit form');
411
+ } finally {
412
+ setIsSubmitting(false);
413
+ }
414
+ };
415
+
416
+ <form onSubmit={handleSubmit}>
417
+ <Input label="Name" name="name" />
418
+ <Input label="Email" name="email" type="email" />
419
+
420
+ <Button type="submit" disabled={isSubmitting}>
421
+ {isSubmitting && <Spinner size="sm" />}
422
+ {isSubmitting ? 'Submitting...' : 'Submit'}
423
+ </Button>
424
+ </form>
425
+ ```
426
+
427
+ ### Infinite Scroll Loading
428
+
429
+ ```typescript
430
+ const [items, setItems] = useState([]);
431
+ const [isLoadingMore, setIsLoadingMore] = useState(false);
432
+ const [hasMore, setHasMore] = useState(true);
433
+
434
+ const loadMore = async () => {
435
+ if (isLoadingMore || !hasMore) return;
436
+
437
+ setIsLoadingMore(true);
438
+ try {
439
+ const newItems = await fetchMoreItems();
440
+ setItems([...items, ...newItems]);
441
+ setHasMore(newItems.length > 0);
442
+ } catch (error) {
443
+ console.error('Failed to load more items');
444
+ } finally {
445
+ setIsLoadingMore(false);
446
+ }
447
+ };
448
+
449
+ <div>
450
+ <div className={css({ display: 'grid', gap: 'md' })}>
451
+ {items.map(item => (
452
+ <ItemCard key={item.id} item={item} />
453
+ ))}
454
+ </div>
455
+
456
+ {isLoadingMore && (
457
+ <div className={css({ display: 'flex', justifyContent: 'center', py: 'lg' })}>
458
+ <Spinner size="lg" />
459
+ </div>
460
+ )}
461
+
462
+ {hasMore && !isLoadingMore && (
463
+ <Button variant="outlined" onClick={loadMore}>
464
+ Load More
465
+ </Button>
466
+ )}
467
+ </div>
468
+ ```
469
+
470
+ ### Search with Debounce
471
+
472
+ ```typescript
473
+ const [searchQuery, setSearchQuery] = useState('');
474
+ const [isSearching, setIsSearching] = useState(false);
475
+ const [results, setResults] = useState([]);
476
+
477
+ useEffect(() => {
478
+ if (!searchQuery) {
479
+ setResults([]);
480
+ return;
481
+ }
482
+
483
+ setIsSearching(true);
484
+
485
+ const debounceTimer = setTimeout(async () => {
486
+ try {
487
+ const data = await searchAPI(searchQuery);
488
+ setResults(data);
489
+ } catch (error) {
490
+ console.error('Search failed');
491
+ } finally {
492
+ setIsSearching(false);
493
+ }
494
+ }, 300);
495
+
496
+ return () => clearTimeout(debounceTimer);
497
+ }, [searchQuery]);
498
+
499
+ <div>
500
+ <InputGroup.Root size="md">
501
+ <InputGroup.Element>
502
+ {isSearching ? (
503
+ <Spinner size="sm" />
504
+ ) : (
505
+ <SearchIcon />
506
+ )}
507
+ </InputGroup.Element>
508
+ <Input
509
+ placeholder="Search..."
510
+ value={searchQuery}
511
+ onChange={(e) => setSearchQuery(e.target.value)}
512
+ />
513
+ </InputGroup.Root>
514
+
515
+ <div className={css({ mt: 'md' })}>
516
+ {results.map(result => (
517
+ <ResultItem key={result.id} result={result} />
518
+ ))}
519
+ </div>
520
+ </div>
521
+ ```
522
+
523
+ ### Skeleton Loading Alternative
524
+
525
+ ```typescript
526
+ // Use spinner for simple loading
527
+ {isLoading ? (
528
+ <div className={css({ display: 'flex', justifyContent: 'center', py: 'lg' })}>
529
+ <Spinner size="lg" />
530
+ </div>
531
+ ) : (
532
+ <Content />
533
+ )}
534
+
535
+ // Or use skeleton for better UX (shows layout)
536
+ {isLoading ? (
537
+ <Skeleton>
538
+ <Skeleton.Text lines={3} />
539
+ <Skeleton.Rectangle height="200px" />
540
+ </Skeleton>
541
+ ) : (
542
+ <Content />
543
+ )}
544
+ ```
545
+
546
+ ## DO NOT
547
+
548
+ ```typescript
549
+ // ❌ Don't use Spinner without context
550
+ <Spinner size="md" /> // Wrong - user doesn't know what's loading
551
+
552
+ // ✅ Provide context
553
+ <div className={css({ display: 'flex', alignItems: 'center', gap: 'sm' })}>
554
+ <Spinner size="md" />
555
+ <span>Loading products...</span>
556
+ </div>
557
+
558
+ // ❌ Don't use Spinner for progress with percentage
559
+ <Spinner size="md" />
560
+ <p>45% complete</p> // Wrong - use ProgressBar
561
+
562
+ // ✅ Use ProgressBar for known progress
563
+ <ProgressBar value={45} max={100} />
564
+
565
+ // ❌ Don't override animation with inline styles
566
+ <Spinner style={{ animation: 'none' }} /> // Wrong - breaks functionality
567
+
568
+ // ❌ Don't use wrong size for context
569
+ <Button>
570
+ <Spinner size="2xl" /> // Wrong - too large for button
571
+ Submit
572
+ </Button>
573
+
574
+ // ✅ Match size to context
575
+ <Button>
576
+ <Spinner size="sm" />
577
+ Submit
578
+ </Button>
579
+
580
+ // ❌ Don't use multiple spinners for single operation
581
+ <div>
582
+ <Spinner size="md" />
583
+ <Spinner size="lg" /> // Wrong - confusing
584
+ <p>Loading...</p>
585
+ </div>
586
+
587
+ // ✅ Use one spinner per loading state
588
+ <div>
589
+ <Spinner size="lg" />
590
+ <p>Loading...</p>
591
+ </div>
592
+
593
+ // ❌ Don't show spinner without disabling interaction
594
+ <Button onClick={handleClick}>
595
+ <Spinner size="sm" />
596
+ Submit
597
+ </Button> // Wrong - button still clickable
598
+
599
+ // ✅ Disable during loading
600
+ <Button onClick={handleClick} disabled={isLoading}>
601
+ {isLoading && <Spinner size="sm" />}
602
+ {isLoading ? 'Submitting...' : 'Submit'}
603
+ </Button>
604
+ ```
605
+
606
+ ## Accessibility
607
+
608
+ The Spinner component follows WCAG 2.1 Level AA standards:
609
+
610
+ - **ARIA Attributes**: Use `role="status"` for loading announcements
611
+ - **Screen Reader Labels**: Provide `aria-label` to describe what's loading
612
+ - **Live Regions**: Consider `aria-live` for dynamic updates
613
+ - **Focus Management**: Don't trap focus during loading
614
+ - **Timeout Considerations**: Provide way to cancel long operations
615
+
616
+ ### Accessibility Best Practices
617
+
618
+ ```typescript
619
+ // ✅ Provide status role and label
620
+ <Spinner role="status" aria-label="Loading content" />
621
+
622
+ // ✅ Use with descriptive text
623
+ <div role="status" aria-live="polite">
624
+ <Spinner size="md" />
625
+ <span>Loading your dashboard...</span>
626
+ </div>
627
+
628
+ // ✅ Announce loading to screen readers
629
+ <div>
630
+ <Spinner role="status" aria-label="Searching products" />
631
+ <span className="sr-only">Searching products, please wait...</span>
632
+ </div>
633
+
634
+ // ✅ Provide cancel option for long operations
635
+ <div>
636
+ <Spinner size="lg" />
637
+ <p>This might take a few minutes...</p>
638
+ <Button variant="outlined" onClick={handleCancel}>
639
+ Cancel
640
+ </Button>
641
+ </div>
642
+
643
+ // ✅ Update aria-label dynamically
644
+ <Spinner
645
+ role="status"
646
+ aria-label={`Loading step ${currentStep} of ${totalSteps}`}
647
+ />
648
+
649
+ // ✅ Hide decorative spinners from screen readers
650
+ <Spinner aria-hidden="true" />
651
+ <p>Loading...</p> // Text provides context instead
652
+ ```
653
+
654
+ ## Size Selection Guide
655
+
656
+ | Scenario | Recommended Size | Reasoning |
657
+ | ------------------ | ---------------- | ---------------------------- |
658
+ | Inline with text | `inherit` | Matches text size |
659
+ | Small buttons | `sm` | Fits button padding |
660
+ | Medium buttons | `sm` or `md` | Proportional to button |
661
+ | Large buttons | `md` or `lg` | Matches larger button |
662
+ | Input fields | `sm` | Fits input height |
663
+ | Cards/sections | `lg` | Visible but not overwhelming |
664
+ | Full page loading | `xl` or `2xl` | Prominent, clear indication |
665
+ | Icon size elements | `xs` to `sm` | Matches icon sizing |
666
+
667
+ ## State Behaviors
668
+
669
+ | State | Visual Change | Behavior |
670
+ | ------------ | -------------------------------- | ------------------------- |
671
+ | **Loading** | Continuous rotation | Indicates ongoing process |
672
+ | **Complete** | Hidden/removed | Operation finished |
673
+ | **Error** | Replaced with error icon/message | Operation failed |
674
+
675
+ ## Responsive Considerations
676
+
677
+ ```typescript
678
+ // Responsive sizing
679
+ <Spinner size={{ base: 'lg', md: 'md' }} />
680
+
681
+ // Responsive loading layout
682
+ <div className={css({
683
+ display: 'flex',
684
+ flexDirection: { base: 'column', md: 'row' },
685
+ alignItems: 'center',
686
+ gap: 'md'
687
+ })}>
688
+ <Spinner size={{ base: 'xl', md: 'lg' }} />
689
+ <p>Loading your content...</p>
690
+ </div>
691
+
692
+ // Mobile-optimized full-page loading
693
+ <div className={css({
694
+ position: 'fixed',
695
+ inset: 0,
696
+ display: 'flex',
697
+ justifyContent: 'center',
698
+ alignItems: 'center',
699
+ bg: 'bg.default'
700
+ })}>
701
+ <Spinner size={{ base: '2xl', md: 'xl' }} />
702
+ </div>
703
+ ```
704
+
705
+ ## Testing
706
+
707
+ ```typescript
708
+ import { render, screen } from '@testing-library/react';
709
+
710
+ test('spinner renders with correct role', () => {
711
+ render(<Spinner role="status" aria-label="Loading" />);
712
+
713
+ const spinner = screen.getByRole('status');
714
+ expect(spinner).toBeInTheDocument();
715
+ expect(spinner).toHaveAttribute('aria-label', 'Loading');
716
+ });
717
+
718
+ test('spinner shows during loading', () => {
719
+ const { rerender } = render(
720
+ <div>
721
+ {true && <Spinner data-testid="spinner" />}
722
+ <p>Content</p>
723
+ </div>
724
+ );
725
+
726
+ expect(screen.getByTestId('spinner')).toBeInTheDocument();
727
+
728
+ rerender(
729
+ <div>
730
+ {false && <Spinner data-testid="spinner" />}
731
+ <p>Content</p>
732
+ </div>
733
+ );
734
+
735
+ expect(screen.queryByTestId('spinner')).not.toBeInTheDocument();
736
+ });
737
+
738
+ test('button is disabled while spinner is shown', () => {
739
+ const isLoading = true;
740
+
741
+ render(
742
+ <Button disabled={isLoading}>
743
+ {isLoading && <Spinner size="sm" />}
744
+ {isLoading ? 'Loading...' : 'Submit'}
745
+ </Button>
746
+ );
747
+
748
+ const button = screen.getByRole('button');
749
+ expect(button).toBeDisabled();
750
+ expect(screen.getByText('Loading...')).toBeInTheDocument();
751
+ });
752
+ ```
753
+
754
+ ## Performance Considerations
755
+
756
+ ```typescript
757
+ // ✅ Conditional rendering for better performance
758
+ {isLoading && <Spinner size="md" />}
759
+
760
+ // ✅ Avoid unnecessary re-renders
761
+ const MemoizedSpinner = memo(Spinner);
762
+
763
+ // ✅ Show spinner only after delay (avoid flash for quick operations)
764
+ const [showSpinner, setShowSpinner] = useState(false);
765
+
766
+ useEffect(() => {
767
+ if (isLoading) {
768
+ const timer = setTimeout(() => setShowSpinner(true), 300);
769
+ return () => clearTimeout(timer);
770
+ } else {
771
+ setShowSpinner(false);
772
+ }
773
+ }, [isLoading]);
774
+
775
+ return showSpinner ? <Spinner size="md" /> : null;
776
+ ```
777
+
778
+ ## Related Components
779
+
780
+ - **ProgressBar** - For operations with known progress percentage
781
+ - **Skeleton** - For placeholder content during loading
782
+ - **Button** - Often contains spinner during loading states
783
+ - **Toast** - For background operation notifications