mirror of
https://github.com/wshobson/agents.git
synced 2026-03-18 17:47:16 +00:00
feat(ui-design): add comprehensive UI/UX design plugin v1.0.0
New plugin covering mobile (iOS, Android, React Native) and web applications with modern design patterns, accessibility, and design systems. Components: - 9 skills: design-system-patterns, accessibility-compliance, responsive-design, mobile-ios-design, mobile-android-design, react-native-design, web-component-design, interaction-design, visual-design-foundations - 4 commands: design-review, create-component, accessibility-audit, design-system-setup - 3 agents: ui-designer, accessibility-expert, design-system-architect Marketplace updated: - Version bumped to 1.3.4 - 102 agents (+3), 116 skills (+9)
This commit is contained in:
@@ -0,0 +1,602 @@
|
||||
# Accessibility Patterns Reference
|
||||
|
||||
## ARIA Patterns for Common Components
|
||||
|
||||
### Modal Dialog
|
||||
|
||||
```tsx
|
||||
import { useEffect, useRef, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Modal({ isOpen, onClose, title, children }: ModalProps) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const previousActiveElement = useRef<Element | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
previousActiveElement.current = document.activeElement;
|
||||
dialogRef.current?.focus();
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
(previousActiveElement.current as HTMLElement)?.focus();
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'Tab') trapFocus(e, dialogRef.current);
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
aria-hidden={!isOpen}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
tabIndex={-1}
|
||||
className="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl"
|
||||
>
|
||||
<h2 id="modal-title" className="text-lg font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close dialog"
|
||||
className="absolute right-4 top-4 p-1"
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</button>
|
||||
<div className="mt-4">{children}</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
function trapFocus(e: KeyboardEvent, container: HTMLElement | null) {
|
||||
if (!container) return;
|
||||
|
||||
const focusableElements = container.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements[focusableElements.length - 1];
|
||||
|
||||
if (e.shiftKey && document.activeElement === firstElement) {
|
||||
e.preventDefault();
|
||||
lastElement.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === lastElement) {
|
||||
e.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dropdown Menu
|
||||
|
||||
```tsx
|
||||
import { useState, useRef, useEffect, type ReactNode } from 'react';
|
||||
|
||||
interface DropdownProps {
|
||||
trigger: ReactNode;
|
||||
children: ReactNode;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function Dropdown({ trigger, children, label }: DropdownProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
setIsOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
} else {
|
||||
focusNextItem(menuRef.current, 1);
|
||||
}
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
if (isOpen) {
|
||||
focusNextItem(menuRef.current, -1);
|
||||
}
|
||||
break;
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
focusFirstItem(menuRef.current);
|
||||
break;
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
focusLastItem(menuRef.current);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative" onKeyDown={handleKeyDown}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isOpen}
|
||||
aria-label={label}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-2 px-3 py-2"
|
||||
>
|
||||
{trigger}
|
||||
<ChevronDownIcon
|
||||
aria-hidden="true"
|
||||
className={`transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
className="absolute left-0 mt-1 min-w-48 rounded-md bg-white py-1 shadow-lg ring-1 ring-black/5"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MenuItemProps {
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function MenuItem({ children, onClick, disabled }: MenuItemProps) {
|
||||
return (
|
||||
<button
|
||||
role="menuitem"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-100 disabled:opacity-50"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function focusNextItem(menu: HTMLElement | null, direction: 1 | -1) {
|
||||
if (!menu) return;
|
||||
const items = menu.querySelectorAll<HTMLElement>('[role="menuitem"]:not([disabled])');
|
||||
const currentIndex = Array.from(items).indexOf(document.activeElement as HTMLElement);
|
||||
const nextIndex = (currentIndex + direction + items.length) % items.length;
|
||||
items[nextIndex]?.focus();
|
||||
}
|
||||
|
||||
function focusFirstItem(menu: HTMLElement | null) {
|
||||
menu?.querySelector<HTMLElement>('[role="menuitem"]:not([disabled])')?.focus();
|
||||
}
|
||||
|
||||
function focusLastItem(menu: HTMLElement | null) {
|
||||
const items = menu?.querySelectorAll<HTMLElement>('[role="menuitem"]:not([disabled])');
|
||||
items?.[items.length - 1]?.focus();
|
||||
}
|
||||
```
|
||||
|
||||
### Combobox / Autocomplete
|
||||
|
||||
```tsx
|
||||
import { useState, useRef, useId, type ChangeEvent, type KeyboardEvent } from 'react';
|
||||
|
||||
interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ComboboxProps {
|
||||
options: Option[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function Combobox({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
placeholder,
|
||||
}: ComboboxProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listboxRef = useRef<HTMLUListElement>(null);
|
||||
const inputId = useId();
|
||||
const listboxId = useId();
|
||||
|
||||
const filteredOptions = options.filter((option) =>
|
||||
option.label.toLowerCase().includes(inputValue.toLowerCase())
|
||||
);
|
||||
|
||||
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value);
|
||||
setIsOpen(true);
|
||||
setActiveIndex(-1);
|
||||
};
|
||||
|
||||
const handleSelect = (option: Option) => {
|
||||
onChange(option.value);
|
||||
setInputValue(option.label);
|
||||
setIsOpen(false);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
} else {
|
||||
setActiveIndex((prev) =>
|
||||
prev < filteredOptions.length - 1 ? prev + 1 : prev
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => (prev > 0 ? prev - 1 : prev));
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (activeIndex >= 0 && filteredOptions[activeIndex]) {
|
||||
handleSelect(filteredOptions[activeIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
setIsOpen(false);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<label htmlFor={inputId} className="block text-sm font-medium mb-1">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={inputId}
|
||||
type="text"
|
||||
role="combobox"
|
||||
aria-expanded={isOpen}
|
||||
aria-autocomplete="list"
|
||||
aria-controls={listboxId}
|
||||
aria-activedescendant={
|
||||
activeIndex >= 0 ? `option-${activeIndex}` : undefined
|
||||
}
|
||||
value={inputValue}
|
||||
placeholder={placeholder}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onBlur={() => setTimeout(() => setIsOpen(false), 200)}
|
||||
className="w-full rounded-md border px-3 py-2"
|
||||
/>
|
||||
|
||||
{isOpen && filteredOptions.length > 0 && (
|
||||
<ul
|
||||
ref={listboxRef}
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
aria-label={label}
|
||||
className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 shadow-lg ring-1 ring-black/5"
|
||||
>
|
||||
{filteredOptions.map((option, index) => (
|
||||
<li
|
||||
key={option.value}
|
||||
id={`option-${index}`}
|
||||
role="option"
|
||||
aria-selected={activeIndex === index}
|
||||
onClick={() => handleSelect(option)}
|
||||
className={`cursor-pointer px-3 py-2 ${
|
||||
activeIndex === index ? 'bg-blue-100' : 'hover:bg-gray-100'
|
||||
} ${value === option.value ? 'font-medium' : ''}`}
|
||||
>
|
||||
{option.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{isOpen && filteredOptions.length === 0 && (
|
||||
<div className="absolute z-10 mt-1 w-full rounded-md bg-white px-3 py-2 shadow-lg">
|
||||
No results found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Form Validation
|
||||
|
||||
```tsx
|
||||
import { useId, type FormEvent } from 'react';
|
||||
|
||||
interface FormFieldProps {
|
||||
label: string;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
children: (props: {
|
||||
id: string;
|
||||
'aria-describedby': string | undefined;
|
||||
'aria-invalid': boolean;
|
||||
}) => ReactNode;
|
||||
}
|
||||
|
||||
export function FormField({ label, error, required, children }: FormFieldProps) {
|
||||
const id = useId();
|
||||
const errorId = `${id}-error`;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label htmlFor={id} className="block text-sm font-medium">
|
||||
{label}
|
||||
{required && (
|
||||
<span aria-hidden="true" className="ml-1 text-red-500">
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{children({
|
||||
id,
|
||||
'aria-describedby': error ? errorId : undefined,
|
||||
'aria-invalid': !!error,
|
||||
})}
|
||||
|
||||
{error && (
|
||||
<p id={errorId} role="alert" className="text-sm text-red-600">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage
|
||||
function ContactForm() {
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
// Validation logic...
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<FormField label="Email" error={errors.email} required>
|
||||
{(props) => (
|
||||
<input
|
||||
{...props}
|
||||
type="email"
|
||||
required
|
||||
className={`w-full rounded border px-3 py-2 ${
|
||||
props['aria-invalid'] ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<button type="submit" className="mt-4 px-4 py-2 bg-blue-600 text-white rounded">
|
||||
Submit
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Skip Links
|
||||
|
||||
```tsx
|
||||
export function SkipLinks() {
|
||||
return (
|
||||
<div className="sr-only focus-within:not-sr-only">
|
||||
<a
|
||||
href="#main-content"
|
||||
className="absolute left-4 top-4 z-50 rounded bg-blue-600 px-4 py-2 text-white focus:outline-none focus:ring-2"
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
<a
|
||||
href="#main-navigation"
|
||||
className="absolute left-4 top-16 z-50 rounded bg-blue-600 px-4 py-2 text-white focus:outline-none focus:ring-2"
|
||||
>
|
||||
Skip to navigation
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Live Regions
|
||||
|
||||
```tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface LiveAnnouncerProps {
|
||||
message: string;
|
||||
politeness?: 'polite' | 'assertive';
|
||||
}
|
||||
|
||||
export function LiveAnnouncer({ message, politeness = 'polite' }: LiveAnnouncerProps) {
|
||||
const [announcement, setAnnouncement] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
// Clear first, then set - ensures screen readers pick up the change
|
||||
setAnnouncement('');
|
||||
const timer = setTimeout(() => setAnnouncement(message), 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, [message]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live={politeness}
|
||||
aria-atomic="true"
|
||||
className="sr-only"
|
||||
>
|
||||
{announcement}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage in a search component
|
||||
function SearchResults({ results, loading }: { results: Item[]; loading: boolean }) {
|
||||
const message = loading
|
||||
? 'Loading results...'
|
||||
: `${results.length} results found`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<LiveAnnouncer message={message} />
|
||||
<ul>{/* results */}</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Focus Management Utilities
|
||||
|
||||
```tsx
|
||||
// useFocusReturn - restore focus after closing
|
||||
function useFocusReturn() {
|
||||
const previousElement = useRef<Element | null>(null);
|
||||
|
||||
const saveFocus = () => {
|
||||
previousElement.current = document.activeElement;
|
||||
};
|
||||
|
||||
const restoreFocus = () => {
|
||||
(previousElement.current as HTMLElement)?.focus();
|
||||
};
|
||||
|
||||
return { saveFocus, restoreFocus };
|
||||
}
|
||||
|
||||
// useFocusTrap - keep focus within container
|
||||
function useFocusTrap(containerRef: RefObject<HTMLElement>, isActive: boolean) {
|
||||
useEffect(() => {
|
||||
if (!isActive || !containerRef.current) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
const focusableSelector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') return;
|
||||
|
||||
const focusableElements = container.querySelectorAll<HTMLElement>(focusableSelector);
|
||||
const first = focusableElements[0];
|
||||
const last = focusableElements[focusableElements.length - 1];
|
||||
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
container.addEventListener('keydown', handleKeyDown);
|
||||
return () => container.removeEventListener('keydown', handleKeyDown);
|
||||
}, [containerRef, isActive]);
|
||||
}
|
||||
```
|
||||
|
||||
## Color Contrast Utilities
|
||||
|
||||
```tsx
|
||||
// Check if colors meet WCAG requirements
|
||||
function getContrastRatio(fg: string, bg: string): number {
|
||||
const getLuminance = (hex: string): number => {
|
||||
const rgb = parseInt(hex.slice(1), 16);
|
||||
const r = (rgb >> 16) & 0xff;
|
||||
const g = (rgb >> 8) & 0xff;
|
||||
const b = rgb & 0xff;
|
||||
|
||||
const [rs, gs, bs] = [r, g, b].map((c) => {
|
||||
c = c / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
|
||||
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
|
||||
};
|
||||
|
||||
const l1 = getLuminance(fg);
|
||||
const l2 = getLuminance(bg);
|
||||
const lighter = Math.max(l1, l2);
|
||||
const darker = Math.min(l1, l2);
|
||||
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
function meetsWCAG(fg: string, bg: string, level: 'AA' | 'AAA' = 'AA'): boolean {
|
||||
const ratio = getContrastRatio(fg, bg);
|
||||
return level === 'AAA' ? ratio >= 7 : ratio >= 4.5;
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,441 @@
|
||||
# Component Patterns Reference
|
||||
|
||||
## Compound Components Deep Dive
|
||||
|
||||
Compound components share implicit state while allowing flexible composition.
|
||||
|
||||
### Implementation with Context
|
||||
|
||||
```tsx
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
} from 'react';
|
||||
|
||||
// Types
|
||||
interface TabsContextValue {
|
||||
activeTab: string;
|
||||
setActiveTab: Dispatch<SetStateAction<string>>;
|
||||
}
|
||||
|
||||
interface TabsProps {
|
||||
defaultValue: string;
|
||||
children: ReactNode;
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
interface TabListProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface TabProps {
|
||||
value: string;
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface TabPanelProps {
|
||||
value: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// Context
|
||||
const TabsContext = createContext<TabsContextValue | null>(null);
|
||||
|
||||
function useTabs() {
|
||||
const context = useContext(TabsContext);
|
||||
if (!context) {
|
||||
throw new Error('Tabs components must be used within <Tabs>');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
// Root Component
|
||||
export function Tabs({ defaultValue, children, onChange }: TabsProps) {
|
||||
const [activeTab, setActiveTab] = useState(defaultValue);
|
||||
|
||||
const handleChange: Dispatch<SetStateAction<string>> = useCallback(
|
||||
(value) => {
|
||||
const newValue = typeof value === 'function' ? value(activeTab) : value;
|
||||
setActiveTab(newValue);
|
||||
onChange?.(newValue);
|
||||
},
|
||||
[activeTab, onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<TabsContext.Provider value={{ activeTab, setActiveTab: handleChange }}>
|
||||
<div className="tabs">{children}</div>
|
||||
</TabsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// Tab List (container for tab triggers)
|
||||
Tabs.List = function TabList({ children, className }: TabListProps) {
|
||||
return (
|
||||
<div role="tablist" className={`flex border-b ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Individual Tab (trigger)
|
||||
Tabs.Tab = function Tab({ value, children, disabled }: TabProps) {
|
||||
const { activeTab, setActiveTab } = useTabs();
|
||||
const isActive = activeTab === value;
|
||||
|
||||
return (
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls={`panel-${value}`}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
disabled={disabled}
|
||||
onClick={() => setActiveTab(value)}
|
||||
className={`
|
||||
px-4 py-2 font-medium transition-colors
|
||||
${isActive
|
||||
? 'border-b-2 border-blue-600 text-blue-600'
|
||||
: 'text-gray-600 hover:text-gray-900'}
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// Tab Panel (content)
|
||||
Tabs.Panel = function TabPanel({ value, children }: TabPanelProps) {
|
||||
const { activeTab } = useTabs();
|
||||
|
||||
if (activeTab !== value) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
id={`panel-${value}`}
|
||||
aria-labelledby={`tab-${value}`}
|
||||
tabIndex={0}
|
||||
className="py-4"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```tsx
|
||||
<Tabs defaultValue="overview" onChange={console.log}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="overview">Overview</Tabs.Tab>
|
||||
<Tabs.Tab value="features">Features</Tabs.Tab>
|
||||
<Tabs.Tab value="pricing" disabled>Pricing</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="overview">
|
||||
<h2>Product Overview</h2>
|
||||
<p>Description here...</p>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="features">
|
||||
<h2>Key Features</h2>
|
||||
<ul>...</ul>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
```
|
||||
|
||||
## Render Props Pattern
|
||||
|
||||
Delegate rendering control to the consumer while providing state and helpers.
|
||||
|
||||
```tsx
|
||||
interface DataLoaderRenderProps<T> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
interface DataLoaderProps<T> {
|
||||
url: string;
|
||||
children: (props: DataLoaderRenderProps<T>) => ReactNode;
|
||||
}
|
||||
|
||||
function DataLoader<T>({ url, children }: DataLoaderProps<T>) {
|
||||
const [state, setState] = useState<{
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
}>({
|
||||
data: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setState(prev => ({ ...prev, loading: true, error: null }));
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Fetch failed');
|
||||
const data = await response.json();
|
||||
setState({ data, loading: false, error: null });
|
||||
} catch (error) {
|
||||
setState(prev => ({ ...prev, loading: false, error: error as Error }));
|
||||
}
|
||||
}, [url]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
return <>{children({ ...state, refetch: fetchData })}</>;
|
||||
}
|
||||
|
||||
// Usage
|
||||
<DataLoader<User[]> url="/api/users">
|
||||
{({ data, loading, error, refetch }) => {
|
||||
if (loading) return <Spinner />;
|
||||
if (error) return <ErrorMessage error={error} onRetry={refetch} />;
|
||||
return <UserList users={data!} />;
|
||||
}}
|
||||
</DataLoader>
|
||||
```
|
||||
|
||||
## Polymorphic Components
|
||||
|
||||
Components that can render as different HTML elements.
|
||||
|
||||
```tsx
|
||||
type AsProp<C extends React.ElementType> = {
|
||||
as?: C;
|
||||
};
|
||||
|
||||
type PropsToOmit<C extends React.ElementType, P> = keyof (AsProp<C> & P);
|
||||
|
||||
type PolymorphicComponentProp<
|
||||
C extends React.ElementType,
|
||||
Props = {}
|
||||
> = React.PropsWithChildren<Props & AsProp<C>> &
|
||||
Omit<React.ComponentPropsWithoutRef<C>, PropsToOmit<C, Props>>;
|
||||
|
||||
interface TextOwnProps {
|
||||
variant?: 'body' | 'heading' | 'label';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
type TextProps<C extends React.ElementType> = PolymorphicComponentProp<C, TextOwnProps>;
|
||||
|
||||
function Text<C extends React.ElementType = 'span'>({
|
||||
as,
|
||||
variant = 'body',
|
||||
size = 'md',
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TextProps<C>) {
|
||||
const Component = as || 'span';
|
||||
|
||||
const variantClasses = {
|
||||
body: 'font-normal',
|
||||
heading: 'font-bold',
|
||||
label: 'font-medium uppercase tracking-wide',
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'text-sm',
|
||||
md: 'text-base',
|
||||
lg: 'text-lg',
|
||||
};
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={`${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage
|
||||
<Text>Default span</Text>
|
||||
<Text as="p" variant="body" size="lg">Paragraph</Text>
|
||||
<Text as="h1" variant="heading" size="lg">Heading</Text>
|
||||
<Text as="label" variant="label" htmlFor="input">Label</Text>
|
||||
```
|
||||
|
||||
## Controlled vs Uncontrolled Pattern
|
||||
|
||||
Support both modes for maximum flexibility.
|
||||
|
||||
```tsx
|
||||
interface InputProps {
|
||||
// Controlled
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
// Uncontrolled
|
||||
defaultValue?: string;
|
||||
// Common
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function Input({
|
||||
value: controlledValue,
|
||||
onChange,
|
||||
defaultValue = '',
|
||||
...props
|
||||
}: InputProps) {
|
||||
const [internalValue, setInternalValue] = useState(defaultValue);
|
||||
|
||||
// Determine if controlled
|
||||
const isControlled = controlledValue !== undefined;
|
||||
const value = isControlled ? controlledValue : internalValue;
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
|
||||
if (!isControlled) {
|
||||
setInternalValue(newValue);
|
||||
}
|
||||
|
||||
onChange?.(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Controlled usage
|
||||
const [search, setSearch] = useState('');
|
||||
<Input value={search} onChange={setSearch} />
|
||||
|
||||
// Uncontrolled usage
|
||||
<Input defaultValue="initial" onChange={console.log} />
|
||||
```
|
||||
|
||||
## Slot Pattern
|
||||
|
||||
Allow consumers to replace internal parts.
|
||||
|
||||
```tsx
|
||||
interface CardProps {
|
||||
children: ReactNode;
|
||||
header?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
media?: ReactNode;
|
||||
}
|
||||
|
||||
function Card({ children, header, footer, media }: CardProps) {
|
||||
return (
|
||||
<article className="rounded-lg border bg-white shadow-sm">
|
||||
{media && (
|
||||
<div className="aspect-video overflow-hidden rounded-t-lg">
|
||||
{media}
|
||||
</div>
|
||||
)}
|
||||
{header && (
|
||||
<header className="border-b px-4 py-3">
|
||||
{header}
|
||||
</header>
|
||||
)}
|
||||
<div className="px-4 py-4">
|
||||
{children}
|
||||
</div>
|
||||
{footer && (
|
||||
<footer className="border-t px-4 py-3 bg-gray-50 rounded-b-lg">
|
||||
{footer}
|
||||
</footer>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage with slots
|
||||
<Card
|
||||
media={<img src="/image.jpg" alt="" />}
|
||||
header={<h2 className="font-semibold">Card Title</h2>}
|
||||
footer={<Button>Action</Button>}
|
||||
>
|
||||
<p>Card content goes here.</p>
|
||||
</Card>
|
||||
```
|
||||
|
||||
## Forward Ref Pattern
|
||||
|
||||
Allow parent components to access the underlying DOM node.
|
||||
|
||||
```tsx
|
||||
import { forwardRef, useRef, useImperativeHandle } from 'react';
|
||||
|
||||
interface InputHandle {
|
||||
focus: () => void;
|
||||
clear: () => void;
|
||||
getValue: () => string;
|
||||
}
|
||||
|
||||
interface FancyInputProps {
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const FancyInput = forwardRef<InputHandle, FancyInputProps>(
|
||||
({ label, placeholder }, ref) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => inputRef.current?.focus(),
|
||||
clear: () => {
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
},
|
||||
getValue: () => inputRef.current?.value ?? '',
|
||||
}));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{label}</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
FancyInput.displayName = 'FancyInput';
|
||||
|
||||
// Usage
|
||||
function Form() {
|
||||
const inputRef = useRef<InputHandle>(null);
|
||||
|
||||
const handleSubmit = () => {
|
||||
console.log(inputRef.current?.getValue());
|
||||
inputRef.current?.clear();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<FancyInput ref={inputRef} label="Name" />
|
||||
<button type="button" onClick={() => inputRef.current?.focus()}>
|
||||
Focus Input
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,585 @@
|
||||
# CSS Styling Approaches Reference
|
||||
|
||||
## Comparison Matrix
|
||||
|
||||
| Approach | Runtime | Bundle Size | Learning Curve | Dynamic Styles | SSR |
|
||||
|----------|---------|-------------|----------------|----------------|-----|
|
||||
| CSS Modules | None | Minimal | Low | Limited | Yes |
|
||||
| Tailwind | None | Small (purged) | Medium | Via classes | Yes |
|
||||
| styled-components | Yes | Medium | Medium | Full | Yes* |
|
||||
| Emotion | Yes | Medium | Medium | Full | Yes |
|
||||
| Vanilla Extract | None | Minimal | High | Limited | Yes |
|
||||
|
||||
## CSS Modules
|
||||
|
||||
Scoped CSS with zero runtime overhead.
|
||||
|
||||
### Setup
|
||||
|
||||
```tsx
|
||||
// Button.module.css
|
||||
.button {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background-color: #2563eb;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.primary:hover {
|
||||
background-color: #1d4ed8;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background-color: #f3f4f6;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.secondary:hover {
|
||||
background-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.small {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.large {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Button.tsx
|
||||
import styles from './Button.module.css';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface ButtonProps {
|
||||
variant?: 'primary' | 'secondary';
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
size = 'medium',
|
||||
children,
|
||||
onClick,
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
styles.button,
|
||||
styles[variant],
|
||||
size !== 'medium' && styles[size]
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Composition
|
||||
|
||||
```css
|
||||
/* base.module.css */
|
||||
.visuallyHidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Button.module.css */
|
||||
.srOnly {
|
||||
composes: visuallyHidden from './base.module.css';
|
||||
}
|
||||
```
|
||||
|
||||
## Tailwind CSS
|
||||
|
||||
Utility-first CSS with design system constraints.
|
||||
|
||||
### Class Variance Authority (CVA)
|
||||
|
||||
```tsx
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
// Base styles
|
||||
'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Tailwind Merge Utility
|
||||
|
||||
```tsx
|
||||
// lib/utils.ts
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
// Usage - handles conflicting classes
|
||||
cn('px-4 py-2', 'px-6'); // => 'py-2 px-6'
|
||||
cn('text-red-500', condition && 'text-blue-500'); // => 'text-blue-500' if condition
|
||||
```
|
||||
|
||||
### Custom Plugin
|
||||
|
||||
```js
|
||||
// tailwind.config.js
|
||||
const plugin = require('tailwindcss/plugin');
|
||||
|
||||
module.exports = {
|
||||
plugins: [
|
||||
plugin(function({ addUtilities, addComponents, theme }) {
|
||||
// Add utilities
|
||||
addUtilities({
|
||||
'.text-balance': {
|
||||
'text-wrap': 'balance',
|
||||
},
|
||||
'.scrollbar-hide': {
|
||||
'-ms-overflow-style': 'none',
|
||||
'scrollbar-width': 'none',
|
||||
'&::-webkit-scrollbar': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Add components
|
||||
addComponents({
|
||||
'.card': {
|
||||
backgroundColor: theme('colors.white'),
|
||||
borderRadius: theme('borderRadius.lg'),
|
||||
padding: theme('spacing.6'),
|
||||
boxShadow: theme('boxShadow.md'),
|
||||
},
|
||||
});
|
||||
}),
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
## styled-components
|
||||
|
||||
CSS-in-JS with template literals.
|
||||
|
||||
```tsx
|
||||
import styled, { css, keyframes } from 'styled-components';
|
||||
|
||||
// Keyframes
|
||||
const fadeIn = keyframes`
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
`;
|
||||
|
||||
// Base button with variants
|
||||
interface ButtonProps {
|
||||
$variant?: 'primary' | 'secondary' | 'ghost';
|
||||
$size?: 'sm' | 'md' | 'lg';
|
||||
$isLoading?: boolean;
|
||||
}
|
||||
|
||||
const sizeStyles = {
|
||||
sm: css`
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
`,
|
||||
md: css`
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 1rem;
|
||||
`,
|
||||
lg: css`
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1.125rem;
|
||||
`,
|
||||
};
|
||||
|
||||
const variantStyles = {
|
||||
primary: css`
|
||||
background-color: ${({ theme }) => theme.colors.primary};
|
||||
color: white;
|
||||
&:hover:not(:disabled) {
|
||||
background-color: ${({ theme }) => theme.colors.primaryHover};
|
||||
}
|
||||
`,
|
||||
secondary: css`
|
||||
background-color: ${({ theme }) => theme.colors.secondary};
|
||||
color: ${({ theme }) => theme.colors.text};
|
||||
&:hover:not(:disabled) {
|
||||
background-color: ${({ theme }) => theme.colors.secondaryHover};
|
||||
}
|
||||
`,
|
||||
ghost: css`
|
||||
background-color: transparent;
|
||||
color: ${({ theme }) => theme.colors.text};
|
||||
&:hover:not(:disabled) {
|
||||
background-color: ${({ theme }) => theme.colors.ghost};
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
const Button = styled.button<ButtonProps>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
animation: ${fadeIn} 0.3s ease;
|
||||
|
||||
${({ $size = 'md' }) => sizeStyles[$size]}
|
||||
${({ $variant = 'primary' }) => variantStyles[$variant]}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
${({ $isLoading }) =>
|
||||
$isLoading &&
|
||||
css`
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
`}
|
||||
`;
|
||||
|
||||
// Extending components
|
||||
const IconButton = styled(Button)`
|
||||
padding: 0.5rem;
|
||||
aspect-ratio: 1;
|
||||
`;
|
||||
|
||||
// Theme provider
|
||||
const theme = {
|
||||
colors: {
|
||||
primary: '#2563eb',
|
||||
primaryHover: '#1d4ed8',
|
||||
secondary: '#f3f4f6',
|
||||
secondaryHover: '#e5e7eb',
|
||||
ghost: 'rgba(0, 0, 0, 0.05)',
|
||||
text: '#1f2937',
|
||||
},
|
||||
};
|
||||
|
||||
// Usage
|
||||
<ThemeProvider theme={theme}>
|
||||
<Button $variant="primary" $size="lg">
|
||||
Click me
|
||||
</Button>
|
||||
</ThemeProvider>
|
||||
```
|
||||
|
||||
## Emotion
|
||||
|
||||
Flexible CSS-in-JS with object and template syntax.
|
||||
|
||||
```tsx
|
||||
/** @jsxImportSource @emotion/react */
|
||||
import { css, Theme, ThemeProvider, useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
// Theme typing
|
||||
declare module '@emotion/react' {
|
||||
export interface Theme {
|
||||
colors: {
|
||||
primary: string;
|
||||
background: string;
|
||||
text: string;
|
||||
};
|
||||
spacing: (factor: number) => string;
|
||||
}
|
||||
}
|
||||
|
||||
const theme: Theme = {
|
||||
colors: {
|
||||
primary: '#2563eb',
|
||||
background: '#ffffff',
|
||||
text: '#1f2937',
|
||||
},
|
||||
spacing: (factor: number) => `${factor * 0.25}rem`,
|
||||
};
|
||||
|
||||
// Object syntax
|
||||
const cardStyles = (theme: Theme) => css({
|
||||
backgroundColor: theme.colors.background,
|
||||
padding: theme.spacing(4),
|
||||
borderRadius: '0.5rem',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
});
|
||||
|
||||
// Template literal syntax
|
||||
const buttonStyles = css`
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
`;
|
||||
|
||||
// Styled component with theme
|
||||
const Card = styled.div`
|
||||
background-color: ${({ theme }) => theme.colors.background};
|
||||
padding: ${({ theme }) => theme.spacing(4)};
|
||||
border-radius: 0.5rem;
|
||||
`;
|
||||
|
||||
// Component with css prop
|
||||
function Alert({ children }: { children: React.ReactNode }) {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<div
|
||||
css={css`
|
||||
padding: ${theme.spacing(3)};
|
||||
background-color: ${theme.colors.primary}10;
|
||||
border-left: 4px solid ${theme.colors.primary};
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage
|
||||
<ThemeProvider theme={theme}>
|
||||
<Card>
|
||||
<Alert>Important message</Alert>
|
||||
</Card>
|
||||
</ThemeProvider>
|
||||
```
|
||||
|
||||
## Vanilla Extract
|
||||
|
||||
Zero-runtime CSS-in-JS with full type safety.
|
||||
|
||||
```tsx
|
||||
// styles.css.ts
|
||||
import { style, styleVariants, createTheme } from '@vanilla-extract/css';
|
||||
import { recipe, type RecipeVariants } from '@vanilla-extract/recipes';
|
||||
|
||||
// Theme contract
|
||||
export const [themeClass, vars] = createTheme({
|
||||
color: {
|
||||
primary: '#2563eb',
|
||||
secondary: '#64748b',
|
||||
background: '#ffffff',
|
||||
text: '#1f2937',
|
||||
},
|
||||
space: {
|
||||
small: '0.5rem',
|
||||
medium: '1rem',
|
||||
large: '1.5rem',
|
||||
},
|
||||
radius: {
|
||||
small: '0.25rem',
|
||||
medium: '0.375rem',
|
||||
large: '0.5rem',
|
||||
},
|
||||
});
|
||||
|
||||
// Simple style
|
||||
export const container = style({
|
||||
padding: vars.space.medium,
|
||||
backgroundColor: vars.color.background,
|
||||
});
|
||||
|
||||
// Style variants
|
||||
export const text = styleVariants({
|
||||
primary: { color: vars.color.text },
|
||||
secondary: { color: vars.color.secondary },
|
||||
accent: { color: vars.color.primary },
|
||||
});
|
||||
|
||||
// Recipe (like CVA)
|
||||
export const button = recipe({
|
||||
base: {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 500,
|
||||
borderRadius: vars.radius.medium,
|
||||
transition: 'background-color 0.2s',
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
':disabled': {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
variant: {
|
||||
primary: {
|
||||
backgroundColor: vars.color.primary,
|
||||
color: 'white',
|
||||
':hover': {
|
||||
backgroundColor: '#1d4ed8',
|
||||
},
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: '#f3f4f6',
|
||||
color: vars.color.text,
|
||||
':hover': {
|
||||
backgroundColor: '#e5e7eb',
|
||||
},
|
||||
},
|
||||
},
|
||||
size: {
|
||||
small: {
|
||||
padding: '0.25rem 0.75rem',
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
medium: {
|
||||
padding: '0.5rem 1rem',
|
||||
fontSize: '1rem',
|
||||
},
|
||||
large: {
|
||||
padding: '0.75rem 1.5rem',
|
||||
fontSize: '1.125rem',
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'primary',
|
||||
size: 'medium',
|
||||
},
|
||||
});
|
||||
|
||||
export type ButtonVariants = RecipeVariants<typeof button>;
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Button.tsx
|
||||
import { button, type ButtonVariants, themeClass } from './styles.css';
|
||||
|
||||
interface ButtonProps extends ButtonVariants {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function Button({ variant, size, children, onClick }: ButtonProps) {
|
||||
return (
|
||||
<button className={button({ variant, size })} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// App.tsx - wrap with theme
|
||||
function App() {
|
||||
return (
|
||||
<div className={themeClass}>
|
||||
<Button variant="primary" size="large">
|
||||
Click me
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Critical CSS Extraction
|
||||
|
||||
```tsx
|
||||
// Next.js with styled-components
|
||||
// pages/_document.tsx
|
||||
import Document, { DocumentContext } from 'next/document';
|
||||
import { ServerStyleSheet } from 'styled-components';
|
||||
|
||||
export default class MyDocument extends Document {
|
||||
static async getInitialProps(ctx: DocumentContext) {
|
||||
const sheet = new ServerStyleSheet();
|
||||
const originalRenderPage = ctx.renderPage;
|
||||
|
||||
try {
|
||||
ctx.renderPage = () =>
|
||||
originalRenderPage({
|
||||
enhanceApp: (App) => (props) =>
|
||||
sheet.collectStyles(<App {...props} />),
|
||||
});
|
||||
|
||||
const initialProps = await Document.getInitialProps(ctx);
|
||||
return {
|
||||
...initialProps,
|
||||
styles: [initialProps.styles, sheet.getStyleElement()],
|
||||
};
|
||||
} finally {
|
||||
sheet.seal();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Code Splitting Styles
|
||||
|
||||
```tsx
|
||||
// Dynamically import heavy styled components
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const HeavyChart = dynamic(() => import('./HeavyChart'), {
|
||||
loading: () => <Skeleton height={400} />,
|
||||
ssr: false,
|
||||
});
|
||||
```
|
||||
Reference in New Issue
Block a user