style: format all files with prettier

This commit is contained in:
Seth Hobson
2026-01-19 17:07:03 -05:00
parent 8d37048deb
commit 56848874a2
355 changed files with 15215 additions and 10241 deletions

View File

@@ -21,30 +21,35 @@ Master accessibility implementation to create inclusive experiences that work fo
## Core Capabilities
### 1. WCAG 2.2 Guidelines
- Perceivable: Content must be presentable in different ways
- Operable: Interface must be navigable with keyboard and assistive tech
- Understandable: Content and operation must be clear
- Robust: Content must work with current and future assistive technologies
### 2. ARIA Patterns
- Roles: Define element purpose (button, dialog, navigation)
- States: Indicate current condition (expanded, selected, disabled)
- Properties: Describe relationships and additional info (labelledby, describedby)
- Live regions: Announce dynamic content changes
### 3. Keyboard Navigation
- Focus order and tab sequence
- Focus indicators and visible focus states
- Keyboard shortcuts and hotkeys
- Focus trapping for modals and dialogs
### 4. Screen Reader Support
- Semantic HTML structure
- Alternative text for images
- Proper heading hierarchy
- Skip links and landmarks
### 5. Mobile Accessibility
- Touch target sizing (44x44dp minimum)
- VoiceOver and TalkBack compatibility
- Gesture alternatives
@@ -54,18 +59,18 @@ Master accessibility implementation to create inclusive experiences that work fo
### WCAG 2.2 Success Criteria Checklist
| Level | Criterion | Description |
|-------|-----------|-------------|
| A | 1.1.1 | Non-text content has text alternatives |
| A | 1.3.1 | Info and relationships programmatically determinable |
| A | 2.1.1 | All functionality keyboard accessible |
| A | 2.4.1 | Skip to main content mechanism |
| AA | 1.4.3 | Contrast ratio 4.5:1 (text), 3:1 (large text) |
| AA | 1.4.11 | Non-text contrast 3:1 |
| AA | 2.4.7 | Focus visible |
| AA | 2.5.8 | Target size minimum 24x24px (NEW in 2.2) |
| AAA | 1.4.6 | Enhanced contrast 7:1 |
| AAA | 2.5.5 | Target size minimum 44x44px |
| Level | Criterion | Description |
| ----- | --------- | ---------------------------------------------------- |
| A | 1.1.1 | Non-text content has text alternatives |
| A | 1.3.1 | Info and relationships programmatically determinable |
| A | 2.1.1 | All functionality keyboard accessible |
| A | 2.4.1 | Skip to main content mechanism |
| AA | 1.4.3 | Contrast ratio 4.5:1 (text), 3:1 (large text) |
| AA | 1.4.11 | Non-text contrast 3:1 |
| AA | 2.4.7 | Focus visible |
| AA | 2.5.8 | Target size minimum 24x24px (NEW in 2.2) |
| AAA | 1.4.6 | Enhanced contrast 7:1 |
| AAA | 2.5.5 | Target size minimum 44x44px |
## Key Patterns
@@ -73,13 +78,13 @@ Master accessibility implementation to create inclusive experiences that work fo
```tsx
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary';
variant?: "primary" | "secondary";
isLoading?: boolean;
}
function AccessibleButton({
children,
variant = 'primary',
variant = "primary",
isLoading = false,
disabled,
...props
@@ -94,11 +99,11 @@ function AccessibleButton({
aria-disabled={disabled || isLoading}
className={cn(
// Visible focus ring
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
// Minimum touch target size (44x44px)
'min-h-[44px] min-w-[44px]',
variant === 'primary' && 'bg-primary text-primary-foreground',
(disabled || isLoading) && 'opacity-50 cursor-not-allowed'
"min-h-[44px] min-w-[44px]",
variant === "primary" && "bg-primary text-primary-foreground",
(disabled || isLoading) && "opacity-50 cursor-not-allowed",
)}
{...props}
>
@@ -118,8 +123,8 @@ function AccessibleButton({
### Pattern 2: Accessible Modal Dialog
```tsx
import * as React from 'react';
import { FocusTrap } from '@headlessui/react';
import * as React from "react";
import { FocusTrap } from "@headlessui/react";
interface DialogProps {
isOpen: boolean;
@@ -135,21 +140,21 @@ function AccessibleDialog({ isOpen, onClose, title, children }: DialogProps) {
// Close on Escape key
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
if (e.key === "Escape" && isOpen) {
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
// Prevent body scroll when open
React.useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
document.body.style.overflow = "hidden";
}
return () => {
document.body.style.overflow = '';
document.body.style.overflow = "";
};
}, [isOpen]);
@@ -227,7 +232,9 @@ function AccessibleForm() {
<div className="space-y-2">
<label htmlFor="email" className="block font-medium">
Email address
<span aria-hidden="true" className="text-destructive ml-1">*</span>
<span aria-hidden="true" className="text-destructive ml-1">
*
</span>
<span className="sr-only">(required)</span>
</label>
<input
@@ -237,10 +244,10 @@ function AccessibleForm() {
required
aria-required="true"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : 'email-hint'}
aria-describedby={errors.email ? "email-error" : "email-hint"}
className={cn(
'w-full px-3 py-2 border rounded-md',
errors.email && 'border-destructive'
"w-full px-3 py-2 border rounded-md",
errors.email && "border-destructive",
)}
/>
{errors.email ? (
@@ -271,10 +278,10 @@ function SkipLink() {
href="#main-content"
className={cn(
// Hidden by default, visible on focus
'sr-only focus:not-sr-only',
'focus:absolute focus:top-4 focus:left-4 focus:z-50',
'focus:bg-background focus:px-4 focus:py-2 focus:rounded-md',
'focus:ring-2 focus:ring-primary'
"sr-only focus:not-sr-only",
"focus:absolute focus:top-4 focus:left-4 focus:z-50",
"focus:bg-background focus:px-4 focus:py-2 focus:rounded-md",
"focus:ring-2 focus:ring-primary",
)}
>
Skip to main content
@@ -302,12 +309,15 @@ function Layout({ children }) {
```tsx
function useAnnounce() {
const [message, setMessage] = React.useState('');
const [message, setMessage] = React.useState("");
const announce = React.useCallback((text: string, priority: 'polite' | 'assertive' = 'polite') => {
setMessage(''); // Clear first to ensure re-announcement
setTimeout(() => setMessage(text), 100);
}, []);
const announce = React.useCallback(
(text: string, priority: "polite" | "assertive" = "polite") => {
setMessage(""); // Clear first to ensure re-announcement
setTimeout(() => setMessage(text), 100);
},
[],
);
const Announcer = () => (
<div

View File

@@ -73,7 +73,7 @@ function Accordion({ items }) {
onClick={() => setOpenIndex(isOpen ? -1 : index)}
>
{item.title}
<span aria-hidden="true">{isOpen ? '' : '+'}</span>
<span aria-hidden="true">{isOpen ? "" : "+"}</span>
</button>
</h3>
<div
@@ -103,16 +103,16 @@ function Tabs({ tabs }) {
let newIndex = index;
switch (e.key) {
case 'ArrowRight':
case "ArrowRight":
newIndex = (index + 1) % tabs.length;
break;
case 'ArrowLeft':
case "ArrowLeft":
newIndex = (index - 1 + tabs.length) % tabs.length;
break;
case 'Home':
case "Home":
newIndex = 0;
break;
case 'End':
case "End":
newIndex = tabs.length - 1;
break;
default:
@@ -172,7 +172,7 @@ function MenuButton({ label, items }) {
const handleKeyDown = (e) => {
switch (e.key) {
case 'ArrowDown':
case "ArrowDown":
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
@@ -181,16 +181,16 @@ function MenuButton({ label, items }) {
setActiveIndex((prev) => Math.min(prev + 1, items.length - 1));
}
break;
case 'ArrowUp':
case "ArrowUp":
e.preventDefault();
setActiveIndex((prev) => Math.max(prev - 1, 0));
break;
case 'Escape':
case "Escape":
setIsOpen(false);
buttonRef.current?.focus();
break;
case 'Enter':
case ' ':
case "Enter":
case " ":
if (isOpen && activeIndex >= 0) {
e.preventDefault();
items[activeIndex].onClick();
@@ -253,36 +253,36 @@ function MenuButton({ label, items }) {
```tsx
function Combobox({ options, onSelect, placeholder }) {
const [inputValue, setInputValue] = useState('');
const [inputValue, setInputValue] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const inputRef = useRef(null);
const listboxId = useId();
const filteredOptions = options.filter((opt) =>
opt.toLowerCase().includes(inputValue.toLowerCase())
opt.toLowerCase().includes(inputValue.toLowerCase()),
);
const handleKeyDown = (e) => {
switch (e.key) {
case 'ArrowDown':
case "ArrowDown":
e.preventDefault();
setIsOpen(true);
setActiveIndex((prev) =>
Math.min(prev + 1, filteredOptions.length - 1)
Math.min(prev + 1, filteredOptions.length - 1),
);
break;
case 'ArrowUp':
case "ArrowUp":
e.preventDefault();
setActiveIndex((prev) => Math.max(prev - 1, 0));
break;
case 'Enter':
case "Enter":
if (activeIndex >= 0) {
e.preventDefault();
selectOption(filteredOptions[activeIndex]);
}
break;
case 'Escape':
case "Escape":
setIsOpen(false);
setActiveIndex(-1);
break;
@@ -396,16 +396,16 @@ function Toolbar({ items }) {
let newIndex = activeIndex;
switch (e.key) {
case 'ArrowRight':
case "ArrowRight":
newIndex = (activeIndex + 1) % items.length;
break;
case 'ArrowLeft':
case "ArrowLeft":
newIndex = (activeIndex - 1 + items.length) % items.length;
break;
case 'Home':
case "Home":
newIndex = 0;
break;
case 'End':
case "End":
newIndex = items.length - 1;
break;
default:
@@ -414,7 +414,7 @@ function Toolbar({ items }) {
e.preventDefault();
setActiveIndex(newIndex);
toolbarRef.current?.querySelectorAll('button')[newIndex]?.focus();
toolbarRef.current?.querySelectorAll("button")[newIndex]?.focus();
};
return (

View File

@@ -34,12 +34,11 @@ Mobile accessibility ensures apps work for users with disabilities on iOS and An
// Ensure adequate spacing between touch targets
function ButtonGroup({ buttons }) {
return (
<div className="flex gap-3"> {/* 12px minimum gap */}
<div className="flex gap-3">
{" "}
{/* 12px minimum gap */}
{buttons.map((btn) => (
<button
key={btn.id}
className="min-w-[44px] min-h-[44px] px-4 py-2"
>
<button key={btn.id} className="min-w-[44px] min-h-[44px] px-4 py-2">
{btn.label}
</button>
))}
@@ -66,7 +65,7 @@ function IconButton({ icon, label, onClick }) {
### React Native Accessibility Props
```tsx
import { View, Text, TouchableOpacity, AccessibilityInfo } from 'react-native';
import { View, Text, TouchableOpacity, AccessibilityInfo } from "react-native";
// Basic accessible button
function AccessibleButton({ onPress, title, hint }) {
@@ -91,15 +90,15 @@ function ProductCard({ product }) {
accessibilityLabel={`${product.name}, ${product.price}, ${product.rating} stars`}
accessibilityRole="button"
accessibilityActions={[
{ name: 'activate', label: 'View details' },
{ name: 'addToCart', label: 'Add to cart' },
{ name: "activate", label: "View details" },
{ name: "addToCart", label: "Add to cart" },
]}
onAccessibilityAction={(event) => {
switch (event.nativeEvent.actionName) {
case 'addToCart':
case "addToCart":
addToCart(product);
break;
case 'activate':
case "activate":
viewDetails(product);
break;
}
@@ -356,11 +355,9 @@ function SwipeableCard({ item, onDelete }) {
return (
<View
accessible={true}
accessibilityActions={[
{ name: 'delete', label: 'Delete item' },
]}
accessibilityActions={[{ name: "delete", label: "Delete item" }]}
onAccessibilityAction={(event) => {
if (event.nativeEvent.actionName === 'delete') {
if (event.nativeEvent.actionName === "delete") {
onDelete(item);
}
}}
@@ -382,7 +379,7 @@ function SwipeableCard({ item, onDelete }) {
<TouchableOpacity
accessibilityLabel={`Delete ${item.title}`}
onPress={() => onDelete(item)}
style={{ position: 'absolute', right: 0 }}
style={{ position: "absolute", right: 0 }}
>
<Text>Delete</Text>
</TouchableOpacity>
@@ -395,7 +392,7 @@ function SwipeableCard({ item, onDelete }) {
```tsx
// Respect reduced motion preference
import { AccessibilityInfo } from 'react-native';
import { AccessibilityInfo } from "react-native";
function AnimatedComponent() {
const [reduceMotion, setReduceMotion] = useState(false);
@@ -404,8 +401,8 @@ function AnimatedComponent() {
AccessibilityInfo.isReduceMotionEnabled().then(setReduceMotion);
const subscription = AccessibilityInfo.addEventListener(
'reduceMotionChanged',
setReduceMotion
"reduceMotionChanged",
setReduceMotion,
);
return () => subscription.remove();
@@ -414,9 +411,7 @@ function AnimatedComponent() {
return (
<Animated.View
style={{
transform: reduceMotion
? []
: [{ translateX: animatedValue }],
transform: reduceMotion ? [] : [{ translateX: animatedValue }],
opacity: reduceMotion ? 1 : animatedOpacity,
}}
>
@@ -503,6 +498,7 @@ const scaledFontSize = (size: number) => {
```markdown
## VoiceOver (iOS) Testing
- [ ] All interactive elements have labels
- [ ] Swipe navigation covers all content in logical order
- [ ] Custom actions available for complex interactions
@@ -511,6 +507,7 @@ const scaledFontSize = (size: number) => {
- [ ] Images have appropriate descriptions or are hidden
## TalkBack (Android) Testing
- [ ] Focus order is logical
- [ ] Touch exploration works correctly
- [ ] Custom actions available
@@ -519,12 +516,14 @@ const scaledFontSize = (size: number) => {
- [ ] Grouped content read together
## Motor Accessibility
- [ ] Touch targets at least 44x44 points
- [ ] Adequate spacing between targets (8dp minimum)
- [ ] Alternatives to complex gestures
- [ ] No time-limited interactions
## Visual Accessibility
- [ ] Text scales to 200% without loss
- [ ] Content visible in high contrast mode
- [ ] Color not sole indicator

View File

@@ -259,7 +259,7 @@ function Tooltip({ content, children }) {
<div
role="tooltip"
// Dismissible: user can close without moving pointer
onKeyDown={(e) => e.key === 'Escape' && setIsVisible(false)}
onKeyDown={(e) => e.key === "Escape" && setIsVisible(false)}
// Hoverable: content stays visible when pointer moves to it
onMouseEnter={() => setIsVisible(true)}
onMouseLeave={() => setIsVisible(false)}
@@ -292,7 +292,7 @@ function CustomButton({ onClick, children }) {
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
@@ -331,10 +331,10 @@ function Modal({ isOpen, onClose, children }) {
// Allow Escape to close
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key === "Escape") onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
return (
@@ -357,12 +357,18 @@ function Modal({ isOpen, onClose, children }) {
```tsx
// Skip links
<body>
<a href="#main" className="skip-link">Skip to main content</a>
<a href="#nav" className="skip-link">Skip to navigation</a>
<a href="#main" className="skip-link">
Skip to main content
</a>
<a href="#nav" className="skip-link">
Skip to navigation
</a>
<header>...</header>
<nav id="nav" aria-label="Main">...</nav>
<nav id="nav" aria-label="Main">
...
</nav>
<main id="main" tabIndex={-1}>
{/* Main content */}
@@ -455,8 +461,12 @@ Content and interface must be understandable.
```html
<!DOCTYPE html>
<html lang="en">
<head>...</head>
<body>...</body>
<head>
...
</head>
<body>
...
</body>
</html>
```
@@ -571,7 +581,7 @@ function CustomCheckbox({ checked, onChange, label }) {
aria-label={label}
onClick={() => onChange(!checked)}
>
{checked ? '✓' : '○'} {label}
{checked ? "✓" : "○"} {label}
</button>
);
}
@@ -587,8 +597,8 @@ function CustomSlider({ value, min, max, label, onChange }) {
aria-label={label}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'ArrowRight') onChange(Math.min(value + 1, max));
if (e.key === 'ArrowLeft') onChange(Math.max(value - 1, min));
if (e.key === "ArrowRight") onChange(Math.min(value + 1, max));
if (e.key === "ArrowLeft") onChange(Math.max(value - 1, min));
}}
>
<div style={{ width: `${((value - min) / (max - min)) * 100}%` }} />
@@ -601,6 +611,7 @@ function CustomSlider({ value, min, max, label, onChange }) {
```markdown
## Keyboard Testing
- [ ] All interactive elements focusable with Tab
- [ ] Focus order matches visual order
- [ ] Focus indicator always visible
@@ -609,6 +620,7 @@ function CustomSlider({ value, min, max, label, onChange }) {
- [ ] Enter/Space activates buttons and links
## Screen Reader Testing
- [ ] All images have alt text
- [ ] Form inputs have labels
- [ ] Headings in logical order
@@ -617,6 +629,7 @@ function CustomSlider({ value, min, max, label, onChange }) {
- [ ] Error messages announced
## Visual Testing
- [ ] Text contrast at least 4.5:1
- [ ] UI component contrast at least 3:1
- [ ] Works at 200% zoom

View File

@@ -20,6 +20,7 @@ Master design system architecture to create consistent, maintainable, and scalab
## Core Capabilities
### 1. Design Tokens
- Primitive tokens (raw values: colors, sizes, fonts)
- Semantic tokens (contextual meaning: text-primary, surface-elevated)
- Component tokens (specific usage: button-bg, card-border)
@@ -27,6 +28,7 @@ Master design system architecture to create consistent, maintainable, and scalab
- Multi-platform token generation (CSS, iOS, Android)
### 2. Theming Infrastructure
- CSS custom properties architecture
- Theme context providers in React
- Dynamic theme switching
@@ -35,6 +37,7 @@ Master design system architecture to create consistent, maintainable, and scalab
- Reduced motion and high contrast modes
### 3. Component Architecture
- Compound component patterns
- Polymorphic components (as prop)
- Variant and size systems
@@ -43,6 +46,7 @@ Master design system architecture to create consistent, maintainable, and scalab
- Style props and responsive variants
### 4. Token Pipeline
- Figma to code synchronization
- Style Dictionary configuration
- Token transformation and formatting
@@ -56,32 +60,32 @@ const tokens = {
colors: {
// Primitive tokens
gray: {
50: '#fafafa',
100: '#f5f5f5',
900: '#171717',
50: "#fafafa",
100: "#f5f5f5",
900: "#171717",
},
blue: {
500: '#3b82f6',
600: '#2563eb',
500: "#3b82f6",
600: "#2563eb",
},
},
// Semantic tokens (reference primitives)
semantic: {
light: {
'text-primary': 'var(--color-gray-900)',
'text-secondary': 'var(--color-gray-600)',
'surface-default': 'var(--color-white)',
'surface-elevated': 'var(--color-gray-50)',
'border-default': 'var(--color-gray-200)',
'interactive-primary': 'var(--color-blue-500)',
"text-primary": "var(--color-gray-900)",
"text-secondary": "var(--color-gray-600)",
"surface-default": "var(--color-white)",
"surface-elevated": "var(--color-gray-50)",
"border-default": "var(--color-gray-200)",
"interactive-primary": "var(--color-blue-500)",
},
dark: {
'text-primary': 'var(--color-gray-50)',
'text-secondary': 'var(--color-gray-400)',
'surface-default': 'var(--color-gray-900)',
'surface-elevated': 'var(--color-gray-800)',
'border-default': 'var(--color-gray-700)',
'interactive-primary': 'var(--color-blue-400)',
"text-primary": "var(--color-gray-50)",
"text-secondary": "var(--color-gray-400)",
"surface-default": "var(--color-gray-900)",
"surface-elevated": "var(--color-gray-800)",
"border-default": "var(--color-gray-700)",
"interactive-primary": "var(--color-blue-400)",
},
},
};
@@ -135,13 +139,13 @@ const tokens = {
### Pattern 2: Theme Switching with React
```tsx
import { createContext, useContext, useEffect, useState } from 'react';
import { createContext, useContext, useEffect, useState } from "react";
type Theme = 'light' | 'dark' | 'system';
type Theme = "light" | "dark" | "system";
interface ThemeContextValue {
theme: Theme;
resolvedTheme: 'light' | 'dark';
resolvedTheme: "light" | "dark";
setTheme: (theme: Theme) => void;
}
@@ -149,37 +153,37 @@ const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => {
if (typeof window !== 'undefined') {
return (localStorage.getItem('theme') as Theme) || 'system';
if (typeof window !== "undefined") {
return (localStorage.getItem("theme") as Theme) || "system";
}
return 'system';
return "system";
});
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');
const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">("light");
useEffect(() => {
const root = document.documentElement;
const applyTheme = (isDark: boolean) => {
root.classList.remove('light', 'dark');
root.classList.add(isDark ? 'dark' : 'light');
setResolvedTheme(isDark ? 'dark' : 'light');
root.classList.remove("light", "dark");
root.classList.add(isDark ? "dark" : "light");
setResolvedTheme(isDark ? "dark" : "light");
};
if (theme === 'system') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
if (theme === "system") {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
applyTheme(mediaQuery.matches);
const handler = (e: MediaQueryListEvent) => applyTheme(e.matches);
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
mediaQuery.addEventListener("change", handler);
return () => mediaQuery.removeEventListener("change", handler);
} else {
applyTheme(theme === 'dark');
applyTheme(theme === "dark");
}
}, [theme]);
useEffect(() => {
localStorage.setItem('theme', theme);
localStorage.setItem("theme", theme);
}, [theme]);
return (
@@ -191,7 +195,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) throw new Error('useTheme must be used within ThemeProvider');
if (!context) throw new Error("useTheme must be used within ThemeProvider");
return context;
};
```
@@ -199,45 +203,52 @@ export const useTheme = () => {
### Pattern 3: Variant System with CVA
```tsx
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
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 font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
"inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-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',
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: {
sm: 'h-9 px-3 text-sm',
md: 'h-10 px-4 text-sm',
lg: 'h-11 px-8 text-base',
icon: 'h-10 w-10',
sm: "h-9 px-3 text-sm",
md: "h-10 px-4 text-sm",
lg: "h-11 px-8 text-base",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: 'default',
size: 'md',
variant: "default",
size: "md",
},
}
},
);
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export function Button({ className, variant, size, ...props }: ButtonProps) {
return (
<button className={cn(buttonVariants({ variant, size, className }))} {...props} />
<button
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
```
@@ -247,44 +258,52 @@ export function Button({ className, variant, size, ...props }: ButtonProps) {
```javascript
// style-dictionary.config.js
module.exports = {
source: ['tokens/**/*.json'],
source: ["tokens/**/*.json"],
platforms: {
css: {
transformGroup: 'css',
buildPath: 'dist/css/',
files: [{
destination: 'variables.css',
format: 'css/variables',
options: {
outputReferences: true, // Preserve token references
transformGroup: "css",
buildPath: "dist/css/",
files: [
{
destination: "variables.css",
format: "css/variables",
options: {
outputReferences: true, // Preserve token references
},
},
}],
],
},
scss: {
transformGroup: 'scss',
buildPath: 'dist/scss/',
files: [{
destination: '_variables.scss',
format: 'scss/variables',
}],
transformGroup: "scss",
buildPath: "dist/scss/",
files: [
{
destination: "_variables.scss",
format: "scss/variables",
},
],
},
ios: {
transformGroup: 'ios-swift',
buildPath: 'dist/ios/',
files: [{
destination: 'DesignTokens.swift',
format: 'ios-swift/class.swift',
className: 'DesignTokens',
}],
transformGroup: "ios-swift",
buildPath: "dist/ios/",
files: [
{
destination: "DesignTokens.swift",
format: "ios-swift/class.swift",
className: "DesignTokens",
},
],
},
android: {
transformGroup: 'android',
buildPath: 'dist/android/',
files: [{
destination: 'colors.xml',
format: 'android/colors',
filter: { attributes: { category: 'color' } },
}],
transformGroup: "android",
buildPath: "dist/android/",
files: [
{
destination: "colors.xml",
format: "android/colors",
filter: { attributes: { category: "color" } },
},
],
},
},
};

View File

@@ -10,20 +10,22 @@ Compound components share implicit state through React context, allowing flexibl
```tsx
// Compound component pattern
import * as React from 'react';
import * as React from "react";
interface AccordionContextValue {
openItems: Set<string>;
toggle: (id: string) => void;
type: 'single' | 'multiple';
type: "single" | "multiple";
}
const AccordionContext = React.createContext<AccordionContextValue | null>(null);
const AccordionContext = React.createContext<AccordionContextValue | null>(
null,
);
function useAccordionContext() {
const context = React.useContext(AccordionContext);
if (!context) {
throw new Error('Accordion components must be used within an Accordion');
throw new Error("Accordion components must be used within an Accordion");
}
return context;
}
@@ -31,13 +33,17 @@ function useAccordionContext() {
// Root component
interface AccordionProps {
children: React.ReactNode;
type?: 'single' | 'multiple';
type?: "single" | "multiple";
defaultOpen?: string[];
}
function Accordion({ children, type = 'single', defaultOpen = [] }: AccordionProps) {
function Accordion({
children,
type = "single",
defaultOpen = [],
}: AccordionProps) {
const [openItems, setOpenItems] = React.useState<Set<string>>(
new Set(defaultOpen)
new Set(defaultOpen),
);
const toggle = React.useCallback(
@@ -47,7 +53,7 @@ function Accordion({ children, type = 'single', defaultOpen = [] }: AccordionPro
if (next.has(id)) {
next.delete(id);
} else {
if (type === 'single') {
if (type === "single") {
next.clear();
}
next.add(id);
@@ -55,7 +61,7 @@ function Accordion({ children, type = 'single', defaultOpen = [] }: AccordionPro
return next;
});
},
[type]
[type],
);
return (
@@ -93,7 +99,7 @@ function AccordionTrigger({ children }: { children: React.ReactNode }) {
>
{children}
<ChevronDown
className={`h-4 w-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
className={`h-4 w-4 transition-transform ${isOpen ? "rotate-180" : ""}`}
/>
</button>
);
@@ -120,7 +126,7 @@ export const AccordionCompound = Object.assign(Accordion, {
// Usage
function Example() {
return (
<AccordionCompound type="single" defaultOpen={['item-1']}>
<AccordionCompound type="single" defaultOpen={["item-1"]}>
<AccordionCompound.Item id="item-1">
<AccordionCompound.Trigger>Is it accessible?</AccordionCompound.Trigger>
<AccordionCompound.Content>
@@ -144,7 +150,7 @@ Polymorphic components can render as different HTML elements or other components
```tsx
// Polymorphic component with proper TypeScript support
import * as React from 'react';
import * as React from "react";
type AsProp<C extends React.ElementType> = {
as?: C;
@@ -154,64 +160,71 @@ type PropsToOmit<C extends React.ElementType, P> = keyof (AsProp<C> & P);
type PolymorphicComponentProp<
C extends React.ElementType,
Props = {}
Props = {},
> = React.PropsWithChildren<Props & AsProp<C>> &
Omit<React.ComponentPropsWithoutRef<C>, PropsToOmit<C, Props>>;
type PolymorphicRef<C extends React.ElementType> =
React.ComponentPropsWithRef<C>['ref'];
React.ComponentPropsWithRef<C>["ref"];
type PolymorphicComponentPropWithRef<
C extends React.ElementType,
Props = {}
Props = {},
> = PolymorphicComponentProp<C, Props> & { ref?: PolymorphicRef<C> };
// Button component
interface ButtonOwnProps {
variant?: 'default' | 'outline' | 'ghost';
size?: 'sm' | 'md' | 'lg';
variant?: "default" | "outline" | "ghost";
size?: "sm" | "md" | "lg";
}
type ButtonProps<C extends React.ElementType = 'button'> =
type ButtonProps<C extends React.ElementType = "button"> =
PolymorphicComponentPropWithRef<C, ButtonOwnProps>;
const Button = React.forwardRef(
<C extends React.ElementType = 'button'>(
{ as, variant = 'default', size = 'md', className, children, ...props }: ButtonProps<C>,
ref?: PolymorphicRef<C>
<C extends React.ElementType = "button">(
{
as,
variant = "default",
size = "md",
className,
children,
...props
}: ButtonProps<C>,
ref?: PolymorphicRef<C>,
) => {
const Component = as || 'button';
const Component = as || "button";
const variantClasses = {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
outline: 'border border-input bg-background hover:bg-accent',
ghost: 'hover:bg-accent hover:text-accent-foreground',
default: "bg-primary text-primary-foreground hover:bg-primary/90",
outline: "border border-input bg-background hover:bg-accent",
ghost: "hover:bg-accent hover:text-accent-foreground",
};
const sizeClasses = {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-sm',
lg: 'h-12 px-6 text-base',
sm: "h-8 px-3 text-sm",
md: "h-10 px-4 text-sm",
lg: "h-12 px-6 text-base",
};
return (
<Component
ref={ref}
className={cn(
'inline-flex items-center justify-center rounded-md font-medium transition-colors',
"inline-flex items-center justify-center rounded-md font-medium transition-colors",
variantClasses[variant],
sizeClasses[size],
className
className,
)}
{...props}
>
{children}
</Component>
);
}
},
);
Button.displayName = 'Button';
Button.displayName = "Button";
// Usage
function Example() {
@@ -242,31 +255,31 @@ Slots allow users to replace default elements with custom implementations.
```tsx
// Slot pattern for customizable components
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean;
variant?: 'default' | 'outline';
variant?: "default" | "outline";
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ asChild = false, variant = 'default', className, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
({ asChild = false, variant = "default", className, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
className={cn(
'inline-flex items-center justify-center rounded-md font-medium',
variant === 'default' && 'bg-primary text-primary-foreground',
variant === 'outline' && 'border border-input bg-background',
className
"inline-flex items-center justify-center rounded-md font-medium",
variant === "default" && "bg-primary text-primary-foreground",
variant === "outline" && "border border-input bg-background",
className,
)}
{...props}
/>
);
}
},
);
// Usage - Button styles applied to child element
@@ -285,7 +298,7 @@ Headless components provide behavior without styling, enabling complete visual c
```tsx
// Headless toggle hook
import * as React from 'react';
import * as React from "react";
interface UseToggleProps {
defaultPressed?: boolean;
@@ -315,8 +328,8 @@ function useToggle({
pressed,
toggle,
buttonProps: {
role: 'switch' as const,
'aria-checked': pressed,
role: "switch" as const,
"aria-checked": pressed,
onClick: toggle,
},
};
@@ -334,7 +347,8 @@ function useListbox<T>({
defaultSelectedIndex = -1,
onSelect,
}: UseListboxProps<T>) {
const [selectedIndex, setSelectedIndex] = React.useState(defaultSelectedIndex);
const [selectedIndex, setSelectedIndex] =
React.useState(defaultSelectedIndex);
const [highlightedIndex, setHighlightedIndex] = React.useState(-1);
const select = React.useCallback(
@@ -342,40 +356,40 @@ function useListbox<T>({
setSelectedIndex(index);
onSelect?.(items[index], index);
},
[items, onSelect]
[items, onSelect],
);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent) => {
switch (event.key) {
case 'ArrowDown':
case "ArrowDown":
event.preventDefault();
setHighlightedIndex((prev) =>
prev < items.length - 1 ? prev + 1 : prev
prev < items.length - 1 ? prev + 1 : prev,
);
break;
case 'ArrowUp':
case "ArrowUp":
event.preventDefault();
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : prev));
break;
case 'Enter':
case ' ':
case "Enter":
case " ":
event.preventDefault();
if (highlightedIndex >= 0) {
select(highlightedIndex);
}
break;
case 'Home':
case "Home":
event.preventDefault();
setHighlightedIndex(0);
break;
case 'End':
case "End":
event.preventDefault();
setHighlightedIndex(items.length - 1);
break;
}
},
[items.length, highlightedIndex, select]
[items.length, highlightedIndex, select],
);
return {
@@ -384,13 +398,13 @@ function useListbox<T>({
select,
setHighlightedIndex,
listboxProps: {
role: 'listbox' as const,
role: "listbox" as const,
tabIndex: 0,
onKeyDown: handleKeyDown,
},
getOptionProps: (index: number) => ({
role: 'option' as const,
'aria-selected': index === selectedIndex,
role: "option" as const,
"aria-selected": index === selectedIndex,
onClick: () => select(index),
onMouseEnter: () => setHighlightedIndex(index),
}),
@@ -461,28 +475,28 @@ function Badge({ className, variant, size, ...props }: BadgeProps) {
## Responsive Variants
```tsx
import { cva } from 'class-variance-authority';
import { cva } from "class-variance-authority";
// Responsive variant configuration
const containerVariants = cva('mx-auto w-full px-4', {
const containerVariants = cva("mx-auto w-full px-4", {
variants: {
size: {
sm: 'max-w-screen-sm',
md: 'max-w-screen-md',
lg: 'max-w-screen-lg',
xl: 'max-w-screen-xl',
full: 'max-w-full',
sm: "max-w-screen-sm",
md: "max-w-screen-md",
lg: "max-w-screen-lg",
xl: "max-w-screen-xl",
full: "max-w-full",
},
padding: {
none: 'px-0',
sm: 'px-4 md:px-6',
md: 'px-4 md:px-8 lg:px-12',
lg: 'px-6 md:px-12 lg:px-20',
none: "px-0",
sm: "px-4 md:px-6",
md: "px-4 md:px-8 lg:px-12",
lg: "px-6 md:px-12 lg:px-20",
},
},
defaultVariants: {
size: 'lg',
padding: 'md',
size: "lg",
padding: "md",
},
});
@@ -498,23 +512,23 @@ interface ResponsiveValue<T> {
function getResponsiveClasses<T extends string>(
prop: T | ResponsiveValue<T> | undefined,
classMap: Record<T, string>,
responsiveClassMap: Record<string, Record<T, string>>
responsiveClassMap: Record<string, Record<T, string>>,
): string {
if (!prop) return '';
if (!prop) return "";
if (typeof prop === 'string') {
if (typeof prop === "string") {
return classMap[prop];
}
return Object.entries(prop)
.map(([breakpoint, value]) => {
if (breakpoint === 'base') {
if (breakpoint === "base") {
return classMap[value as T];
}
return responsiveClassMap[breakpoint]?.[value as T];
})
.filter(Boolean)
.join(' ');
.join(" ");
}
```
@@ -555,7 +569,7 @@ function DataList<T>({
keyExtractor={(user) => user.id}
renderItem={(user) => <UserCard user={user} />}
renderEmpty={() => <EmptyState message="No users found" />}
/>
/>;
```
### Children as Function
@@ -577,11 +591,11 @@ function Disclosure({ children, defaultOpen = false }: DisclosureProps) {
<Disclosure>
{({ isOpen, toggle }) => (
<>
<button onClick={toggle}>{isOpen ? 'Close' : 'Open'}</button>
<button onClick={toggle}>{isOpen ? "Close" : "Open"}</button>
{isOpen && <div>Content</div>}
</>
)}
</Disclosure>
</Disclosure>;
```
## Best Practices

View File

@@ -129,9 +129,15 @@ Design tokens are the atomic values of a design system - the smallest pieces tha
{
"shadow": {
"sm": { "value": "0 1px 2px 0 rgb(0 0 0 / 0.05)" },
"md": { "value": "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)" },
"lg": { "value": "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)" },
"xl": { "value": "0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)" }
"md": {
"value": "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)"
},
"lg": {
"value": "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)"
},
"xl": {
"value": "0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)"
}
},
"radius": {
"none": { "value": "0" },
@@ -302,13 +308,13 @@ Examples:
### Style Dictionary Transforms
```javascript
const StyleDictionary = require('style-dictionary');
const StyleDictionary = require("style-dictionary");
// Custom transform for px to rem
StyleDictionary.registerTransform({
name: 'size/pxToRem',
type: 'value',
matcher: (token) => token.attributes.category === 'size',
name: "size/pxToRem",
type: "value",
matcher: (token) => token.attributes.category === "size",
transformer: (token) => {
const value = parseFloat(token.value);
return `${value / 16}rem`;
@@ -317,14 +323,14 @@ StyleDictionary.registerTransform({
// Custom format for CSS custom properties
StyleDictionary.registerFormat({
name: 'css/customProperties',
formatter: function({ dictionary, options }) {
const tokens = dictionary.allTokens.map(token => {
const name = token.name.replace(/\./g, '-');
name: "css/customProperties",
formatter: function ({ dictionary, options }) {
const tokens = dictionary.allTokens.map((token) => {
const name = token.name.replace(/\./g, "-");
return ` --${name}: ${token.value};`;
});
return `:root {\n${tokens.join('\n')}\n}`;
return `:root {\n${tokens.join("\n")}\n}`;
},
});
```
@@ -399,10 +405,10 @@ interface TokenValidation {
function validateContrast(
foreground: string,
background: string,
level: 'AA' | 'AAA' = 'AA'
level: "AA" | "AAA" = "AA",
): boolean {
const ratio = getContrastRatio(foreground, background);
return level === 'AA' ? ratio >= 4.5 : ratio >= 7;
return level === "AA" ? ratio >= 4.5 : ratio >= 7;
}
```

View File

@@ -16,7 +16,7 @@ A robust theming system enables applications to support multiple visual appearan
/* Base tokens that don't change */
--font-sans: Inter, system-ui, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--font-mono: "JetBrains Mono", monospace;
/* Animation tokens */
--duration-fast: 150ms;
@@ -34,7 +34,7 @@ A robust theming system enables applications to support multiple visual appearan
/* 2. Light theme (default) */
:root,
[data-theme='light'] {
[data-theme="light"] {
--color-bg: #ffffff;
--color-bg-subtle: #f8fafc;
--color-bg-muted: #f1f5f9;
@@ -57,7 +57,7 @@ A robust theming system enables applications to support multiple visual appearan
}
/* 3. Dark theme */
[data-theme='dark'] {
[data-theme="dark"] {
--color-bg: #0f172a;
--color-bg-subtle: #1e293b;
--color-bg-muted: #334155;
@@ -81,7 +81,7 @@ A robust theming system enables applications to support multiple visual appearan
/* 4. System preference detection */
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
:root:not([data-theme="light"]) {
/* Inherit dark theme values */
--color-bg: #0f172a;
/* ... other dark values */
@@ -129,16 +129,16 @@ A robust theming system enables applications to support multiple visual appearan
```tsx
// theme-provider.tsx
import * as React from 'react';
import * as React from "react";
type Theme = 'light' | 'dark' | 'system';
type ResolvedTheme = 'light' | 'dark';
type Theme = "light" | "dark" | "system";
type ResolvedTheme = "light" | "dark";
interface ThemeProviderProps {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
attribute?: 'class' | 'data-theme';
attribute?: "class" | "data-theme";
enableSystem?: boolean;
disableTransitionOnChange?: boolean;
}
@@ -150,31 +150,32 @@ interface ThemeProviderState {
toggleTheme: () => void;
}
const ThemeProviderContext = React.createContext<ThemeProviderState | undefined>(
undefined
);
const ThemeProviderContext = React.createContext<
ThemeProviderState | undefined
>(undefined);
export function ThemeProvider({
children,
defaultTheme = 'system',
storageKey = 'theme',
attribute = 'data-theme',
defaultTheme = "system",
storageKey = "theme",
attribute = "data-theme",
enableSystem = true,
disableTransitionOnChange = false,
}: ThemeProviderProps) {
const [theme, setThemeState] = React.useState<Theme>(() => {
if (typeof window === 'undefined') return defaultTheme;
if (typeof window === "undefined") return defaultTheme;
return (localStorage.getItem(storageKey) as Theme) || defaultTheme;
});
const [resolvedTheme, setResolvedTheme] = React.useState<ResolvedTheme>('light');
const [resolvedTheme, setResolvedTheme] =
React.useState<ResolvedTheme>("light");
// Get system preference
const getSystemTheme = React.useCallback((): ResolvedTheme => {
if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
if (typeof window === "undefined") return "light";
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}, []);
// Apply theme to DOM
@@ -184,11 +185,11 @@ export function ThemeProvider({
// Disable transitions temporarily
if (disableTransitionOnChange) {
const css = document.createElement('style');
const css = document.createElement("style");
css.appendChild(
document.createTextNode(
`*,*::before,*::after{transition:none!important}`
)
`*,*::before,*::after{transition:none!important}`,
),
);
document.head.appendChild(css);
@@ -202,8 +203,8 @@ export function ThemeProvider({
}
// Apply attribute
if (attribute === 'class') {
root.classList.remove('light', 'dark');
if (attribute === "class") {
root.classList.remove("light", "dark");
root.classList.add(newTheme);
} else {
root.setAttribute(attribute, newTheme);
@@ -214,27 +215,27 @@ export function ThemeProvider({
setResolvedTheme(newTheme);
},
[attribute, disableTransitionOnChange]
[attribute, disableTransitionOnChange],
);
// Handle theme changes
React.useEffect(() => {
const resolved = theme === 'system' ? getSystemTheme() : theme;
const resolved = theme === "system" ? getSystemTheme() : theme;
applyTheme(resolved);
}, [theme, applyTheme, getSystemTheme]);
// Listen for system theme changes
React.useEffect(() => {
if (!enableSystem || theme !== 'system') return;
if (!enableSystem || theme !== "system") return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
applyTheme(getSystemTheme());
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, [theme, enableSystem, applyTheme, getSystemTheme]);
// Persist to localStorage
@@ -243,11 +244,11 @@ export function ThemeProvider({
localStorage.setItem(storageKey, newTheme);
setThemeState(newTheme);
},
[storageKey]
[storageKey],
);
const toggleTheme = React.useCallback(() => {
setTheme(resolvedTheme === 'light' ? 'dark' : 'light');
setTheme(resolvedTheme === "light" ? "dark" : "light");
}, [resolvedTheme, setTheme]);
const value = React.useMemo(
@@ -257,7 +258,7 @@ export function ThemeProvider({
setTheme,
toggleTheme,
}),
[theme, resolvedTheme, setTheme, toggleTheme]
[theme, resolvedTheme, setTheme, toggleTheme],
);
return (
@@ -270,7 +271,7 @@ export function ThemeProvider({
export function useTheme() {
const context = React.useContext(ThemeProviderContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}
@@ -280,8 +281,8 @@ export function useTheme() {
```tsx
// theme-toggle.tsx
import { Moon, Sun, Monitor } from 'lucide-react';
import { useTheme } from './theme-provider';
import { Moon, Sun, Monitor } from "lucide-react";
import { useTheme } from "./theme-provider";
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
@@ -289,27 +290,27 @@ export function ThemeToggle() {
return (
<div className="flex items-center gap-1 rounded-lg bg-muted p-1">
<button
onClick={() => setTheme('light')}
onClick={() => setTheme("light")}
className={`rounded-md p-2 ${
theme === 'light' ? 'bg-background shadow-sm' : ''
theme === "light" ? "bg-background shadow-sm" : ""
}`}
aria-label="Light theme"
>
<Sun className="h-4 w-4" />
</button>
<button
onClick={() => setTheme('dark')}
onClick={() => setTheme("dark")}
className={`rounded-md p-2 ${
theme === 'dark' ? 'bg-background shadow-sm' : ''
theme === "dark" ? "bg-background shadow-sm" : ""
}`}
aria-label="Dark theme"
>
<Moon className="h-4 w-4" />
</button>
<button
onClick={() => setTheme('system')}
onClick={() => setTheme("system")}
className={`rounded-md p-2 ${
theme === 'system' ? 'bg-background shadow-sm' : ''
theme === "system" ? "bg-background shadow-sm" : ""
}`}
aria-label="System theme"
>
@@ -326,42 +327,42 @@ export function ThemeToggle() {
```css
/* Brand A - Corporate Blue */
[data-brand='corporate'] {
[data-brand="corporate"] {
--brand-primary: #0066cc;
--brand-primary-hover: #0052a3;
--brand-secondary: #f0f7ff;
--brand-accent: #00a3e0;
--brand-font-heading: 'Helvetica Neue', sans-serif;
--brand-font-body: 'Open Sans', sans-serif;
--brand-font-heading: "Helvetica Neue", sans-serif;
--brand-font-body: "Open Sans", sans-serif;
--brand-radius: 0.25rem;
--brand-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* Brand B - Modern Startup */
[data-brand='startup'] {
[data-brand="startup"] {
--brand-primary: #7c3aed;
--brand-primary-hover: #6d28d9;
--brand-secondary: #faf5ff;
--brand-accent: #f472b6;
--brand-font-heading: 'Poppins', sans-serif;
--brand-font-body: 'Inter', sans-serif;
--brand-font-heading: "Poppins", sans-serif;
--brand-font-body: "Inter", sans-serif;
--brand-radius: 1rem;
--brand-shadow: 0 4px 12px rgba(124, 58, 237, 0.15);
}
/* Brand C - Minimal */
[data-brand='minimal'] {
[data-brand="minimal"] {
--brand-primary: #171717;
--brand-primary-hover: #404040;
--brand-secondary: #fafafa;
--brand-accent: #171717;
--brand-font-heading: 'Space Grotesk', sans-serif;
--brand-font-body: 'IBM Plex Sans', sans-serif;
--brand-font-heading: "Space Grotesk", sans-serif;
--brand-font-body: "IBM Plex Sans", sans-serif;
--brand-radius: 0;
--brand-shadow: none;
@@ -402,7 +403,7 @@ export function ThemeToggle() {
--color-accent: #0000ee;
}
[data-theme='dark'] {
[data-theme="dark"] {
--color-text: #ffffff;
--color-text-muted: #ffffff;
--color-bg: #000000;
@@ -470,9 +471,9 @@ export default function RootLayout({ children }) {
```tsx
// theme.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider, useTheme } from './theme-provider';
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ThemeProvider, useTheme } from "./theme-provider";
function TestComponent() {
const { theme, setTheme, resolvedTheme } = useTheme();
@@ -480,34 +481,34 @@ function TestComponent() {
<div>
<span data-testid="theme">{theme}</span>
<span data-testid="resolved">{resolvedTheme}</span>
<button onClick={() => setTheme('dark')}>Set Dark</button>
<button onClick={() => setTheme("dark")}>Set Dark</button>
</div>
);
}
describe('ThemeProvider', () => {
it('should default to system theme', () => {
describe("ThemeProvider", () => {
it("should default to system theme", () => {
render(
<ThemeProvider>
<TestComponent />
</ThemeProvider>
</ThemeProvider>,
);
expect(screen.getByTestId('theme')).toHaveTextContent('system');
expect(screen.getByTestId("theme")).toHaveTextContent("system");
});
it('should switch to dark theme', async () => {
it("should switch to dark theme", async () => {
const user = userEvent.setup();
render(
<ThemeProvider>
<TestComponent />
</ThemeProvider>
</ThemeProvider>,
);
await user.click(screen.getByText('Set Dark'));
expect(screen.getByTestId('theme')).toHaveTextContent('dark');
expect(document.documentElement).toHaveAttribute('data-theme', 'dark');
await user.click(screen.getByText("Set Dark"));
expect(screen.getByTestId("theme")).toHaveTextContent("dark");
expect(document.documentElement).toHaveAttribute("data-theme", "dark");
});
});
```

View File

@@ -23,6 +23,7 @@ Create engaging, intuitive interactions through motion, feedback, and thoughtful
### 1. Purposeful Motion
Motion should communicate, not decorate:
- **Feedback**: Confirm user actions occurred
- **Orientation**: Show where elements come from/go to
- **Focus**: Direct attention to important changes
@@ -30,27 +31,27 @@ Motion should communicate, not decorate:
### 2. Timing Guidelines
| Duration | Use Case |
|----------|----------|
| 100-150ms | Micro-feedback (hovers, clicks) |
| 200-300ms | Small transitions (toggles, dropdowns) |
| Duration | Use Case |
| --------- | ----------------------------------------- |
| 100-150ms | Micro-feedback (hovers, clicks) |
| 200-300ms | Small transitions (toggles, dropdowns) |
| 300-500ms | Medium transitions (modals, page changes) |
| 500ms+ | Complex choreographed animations |
| 500ms+ | Complex choreographed animations |
### 3. Easing Functions
```css
/* Common easings */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1); /* Decelerate - entering */
--ease-in: cubic-bezier(0.55, 0, 1, 0.45); /* Accelerate - exiting */
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); /* Both - moving between */
--spring: cubic-bezier(0.34, 1.56, 0.64, 1); /* Overshoot - playful */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1); /* Decelerate - entering */
--ease-in: cubic-bezier(0.55, 0, 1, 0.45); /* Accelerate - exiting */
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); /* Both - moving between */
--spring: cubic-bezier(0.34, 1.56, 0.64, 1); /* Overshoot - playful */
```
## Quick Start: Button Microinteraction
```tsx
import { motion } from 'framer-motion';
import { motion } from "framer-motion";
export function InteractiveButton({ children, onClick }) {
return (
@@ -58,7 +59,7 @@ export function InteractiveButton({ children, onClick }) {
onClick={onClick}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
className="px-4 py-2 bg-blue-600 text-white rounded-lg"
>
{children}
@@ -72,6 +73,7 @@ export function InteractiveButton({ children, onClick }) {
### 1. Loading States
**Skeleton Screens**: Preserve layout while loading
```tsx
function CardSkeleton() {
return (
@@ -85,6 +87,7 @@ function CardSkeleton() {
```
**Progress Indicators**: Show determinate progress
```tsx
function ProgressBar({ progress }: { progress: number }) {
return (
@@ -93,7 +96,7 @@ function ProgressBar({ progress }: { progress: number }) {
className="h-full bg-blue-600"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ ease: 'easeOut' }}
transition={{ ease: "easeOut" }}
/>
</div>
);
@@ -103,6 +106,7 @@ function ProgressBar({ progress }: { progress: number }) {
### 2. State Transitions
**Toggle with smooth transition**:
```tsx
function Toggle({ checked, onChange }) {
return (
@@ -112,13 +116,13 @@ function Toggle({ checked, onChange }) {
onClick={() => onChange(!checked)}
className={`
relative w-12 h-6 rounded-full transition-colors duration-200
${checked ? 'bg-blue-600' : 'bg-gray-300'}
${checked ? "bg-blue-600" : "bg-gray-300"}
`}
>
<motion.span
className="absolute top-1 left-1 w-4 h-4 bg-white rounded-full shadow"
animate={{ x: checked ? 24 : 0 }}
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
/>
</button>
);
@@ -128,8 +132,9 @@ function Toggle({ checked, onChange }) {
### 3. Page Transitions
**Framer Motion layout animations**:
```tsx
import { AnimatePresence, motion } from 'framer-motion';
import { AnimatePresence, motion } from "framer-motion";
function PageTransition({ children, key }) {
return (
@@ -151,6 +156,7 @@ function PageTransition({ children, key }) {
### 4. Feedback Patterns
**Ripple effect on click**:
```tsx
function RippleButton({ children, onClick }) {
const [ripples, setRipples] = useState([]);
@@ -162,9 +168,9 @@ function RippleButton({ children, onClick }) {
y: e.clientY - rect.top,
id: Date.now(),
};
setRipples(prev => [...prev, ripple]);
setRipples((prev) => [...prev, ripple]);
setTimeout(() => {
setRipples(prev => prev.filter(r => r.id !== ripple.id));
setRipples((prev) => prev.filter((r) => r.id !== ripple.id));
}, 600);
onClick?.(e);
};
@@ -172,7 +178,7 @@ function RippleButton({ children, onClick }) {
return (
<button onClick={handleClick} className="relative overflow-hidden">
{children}
{ripples.map(ripple => (
{ripples.map((ripple) => (
<span
key={ripple.id}
className="absolute bg-white/30 rounded-full animate-ripple"
@@ -187,6 +193,7 @@ function RippleButton({ children, onClick }) {
### 5. Gesture Interactions
**Swipe to dismiss**:
```tsx
function SwipeCard({ children, onDismiss }) {
return (
@@ -212,29 +219,50 @@ function SwipeCard({ children, onDismiss }) {
```css
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@keyframes spin {
to { transform: rotate(360deg); }
to {
transform: rotate(360deg);
}
}
.animate-fadeIn { animation: fadeIn 0.3s ease-out; }
.animate-pulse { animation: pulse 2s ease-in-out infinite; }
.animate-spin { animation: spin 1s linear infinite; }
.animate-fadeIn {
animation: fadeIn 0.3s ease-out;
}
.animate-pulse {
animation: pulse 2s ease-in-out infinite;
}
.animate-spin {
animation: spin 1s linear infinite;
}
```
### CSS Transitions
```css
.card {
transition: transform 0.2s ease-out, box-shadow 0.2s ease-out;
transition:
transform 0.2s ease-out,
box-shadow 0.2s ease-out;
}
.card:hover {
@@ -248,7 +276,9 @@ function SwipeCard({ children, onDismiss }) {
```css
/* Respect user motion preferences */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
@@ -259,7 +289,7 @@ function SwipeCard({ children, onDismiss }) {
```tsx
function AnimatedComponent() {
const prefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
"(prefers-reduced-motion: reduce)",
).matches;
return (

View File

@@ -7,7 +7,7 @@ The most popular React animation library with declarative API.
### Basic Animations
```tsx
import { motion, AnimatePresence } from 'framer-motion';
import { motion, AnimatePresence } from "framer-motion";
// Simple animation
function FadeIn({ children }) {
@@ -29,7 +29,7 @@ function InteractiveCard() {
<motion.div
whileHover={{ scale: 1.02, y: -4 }}
whileTap={{ scale: 0.98 }}
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
className="p-6 bg-white rounded-lg shadow"
>
Hover or tap me
@@ -44,9 +44,9 @@ function PulseButton() {
animate={{
scale: [1, 1.05, 1],
boxShadow: [
'0 0 0 0 rgba(59, 130, 246, 0.5)',
'0 0 0 10px rgba(59, 130, 246, 0)',
'0 0 0 0 rgba(59, 130, 246, 0)',
"0 0 0 0 rgba(59, 130, 246, 0.5)",
"0 0 0 10px rgba(59, 130, 246, 0)",
"0 0 0 0 rgba(59, 130, 246, 0)",
],
}}
transition={{ duration: 2, repeat: Infinity }}
@@ -61,7 +61,7 @@ function PulseButton() {
### Layout Animations
```tsx
import { motion, LayoutGroup } from 'framer-motion';
import { motion, LayoutGroup } from "framer-motion";
// Shared layout animation
function TabIndicator({ activeTab, tabs }) {
@@ -78,7 +78,7 @@ function TabIndicator({ activeTab, tabs }) {
<motion.div
layoutId="activeTab"
className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600"
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
/>
)}
</button>
@@ -131,11 +131,7 @@ const itemVariants = {
function StaggeredList({ items }) {
return (
<motion.ul
variants={containerVariants}
initial="hidden"
animate="visible"
>
<motion.ul variants={containerVariants} initial="hidden" animate="visible">
{items.map((item) => (
<motion.li key={item.id} variants={itemVariants}>
{item.content}
@@ -149,8 +145,8 @@ function StaggeredList({ items }) {
### Page Transitions
```tsx
import { AnimatePresence, motion } from 'framer-motion';
import { useRouter } from 'next/router';
import { AnimatePresence, motion } from "framer-motion";
import { useRouter } from "next/router";
const pageVariants = {
initial: { opacity: 0, x: -20 },
@@ -185,8 +181,8 @@ Industry-standard animation library for complex, performant animations.
### Basic Timeline
```tsx
import { useRef, useLayoutEffect } from 'react';
import gsap from 'gsap';
import { useRef, useLayoutEffect } from "react";
import gsap from "gsap";
function AnimatedHero() {
const containerRef = useRef<HTMLDivElement>(null);
@@ -195,7 +191,7 @@ function AnimatedHero() {
useLayoutEffect(() => {
const ctx = gsap.context(() => {
const tl = gsap.timeline({ defaults: { ease: 'power3.out' } });
const tl = gsap.timeline({ defaults: { ease: "power3.out" } });
tl.from(titleRef.current, {
y: 50,
@@ -209,9 +205,9 @@ function AnimatedHero() {
opacity: 0,
duration: 0.6,
},
'-=0.4' // Start 0.4s before previous ends
"-=0.4", // Start 0.4s before previous ends
)
.from('.cta-button', {
.from(".cta-button", {
scale: 0.8,
opacity: 0,
duration: 0.4,
@@ -234,9 +230,9 @@ function AnimatedHero() {
### ScrollTrigger
```tsx
import { useLayoutEffect, useRef } from 'react';
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { useLayoutEffect, useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
@@ -249,24 +245,24 @@ function ParallaxSection() {
// Parallax image
gsap.to(imageRef.current, {
yPercent: -20,
ease: 'none',
ease: "none",
scrollTrigger: {
trigger: sectionRef.current,
start: 'top bottom',
end: 'bottom top',
start: "top bottom",
end: "bottom top",
scrub: true,
},
});
// Fade in content
gsap.from('.content-block', {
gsap.from(".content-block", {
opacity: 0,
y: 50,
stagger: 0.2,
scrollTrigger: {
trigger: sectionRef.current,
start: 'top 80%',
end: 'top 20%',
start: "top 80%",
end: "top 20%",
scrub: 1,
},
});
@@ -290,9 +286,9 @@ function ParallaxSection() {
### Text Animation
```tsx
import { useLayoutEffect, useRef } from 'react';
import gsap from 'gsap';
import { SplitText } from 'gsap/SplitText';
import { useLayoutEffect, useRef } from "react";
import gsap from "gsap";
import { SplitText } from "gsap/SplitText";
gsap.registerPlugin(SplitText);
@@ -301,8 +297,8 @@ function AnimatedHeadline({ text }) {
useLayoutEffect(() => {
const split = new SplitText(textRef.current, {
type: 'chars,words',
charsClass: 'char',
type: "chars,words",
charsClass: "char",
});
gsap.from(split.chars, {
@@ -311,7 +307,7 @@ function AnimatedHeadline({ text }) {
rotateX: -90,
stagger: 0.02,
duration: 0.8,
ease: 'back.out(1.7)',
ease: "back.out(1.7)",
});
return () => split.revert();
@@ -362,7 +358,7 @@ Native browser animation API for simple animations.
function useWebAnimation(
ref: RefObject<HTMLElement>,
keyframes: Keyframe[],
options: KeyframeAnimationOptions
options: KeyframeAnimationOptions,
) {
useEffect(() => {
if (!ref.current) return;
@@ -380,14 +376,14 @@ function SlideIn({ children }) {
useWebAnimation(
elementRef,
[
{ transform: 'translateX(-100%)', opacity: 0 },
{ transform: 'translateX(0)', opacity: 1 },
{ transform: "translateX(-100%)", opacity: 0 },
{ transform: "translateX(0)", opacity: 1 },
],
{
duration: 300,
easing: 'cubic-bezier(0.16, 1, 0.3, 1)',
fill: 'forwards',
}
easing: "cubic-bezier(0.16, 1, 0.3, 1)",
fill: "forwards",
},
);
return <div ref={elementRef}>{children}</div>;
@@ -400,7 +396,7 @@ Native browser API for page transitions.
```tsx
// Check support
const supportsViewTransitions = 'startViewTransition' in document;
const supportsViewTransitions = "startViewTransition" in document;
// Simple page transition
async function navigateTo(url: string) {
@@ -456,7 +452,9 @@ function ProductCard({ product }) {
/* Only animate transform and opacity for 60fps */
.smooth {
transition: transform 0.3s ease, opacity 0.3s ease;
transition:
transform 0.3s ease,
opacity 0.3s ease;
}
/* Avoid animating these (cause reflow) */
@@ -472,12 +470,12 @@ function useReducedMotion() {
const [prefersReduced, setPrefersReduced] = useState(false);
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
setPrefersReduced(mq.matches);
const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
return prefersReduced;

View File

@@ -5,7 +5,7 @@
### Loading Button
```tsx
import { motion, AnimatePresence } from 'framer-motion';
import { motion, AnimatePresence } from "framer-motion";
interface LoadingButtonProps {
isLoading: boolean;
@@ -71,17 +71,19 @@ function Spinner({ className }: { className?: string }) {
```tsx
function SubmitButton({ onSubmit }: { onSubmit: () => Promise<void> }) {
const [state, setState] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [state, setState] = useState<"idle" | "loading" | "success" | "error">(
"idle",
);
const handleClick = async () => {
setState('loading');
setState("loading");
try {
await onSubmit();
setState('success');
setTimeout(() => setState('idle'), 2000);
setState("success");
setTimeout(() => setState("idle"), 2000);
} catch {
setState('error');
setTimeout(() => setState('idle'), 2000);
setState("error");
setTimeout(() => setState("idle"), 2000);
}
};
@@ -93,18 +95,20 @@ function SubmitButton({ onSubmit }: { onSubmit: () => Promise<void> }) {
};
const colors = {
idle: 'bg-blue-600 hover:bg-blue-700',
loading: 'bg-blue-600',
success: 'bg-green-600',
error: 'bg-red-600',
idle: "bg-blue-600 hover:bg-blue-700",
loading: "bg-blue-600",
success: "bg-green-600",
error: "bg-red-600",
};
return (
<motion.button
onClick={handleClick}
disabled={state === 'loading'}
disabled={state === "loading"}
className={`flex items-center gap-2 px-4 py-2 text-white rounded-lg transition-colors ${colors[state]}`}
animate={{ scale: state === 'success' || state === 'error' ? [1, 1.05, 1] : 1 }}
animate={{
scale: state === "success" || state === "error" ? [1, 1.05, 1] : 1,
}}
>
<AnimatePresence mode="wait">
{icons[state] && (
@@ -118,10 +122,10 @@ function SubmitButton({ onSubmit }: { onSubmit: () => Promise<void> }) {
</motion.span>
)}
</AnimatePresence>
{state === 'idle' && 'Submit'}
{state === 'loading' && 'Submitting...'}
{state === 'success' && 'Done!'}
{state === 'error' && 'Failed'}
{state === "idle" && "Submit"}
{state === "loading" && "Submitting..."}
{state === "success" && "Done!"}
{state === "error" && "Failed"}
</motion.button>
);
}
@@ -132,10 +136,16 @@ function SubmitButton({ onSubmit }: { onSubmit: () => Promise<void> }) {
### Floating Label Input
```tsx
import { useState, useId } from 'react';
import { useState, useId } from "react";
function FloatingInput({ label, type = 'text' }: { label: string; type?: string }) {
const [value, setValue] = useState('');
function FloatingInput({
label,
type = "text",
}: {
label: string;
type?: string;
}) {
const [value, setValue] = useState("");
const [isFocused, setIsFocused] = useState(false);
const id = useId();
@@ -156,9 +166,10 @@ function FloatingInput({ label, type = 'text' }: { label: string; type?: string
<label
htmlFor={id}
className={`absolute left-4 transition-all duration-200 pointer-events-none
${isFloating
? 'top-0 -translate-y-1/2 text-xs bg-white px-1 text-blue-600'
: 'top-1/2 -translate-y-1/2 text-gray-500'
${
isFloating
? "top-0 -translate-y-1/2 text-xs bg-white px-1 text-blue-600"
: "top-1/2 -translate-y-1/2 text-gray-500"
}`}
>
{label}
@@ -171,7 +182,7 @@ function FloatingInput({ label, type = 'text' }: { label: string; type?: string
### Shake on Error
```tsx
import { motion, useAnimation } from 'framer-motion';
import { motion, useAnimation } from "framer-motion";
function ShakeInput({ error, ...props }: InputProps & { error?: string }) {
const controls = useAnimation();
@@ -190,7 +201,7 @@ function ShakeInput({ error, ...props }: InputProps & { error?: string }) {
<input
{...props}
className={`w-full px-4 py-2 border rounded-lg ${
error ? 'border-red-500' : 'border-gray-300'
error ? "border-red-500" : "border-gray-300"
}`}
/>
{error && (
@@ -211,7 +222,7 @@ function ShakeInput({ error, ...props }: InputProps & { error?: string }) {
```tsx
function TextareaWithCount({ maxLength = 280 }: { maxLength?: number }) {
const [value, setValue] = useState('');
const [value, setValue] = useState("");
const remaining = maxLength - value.length;
const isNearLimit = remaining <= 20;
const isOverLimit = remaining < 0;
@@ -226,7 +237,11 @@ function TextareaWithCount({ maxLength = 280 }: { maxLength?: number }) {
/>
<motion.span
className={`absolute bottom-2 right-2 text-sm ${
isOverLimit ? 'text-red-500' : isNearLimit ? 'text-yellow-500' : 'text-gray-400'
isOverLimit
? "text-red-500"
: isNearLimit
? "text-yellow-500"
: "text-gray-400"
}`}
animate={{ scale: isNearLimit ? [1, 1.1, 1] : 1 }}
transition={{ duration: 0.2 }}
@@ -243,23 +258,23 @@ function TextareaWithCount({ maxLength = 280 }: { maxLength?: number }) {
### Toast Notifications
```tsx
import { motion, AnimatePresence } from 'framer-motion';
import { createContext, useContext, useState, useCallback } from 'react';
import { motion, AnimatePresence } from "framer-motion";
import { createContext, useContext, useState, useCallback } from "react";
interface Toast {
id: string;
message: string;
type: 'success' | 'error' | 'info';
type: "success" | "error" | "info";
}
const ToastContext = createContext<{
addToast: (message: string, type: Toast['type']) => void;
addToast: (message: string, type: Toast["type"]) => void;
} | null>(null);
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const addToast = useCallback((message: string, type: Toast['type']) => {
const addToast = useCallback((message: string, type: Toast["type"]) => {
const id = Date.now().toString();
setToasts((prev) => [...prev, { id, message, type }]);
setTimeout(() => {
@@ -279,8 +294,11 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, x: 100, scale: 0.95 }}
className={`px-4 py-3 rounded-lg shadow-lg ${
toast.type === 'success' ? 'bg-green-600' :
toast.type === 'error' ? 'bg-red-600' : 'bg-blue-600'
toast.type === "success"
? "bg-green-600"
: toast.type === "error"
? "bg-red-600"
: "bg-blue-600"
} text-white`}
>
{toast.message}
@@ -294,7 +312,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
export function useToast() {
const context = useContext(ToastContext);
if (!context) throw new Error('useToast must be within ToastProvider');
if (!context) throw new Error("useToast must be within ToastProvider");
return context;
}
```
@@ -304,7 +322,7 @@ export function useToast() {
```tsx
function ConfirmButton({
onConfirm,
confirmText = 'Click again to confirm',
confirmText = "Click again to confirm",
children,
}: {
onConfirm: () => void;
@@ -333,13 +351,13 @@ function ConfirmButton({
<motion.button
onClick={handleClick}
className={`px-4 py-2 rounded-lg transition-colors ${
isPending ? 'bg-red-600 text-white' : 'bg-gray-200 text-gray-800'
isPending ? "bg-red-600 text-white" : "bg-gray-200 text-gray-800"
}`}
animate={{ scale: isPending ? [1, 1.02, 1] : 1 }}
>
<AnimatePresence mode="wait">
<motion.span
key={isPending ? 'confirm' : 'idle'}
key={isPending ? "confirm" : "idle"}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
@@ -357,8 +375,8 @@ function ConfirmButton({
### Active Link Indicator
```tsx
import { motion } from 'framer-motion';
import { usePathname } from 'next/navigation';
import { motion } from "framer-motion";
import { usePathname } from "next/navigation";
function Navigation({ items }: { items: { href: string; label: string }[] }) {
const pathname = usePathname();
@@ -372,14 +390,14 @@ function Navigation({ items }: { items: { href: string; label: string }[] }) {
key={item.href}
href={item.href}
className={`relative px-4 py-2 text-sm font-medium ${
isActive ? 'text-white' : 'text-gray-600 hover:text-gray-900'
isActive ? "text-white" : "text-gray-600 hover:text-gray-900"
}`}
>
{isActive && (
<motion.div
layoutId="activeNav"
className="absolute inset-0 bg-blue-600 rounded-md"
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
/>
)}
<span className="relative z-10">{item.label}</span>
@@ -400,9 +418,9 @@ function MenuIcon({ isOpen }: { isOpen: boolean }) {
<motion.span
className="absolute left-0 h-0.5 w-6 bg-current"
animate={{
top: isOpen ? '50%' : '25%',
top: isOpen ? "50%" : "25%",
rotate: isOpen ? 45 : 0,
translateY: isOpen ? '-50%' : 0,
translateY: isOpen ? "-50%" : 0,
}}
transition={{ duration: 0.2 }}
/>
@@ -414,9 +432,9 @@ function MenuIcon({ isOpen }: { isOpen: boolean }) {
<motion.span
className="absolute left-0 h-0.5 w-6 bg-current"
animate={{
bottom: isOpen ? '50%' : '25%',
bottom: isOpen ? "50%" : "25%",
rotate: isOpen ? -45 : 0,
translateY: isOpen ? '50%' : 0,
translateY: isOpen ? "50%" : 0,
}}
transition={{ duration: 0.2 }}
/>
@@ -453,7 +471,7 @@ function LikeButton({ postId, initialLiked, initialCount }) {
<motion.button
onClick={handleLike}
whileTap={{ scale: 0.9 }}
className={`flex items-center gap-2 ${liked ? 'text-red-500' : 'text-gray-500'}`}
className={`flex items-center gap-2 ${liked ? "text-red-500" : "text-gray-500"}`}
>
<motion.span
animate={{ scale: liked ? [1, 1.3, 1] : 1 }}
@@ -479,7 +497,7 @@ function LikeButton({ postId, initialLiked, initialCount }) {
### Pull to Refresh
```tsx
import { motion, useMotionValue, useTransform } from 'framer-motion';
import { motion, useMotionValue, useTransform } from "framer-motion";
function PullToRefresh({ onRefresh, children }) {
const y = useMotionValue(0);

View File

@@ -3,7 +3,7 @@
## Intersection Observer Hook
```tsx
import { useEffect, useRef, useState, type RefObject } from 'react';
import { useEffect, useRef, useState, type RefObject } from "react";
interface UseInViewOptions {
threshold?: number | number[];
@@ -13,7 +13,7 @@ interface UseInViewOptions {
function useInView<T extends HTMLElement>({
threshold = 0,
rootMargin = '0px',
rootMargin = "0px",
triggerOnce = false,
}: UseInViewOptions = {}): [RefObject<T>, boolean] {
const ref = useRef<T>(null);
@@ -31,7 +31,7 @@ function useInView<T extends HTMLElement>({
observer.unobserve(element);
}
},
{ threshold, rootMargin }
{ threshold, rootMargin },
);
observer.observe(element);
@@ -49,7 +49,7 @@ function FadeInSection({ children }) {
<div
ref={ref}
className={`transition-all duration-700 ${
isInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'
isInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
}`}
>
{children}
@@ -61,7 +61,7 @@ function FadeInSection({ children }) {
## Scroll Progress Indicator
```tsx
import { motion, useScroll, useSpring } from 'framer-motion';
import { motion, useScroll, useSpring } from "framer-motion";
function ScrollProgress() {
const { scrollYProgress } = useScroll();
@@ -104,26 +104,23 @@ function ScrollProgress() {
### Framer Motion Parallax
```tsx
import { motion, useScroll, useTransform } from 'framer-motion';
import { motion, useScroll, useTransform } from "framer-motion";
function ParallaxHero() {
const ref = useRef(null);
const { scrollYProgress } = useScroll({
target: ref,
offset: ['start start', 'end start'],
offset: ["start start", "end start"],
});
const y = useTransform(scrollYProgress, [0, 1], ['0%', '50%']);
const y = useTransform(scrollYProgress, [0, 1], ["0%", "50%"]);
const opacity = useTransform(scrollYProgress, [0, 0.5], [1, 0]);
const scale = useTransform(scrollYProgress, [0, 1], [1, 1.2]);
return (
<section ref={ref} className="relative h-screen overflow-hidden">
{/* Background image with parallax */}
<motion.div
style={{ y, scale }}
className="absolute inset-0"
>
<motion.div style={{ y, scale }} className="absolute inset-0">
<img src="/hero-bg.jpg" alt="" className="w-full h-full object-cover" />
</motion.div>
@@ -148,7 +145,7 @@ function ScrollAnimation() {
const containerRef = useRef(null);
const { scrollYProgress } = useScroll({
target: containerRef,
offset: ['start end', 'end start'],
offset: ["start end", "end start"],
});
// Different transformations based on scroll progress
@@ -157,7 +154,7 @@ function ScrollAnimation() {
const backgroundColor = useTransform(
scrollYProgress,
[0, 0.5, 1],
['#3b82f6', '#8b5cf6', '#ec4899']
["#3b82f6", "#8b5cf6", "#ec4899"],
);
return (
@@ -180,13 +177,13 @@ function HorizontalScroll({ items }) {
const containerRef = useRef(null);
const { scrollYProgress } = useScroll({
target: containerRef,
offset: ['start start', 'end end'],
offset: ["start start", "end end"],
});
const x = useTransform(
scrollYProgress,
[0, 1],
['0%', `-${(items.length - 1) * 100}%`]
["0%", `-${(items.length - 1) * 100}%`],
);
return (
@@ -239,7 +236,7 @@ function StaggeredList({ items }) {
```tsx
function TextReveal({ text }) {
const [ref, isInView] = useInView({ threshold: 0.5, triggerOnce: true });
const words = text.split(' ');
const words = text.split(" ");
return (
<p ref={ref} className="text-4xl font-bold">
@@ -268,8 +265,8 @@ function ClipReveal({ children }) {
return (
<motion.div
ref={ref}
initial={{ clipPath: 'inset(0 100% 0 0)' }}
animate={isInView ? { clipPath: 'inset(0 0% 0 0)' } : {}}
initial={{ clipPath: "inset(0 100% 0 0)" }}
animate={isInView ? { clipPath: "inset(0 0% 0 0)" } : {}}
transition={{ duration: 0.8, ease: [0.16, 1, 0.3, 1] }}
>
{children}
@@ -285,7 +282,7 @@ function StickySection({ title, content, image }) {
const ref = useRef(null);
const { scrollYProgress } = useScroll({
target: ref,
offset: ['start start', 'end start'],
offset: ["start start", "end start"],
});
const opacity = useTransform(scrollYProgress, [0, 0.5, 1], [1, 1, 0]);
@@ -373,7 +370,10 @@ function FullPageScroll({ sections }) {
return (
<div className="snap-container">
{sections.map((section, i) => (
<section key={i} className="snap-section flex items-center justify-center">
<section
key={i}
className="snap-section flex items-center justify-center"
>
{section}
</section>
))}
@@ -403,14 +403,14 @@ function useThrottledScroll(callback, delay = 16) {
}
};
window.addEventListener('scroll', handler, { passive: true });
return () => window.removeEventListener('scroll', handler);
window.addEventListener("scroll", handler, { passive: true });
return () => window.removeEventListener("scroll", handler);
}, [callback, delay]);
}
// Use transform instead of top/left
// Good
const goodAnimation = { transform: 'translateY(100px)' };
const goodAnimation = { transform: "translateY(100px)" };
// Bad (causes reflow)
const badAnimation = { top: '100px' };
const badAnimation = { top: "100px" };
```

View File

@@ -27,6 +27,7 @@ Master Material Design 3 (Material You) and Jetpack Compose to build modern, ada
**Large Screens**: Responsive layouts for tablets and foldables
**Material Components:**
- Cards, Buttons, FABs, Chips
- Navigation (rail, drawer, bottom nav)
- Text fields, Dialogs, Sheets
@@ -35,6 +36,7 @@ Master Material Design 3 (Material You) and Jetpack Compose to build modern, ada
### 2. Jetpack Compose Layout System
**Column and Row:**
```kotlin
// Vertical arrangement with alignment
Column(
@@ -69,6 +71,7 @@ Row(
```
**Lazy Lists and Grids:**
```kotlin
// Lazy column with sticky headers
LazyColumn {
@@ -105,6 +108,7 @@ LazyVerticalGrid(
### 3. Navigation Patterns
**Bottom Navigation:**
```kotlin
@Composable
fun MainScreen() {
@@ -151,6 +155,7 @@ fun MainScreen() {
```
**Navigation Drawer:**
```kotlin
@Composable
fun DrawerNavigation() {
@@ -205,6 +210,7 @@ fun DrawerNavigation() {
### 4. Material 3 Theming
**Color Scheme:**
```kotlin
// Dynamic color (Android 12+)
val dynamicColorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
@@ -231,6 +237,7 @@ private val LightColorScheme = lightColorScheme(
```
**Typography:**
```kotlin
val AppTypography = Typography(
displayLarge = TextStyle(
@@ -269,6 +276,7 @@ val AppTypography = Typography(
### 5. Component Examples
**Cards:**
```kotlin
@Composable
fun FeatureCard(
@@ -312,6 +320,7 @@ fun FeatureCard(
```
**Buttons:**
```kotlin
// Filled button (primary action)
Button(onClick = { }) {

View File

@@ -27,6 +27,7 @@ Master iOS Human Interface Guidelines (HIG) and SwiftUI patterns to build polish
**Depth**: Visual layers and motion convey hierarchy and enable navigation
**Platform Considerations:**
- **iOS**: Touch-first, compact displays, portrait orientation
- **iPadOS**: Larger canvas, multitasking, pointer support
- **visionOS**: Spatial computing, eye/hand input
@@ -34,6 +35,7 @@ Master iOS Human Interface Guidelines (HIG) and SwiftUI patterns to build polish
### 2. SwiftUI Layout System
**Stack-Based Layouts:**
```swift
// Vertical stack with alignment
VStack(alignment: .leading, spacing: 12) {
@@ -55,6 +57,7 @@ HStack {
```
**Grid Layouts:**
```swift
// Adaptive grid that fills available width
LazyVGrid(columns: [
@@ -80,6 +83,7 @@ LazyVGrid(columns: [
### 3. Navigation Patterns
**NavigationStack (iOS 16+):**
```swift
struct ContentView: View {
@State private var path = NavigationPath()
@@ -101,6 +105,7 @@ struct ContentView: View {
```
**TabView:**
```swift
struct MainTabView: View {
@State private var selectedTab = 0
@@ -132,6 +137,7 @@ struct MainTabView: View {
### 4. System Integration
**SF Symbols:**
```swift
// Basic symbol
Image(systemName: "heart.fill")
@@ -150,6 +156,7 @@ Image(systemName: "bell.fill")
```
**Dynamic Type:**
```swift
// Use semantic fonts
Text("Headline")
@@ -166,6 +173,7 @@ Text("Custom")
### 5. Visual Design
**Colors and Materials:**
```swift
// Semantic colors that adapt to light/dark mode
Text("Primary")
@@ -185,6 +193,7 @@ Text("Overlay")
```
**Shadows and Depth:**
```swift
// Standard card shadow
RoundedRectangle(cornerRadius: 16)

View File

@@ -3,6 +3,7 @@
## Lists and Collections
### Basic List
```swift
struct ItemListView: View {
@State private var items: [Item] = []
@@ -32,6 +33,7 @@ struct ItemListView: View {
```
### Sectioned List
```swift
struct SectionedListView: View {
let groupedItems: [String: [Item]]
@@ -52,6 +54,7 @@ struct SectionedListView: View {
```
### Search Integration
```swift
struct SearchableListView: View {
@State private var searchText = ""
@@ -85,6 +88,7 @@ struct SearchableListView: View {
## Forms and Input
### Settings Form
```swift
struct SettingsView: View {
@AppStorage("notifications") private var notificationsEnabled = true
@@ -125,6 +129,7 @@ struct SettingsView: View {
```
### Custom Input Fields
```swift
struct ValidatedTextField: View {
let title: String
@@ -171,6 +176,7 @@ struct ValidatedTextField: View {
## Buttons and Actions
### Button Styles
```swift
// Primary filled button
Button("Continue") {
@@ -202,6 +208,7 @@ struct ScaleButtonStyle: ButtonStyle {
```
### Menu and Context Menu
```swift
// Menu button
Menu {
@@ -226,6 +233,7 @@ Text("Long press me")
## Sheets and Modals
### Sheet Presentation
```swift
struct ParentView: View {
@State private var showSettings = false
@@ -269,6 +277,7 @@ struct SettingsSheet: View {
```
### Confirmation Dialog
```swift
struct DeleteConfirmationView: View {
@State private var showConfirmation = false
@@ -296,6 +305,7 @@ struct DeleteConfirmationView: View {
## Loading and Progress
### Progress Indicators
```swift
// Indeterminate spinner
ProgressView()
@@ -334,6 +344,7 @@ struct LoadingOverlay: View {
```
### Skeleton Loading
```swift
struct SkeletonRow: View {
@State private var isAnimating = false
@@ -366,6 +377,7 @@ struct SkeletonRow: View {
## Async Content Loading
### AsyncImage
```swift
AsyncImage(url: imageURL) { phase in
switch phase {
@@ -387,6 +399,7 @@ AsyncImage(url: imageURL) { phase in
```
### Task-Based Loading
```swift
struct AsyncContentView: View {
@State private var items: [Item] = []
@@ -435,6 +448,7 @@ struct AsyncContentView: View {
## Animations
### Implicit Animations
```swift
struct AnimatedCard: View {
@State private var isExpanded = false
@@ -461,6 +475,7 @@ struct AnimatedCard: View {
```
### Custom Transitions
```swift
extension AnyTransition {
static var slideAndFade: AnyTransition {
@@ -477,6 +492,7 @@ extension AnyTransition {
```
### Phase Animator (iOS 17+)
```swift
struct PulsingButton: View {
var body: some View {
@@ -495,6 +511,7 @@ struct PulsingButton: View {
## Gestures
### Drag Gesture
```swift
struct DraggableCard: View {
@State private var offset = CGSize.zero
@@ -524,6 +541,7 @@ struct DraggableCard: View {
```
### Simultaneous Gestures
```swift
struct ZoomableImage: View {
@State private var scale: CGFloat = 1.0

View File

@@ -23,6 +23,7 @@ Master React Native styling patterns, React Navigation, and Reanimated 3 to buil
### 1. StyleSheet and Styling
**Basic StyleSheet:**
```typescript
import { StyleSheet, View, Text } from 'react-native';
@@ -56,6 +57,7 @@ function Card() {
```
**Dynamic Styles:**
```typescript
interface CardProps {
variant: 'primary' | 'secondary';
@@ -99,30 +101,31 @@ const styles = StyleSheet.create({
### 2. Flexbox Layout
**Row and Column Layouts:**
```typescript
const styles = StyleSheet.create({
// Vertical stack (column)
column: {
flexDirection: 'column',
flexDirection: "column",
gap: 12,
},
// Horizontal stack (row)
row: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: 8,
},
// Space between items
spaceBetween: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
// Centered content
centered: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
justifyContent: "center",
alignItems: "center",
},
// Fill remaining space
fill: {
@@ -134,6 +137,7 @@ const styles = StyleSheet.create({
### 3. React Navigation Setup
**Stack Navigator:**
```typescript
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
@@ -177,6 +181,7 @@ function AppNavigator() {
```
**Tab Navigator:**
```typescript
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { Ionicons } from '@expo/vector-icons';
@@ -216,6 +221,7 @@ function TabNavigator() {
### 4. Reanimated 3 Basics
**Animated Values:**
```typescript
import Animated, {
useSharedValue,
@@ -248,6 +254,7 @@ function AnimatedBox() {
```
**Gesture Handler Integration:**
```typescript
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
@@ -290,14 +297,14 @@ function DraggableCard() {
### 5. Platform-Specific Styling
```typescript
import { Platform, StyleSheet } from 'react-native';
import { Platform, StyleSheet } from "react-native";
const styles = StyleSheet.create({
container: {
padding: 16,
...Platform.select({
ios: {
shadowColor: '#000',
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
@@ -308,14 +315,14 @@ const styles = StyleSheet.create({
}),
},
text: {
fontFamily: Platform.OS === 'ios' ? 'SF Pro Text' : 'Roboto',
fontFamily: Platform.OS === "ios" ? "SF Pro Text" : "Roboto",
fontSize: 16,
},
});
// Platform-specific components
import { Platform } from 'react-native';
const StatusBarHeight = Platform.OS === 'ios' ? 44 : 0;
import { Platform } from "react-native";
const StatusBarHeight = Platform.OS === "ios" ? 44 : 0;
```
## Quick Start Component

View File

@@ -18,9 +18,12 @@ npm install react-native-screens react-native-safe-area-context
```typescript
// navigation/types.ts
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
import { CompositeScreenProps, NavigatorScreenParams } from '@react-navigation/native';
import { NativeStackScreenProps } from "@react-navigation/native-stack";
import { BottomTabScreenProps } from "@react-navigation/bottom-tabs";
import {
CompositeScreenProps,
NavigatorScreenParams,
} from "@react-navigation/native";
// Define param lists for each navigator
export type RootStackParamList = {
@@ -63,9 +66,9 @@ declare global {
```typescript
// hooks/useAppNavigation.ts
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { RootStackParamList } from './types';
import { useNavigation, useRoute, RouteProp } from "@react-navigation/native";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { RootStackParamList } from "./types";
export function useAppNavigation() {
return useNavigation<NativeStackNavigationProp<RootStackParamList>>();
@@ -464,9 +467,9 @@ function App() {
### Handling Deep Links
```typescript
import { useEffect } from 'react';
import { Linking } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { useEffect } from "react";
import { Linking } from "react-native";
import { useNavigation } from "@react-navigation/native";
function useDeepLinkHandler() {
const navigation = useNavigation();
@@ -481,7 +484,7 @@ function useDeepLinkHandler() {
};
// Handle URL changes
const subscription = Linking.addEventListener('url', ({ url }) => {
const subscription = Linking.addEventListener("url", ({ url }) => {
handleDeepLink(url);
});

View File

@@ -712,9 +712,12 @@ function BottomSheet({ children }) {
```typescript
// Memoize animated style when dependencies don't change
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}), []); // Empty deps if no external dependencies
const animatedStyle = useAnimatedStyle(
() => ({
transform: [{ translateX: translateX.value }],
}),
[],
); // Empty deps if no external dependencies
// Use useMemo for complex calculations outside worklets
const threshold = useMemo(() => calculateThreshold(screenWidth), [screenWidth]);
@@ -725,7 +728,7 @@ const threshold = useMemo(() => calculateThreshold(screenWidth), [screenWidth]);
```typescript
// Do: Keep worklets simple
const simpleWorklet = () => {
'worklet';
"worklet";
return scale.value * 2;
};
@@ -738,7 +741,7 @@ const onComplete = () => {
};
opacity.value = withTiming(1, {}, (finished) => {
'worklet';
"worklet";
if (finished) {
runOnJS(onComplete)();
}

View File

@@ -5,7 +5,7 @@
### Creating Styles
```typescript
import { StyleSheet, ViewStyle, TextStyle, ImageStyle } from 'react-native';
import { StyleSheet, ViewStyle, TextStyle, ImageStyle } from "react-native";
// Typed styles for better IDE support
interface Styles {
@@ -18,12 +18,12 @@ const styles = StyleSheet.create<Styles>({
container: {
flex: 1,
padding: 16,
backgroundColor: '#ffffff',
backgroundColor: "#ffffff",
},
title: {
fontSize: 24,
fontWeight: '700',
color: '#1f2937',
fontWeight: "700",
color: "#1f2937",
},
image: {
width: 100,
@@ -425,13 +425,10 @@ export function Spacer({ size, flex }: SpacerProps) {
### Cross-Platform Shadows
```typescript
import { Platform, ViewStyle } from 'react-native';
import { Platform, ViewStyle } from "react-native";
export function createShadow(
elevation: number,
color = '#000000'
): ViewStyle {
if (Platform.OS === 'android') {
export function createShadow(elevation: number, color = "#000000"): ViewStyle {
if (Platform.OS === "android") {
return { elevation };
}
@@ -483,7 +480,7 @@ export const shadows = {
// Usage
const styles = StyleSheet.create({
card: {
backgroundColor: '#ffffff',
backgroundColor: "#ffffff",
borderRadius: 12,
padding: 16,
...shadows.md,

View File

@@ -21,24 +21,28 @@ Master modern responsive design techniques to create interfaces that adapt seaml
## Core Capabilities
### 1. Container Queries
- Component-level responsiveness independent of viewport
- Container query units (cqi, cqw, cqh)
- Style queries for conditional styling
- Fallbacks for browser support
### 2. Fluid Typography & Spacing
- CSS clamp() for fluid scaling
- Viewport-relative units (vw, vh, dvh)
- Fluid type scales with min/max bounds
- Responsive spacing systems
### 3. Layout Patterns
- CSS Grid for 2D layouts
- Flexbox for 1D distribution
- Intrinsic layouts (content-based sizing)
- Subgrid for nested grid alignment
### 4. Breakpoint Strategy
- Mobile-first media queries
- Content-based breakpoints
- Design token integration
@@ -51,11 +55,21 @@ Master modern responsive design techniques to create interfaces that adapt seaml
```css
/* Mobile-first breakpoints */
/* Base: Mobile (< 640px) */
@media (min-width: 640px) { /* sm: Landscape phones, small tablets */ }
@media (min-width: 768px) { /* md: Tablets */ }
@media (min-width: 1024px) { /* lg: Laptops, small desktops */ }
@media (min-width: 1280px) { /* xl: Desktops */ }
@media (min-width: 1536px) { /* 2xl: Large desktops */ }
@media (min-width: 640px) {
/* sm: Landscape phones, small tablets */
}
@media (min-width: 768px) {
/* md: Tablets */
}
@media (min-width: 1024px) {
/* lg: Laptops, small desktops */
}
@media (min-width: 1280px) {
/* xl: Desktops */
}
@media (min-width: 1536px) {
/* 2xl: Large desktops */
}
/* Tailwind CSS equivalent */
/* sm: @media (min-width: 640px) */
@@ -148,10 +162,18 @@ function ResponsiveCard({ title, image, description }) {
}
/* Usage */
h1 { font-size: var(--text-4xl); }
h2 { font-size: var(--text-3xl); }
h3 { font-size: var(--text-2xl); }
p { font-size: var(--text-base); }
h1 {
font-size: var(--text-4xl);
}
h2 {
font-size: var(--text-3xl);
}
h3 {
font-size: var(--text-2xl);
}
p {
font-size: var(--text-base);
}
/* Fluid spacing scale */
:root {
@@ -165,7 +187,12 @@ p { font-size: var(--text-base); }
```tsx
// Utility function for fluid values
function fluidValue(minSize: number, maxSize: number, minWidth = 320, maxWidth = 1280) {
function fluidValue(
minSize: number,
maxSize: number,
minWidth = 320,
maxWidth = 1280,
) {
const slope = (maxSize - minSize) / (maxWidth - minWidth);
const yAxisIntersection = -minWidth * slope + minSize;
@@ -178,7 +205,7 @@ const fluidTypeScale = {
base: fluidValue(1, 1.125),
lg: fluidValue(1.25, 1.5),
xl: fluidValue(1.5, 2),
'2xl': fluidValue(2, 3),
"2xl": fluidValue(2, 3),
};
```
@@ -230,19 +257,23 @@ const fluidTypeScale = {
}
}
.header { grid-area: header; }
.main { grid-area: main; }
.sidebar { grid-area: sidebar; }
.footer { grid-area: footer; }
.header {
grid-area: header;
}
.main {
grid-area: main;
}
.sidebar {
grid-area: sidebar;
}
.footer {
grid-area: footer;
}
```
```tsx
// Responsive grid component
function ResponsiveGrid({
children,
minItemWidth = '250px',
gap = '1.5rem',
}) {
function ResponsiveGrid({ children, minItemWidth = "250px", gap = "1.5rem" }) {
return (
<div
className="grid"
@@ -292,12 +323,12 @@ function ResponsiveNav({ items }) {
id="nav-menu"
className={cn(
// Base: hidden on mobile
'absolute top-full left-0 right-0 bg-background border-b',
'flex flex-col',
"absolute top-full left-0 right-0 bg-background border-b",
"flex flex-col",
// Mobile: slide down
isOpen ? 'flex' : 'hidden',
isOpen ? "flex" : "hidden",
// Desktop: always visible, horizontal
'lg:static lg:flex lg:flex-row lg:border-0 lg:bg-transparent'
"lg:static lg:flex lg:flex-row lg:border-0 lg:bg-transparent",
)}
>
{items.map((item) => (
@@ -305,9 +336,9 @@ function ResponsiveNav({ items }) {
<a
href={item.href}
className={cn(
'block px-4 py-3',
'lg:px-3 lg:py-2',
'hover:bg-muted lg:hover:bg-transparent lg:hover:text-primary'
"block px-4 py-3",
"lg:px-3 lg:py-2",
"hover:bg-muted lg:hover:bg-transparent lg:hover:text-primary",
)}
>
{item.label}

View File

@@ -52,11 +52,21 @@ Start with the smallest screen, then progressively enhance for larger screens.
/* xl: 1280px - Desktops */
/* 2xl: 1536px - Large desktops */
@media (min-width: 640px) { /* sm */ }
@media (min-width: 768px) { /* md */ }
@media (min-width: 1024px) { /* lg */ }
@media (min-width: 1280px) { /* xl */ }
@media (min-width: 1536px) { /* 2xl */ }
@media (min-width: 640px) {
/* sm */
}
@media (min-width: 768px) {
/* md */
}
@media (min-width: 1024px) {
/* lg */
}
@media (min-width: 1280px) {
/* xl */
}
@media (min-width: 1536px) {
/* 2xl */
}
```
### Bootstrap 5
@@ -69,11 +79,21 @@ Start with the smallest screen, then progressively enhance for larger screens.
/* xl: 1200px */
/* xxl: 1400px */
@media (min-width: 576px) { /* sm */ }
@media (min-width: 768px) { /* md */ }
@media (min-width: 992px) { /* lg */ }
@media (min-width: 1200px) { /* xl */ }
@media (min-width: 1400px) { /* xxl */ }
@media (min-width: 576px) {
/* sm */
}
@media (min-width: 768px) {
/* md */
}
@media (min-width: 992px) {
/* lg */
}
@media (min-width: 1200px) {
/* xl */
}
@media (min-width: 1400px) {
/* xxl */
}
```
### Minimalist Scale
@@ -89,8 +109,12 @@ Start with the smallest screen, then progressively enhance for larger screens.
--bp-lg: 1024px;
}
@media (min-width: 600px) { /* Medium */ }
@media (min-width: 1024px) { /* Large */ }
@media (min-width: 600px) {
/* Medium */
}
@media (min-width: 1024px) {
/* Large */
}
```
## Content-Based Breakpoints
@@ -101,7 +125,9 @@ Instead of using device-based breakpoints, identify where your content naturally
```css
/* Bad: Device-based thinking */
@media (min-width: 768px) { /* iPad breakpoint */ }
@media (min-width: 768px) {
/* iPad breakpoint */
}
/* Good: Content-based thinking */
/* Breakpoint where sidebar fits comfortably next to content */
@@ -133,7 +159,7 @@ function findBreakpoints(selector) {
// Check for overflow, wrapping, or layout issues
if (element.scrollWidth > element.clientWidth) {
breakpoints.push({ width, issue: 'overflow' });
breakpoints.push({ width, issue: "overflow" });
}
}
@@ -179,7 +205,7 @@ export const breakpoints = {
md: 768,
lg: 1024,
xl: 1280,
'2xl': 1536,
"2xl": 1536,
} as const;
// Media query hook
@@ -191,8 +217,8 @@ function useMediaQuery(query: string): boolean {
setMatches(media.matches);
const listener = () => setMatches(media.matches);
media.addEventListener('change', listener);
return () => media.removeEventListener('change', listener);
media.addEventListener("change", listener);
return () => media.removeEventListener("change", listener);
}, [query]);
return matches;
@@ -209,7 +235,15 @@ function useBreakpoint() {
isMobile: !isSmall,
isTablet: isSmall && !isLarge,
isDesktop: isLarge,
current: isXLarge ? 'xl' : isLarge ? 'lg' : isMedium ? 'md' : isSmall ? 'sm' : 'base',
current: isXLarge
? "xl"
: isLarge
? "lg"
: isMedium
? "md"
: isSmall
? "sm"
: "base",
};
}
```
@@ -444,11 +478,14 @@ function useBreakpoint() {
}
/* Handle page breaks */
h1, h2, h3 {
h1,
h2,
h3 {
page-break-after: avoid;
}
img, table {
img,
table {
page-break-inside: avoid;
}
@@ -519,13 +556,16 @@ async function testBreakpoints(page, breakpoints) {
// Check for horizontal overflow
const hasOverflow = await page.evaluate(() => {
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
return (
document.documentElement.scrollWidth >
document.documentElement.clientWidth
);
});
// Check for elements going off-screen
const offscreenElements = await page.evaluate(() => {
const elements = document.querySelectorAll('*');
return Array.from(elements).filter(el => {
const elements = document.querySelectorAll("*");
return Array.from(elements).filter((el) => {
const rect = el.getBoundingClientRect();
return rect.right > window.innerWidth || rect.left < 0;
}).length;

View File

@@ -63,22 +63,30 @@ Container queries have excellent modern browser support (Chrome 105+, Firefox 11
/* Minimum width */
@container (min-width: 300px) {
.element { /* styles */ }
.element {
/* styles */
}
}
/* Maximum width */
@container (max-width: 500px) {
.element { /* styles */ }
.element {
/* styles */
}
}
/* Range syntax */
@container (300px <= width <= 600px) {
.element { /* styles */ }
.element {
/* styles */
}
}
/* Exact width */
@container (width: 400px) {
.element { /* styles */ }
.element {
/* styles */
}
}
```
@@ -87,17 +95,23 @@ Container queries have excellent modern browser support (Chrome 105+, Firefox 11
```css
/* AND condition */
@container (min-width: 400px) and (max-width: 800px) {
.element { /* styles */ }
.element {
/* styles */
}
}
/* OR condition */
@container (max-width: 300px) or (min-width: 800px) {
.element { /* styles */ }
.element {
/* styles */
}
}
/* NOT condition */
@container not (min-width: 400px) {
.element { /* styles */ }
.element {
/* styles */
}
}
```
@@ -403,9 +417,7 @@ Style queries allow querying CSS custom property values. Currently limited suppo
// Tailwind v3.2+ supports container queries
// tailwind.config.js
module.exports = {
plugins: [
require('@tailwindcss/container-queries'),
],
plugins: [require("@tailwindcss/container-queries")],
};
// Component usage
@@ -437,13 +449,9 @@ function Dashboard() {
return (
<div className="@container/main">
<aside className="@container/sidebar">
<nav className="flex flex-col @lg/sidebar:flex-row">
{/* ... */}
</nav>
<nav className="flex flex-col @lg/sidebar:flex-row">{/* ... */}</nav>
</aside>
<main className="@lg/main:grid @lg/main:grid-cols-2">
{/* ... */}
</main>
<main className="@lg/main:grid @lg/main:grid-cols-2">{/* ... */}</main>
</div>
);
}
@@ -506,10 +514,18 @@ function Dashboard() {
```css
/* Avoid over-nesting containers */
/* Bad: Too many nested containers */
.level-1 { container-type: inline-size; }
.level-2 { container-type: inline-size; }
.level-3 { container-type: inline-size; }
.level-4 { container-type: inline-size; }
.level-1 {
container-type: inline-size;
}
.level-2 {
container-type: inline-size;
}
.level-3 {
container-type: inline-size;
}
.level-4 {
container-type: inline-size;
}
/* Good: Strategic container placement */
.component-wrapper {
@@ -528,16 +544,16 @@ function Dashboard() {
```javascript
// Test container query support
const supportsContainerQueries = CSS.supports('container-type', 'inline-size');
const supportsContainerQueries = CSS.supports("container-type", "inline-size");
// Resize observer for testing
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
console.log('Container width:', entry.contentRect.width);
console.log("Container width:", entry.contentRect.width);
}
});
observer.observe(document.querySelector('.container'));
observer.observe(document.querySelector(".container"));
```
## Resources

View File

@@ -61,9 +61,9 @@ const typeScale = {
base: fluidType({ minFontSize: 16, maxFontSize: 18 }),
lg: fluidType({ minFontSize: 18, maxFontSize: 20 }),
xl: fluidType({ minFontSize: 20, maxFontSize: 24 }),
'2xl': fluidType({ minFontSize: 24, maxFontSize: 32 }),
'3xl': fluidType({ minFontSize: 30, maxFontSize: 48 }),
'4xl': fluidType({ minFontSize: 36, maxFontSize: 60 }),
"2xl": fluidType({ minFontSize: 24, maxFontSize: 32 }),
"3xl": fluidType({ minFontSize: 30, maxFontSize: 48 }),
"4xl": fluidType({ minFontSize: 36, maxFontSize: 60 }),
};
```
@@ -98,14 +98,34 @@ body {
line-height: var(--leading-normal);
}
h1 { font-size: var(--text-4xl); line-height: var(--leading-tight); }
h2 { font-size: var(--text-3xl); line-height: var(--leading-tight); }
h3 { font-size: var(--text-2xl); line-height: var(--leading-tight); }
h4 { font-size: var(--text-xl); line-height: var(--leading-normal); }
h5 { font-size: var(--text-lg); line-height: var(--leading-normal); }
h6 { font-size: var(--text-base); line-height: var(--leading-normal); }
h1 {
font-size: var(--text-4xl);
line-height: var(--leading-tight);
}
h2 {
font-size: var(--text-3xl);
line-height: var(--leading-tight);
}
h3 {
font-size: var(--text-2xl);
line-height: var(--leading-tight);
}
h4 {
font-size: var(--text-xl);
line-height: var(--leading-normal);
}
h5 {
font-size: var(--text-lg);
line-height: var(--leading-normal);
}
h6 {
font-size: var(--text-base);
line-height: var(--leading-normal);
}
small { font-size: var(--text-sm); }
small {
font-size: var(--text-sm);
}
```
## Fluid Spacing
@@ -184,10 +204,7 @@ small { font-size: var(--text-sm); }
/* Grid that fills available space */
.auto-grid {
display: grid;
grid-template-columns: repeat(
auto-fit,
minmax(min(100%, 250px), 1fr)
);
grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr));
gap: var(--space-md);
}
@@ -338,8 +355,8 @@ small { font-size: var(--text-sm); }
}
/* Limit columns */
.switcher > :nth-last-child(n+4),
.switcher > :nth-last-child(n+4) ~ * {
.switcher > :nth-last-child(n + 4),
.switcher > :nth-last-child(n + 4) ~ * {
flex-basis: 100%;
}
```
@@ -405,10 +422,7 @@ small { font-size: var(--text-sm); }
.card-grid {
/* Each card at least 200px, fill available space */
grid-template-columns: repeat(
auto-fit,
minmax(max(200px, 100%/4), 1fr)
);
grid-template-columns: repeat(auto-fit, minmax(max(200px, 100%/4), 1fr));
}
```
@@ -473,21 +487,47 @@ small { font-size: var(--text-sm); }
```css
/* Tailwind-style fluid utilities */
.text-fluid-sm { font-size: var(--text-sm); }
.text-fluid-base { font-size: var(--text-base); }
.text-fluid-lg { font-size: var(--text-lg); }
.text-fluid-xl { font-size: var(--text-xl); }
.text-fluid-2xl { font-size: var(--text-2xl); }
.text-fluid-3xl { font-size: var(--text-3xl); }
.text-fluid-4xl { font-size: var(--text-4xl); }
.text-fluid-sm {
font-size: var(--text-sm);
}
.text-fluid-base {
font-size: var(--text-base);
}
.text-fluid-lg {
font-size: var(--text-lg);
}
.text-fluid-xl {
font-size: var(--text-xl);
}
.text-fluid-2xl {
font-size: var(--text-2xl);
}
.text-fluid-3xl {
font-size: var(--text-3xl);
}
.text-fluid-4xl {
font-size: var(--text-4xl);
}
.p-fluid-sm { padding: var(--space-sm); }
.p-fluid-md { padding: var(--space-md); }
.p-fluid-lg { padding: var(--space-lg); }
.p-fluid-sm {
padding: var(--space-sm);
}
.p-fluid-md {
padding: var(--space-md);
}
.p-fluid-lg {
padding: var(--space-lg);
}
.gap-fluid-sm { gap: var(--space-sm); }
.gap-fluid-md { gap: var(--space-md); }
.gap-fluid-lg { gap: var(--space-lg); }
.gap-fluid-sm {
gap: var(--space-sm);
}
.gap-fluid-md {
gap: var(--space-md);
}
.gap-fluid-lg {
gap: var(--space-lg);
}
```
## Resources

View File

@@ -23,17 +23,18 @@ Build cohesive, accessible visual systems using typography, color, spacing, and
### 1. Typography Scale
**Modular Scale** (ratio-based sizing):
```css
:root {
--font-size-xs: 0.75rem; /* 12px */
--font-size-sm: 0.875rem; /* 14px */
--font-size-base: 1rem; /* 16px */
--font-size-lg: 1.125rem; /* 18px */
--font-size-xl: 1.25rem; /* 20px */
--font-size-2xl: 1.5rem; /* 24px */
--font-size-xs: 0.75rem; /* 12px */
--font-size-sm: 0.875rem; /* 14px */
--font-size-base: 1rem; /* 16px */
--font-size-lg: 1.125rem; /* 18px */
--font-size-xl: 1.25rem; /* 20px */
--font-size-2xl: 1.5rem; /* 24px */
--font-size-3xl: 1.875rem; /* 30px */
--font-size-4xl: 2.25rem; /* 36px */
--font-size-5xl: 3rem; /* 48px */
--font-size-4xl: 2.25rem; /* 36px */
--font-size-5xl: 3rem; /* 48px */
}
```
@@ -47,24 +48,26 @@ Build cohesive, accessible visual systems using typography, color, spacing, and
### 2. Spacing System
**8-point grid** (industry standard):
```css
:root {
--space-1: 0.25rem; /* 4px */
--space-2: 0.5rem; /* 8px */
--space-3: 0.75rem; /* 12px */
--space-4: 1rem; /* 16px */
--space-5: 1.25rem; /* 20px */
--space-6: 1.5rem; /* 24px */
--space-8: 2rem; /* 32px */
--space-10: 2.5rem; /* 40px */
--space-12: 3rem; /* 48px */
--space-16: 4rem; /* 64px */
--space-1: 0.25rem; /* 4px */
--space-2: 0.5rem; /* 8px */
--space-3: 0.75rem; /* 12px */
--space-4: 1rem; /* 16px */
--space-5: 1.25rem; /* 20px */
--space-6: 1.5rem; /* 24px */
--space-8: 2rem; /* 32px */
--space-10: 2.5rem; /* 40px */
--space-12: 3rem; /* 48px */
--space-16: 4rem; /* 64px */
}
```
### 3. Color System
**Semantic color tokens**:
```css
:root {
/* Brand */
@@ -100,29 +103,29 @@ module.exports = {
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
sans: ["Inter", "system-ui", "sans-serif"],
mono: ["JetBrains Mono", "monospace"],
},
fontSize: {
xs: ['0.75rem', { lineHeight: '1rem' }],
sm: ['0.875rem', { lineHeight: '1.25rem' }],
base: ['1rem', { lineHeight: '1.5rem' }],
lg: ['1.125rem', { lineHeight: '1.75rem' }],
xl: ['1.25rem', { lineHeight: '1.75rem' }],
'2xl': ['1.5rem', { lineHeight: '2rem' }],
xs: ["0.75rem", { lineHeight: "1rem" }],
sm: ["0.875rem", { lineHeight: "1.25rem" }],
base: ["1rem", { lineHeight: "1.5rem" }],
lg: ["1.125rem", { lineHeight: "1.75rem" }],
xl: ["1.25rem", { lineHeight: "1.75rem" }],
"2xl": ["1.5rem", { lineHeight: "2rem" }],
},
colors: {
brand: {
50: '#eff6ff',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
50: "#eff6ff",
500: "#3b82f6",
600: "#2563eb",
700: "#1d4ed8",
},
},
spacing: {
// Extends default with custom values
'18': '4.5rem',
'88': '22rem',
18: "4.5rem",
88: "22rem",
},
},
},
@@ -134,6 +137,7 @@ module.exports = {
### Font Pairing
**Safe combinations**:
- Heading: **Inter** / Body: **Inter** (single family)
- Heading: **Playfair Display** / Body: **Source Sans Pro** (contrast)
- Heading: **Space Grotesk** / Body: **IBM Plex Sans** (geometric)
@@ -159,8 +163,8 @@ p {
```css
/* Prevent layout shift */
@font-face {
font-family: 'Inter';
src: url('/fonts/Inter.woff2') format('woff2');
font-family: "Inter";
src: url("/fonts/Inter.woff2") format("woff2");
font-display: swap;
font-weight: 400 700;
}
@@ -170,12 +174,12 @@ p {
### Contrast Requirements (WCAG)
| Element | Minimum Ratio |
|---------|---------------|
| Body text | 4.5:1 (AA) |
| Large text (18px+) | 3:1 (AA) |
| UI components | 3:1 (AA) |
| Enhanced | 7:1 (AAA) |
| Element | Minimum Ratio |
| ------------------ | ------------- |
| Body text | 4.5:1 (AA) |
| Large text (18px+) | 3:1 (AA) |
| UI components | 3:1 (AA) |
| Enhanced | 7:1 (AAA) |
### Dark Mode Strategy
@@ -204,7 +208,7 @@ p {
function getContrastRatio(foreground: string, background: string): number {
const getLuminance = (hex: string) => {
const rgb = hexToRgb(hex);
const [r, g, b] = rgb.map(c => {
const [r, g, b] = rgb.map((c) => {
c = c / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
@@ -268,7 +272,7 @@ Icon-text gap: 8px (--space-2)
```tsx
interface IconProps {
name: string;
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
size?: "xs" | "sm" | "md" | "lg" | "xl";
className?: string;
}
@@ -280,12 +284,12 @@ const sizeMap = {
xl: 32,
};
export function Icon({ name, size = 'md', className }: IconProps) {
export function Icon({ name, size = "md", className }: IconProps) {
return (
<svg
width={sizeMap[size]}
height={sizeMap[size]}
className={cn('inline-block flex-shrink-0', className)}
className={cn("inline-block flex-shrink-0", className)}
aria-hidden="true"
>
<use href={`/icons.svg#${name}`} />

View File

@@ -15,7 +15,7 @@ Using OKLCH for perceptually uniform color scales:
--blue-200: oklch(86% 0.08 250);
--blue-300: oklch(75% 0.12 250);
--blue-400: oklch(65% 0.16 250);
--blue-500: oklch(55% 0.20 250); /* Primary */
--blue-500: oklch(55% 0.2 250); /* Primary */
--blue-600: oklch(48% 0.18 250);
--blue-700: oklch(40% 0.16 250);
--blue-800: oklch(32% 0.12 250);
@@ -27,26 +27,29 @@ Using OKLCH for perceptually uniform color scales:
### Programmatic Scale Generation
```tsx
function generateColorScale(hue: number, saturation: number = 100): Record<string, string> {
function generateColorScale(
hue: number,
saturation: number = 100,
): Record<string, string> {
const lightnessStops = [
{ name: '50', l: 97 },
{ name: '100', l: 93 },
{ name: '200', l: 85 },
{ name: '300', l: 75 },
{ name: '400', l: 65 },
{ name: '500', l: 55 },
{ name: '600', l: 45 },
{ name: '700', l: 35 },
{ name: '800', l: 25 },
{ name: '900', l: 18 },
{ name: '950', l: 12 },
{ name: "50", l: 97 },
{ name: "100", l: 93 },
{ name: "200", l: 85 },
{ name: "300", l: 75 },
{ name: "400", l: 65 },
{ name: "500", l: 55 },
{ name: "600", l: 45 },
{ name: "700", l: 35 },
{ name: "800", l: 25 },
{ name: "900", l: 18 },
{ name: "950", l: 12 },
];
return Object.fromEntries(
lightnessStops.map(({ name, l }) => [
name,
`hsl(${hue}, ${saturation}%, ${l}%)`,
])
]),
);
}
@@ -167,49 +170,50 @@ const error = generateColorScale(0); // Red
### React Theme Context
```tsx
import { createContext, useContext, useEffect, useState } from 'react';
import { createContext, useContext, useEffect, useState } from "react";
type Theme = 'light' | 'dark' | 'system';
type Theme = "light" | "dark" | "system";
interface ThemeContextValue {
theme: Theme;
setTheme: (theme: Theme) => void;
resolvedTheme: 'light' | 'dark';
resolvedTheme: "light" | "dark";
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>('system');
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');
const [theme, setTheme] = useState<Theme>("system");
const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">("light");
useEffect(() => {
const root = document.documentElement;
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
setResolvedTheme(systemTheme);
root.setAttribute('data-theme', systemTheme);
root.setAttribute("data-theme", systemTheme);
} else {
setResolvedTheme(theme);
root.setAttribute('data-theme', theme);
root.setAttribute("data-theme", theme);
}
}, [theme]);
useEffect(() => {
if (theme !== 'system') return;
if (theme !== "system") return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handler = (e: MediaQueryListEvent) => {
const newTheme = e.matches ? 'dark' : 'light';
const newTheme = e.matches ? "dark" : "light";
setResolvedTheme(newTheme);
document.documentElement.setAttribute('data-theme', newTheme);
document.documentElement.setAttribute("data-theme", newTheme);
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
mediaQuery.addEventListener("change", handler);
return () => mediaQuery.removeEventListener("change", handler);
}, [theme]);
return (
@@ -221,7 +225,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) throw new Error('useTheme must be within ThemeProvider');
if (!context) throw new Error("useTheme must be within ThemeProvider");
return context;
}
```
@@ -233,7 +237,7 @@ export function useTheme() {
```tsx
function hexToRgb(hex: string): [number, number, number] {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (!result) throw new Error('Invalid hex color');
if (!result) throw new Error("Invalid hex color");
return [
parseInt(result[1], 16),
parseInt(result[2], 16),
@@ -265,8 +269,8 @@ function getContrastRatio(hex1: string, hex2: string): number {
function meetsWCAG(
foreground: string,
background: string,
size: 'normal' | 'large' = 'normal',
level: 'AA' | 'AAA' = 'AA'
size: "normal" | "large" = "normal",
level: "AA" | "AAA" = "AA",
): boolean {
const ratio = getContrastRatio(foreground, background);
@@ -279,8 +283,8 @@ function meetsWCAG(
}
// Usage
meetsWCAG('#ffffff', '#3b82f6'); // true (4.5:1 for AA normal)
meetsWCAG('#ffffff', '#60a5fa'); // false (below 4.5:1)
meetsWCAG("#ffffff", "#3b82f6"); // true (4.5:1 for AA normal)
meetsWCAG("#ffffff", "#60a5fa"); // false (below 4.5:1)
```
### Accessible Color Pairs
@@ -292,14 +296,14 @@ function getAccessibleTextColor(backgroundColor: string): string {
const luminance = getLuminance(r, g, b);
// Use white text on dark backgrounds, black on light
return luminance > 0.179 ? '#111827' : '#ffffff';
return luminance > 0.179 ? "#111827" : "#ffffff";
}
// Find the nearest accessible shade
function findAccessibleShade(
textColor: string,
backgroundScale: string[],
minContrast: number = 4.5
minContrast: number = 4.5,
): string | null {
for (const shade of backgroundScale) {
if (getContrastRatio(textColor, shade) >= minContrast) {
@@ -315,26 +319,22 @@ function findAccessibleShade(
### Harmony Functions
```tsx
type HarmonyType = 'complementary' | 'triadic' | 'analogous' | 'split-complementary';
type HarmonyType =
| "complementary"
| "triadic"
| "analogous"
| "split-complementary";
function generateHarmony(baseHue: number, type: HarmonyType): number[] {
switch (type) {
case 'complementary':
case "complementary":
return [baseHue, (baseHue + 180) % 360];
case 'triadic':
case "triadic":
return [baseHue, (baseHue + 120) % 360, (baseHue + 240) % 360];
case 'analogous':
return [
(baseHue - 30 + 360) % 360,
baseHue,
(baseHue + 30) % 360,
];
case 'split-complementary':
return [
baseHue,
(baseHue + 150) % 360,
(baseHue + 210) % 360,
];
case "analogous":
return [(baseHue - 30 + 360) % 360, baseHue, (baseHue + 30) % 360];
case "split-complementary":
return [baseHue, (baseHue + 150) % 360, (baseHue + 210) % 360];
default:
return [baseHue];
}
@@ -343,13 +343,13 @@ function generateHarmony(baseHue: number, type: HarmonyType): number[] {
// Generate palette from harmony
function generateHarmoniousPalette(
baseHue: number,
type: HarmonyType
type: HarmonyType,
): Record<string, string> {
const hues = generateHarmony(baseHue, type);
const names = ['primary', 'secondary', 'tertiary'];
const names = ["primary", "secondary", "tertiary"];
return Object.fromEntries(
hues.map((hue, i) => [names[i] || `color-${i}`, `hsl(${hue}, 70%, 50%)`])
hues.map((hue, i) => [names[i] || `color-${i}`, `hsl(${hue}, 70%, 50%)`]),
);
}
```
@@ -358,7 +358,7 @@ function generateHarmoniousPalette(
```tsx
// Simulate color blindness
type ColorBlindnessType = 'protanopia' | 'deuteranopia' | 'tritanopia';
type ColorBlindnessType = "protanopia" | "deuteranopia" | "tritanopia";
// Matrix transforms for common types
const colorBlindnessMatrices: Record<ColorBlindnessType, number[][]> = {

View File

@@ -14,28 +14,28 @@ The 8-point grid is the industry standard for consistent spacing.
/* Spacing scale */
--space-0: 0;
--space-px: 1px;
--space-0-5: calc(var(--space-unit) * 0.5); /* 2px */
--space-1: var(--space-unit); /* 4px */
--space-1-5: calc(var(--space-unit) * 1.5); /* 6px */
--space-2: calc(var(--space-unit) * 2); /* 8px */
--space-2-5: calc(var(--space-unit) * 2.5); /* 10px */
--space-3: calc(var(--space-unit) * 3); /* 12px */
--space-3-5: calc(var(--space-unit) * 3.5); /* 14px */
--space-4: calc(var(--space-unit) * 4); /* 16px */
--space-5: calc(var(--space-unit) * 5); /* 20px */
--space-6: calc(var(--space-unit) * 6); /* 24px */
--space-7: calc(var(--space-unit) * 7); /* 28px */
--space-8: calc(var(--space-unit) * 8); /* 32px */
--space-9: calc(var(--space-unit) * 9); /* 36px */
--space-10: calc(var(--space-unit) * 10); /* 40px */
--space-11: calc(var(--space-unit) * 11); /* 44px */
--space-12: calc(var(--space-unit) * 12); /* 48px */
--space-14: calc(var(--space-unit) * 14); /* 56px */
--space-16: calc(var(--space-unit) * 16); /* 64px */
--space-20: calc(var(--space-unit) * 20); /* 80px */
--space-24: calc(var(--space-unit) * 24); /* 96px */
--space-28: calc(var(--space-unit) * 28); /* 112px */
--space-32: calc(var(--space-unit) * 32); /* 128px */
--space-0-5: calc(var(--space-unit) * 0.5); /* 2px */
--space-1: var(--space-unit); /* 4px */
--space-1-5: calc(var(--space-unit) * 1.5); /* 6px */
--space-2: calc(var(--space-unit) * 2); /* 8px */
--space-2-5: calc(var(--space-unit) * 2.5); /* 10px */
--space-3: calc(var(--space-unit) * 3); /* 12px */
--space-3-5: calc(var(--space-unit) * 3.5); /* 14px */
--space-4: calc(var(--space-unit) * 4); /* 16px */
--space-5: calc(var(--space-unit) * 5); /* 20px */
--space-6: calc(var(--space-unit) * 6); /* 24px */
--space-7: calc(var(--space-unit) * 7); /* 28px */
--space-8: calc(var(--space-unit) * 8); /* 32px */
--space-9: calc(var(--space-unit) * 9); /* 36px */
--space-10: calc(var(--space-unit) * 10); /* 40px */
--space-11: calc(var(--space-unit) * 11); /* 44px */
--space-12: calc(var(--space-unit) * 12); /* 48px */
--space-14: calc(var(--space-unit) * 14); /* 56px */
--space-16: calc(var(--space-unit) * 16); /* 64px */
--space-20: calc(var(--space-unit) * 20); /* 80px */
--space-24: calc(var(--space-unit) * 24); /* 96px */
--space-28: calc(var(--space-unit) * 28); /* 112px */
--space-32: calc(var(--space-unit) * 32); /* 128px */
}
```
@@ -44,20 +44,20 @@ The 8-point grid is the industry standard for consistent spacing.
```css
:root {
/* Component-level spacing */
--spacing-xs: var(--space-1); /* 4px - tight spacing */
--spacing-sm: var(--space-2); /* 8px - compact spacing */
--spacing-md: var(--space-4); /* 16px - default spacing */
--spacing-lg: var(--space-6); /* 24px - comfortable spacing */
--spacing-xl: var(--space-8); /* 32px - loose spacing */
--spacing-2xl: var(--space-12); /* 48px - generous spacing */
--spacing-3xl: var(--space-16); /* 64px - section spacing */
--spacing-xs: var(--space-1); /* 4px - tight spacing */
--spacing-sm: var(--space-2); /* 8px - compact spacing */
--spacing-md: var(--space-4); /* 16px - default spacing */
--spacing-lg: var(--space-6); /* 24px - comfortable spacing */
--spacing-xl: var(--space-8); /* 32px - loose spacing */
--spacing-2xl: var(--space-12); /* 48px - generous spacing */
--spacing-3xl: var(--space-16); /* 64px - section spacing */
/* Specific use cases */
--spacing-inline: var(--space-2); /* Between inline elements */
--spacing-stack: var(--space-4); /* Between stacked elements */
--spacing-inset: var(--space-4); /* Padding inside containers */
--spacing-section: var(--space-16); /* Between major sections */
--spacing-page: var(--space-24); /* Page margins */
--spacing-inline: var(--space-2); /* Between inline elements */
--spacing-stack: var(--space-4); /* Between stacked elements */
--spacing-inset: var(--space-4); /* Padding inside containers */
--spacing-section: var(--space-16); /* Between major sections */
--spacing-page: var(--space-24); /* Page margins */
}
```
@@ -67,18 +67,17 @@ The 8-point grid is the industry standard for consistent spacing.
// Tailwind-like spacing scale generator
function createSpacingScale(baseUnit: number = 4): Record<string, string> {
const scale: Record<string, string> = {
'0': '0',
'px': '1px',
"0": "0",
px: "1px",
};
const multipliers = [
0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 5, 6, 7, 8, 9, 10,
11, 12, 14, 16, 20, 24, 28, 32, 36, 40, 44, 48,
52, 56, 60, 64, 72, 80, 96,
0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14, 16, 20, 24,
28, 32, 36, 40, 44, 48, 52, 56, 60, 64, 72, 80, 96,
];
for (const m of multipliers) {
const key = m % 1 === 0 ? String(m) : String(m).replace('.', '-');
const key = m % 1 === 0 ? String(m) : String(m).replace(".", "-");
scale[key] = `${baseUnit * m}px`;
}
@@ -140,15 +139,15 @@ function createSpacingScale(baseUnit: number = 4): Record<string, string> {
```css
:root {
/* Icon sizes aligned to spacing grid */
--icon-xs: 12px; /* Inline decorators */
--icon-sm: 16px; /* Small UI elements */
--icon-md: 20px; /* Default size */
--icon-lg: 24px; /* Emphasis */
--icon-xl: 32px; /* Large displays */
--icon-2xl: 48px; /* Hero icons */
--icon-xs: 12px; /* Inline decorators */
--icon-sm: 16px; /* Small UI elements */
--icon-md: 20px; /* Default size */
--icon-lg: 24px; /* Emphasis */
--icon-xl: 32px; /* Large displays */
--icon-2xl: 48px; /* Hero icons */
/* Touch target sizes */
--touch-target-min: 44px; /* WCAG minimum */
--touch-target-min: 44px; /* WCAG minimum */
--touch-target-comfortable: 48px;
}
```
@@ -156,11 +155,11 @@ function createSpacingScale(baseUnit: number = 4): Record<string, string> {
### SVG Icon Component
```tsx
import { forwardRef, type SVGProps } from 'react';
import { forwardRef, type SVGProps } from "react";
interface IconProps extends SVGProps<SVGSVGElement> {
name: string;
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
label?: string;
}
@@ -170,11 +169,11 @@ const sizeMap = {
md: 20,
lg: 24,
xl: 32,
'2xl': 48,
"2xl": 48,
};
export const Icon = forwardRef<SVGSVGElement, IconProps>(
({ name, size = 'md', label, className, ...props }, ref) => {
({ name, size = "md", label, className, ...props }, ref) => {
const pixelSize = sizeMap[size];
return (
@@ -185,16 +184,16 @@ export const Icon = forwardRef<SVGSVGElement, IconProps>(
className={`inline-block flex-shrink-0 ${className}`}
aria-hidden={!label}
aria-label={label}
role={label ? 'img' : undefined}
role={label ? "img" : undefined}
{...props}
>
<use href={`/icons.svg#${name}`} />
</svg>
);
}
},
);
Icon.displayName = 'Icon';
Icon.displayName = "Icon";
```
### Icon Button Patterns
@@ -203,27 +202,27 @@ Icon.displayName = 'Icon';
interface IconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
icon: string;
label: string;
size?: 'sm' | 'md' | 'lg';
variant?: 'solid' | 'ghost' | 'outline';
size?: "sm" | "md" | "lg";
variant?: "solid" | "ghost" | "outline";
}
const sizeClasses = {
sm: 'p-1.5', /* 32px total with 16px icon */
md: 'p-2', /* 40px total with 20px icon */
lg: 'p-2.5', /* 48px total with 24px icon */
sm: "p-1.5" /* 32px total with 16px icon */,
md: "p-2" /* 40px total with 20px icon */,
lg: "p-2.5" /* 48px total with 24px icon */,
};
const iconSizes = {
sm: 'sm' as const,
md: 'md' as const,
lg: 'lg' as const,
sm: "sm" as const,
md: "md" as const,
lg: "lg" as const,
};
export function IconButton({
icon,
label,
size = 'md',
variant = 'ghost',
size = "md",
variant = "ghost",
className,
...props
}: IconButtonProps) {
@@ -233,9 +232,9 @@ export function IconButton({
inline-flex items-center justify-center rounded-lg
transition-colors focus-visible:outline-none focus-visible:ring-2
${sizeClasses[size]}
${variant === 'solid' && 'bg-blue-600 text-white hover:bg-blue-700'}
${variant === 'ghost' && 'hover:bg-gray-100'}
${variant === 'outline' && 'border border-gray-300 hover:bg-gray-50'}
${variant === "solid" && "bg-blue-600 text-white hover:bg-blue-700"}
${variant === "ghost" && "hover:bg-gray-100"}
${variant === "outline" && "border border-gray-300 hover:bg-gray-50"}
${className}
`}
aria-label={label}
@@ -251,62 +250,62 @@ export function IconButton({
```tsx
// Build script for SVG sprite
import { readdir, readFile, writeFile } from 'fs/promises';
import { optimize } from 'svgo';
import { readdir, readFile, writeFile } from "fs/promises";
import { optimize } from "svgo";
async function buildIconSprite(iconDir: string, outputPath: string) {
const files = await readdir(iconDir);
const svgFiles = files.filter((f) => f.endsWith('.svg'));
const svgFiles = files.filter((f) => f.endsWith(".svg"));
const symbols = await Promise.all(
svgFiles.map(async (file) => {
const content = await readFile(`${iconDir}/${file}`, 'utf-8');
const name = file.replace('.svg', '');
const content = await readFile(`${iconDir}/${file}`, "utf-8");
const name = file.replace(".svg", "");
// Optimize SVG
const result = optimize(content, {
plugins: [
'removeDoctype',
'removeXMLProcInst',
'removeComments',
'removeMetadata',
'removeTitle',
'removeDesc',
'removeUselessDefs',
'removeEditorsNSData',
'removeEmptyAttrs',
'removeHiddenElems',
'removeEmptyText',
'removeEmptyContainers',
'convertStyleToAttrs',
'convertColors',
'convertPathData',
'convertTransform',
'removeUnknownsAndDefaults',
'removeNonInheritableGroupAttrs',
'removeUselessStrokeAndFill',
'removeUnusedNS',
'cleanupNumericValues',
'cleanupListOfValues',
'moveElemsAttrsToGroup',
'moveGroupAttrsToElems',
'collapseGroups',
'mergePaths',
"removeDoctype",
"removeXMLProcInst",
"removeComments",
"removeMetadata",
"removeTitle",
"removeDesc",
"removeUselessDefs",
"removeEditorsNSData",
"removeEmptyAttrs",
"removeHiddenElems",
"removeEmptyText",
"removeEmptyContainers",
"convertStyleToAttrs",
"convertColors",
"convertPathData",
"convertTransform",
"removeUnknownsAndDefaults",
"removeNonInheritableGroupAttrs",
"removeUselessStrokeAndFill",
"removeUnusedNS",
"cleanupNumericValues",
"cleanupListOfValues",
"moveElemsAttrsToGroup",
"moveGroupAttrsToElems",
"collapseGroups",
"mergePaths",
],
});
// Extract viewBox and content
const viewBoxMatch = result.data.match(/viewBox="([^"]+)"/);
const viewBox = viewBoxMatch ? viewBoxMatch[1] : '0 0 24 24';
const viewBox = viewBoxMatch ? viewBoxMatch[1] : "0 0 24 24";
const innerContent = result.data
.replace(/<svg[^>]*>/, '')
.replace(/<\/svg>/, '');
.replace(/<svg[^>]*>/, "")
.replace(/<\/svg>/, "");
return `<symbol id="${name}" viewBox="${viewBox}">${innerContent}</symbol>`;
})
}),
);
const sprite = `<svg xmlns="http://www.w3.org/2000/svg" style="display:none">${symbols.join('')}</svg>`;
const sprite = `<svg xmlns="http://www.w3.org/2000/svg" style="display:none">${symbols.join("")}</svg>`;
await writeFile(outputPath, sprite);
console.log(`Generated sprite with ${symbols.length} icons`);
@@ -317,7 +316,7 @@ async function buildIconSprite(iconDir: string, outputPath: string) {
```tsx
// Lucide React
import { Home, Settings, User, Search } from 'lucide-react';
import { Home, Settings, User, Search } from "lucide-react";
function Navigation() {
return (
@@ -331,8 +330,8 @@ function Navigation() {
}
// Heroicons
import { HomeIcon, Cog6ToothIcon } from '@heroicons/react/24/outline';
import { HomeIcon as HomeIconSolid } from '@heroicons/react/24/solid';
import { HomeIcon, Cog6ToothIcon } from "@heroicons/react/24/outline";
import { HomeIcon as HomeIconSolid } from "@heroicons/react/24/solid";
function ToggleIcon({ active }: { active: boolean }) {
const Icon = active ? HomeIconSolid : HomeIcon;
@@ -340,7 +339,7 @@ function ToggleIcon({ active }: { active: boolean }) {
}
// Radix Icons
import { HomeIcon, GearIcon } from '@radix-ui/react-icons';
import { HomeIcon, GearIcon } from "@radix-ui/react-icons";
```
## Sizing Systems
@@ -350,29 +349,29 @@ import { HomeIcon, GearIcon } from '@radix-ui/react-icons';
```css
:root {
/* Fixed sizes */
--size-4: 1rem; /* 16px */
--size-5: 1.25rem; /* 20px */
--size-6: 1.5rem; /* 24px */
--size-8: 2rem; /* 32px */
--size-10: 2.5rem; /* 40px */
--size-12: 3rem; /* 48px */
--size-14: 3.5rem; /* 56px */
--size-16: 4rem; /* 64px */
--size-20: 5rem; /* 80px */
--size-24: 6rem; /* 96px */
--size-32: 8rem; /* 128px */
--size-4: 1rem; /* 16px */
--size-5: 1.25rem; /* 20px */
--size-6: 1.5rem; /* 24px */
--size-8: 2rem; /* 32px */
--size-10: 2.5rem; /* 40px */
--size-12: 3rem; /* 48px */
--size-14: 3.5rem; /* 56px */
--size-16: 4rem; /* 64px */
--size-20: 5rem; /* 80px */
--size-24: 6rem; /* 96px */
--size-32: 8rem; /* 128px */
/* Component heights */
--height-input-sm: var(--size-8); /* 32px */
--height-input-md: var(--size-10); /* 40px */
--height-input-lg: var(--size-12); /* 48px */
--height-input-sm: var(--size-8); /* 32px */
--height-input-md: var(--size-10); /* 40px */
--height-input-lg: var(--size-12); /* 48px */
/* Avatar sizes */
--avatar-xs: var(--size-6); /* 24px */
--avatar-sm: var(--size-8); /* 32px */
--avatar-md: var(--size-10); /* 40px */
--avatar-lg: var(--size-12); /* 48px */
--avatar-xl: var(--size-16); /* 64px */
--avatar-xs: var(--size-6); /* 24px */
--avatar-sm: var(--size-8); /* 32px */
--avatar-md: var(--size-10); /* 40px */
--avatar-lg: var(--size-12); /* 48px */
--avatar-xl: var(--size-16); /* 64px */
--avatar-2xl: var(--size-24); /* 96px */
}
```
@@ -407,13 +406,13 @@ import { HomeIcon, GearIcon } from '@radix-ui/react-icons';
```css
:root {
--radius-none: 0;
--radius-sm: 0.125rem; /* 2px */
--radius-sm: 0.125rem; /* 2px */
--radius-default: 0.25rem; /* 4px */
--radius-md: 0.375rem; /* 6px */
--radius-lg: 0.5rem; /* 8px */
--radius-xl: 0.75rem; /* 12px */
--radius-2xl: 1rem; /* 16px */
--radius-3xl: 1.5rem; /* 24px */
--radius-md: 0.375rem; /* 6px */
--radius-lg: 0.5rem; /* 8px */
--radius-xl: 0.75rem; /* 12px */
--radius-2xl: 1rem; /* 16px */
--radius-3xl: 1.5rem; /* 24px */
--radius-full: 9999px;
/* Component-specific */

View File

@@ -9,17 +9,21 @@ A modular scale creates harmonious relationships between font sizes using a math
```tsx
// Common ratios
const RATIOS = {
minorSecond: 1.067, // 16:15
majorSecond: 1.125, // 9:8
minorThird: 1.2, // 6:5
majorThird: 1.25, // 5:4
perfectFourth: 1.333, // 4:3
minorSecond: 1.067, // 16:15
majorSecond: 1.125, // 9:8
minorThird: 1.2, // 6:5
majorThird: 1.25, // 5:4
perfectFourth: 1.333, // 4:3
augmentedFourth: 1.414, // √2
perfectFifth: 1.5, // 3:2
goldenRatio: 1.618, // φ
perfectFifth: 1.5, // 3:2
goldenRatio: 1.618, // φ
};
function generateScale(baseSize: number, ratio: number, steps: number): number[] {
function generateScale(
baseSize: number,
ratio: number,
steps: number,
): number[] {
const scale: number[] = [];
for (let i = -2; i <= steps; i++) {
scale.push(Math.round(baseSize * Math.pow(ratio, i) * 100) / 100);
@@ -37,17 +41,17 @@ const typeScale = generateScale(16, RATIOS.perfectFourth, 6);
```css
:root {
/* Base scale using perfect fourth (1.333) */
--font-size-2xs: 0.563rem; /* ~9px */
--font-size-xs: 0.75rem; /* 12px */
--font-size-sm: 0.875rem; /* 14px */
--font-size-base: 1rem; /* 16px */
--font-size-md: 1.125rem; /* 18px */
--font-size-lg: 1.333rem; /* ~21px */
--font-size-xl: 1.5rem; /* 24px */
--font-size-2xl: 1.777rem; /* ~28px */
--font-size-3xl: 2.369rem; /* ~38px */
--font-size-4xl: 3.157rem; /* ~50px */
--font-size-5xl: 4.209rem; /* ~67px */
--font-size-2xs: 0.563rem; /* ~9px */
--font-size-xs: 0.75rem; /* 12px */
--font-size-sm: 0.875rem; /* 14px */
--font-size-base: 1rem; /* 16px */
--font-size-md: 1.125rem; /* 18px */
--font-size-lg: 1.333rem; /* ~21px */
--font-size-xl: 1.5rem; /* 24px */
--font-size-2xl: 1.777rem; /* ~28px */
--font-size-3xl: 2.369rem; /* ~38px */
--font-size-4xl: 3.157rem; /* ~50px */
--font-size-5xl: 4.209rem; /* ~67px */
/* Font weights */
--font-weight-normal: 400;
@@ -79,8 +83,8 @@ const typeScale = generateScale(16, RATIOS.perfectFourth, 6);
```css
/* Use font-display to control loading behavior */
@font-face {
font-family: 'Inter';
src: url('/fonts/Inter-Variable.woff2') format('woff2-variations');
font-family: "Inter";
src: url("/fonts/Inter-Variable.woff2") format("woff2-variations");
font-weight: 100 900;
font-style: normal;
font-display: swap; /* Show fallback immediately, swap when loaded */
@@ -88,8 +92,8 @@ const typeScale = generateScale(16, RATIOS.perfectFourth, 6);
/* Optional: size-adjust for better fallback matching */
@font-face {
font-family: 'Inter Fallback';
src: local('Arial');
font-family: "Inter Fallback";
src: local("Arial");
size-adjust: 107%; /* Adjust to match Inter metrics */
ascent-override: 90%;
descent-override: 22%;
@@ -97,7 +101,7 @@ const typeScale = generateScale(16, RATIOS.perfectFourth, 6);
}
body {
font-family: 'Inter', 'Inter Fallback', system-ui, sans-serif;
font-family: "Inter", "Inter Fallback", system-ui, sans-serif;
}
```
@@ -121,15 +125,17 @@ body {
```css
/* Variable font with weight and width axes */
@font-face {
font-family: 'Inter';
src: url('/fonts/Inter-Variable.woff2') format('woff2');
font-family: "Inter";
src: url("/fonts/Inter-Variable.woff2") format("woff2");
font-weight: 100 900;
font-stretch: 75% 125%;
}
/* Use font-variation-settings for fine control */
.custom-weight {
font-variation-settings: 'wght' 450, 'wdth' 95;
font-variation-settings:
"wght" 450,
"wdth" 95;
}
/* Or use standard properties */
@@ -169,9 +175,8 @@ p {
--max-vw: 1200;
line-height: calc(
var(--min-line-height) +
(var(--max-line-height) - var(--min-line-height)) *
((100vw - var(--min-vw) * 1px) / (var(--max-vw) - var(--min-vw)))
var(--min-line-height) + (var(--max-line-height) - var(--min-line-height)) *
((100vw - var(--min-vw) * 1px) / (var(--max-vw) - var(--min-vw)))
);
}
```
@@ -183,15 +188,15 @@ p {
module.exports = {
theme: {
fontSize: {
xs: ['0.75rem', { lineHeight: '1rem' }],
sm: ['0.875rem', { lineHeight: '1.25rem' }],
base: ['1rem', { lineHeight: '1.5rem' }],
lg: ['1.125rem', { lineHeight: '1.75rem' }],
xl: ['1.25rem', { lineHeight: '1.75rem' }],
'2xl': ['1.5rem', { lineHeight: '2rem' }],
'3xl': ['1.875rem', { lineHeight: '2.25rem' }],
'4xl': ['2.25rem', { lineHeight: '2.5rem' }],
'5xl': ['3rem', { lineHeight: '1' }],
xs: ["0.75rem", { lineHeight: "1rem" }],
sm: ["0.875rem", { lineHeight: "1.25rem" }],
base: ["1rem", { lineHeight: "1.5rem" }],
lg: ["1.125rem", { lineHeight: "1.75rem" }],
xl: ["1.25rem", { lineHeight: "1.75rem" }],
"2xl": ["1.5rem", { lineHeight: "2rem" }],
"3xl": ["1.875rem", { lineHeight: "2.25rem" }],
"4xl": ["2.25rem", { lineHeight: "2.5rem" }],
"5xl": ["3rem", { lineHeight: "1" }],
},
},
};
@@ -268,7 +273,9 @@ p {
}
/* Balance headings */
h1, h2, h3 {
h1,
h2,
h3 {
text-wrap: balance;
}
@@ -292,20 +299,20 @@ h1, h2, h3 {
```css
/* Serif heading + Sans body */
:root {
--font-heading: 'Playfair Display', Georgia, serif;
--font-body: 'Source Sans Pro', -apple-system, sans-serif;
--font-heading: "Playfair Display", Georgia, serif;
--font-body: "Source Sans Pro", -apple-system, sans-serif;
}
/* Geometric heading + Humanist body */
:root {
--font-heading: 'Space Grotesk', sans-serif;
--font-body: 'IBM Plex Sans', sans-serif;
--font-heading: "Space Grotesk", sans-serif;
--font-body: "IBM Plex Sans", sans-serif;
}
/* Modern sans heading + Classic serif body */
:root {
--font-heading: 'Inter', system-ui, sans-serif;
--font-body: 'Georgia', Times, serif;
--font-heading: "Inter", system-ui, sans-serif;
--font-body: "Georgia", Times, serif;
}
```
@@ -314,7 +321,7 @@ h1, h2, h3 {
```css
/* Single variable font family for all uses */
:root {
--font-family: 'Inter', system-ui, sans-serif;
--font-family: "Inter", system-ui, sans-serif;
}
h1 {
@@ -405,7 +412,7 @@ p {
font-variant-numeric: tabular-nums lining-nums;
/* Fractions */
font-feature-settings: 'frac' 1;
font-feature-settings: "frac" 1;
}
/* Tabular numbers for aligned columns */

View File

@@ -22,6 +22,7 @@ Build reusable, maintainable UI components using modern frameworks with clean co
### 1. Component Composition Patterns
**Compound Components**: Related components that work together
```tsx
// Usage
<Select value={value} onChange={setValue}>
@@ -34,15 +35,17 @@ Build reusable, maintainable UI components using modern frameworks with clean co
```
**Render Props**: Delegate rendering to parent
```tsx
<DataFetcher url="/api/users">
{({ data, loading, error }) => (
{({ data, loading, error }) =>
loading ? <Spinner /> : <UserList users={data} />
)}
}
</DataFetcher>
```
**Slots (Vue/Svelte)**: Named content injection points
```vue
<template>
<Card>
@@ -55,20 +58,20 @@ Build reusable, maintainable UI components using modern frameworks with clean co
### 2. CSS-in-JS Approaches
| Solution | Approach | Best For |
|----------|----------|----------|
| **Tailwind CSS** | Utility classes | Rapid prototyping, design systems |
| **CSS Modules** | Scoped CSS files | Existing CSS, gradual adoption |
| **styled-components** | Template literals | React, dynamic styling |
| **Emotion** | Object/template styles | Flexible, SSR-friendly |
| **Vanilla Extract** | Zero-runtime | Performance-critical apps |
| Solution | Approach | Best For |
| --------------------- | ---------------------- | --------------------------------- |
| **Tailwind CSS** | Utility classes | Rapid prototyping, design systems |
| **CSS Modules** | Scoped CSS files | Existing CSS, gradual adoption |
| **styled-components** | Template literals | React, dynamic styling |
| **Emotion** | Object/template styles | Flexible, SSR-friendly |
| **Vanilla Extract** | Zero-runtime | Performance-critical apps |
### 3. Component API Design
```tsx
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'ghost';
size?: 'sm' | 'md' | 'lg';
variant?: "primary" | "secondary" | "ghost";
size?: "sm" | "md" | "lg";
isLoading?: boolean;
isDisabled?: boolean;
leftIcon?: React.ReactNode;
@@ -79,6 +82,7 @@ interface ButtonProps {
```
**Principles**:
- Use semantic prop names (`isLoading` vs `loading`)
- Provide sensible defaults
- Support composition via `children`
@@ -87,34 +91,35 @@ interface ButtonProps {
## Quick Start: React Component with Tailwind
```tsx
import { forwardRef, type ComponentPropsWithoutRef } from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { forwardRef, type ComponentPropsWithoutRef } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
"inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200',
ghost: 'hover:bg-gray-100 hover:text-gray-900',
primary: "bg-blue-600 text-white hover:bg-blue-700",
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200",
ghost: "hover:bg-gray-100 hover:text-gray-900",
},
size: {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-sm',
lg: 'h-12 px-6 text-base',
sm: "h-8 px-3 text-sm",
md: "h-10 px-4 text-sm",
lg: "h-12 px-6 text-base",
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
variant: "primary",
size: "md",
},
}
},
);
interface ButtonProps
extends ComponentPropsWithoutRef<'button'>,
extends
ComponentPropsWithoutRef<"button">,
VariantProps<typeof buttonVariants> {
isLoading?: boolean;
}
@@ -130,9 +135,9 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
{isLoading && <Spinner className="mr-2 h-4 w-4" />}
{children}
</button>
)
),
);
Button.displayName = 'Button';
Button.displayName = "Button";
```
## Framework Patterns
@@ -140,7 +145,7 @@ Button.displayName = 'Button';
### React: Compound Components
```tsx
import { createContext, useContext, useState, type ReactNode } from 'react';
import { createContext, useContext, useState, type ReactNode } from "react";
interface AccordionContextValue {
openItems: Set<string>;
@@ -151,7 +156,7 @@ const AccordionContext = createContext<AccordionContextValue | null>(null);
function useAccordion() {
const context = useContext(AccordionContext);
if (!context) throw new Error('Must be used within Accordion');
if (!context) throw new Error("Must be used within Accordion");
return context;
}
@@ -159,7 +164,7 @@ export function Accordion({ children }: { children: ReactNode }) {
const [openItems, setOpenItems] = useState<Set<string>>(new Set());
const toggle = (id: string) => {
setOpenItems(prev => {
setOpenItems((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
@@ -176,8 +181,12 @@ export function Accordion({ children }: { children: ReactNode }) {
Accordion.Item = function AccordionItem({
id,
title,
children
}: { id: string; title: string; children: ReactNode }) {
children,
}: {
id: string;
title: string;
children: ReactNode;
}) {
const { openItems, toggle } = useAccordion();
const isOpen = openItems.has(id);
@@ -196,20 +205,22 @@ Accordion.Item = function AccordionItem({
```vue
<script setup lang="ts">
import { ref, computed, provide, inject, type InjectionKey } from 'vue';
import { ref, computed, provide, inject, type InjectionKey } from "vue";
interface TabsContext {
activeTab: Ref<string>;
setActive: (id: string) => void;
}
const TabsKey: InjectionKey<TabsContext> = Symbol('tabs');
const TabsKey: InjectionKey<TabsContext> = Symbol("tabs");
// Parent component
const activeTab = ref('tab-1');
const activeTab = ref("tab-1");
provide(TabsKey, {
activeTab,
setActive: (id: string) => { activeTab.value = id; }
setActive: (id: string) => {
activeTab.value = id;
},
});
// Child component usage

View File

@@ -5,8 +5,8 @@
### Modal Dialog
```tsx
import { useEffect, useRef, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useEffect, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
interface ModalProps {
isOpen: boolean;
@@ -23,27 +23,27 @@ export function Modal({ isOpen, onClose, title, children }: ModalProps) {
if (isOpen) {
previousActiveElement.current = document.activeElement;
dialogRef.current?.focus();
document.body.style.overflow = 'hidden';
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = '';
document.body.style.overflow = "";
(previousActiveElement.current as HTMLElement)?.focus();
}
return () => {
document.body.style.overflow = '';
document.body.style.overflow = "";
};
}, [isOpen]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key === 'Tab') trapFocus(e, dialogRef.current);
if (e.key === "Escape") onClose();
if (e.key === "Tab") trapFocus(e, dialogRef.current);
};
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
document.addEventListener("keydown", handleKeyDown);
}
return () => document.removeEventListener('keydown', handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
@@ -82,7 +82,7 @@ export function Modal({ isOpen, onClose, title, children }: ModalProps) {
<div className="mt-4">{children}</div>
</div>
</div>,
document.body
document.body,
);
}
@@ -90,7 +90,7 @@ function trapFocus(e: KeyboardEvent, container: HTMLElement | null) {
if (!container) return;
const focusableElements = container.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
@@ -108,7 +108,7 @@ function trapFocus(e: KeyboardEvent, container: HTMLElement | null) {
### Dropdown Menu
```tsx
import { useState, useRef, useEffect, type ReactNode } from 'react';
import { useState, useRef, useEffect, type ReactNode } from "react";
interface DropdownProps {
trigger: ReactNode;
@@ -124,22 +124,25 @@ export function Dropdown({ trigger, children, label }: DropdownProps) {
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'Escape':
case "Escape":
setIsOpen(false);
triggerRef.current?.focus();
break;
case 'ArrowDown':
case "ArrowDown":
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
@@ -147,17 +150,17 @@ export function Dropdown({ trigger, children, label }: DropdownProps) {
focusNextItem(menuRef.current, 1);
}
break;
case 'ArrowUp':
case "ArrowUp":
e.preventDefault();
if (isOpen) {
focusNextItem(menuRef.current, -1);
}
break;
case 'Home':
case "Home":
e.preventDefault();
focusFirstItem(menuRef.current);
break;
case 'End':
case "End":
e.preventDefault();
focusLastItem(menuRef.current);
break;
@@ -177,7 +180,7 @@ export function Dropdown({ trigger, children, label }: DropdownProps) {
{trigger}
<ChevronDownIcon
aria-hidden="true"
className={`transition-transform ${isOpen ? 'rotate-180' : ''}`}
className={`transition-transform ${isOpen ? "rotate-180" : ""}`}
/>
</button>
@@ -217,18 +220,26 @@ export function MenuItem({ children, onClick, disabled }: MenuItemProps) {
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 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();
menu
?.querySelector<HTMLElement>('[role="menuitem"]:not([disabled])')
?.focus();
}
function focusLastItem(menu: HTMLElement | null) {
const items = menu?.querySelectorAll<HTMLElement>('[role="menuitem"]:not([disabled])');
const items = menu?.querySelectorAll<HTMLElement>(
'[role="menuitem"]:not([disabled])',
);
items?.[items.length - 1]?.focus();
}
```
@@ -236,7 +247,13 @@ function focusLastItem(menu: HTMLElement | null) {
### Combobox / Autocomplete
```tsx
import { useState, useRef, useId, type ChangeEvent, type KeyboardEvent } from 'react';
import {
useState,
useRef,
useId,
type ChangeEvent,
type KeyboardEvent,
} from "react";
interface Option {
value: string;
@@ -259,7 +276,7 @@ export function Combobox({
placeholder,
}: ComboboxProps) {
const [isOpen, setIsOpen] = useState(false);
const [inputValue, setInputValue] = useState('');
const [inputValue, setInputValue] = useState("");
const [activeIndex, setActiveIndex] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
@@ -268,7 +285,7 @@ export function Combobox({
const listboxId = useId();
const filteredOptions = options.filter((option) =>
option.label.toLowerCase().includes(inputValue.toLowerCase())
option.label.toLowerCase().includes(inputValue.toLowerCase()),
);
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
@@ -286,27 +303,27 @@ export function Combobox({
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
case "ArrowDown":
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setActiveIndex((prev) =>
prev < filteredOptions.length - 1 ? prev + 1 : prev
prev < filteredOptions.length - 1 ? prev + 1 : prev,
);
}
break;
case 'ArrowUp':
case "ArrowUp":
e.preventDefault();
setActiveIndex((prev) => (prev > 0 ? prev - 1 : prev));
break;
case 'Enter':
case "Enter":
e.preventDefault();
if (activeIndex >= 0 && filteredOptions[activeIndex]) {
handleSelect(filteredOptions[activeIndex]);
}
break;
case 'Escape':
case "Escape":
setIsOpen(false);
break;
}
@@ -353,8 +370,8 @@ export function Combobox({
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' : ''}`}
activeIndex === index ? "bg-blue-100" : "hover:bg-gray-100"
} ${value === option.value ? "font-medium" : ""}`}
>
{option.label}
</li>
@@ -375,7 +392,7 @@ export function Combobox({
### Form Validation
```tsx
import { useId, type FormEvent } from 'react';
import { useId, type FormEvent } from "react";
interface FormFieldProps {
label: string;
@@ -383,12 +400,17 @@ interface FormFieldProps {
required?: boolean;
children: (props: {
id: string;
'aria-describedby': string | undefined;
'aria-invalid': boolean;
"aria-describedby": string | undefined;
"aria-invalid": boolean;
}) => ReactNode;
}
export function FormField({ label, error, required, children }: FormFieldProps) {
export function FormField({
label,
error,
required,
children,
}: FormFieldProps) {
const id = useId();
const errorId = `${id}-error`;
@@ -405,8 +427,8 @@ export function FormField({ label, error, required, children }: FormFieldProps)
{children({
id,
'aria-describedby': error ? errorId : undefined,
'aria-invalid': !!error,
"aria-describedby": error ? errorId : undefined,
"aria-invalid": !!error,
})}
{error && (
@@ -436,13 +458,16 @@ function ContactForm() {
type="email"
required
className={`w-full rounded border px-3 py-2 ${
props['aria-invalid'] ? 'border-red-500' : 'border-gray-300'
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">
<button
type="submit"
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded"
>
Submit
</button>
</form>
@@ -476,19 +501,22 @@ export function SkipLinks() {
## Live Regions
```tsx
import { useState, useEffect } from 'react';
import { useState, useEffect } from "react";
interface LiveAnnouncerProps {
message: string;
politeness?: 'polite' | 'assertive';
politeness?: "polite" | "assertive";
}
export function LiveAnnouncer({ message, politeness = 'polite' }: LiveAnnouncerProps) {
const [announcement, setAnnouncement] = useState('');
export function LiveAnnouncer({
message,
politeness = "polite",
}: LiveAnnouncerProps) {
const [announcement, setAnnouncement] = useState("");
useEffect(() => {
// Clear first, then set - ensures screen readers pick up the change
setAnnouncement('');
setAnnouncement("");
const timer = setTimeout(() => setAnnouncement(message), 100);
return () => clearTimeout(timer);
}, [message]);
@@ -506,9 +534,15 @@ export function LiveAnnouncer({ message, politeness = 'polite' }: LiveAnnouncerP
}
// Usage in a search component
function SearchResults({ results, loading }: { results: Item[]; loading: boolean }) {
function SearchResults({
results,
loading,
}: {
results: Item[];
loading: boolean;
}) {
const message = loading
? 'Loading results...'
? "Loading results..."
: `${results.length} results found`;
return (
@@ -544,12 +578,14 @@ function useFocusTrap(containerRef: RefObject<HTMLElement>, isActive: boolean) {
if (!isActive || !containerRef.current) return;
const container = containerRef.current;
const focusableSelector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const focusableSelector =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
if (e.key !== "Tab") return;
const focusableElements = container.querySelectorAll<HTMLElement>(focusableSelector);
const focusableElements =
container.querySelectorAll<HTMLElement>(focusableSelector);
const first = focusableElements[0];
const last = focusableElements[focusableElements.length - 1];
@@ -562,8 +598,8 @@ function useFocusTrap(containerRef: RefObject<HTMLElement>, isActive: boolean) {
}
};
container.addEventListener('keydown', handleKeyDown);
return () => container.removeEventListener('keydown', handleKeyDown);
container.addEventListener("keydown", handleKeyDown);
return () => container.removeEventListener("keydown", handleKeyDown);
}, [containerRef, isActive]);
}
```
@@ -595,8 +631,12 @@ function getContrastRatio(fg: string, bg: string): number {
return (lighter + 0.05) / (darker + 0.05);
}
function meetsWCAG(fg: string, bg: string, level: 'AA' | 'AAA' = 'AA'): boolean {
function meetsWCAG(
fg: string,
bg: string,
level: "AA" | "AAA" = "AA",
): boolean {
const ratio = getContrastRatio(fg, bg);
return level === 'AAA' ? ratio >= 7 : ratio >= 4.5;
return level === "AAA" ? ratio >= 7 : ratio >= 4.5;
}
```

View File

@@ -15,7 +15,7 @@ import {
type ReactNode,
type Dispatch,
type SetStateAction,
} from 'react';
} from "react";
// Types
interface TabsContextValue {
@@ -51,7 +51,7 @@ const TabsContext = createContext<TabsContextValue | null>(null);
function useTabs() {
const context = useContext(TabsContext);
if (!context) {
throw new Error('Tabs components must be used within <Tabs>');
throw new Error("Tabs components must be used within <Tabs>");
}
return context;
}
@@ -62,11 +62,11 @@ export function Tabs({ defaultValue, children, onChange }: TabsProps) {
const handleChange: Dispatch<SetStateAction<string>> = useCallback(
(value) => {
const newValue = typeof value === 'function' ? value(activeTab) : value;
const newValue = typeof value === "function" ? value(activeTab) : value;
setActiveTab(newValue);
onChange?.(newValue);
},
[activeTab, onChange]
[activeTab, onChange],
);
return (
@@ -100,10 +100,12 @@ Tabs.Tab = function Tab({ value, children, disabled }: TabProps) {
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' : ''}
${
isActive
? "border-b-2 border-blue-600 text-blue-600"
: "text-gray-600 hover:text-gray-900"
}
${disabled ? "opacity-50 cursor-not-allowed" : ""}
`}
>
{children}
@@ -138,7 +140,9 @@ Tabs.Panel = function TabPanel({ value, children }: TabPanelProps) {
<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.Tab value="pricing" disabled>
Pricing
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="overview">
<h2>Product Overview</h2>
@@ -180,14 +184,14 @@ function DataLoader<T>({ url, children }: DataLoaderProps<T>) {
});
const fetchData = useCallback(async () => {
setState(prev => ({ ...prev, loading: true, error: null }));
setState((prev) => ({ ...prev, loading: true, error: null }));
try {
const response = await fetch(url);
if (!response.ok) throw new Error('Fetch failed');
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 }));
setState((prev) => ({ ...prev, loading: false, error: error as Error }));
}
}, [url]);
@@ -205,7 +209,7 @@ function DataLoader<T>({ url, children }: DataLoaderProps<T>) {
if (error) return <ErrorMessage error={error} onRetry={refetch} />;
return <UserList users={data!} />;
}}
</DataLoader>
</DataLoader>;
```
## Polymorphic Components
@@ -343,18 +347,10 @@ 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>
<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>
{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}
@@ -371,7 +367,7 @@ function Card({ children, header, footer, media }: CardProps) {
footer={<Button>Action</Button>}
>
<p>Card content goes here.</p>
</Card>
</Card>;
```
## Forward Ref Pattern
@@ -379,7 +375,7 @@ function Card({ children, header, footer, media }: CardProps) {
Allow parent components to access the underlying DOM node.
```tsx
import { forwardRef, useRef, useImperativeHandle } from 'react';
import { forwardRef, useRef, useImperativeHandle } from "react";
interface InputHandle {
focus: () => void;
@@ -399,9 +395,9 @@ const FancyInput = forwardRef<InputHandle, FancyInputProps>(
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
clear: () => {
if (inputRef.current) inputRef.current.value = '';
if (inputRef.current) inputRef.current.value = "";
},
getValue: () => inputRef.current?.value ?? '',
getValue: () => inputRef.current?.value ?? "",
}));
return (
@@ -415,10 +411,10 @@ const FancyInput = forwardRef<InputHandle, FancyInputProps>(
/>
</div>
);
}
},
);
FancyInput.displayName = 'FancyInput';
FancyInput.displayName = "FancyInput";
// Usage
function Form() {

View File

@@ -2,13 +2,13 @@
## 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 |
| 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
@@ -56,19 +56,19 @@ Scoped CSS with zero runtime overhead.
```tsx
// Button.tsx
import styles from './Button.module.css';
import { clsx } from 'clsx';
import styles from "./Button.module.css";
import { clsx } from "clsx";
interface ButtonProps {
variant?: 'primary' | 'secondary';
size?: 'small' | 'medium' | 'large';
variant?: "primary" | "secondary";
size?: "small" | "medium" | "large";
children: React.ReactNode;
onClick?: () => void;
}
export function Button({
variant = 'primary',
size = 'medium',
variant = "primary",
size = "medium",
children,
onClick,
}: ButtonProps) {
@@ -77,7 +77,7 @@ export function Button({
className={clsx(
styles.button,
styles[variant],
size !== 'medium' && styles[size]
size !== "medium" && styles[size],
)}
onClick={onClick}
>
@@ -104,7 +104,7 @@ export function Button({
/* Button.module.css */
.srOnly {
composes: visuallyHidden from './base.module.css';
composes: visuallyHidden from "./base.module.css";
}
```
@@ -115,38 +115,42 @@ 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';
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',
"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',
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',
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',
variant: "default",
size: "default",
},
}
},
);
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
@@ -160,7 +164,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
{...props}
/>
);
}
},
);
```
@@ -168,48 +172,48 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
```tsx
// lib/utils.ts
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
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
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');
const plugin = require("tailwindcss/plugin");
module.exports = {
plugins: [
plugin(function({ addUtilities, addComponents, theme }) {
plugin(function ({ addUtilities, addComponents, theme }) {
// Add utilities
addUtilities({
'.text-balance': {
'text-wrap': 'balance',
".text-balance": {
"text-wrap": "balance",
},
'.scrollbar-hide': {
'-ms-overflow-style': 'none',
'scrollbar-width': 'none',
'&::-webkit-scrollbar': {
display: 'none',
".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'),
".card": {
backgroundColor: theme("colors.white"),
borderRadius: theme("borderRadius.lg"),
padding: theme("spacing.6"),
boxShadow: theme("boxShadow.md"),
},
});
}),
@@ -222,7 +226,7 @@ module.exports = {
CSS-in-JS with template literals.
```tsx
import styled, { css, keyframes } from 'styled-components';
import styled, { css, keyframes } from "styled-components";
// Keyframes
const fadeIn = keyframes`
@@ -232,8 +236,8 @@ const fadeIn = keyframes`
// Base button with variants
interface ButtonProps {
$variant?: 'primary' | 'secondary' | 'ghost';
$size?: 'sm' | 'md' | 'lg';
$variant?: "primary" | "secondary" | "ghost";
$size?: "sm" | "md" | "lg";
$isLoading?: boolean;
}
@@ -287,8 +291,8 @@ const Button = styled.button<ButtonProps>`
transition: all 0.2s ease;
animation: ${fadeIn} 0.3s ease;
${({ $size = 'md' }) => sizeStyles[$size]}
${({ $variant = 'primary' }) => variantStyles[$variant]}
${({ $size = "md" }) => sizeStyles[$size]}
${({ $variant = "primary" }) => variantStyles[$variant]}
&:disabled {
opacity: 0.5;
@@ -312,12 +316,12 @@ const IconButton = styled(Button)`
// Theme provider
const theme = {
colors: {
primary: '#2563eb',
primaryHover: '#1d4ed8',
secondary: '#f3f4f6',
secondaryHover: '#e5e7eb',
ghost: 'rgba(0, 0, 0, 0.05)',
text: '#1f2937',
primary: "#2563eb",
primaryHover: "#1d4ed8",
secondary: "#f3f4f6",
secondaryHover: "#e5e7eb",
ghost: "rgba(0, 0, 0, 0.05)",
text: "#1f2937",
},
};
@@ -326,7 +330,7 @@ const theme = {
<Button $variant="primary" $size="lg">
Click me
</Button>
</ThemeProvider>
</ThemeProvider>;
```
## Emotion
@@ -335,11 +339,11 @@ 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';
import { css, Theme, ThemeProvider, useTheme } from "@emotion/react";
import styled from "@emotion/styled";
// Theme typing
declare module '@emotion/react' {
declare module "@emotion/react" {
export interface Theme {
colors: {
primary: string;
@@ -352,20 +356,21 @@ declare module '@emotion/react' {
const theme: Theme = {
colors: {
primary: '#2563eb',
background: '#ffffff',
text: '#1f2937',
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)',
});
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`
@@ -407,7 +412,7 @@ function Alert({ children }: { children: React.ReactNode }) {
<Card>
<Alert>Important message</Alert>
</Card>
</ThemeProvider>
</ThemeProvider>;
```
## Vanilla Extract
@@ -416,26 +421,26 @@ 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';
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',
primary: "#2563eb",
secondary: "#64748b",
background: "#ffffff",
text: "#1f2937",
},
space: {
small: '0.5rem',
medium: '1rem',
large: '1.5rem',
small: "0.5rem",
medium: "1rem",
large: "1.5rem",
},
radius: {
small: '0.25rem',
medium: '0.375rem',
large: '0.5rem',
small: "0.25rem",
medium: "0.375rem",
large: "0.5rem",
},
});
@@ -455,54 +460,54 @@ export const text = styleVariants({
// Recipe (like CVA)
export const button = recipe({
base: {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
fontWeight: 500,
borderRadius: vars.radius.medium,
transition: 'background-color 0.2s',
cursor: 'pointer',
border: 'none',
':disabled': {
transition: "background-color 0.2s",
cursor: "pointer",
border: "none",
":disabled": {
opacity: 0.5,
cursor: 'not-allowed',
cursor: "not-allowed",
},
},
variants: {
variant: {
primary: {
backgroundColor: vars.color.primary,
color: 'white',
':hover': {
backgroundColor: '#1d4ed8',
color: "white",
":hover": {
backgroundColor: "#1d4ed8",
},
},
secondary: {
backgroundColor: '#f3f4f6',
backgroundColor: "#f3f4f6",
color: vars.color.text,
':hover': {
backgroundColor: '#e5e7eb',
":hover": {
backgroundColor: "#e5e7eb",
},
},
},
size: {
small: {
padding: '0.25rem 0.75rem',
fontSize: '0.875rem',
padding: "0.25rem 0.75rem",
fontSize: "0.875rem",
},
medium: {
padding: '0.5rem 1rem',
fontSize: '1rem',
padding: "0.5rem 1rem",
fontSize: "1rem",
},
large: {
padding: '0.75rem 1.5rem',
fontSize: '1.125rem',
padding: "0.75rem 1.5rem",
fontSize: "1.125rem",
},
},
},
defaultVariants: {
variant: 'primary',
size: 'medium',
variant: "primary",
size: "medium",
},
});
@@ -511,7 +516,7 @@ export type ButtonVariants = RecipeVariants<typeof button>;
```tsx
// Button.tsx
import { button, type ButtonVariants, themeClass } from './styles.css';
import { button, type ButtonVariants, themeClass } from "./styles.css";
interface ButtonProps extends ButtonVariants {
children: React.ReactNode;
@@ -545,8 +550,8 @@ function App() {
```tsx
// Next.js with styled-components
// pages/_document.tsx
import Document, { DocumentContext } from 'next/document';
import { ServerStyleSheet } from 'styled-components';
import Document, { DocumentContext } from "next/document";
import { ServerStyleSheet } from "styled-components";
export default class MyDocument extends Document {
static async getInitialProps(ctx: DocumentContext) {
@@ -576,9 +581,9 @@ export default class MyDocument extends Document {
```tsx
// Dynamically import heavy styled components
import dynamic from 'next/dynamic';
import dynamic from "next/dynamic";
const HeavyChart = dynamic(() => import('./HeavyChart'), {
const HeavyChart = dynamic(() => import("./HeavyChart"), {
loading: () => <Skeleton height={400} />,
ssr: false,
});