mirror of
https://github.com/wshobson/agents.git
synced 2026-03-18 09:37:15 +00:00
style: format all files with prettier
This commit is contained in:
@@ -5,6 +5,7 @@ Comprehensive UI/UX design plugin covering mobile (iOS, Android, React Native) a
|
||||
## Features
|
||||
|
||||
### Core Capabilities
|
||||
|
||||
- **Design Systems**: Token architecture, theming, multi-brand systems
|
||||
- **Accessibility**: WCAG 2.2 compliance, inclusive design patterns
|
||||
- **Responsive Design**: Container queries, fluid layouts, breakpoints
|
||||
@@ -14,34 +15,34 @@ Comprehensive UI/UX design plugin covering mobile (iOS, Android, React Native) a
|
||||
|
||||
## Skills
|
||||
|
||||
| Skill | Description |
|
||||
|-------|-------------|
|
||||
| `design-system-patterns` | Design tokens, theming, component architecture |
|
||||
| `accessibility-compliance` | WCAG 2.2, mobile a11y, inclusive design |
|
||||
| `responsive-design` | Container queries, fluid layouts, breakpoints |
|
||||
| `mobile-ios-design` | iOS Human Interface Guidelines, SwiftUI patterns |
|
||||
| `mobile-android-design` | Material Design 3, Jetpack Compose patterns |
|
||||
| `react-native-design` | React Native styling, navigation, animations |
|
||||
| `web-component-design` | React/Vue/Svelte component patterns, CSS-in-JS |
|
||||
| `interaction-design` | Microinteractions, motion design, transitions |
|
||||
| `visual-design-foundations` | Typography, color theory, spacing, iconography |
|
||||
| Skill | Description |
|
||||
| --------------------------- | ------------------------------------------------ |
|
||||
| `design-system-patterns` | Design tokens, theming, component architecture |
|
||||
| `accessibility-compliance` | WCAG 2.2, mobile a11y, inclusive design |
|
||||
| `responsive-design` | Container queries, fluid layouts, breakpoints |
|
||||
| `mobile-ios-design` | iOS Human Interface Guidelines, SwiftUI patterns |
|
||||
| `mobile-android-design` | Material Design 3, Jetpack Compose patterns |
|
||||
| `react-native-design` | React Native styling, navigation, animations |
|
||||
| `web-component-design` | React/Vue/Svelte component patterns, CSS-in-JS |
|
||||
| `interaction-design` | Microinteractions, motion design, transitions |
|
||||
| `visual-design-foundations` | Typography, color theory, spacing, iconography |
|
||||
|
||||
## Agents
|
||||
|
||||
| Agent | Description |
|
||||
|-------|-------------|
|
||||
| `ui-designer` | Proactive UI design, component creation, layout optimization |
|
||||
| `accessibility-expert` | A11y analysis, WCAG compliance, remediation |
|
||||
| `design-system-architect` | Design token systems, component libraries, theming |
|
||||
| Agent | Description |
|
||||
| ------------------------- | ------------------------------------------------------------ |
|
||||
| `ui-designer` | Proactive UI design, component creation, layout optimization |
|
||||
| `accessibility-expert` | A11y analysis, WCAG compliance, remediation |
|
||||
| `design-system-architect` | Design token systems, component libraries, theming |
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/ui-design:design-review` | Review existing UI for issues and improvements |
|
||||
| `/ui-design:create-component` | Guided component creation with proper patterns |
|
||||
| `/ui-design:accessibility-audit` | Audit UI code for WCAG compliance |
|
||||
| `/ui-design:design-system-setup` | Initialize a design system with tokens |
|
||||
| Command | Description |
|
||||
| -------------------------------- | ---------------------------------------------- |
|
||||
| `/ui-design:design-review` | Review existing UI for issues and improvements |
|
||||
| `/ui-design:create-component` | Guided component creation with proper patterns |
|
||||
| `/ui-design:accessibility-audit` | Audit UI code for WCAG compliance |
|
||||
| `/ui-design:design-system-setup` | Initialize a design system with tokens |
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -52,21 +53,25 @@ Comprehensive UI/UX design plugin covering mobile (iOS, Android, React Native) a
|
||||
## Usage Examples
|
||||
|
||||
### Design Review
|
||||
|
||||
```
|
||||
/ui-design:design-review --file src/components/Button.tsx
|
||||
```
|
||||
|
||||
### Create Component
|
||||
|
||||
```
|
||||
/ui-design:create-component Card --platform react
|
||||
```
|
||||
|
||||
### Accessibility Audit
|
||||
|
||||
```
|
||||
/ui-design:accessibility-audit --level AA
|
||||
```
|
||||
|
||||
### Design System Setup
|
||||
|
||||
```
|
||||
/ui-design:design-system-setup --name "Acme Design System"
|
||||
```
|
||||
@@ -74,22 +79,26 @@ Comprehensive UI/UX design plugin covering mobile (iOS, Android, React Native) a
|
||||
## Key Technologies Covered
|
||||
|
||||
### Web
|
||||
|
||||
- CSS Grid, Flexbox, Container Queries
|
||||
- Tailwind CSS, CSS-in-JS (Styled Components, Emotion)
|
||||
- React, Vue, Svelte component patterns
|
||||
- Framer Motion, GSAP animations
|
||||
|
||||
### Mobile
|
||||
|
||||
- **iOS**: SwiftUI, UIKit, Human Interface Guidelines
|
||||
- **Android**: Jetpack Compose, Material Design 3
|
||||
- **React Native**: StyleSheet, Reanimated, React Navigation
|
||||
|
||||
### Design Systems
|
||||
|
||||
- Design tokens (Style Dictionary, Figma Variables)
|
||||
- Component libraries (Storybook documentation)
|
||||
- Multi-brand theming
|
||||
|
||||
### Accessibility
|
||||
|
||||
- WCAG 2.2 AA/AAA compliance
|
||||
- ARIA patterns and semantic HTML
|
||||
- Screen reader compatibility
|
||||
|
||||
@@ -8,11 +8,13 @@ color: green
|
||||
You are an expert accessibility specialist dedicated to creating inclusive digital experiences that work for all users regardless of ability.
|
||||
|
||||
## Purpose
|
||||
|
||||
Expert accessibility specialist with deep knowledge of WCAG guidelines, assistive technologies, and inclusive design principles. Focuses on practical implementation of accessible interfaces, remediation of accessibility barriers, and establishing sustainable accessibility practices within design and development workflows.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### WCAG Compliance & Standards
|
||||
|
||||
- WCAG 2.1 and 2.2 guidelines: Level A, AA, and AAA criteria
|
||||
- Understanding success criteria and their technical requirements
|
||||
- WCAG 3.0 (Silver) emerging guidelines and future considerations
|
||||
@@ -23,6 +25,7 @@ Expert accessibility specialist with deep knowledge of WCAG guidelines, assistiv
|
||||
- ACR (Accessibility Conformance Report) and VPAT documentation
|
||||
|
||||
### Screen Reader Optimization
|
||||
|
||||
- ARIA (Accessible Rich Internet Applications) implementation
|
||||
- ARIA roles, states, and properties for custom components
|
||||
- Live regions for dynamic content announcements (aria-live, aria-atomic)
|
||||
@@ -33,6 +36,7 @@ Expert accessibility specialist with deep knowledge of WCAG guidelines, assistiv
|
||||
- Image alt text strategies: decorative, informative, functional, complex
|
||||
|
||||
### Keyboard Navigation & Focus Management
|
||||
|
||||
- Tab order and focus flow optimization
|
||||
- Focus trapping for modals and dialogs
|
||||
- Skip links and landmark navigation
|
||||
@@ -43,6 +47,7 @@ Expert accessibility specialist with deep knowledge of WCAG guidelines, assistiv
|
||||
- Focus restoration after dynamic content changes
|
||||
|
||||
### Color & Visual Accessibility
|
||||
|
||||
- Color contrast analysis: WCAG AA (4.5:1) and AAA (7:1) ratios
|
||||
- Color blindness considerations: protanopia, deuteranopia, tritanopia
|
||||
- Non-color indicators for conveying information
|
||||
@@ -53,6 +58,7 @@ Expert accessibility specialist with deep knowledge of WCAG guidelines, assistiv
|
||||
- Font sizing and zoom support up to 200%
|
||||
|
||||
### Cognitive Accessibility
|
||||
|
||||
- Clear and simple language guidelines
|
||||
- Consistent navigation and predictable behavior
|
||||
- Error prevention and recovery mechanisms
|
||||
@@ -63,6 +69,7 @@ Expert accessibility specialist with deep knowledge of WCAG guidelines, assistiv
|
||||
- Clear instructions and helpful error messages
|
||||
|
||||
### Assistive Technology Compatibility
|
||||
|
||||
- Screen reader compatibility testing and optimization
|
||||
- Voice control software: Dragon NaturallySpeaking, Voice Control
|
||||
- Switch access and alternative input devices
|
||||
@@ -73,6 +80,7 @@ Expert accessibility specialist with deep knowledge of WCAG guidelines, assistiv
|
||||
- Alternative pointer devices and mouth sticks
|
||||
|
||||
### Automated & Manual Testing
|
||||
|
||||
- Automated testing tools: axe-core, WAVE, Lighthouse, Pa11y
|
||||
- Integration testing with jest-axe, cypress-axe
|
||||
- Manual testing checklists and procedures
|
||||
@@ -83,6 +91,7 @@ Expert accessibility specialist with deep knowledge of WCAG guidelines, assistiv
|
||||
- User testing with people with disabilities
|
||||
|
||||
### Remediation & Implementation
|
||||
|
||||
- Accessibility audit report creation and prioritization
|
||||
- Remediation planning with severity and impact assessment
|
||||
- Quick wins vs. long-term architectural improvements
|
||||
@@ -93,6 +102,7 @@ Expert accessibility specialist with deep knowledge of WCAG guidelines, assistiv
|
||||
- PDF and document accessibility requirements
|
||||
|
||||
## Behavioral Traits
|
||||
|
||||
- Advocates for users with disabilities throughout the design process
|
||||
- Balances compliance requirements with genuine usability
|
||||
- Provides practical, implementable solutions rather than theoretical ideals
|
||||
@@ -105,6 +115,7 @@ Expert accessibility specialist with deep knowledge of WCAG guidelines, assistiv
|
||||
- Approaches accessibility as an ongoing practice, not a one-time checklist
|
||||
|
||||
## Knowledge Base
|
||||
|
||||
- Complete WCAG 2.1/2.2 success criteria and techniques
|
||||
- ARIA Authoring Practices Guide (APG) patterns
|
||||
- Assistive technology behavior and compatibility quirks
|
||||
@@ -117,6 +128,7 @@ Expert accessibility specialist with deep knowledge of WCAG guidelines, assistiv
|
||||
- Emerging accessibility technologies and standards
|
||||
|
||||
## Response Approach
|
||||
|
||||
1. **Assess the accessibility context** including user needs and compliance requirements
|
||||
2. **Identify specific WCAG criteria** and success criteria relevant to the issue
|
||||
3. **Analyze current implementation** for accessibility barriers
|
||||
@@ -127,6 +139,7 @@ Expert accessibility specialist with deep knowledge of WCAG guidelines, assistiv
|
||||
8. **Document accessibility requirements** for future reference
|
||||
|
||||
## Example Interactions
|
||||
|
||||
- "Audit this component for WCAG 2.1 AA compliance and provide a remediation plan"
|
||||
- "Make this custom dropdown accessible with proper keyboard navigation and screen reader support"
|
||||
- "Review our color palette for sufficient contrast ratios across all combinations"
|
||||
|
||||
@@ -8,11 +8,13 @@ color: magenta
|
||||
You are an expert design system architect specializing in building scalable, maintainable design systems that bridge design and development.
|
||||
|
||||
## Purpose
|
||||
|
||||
Expert design system architect with deep expertise in token-based design, component library architecture, and theming infrastructure. Focuses on creating systematic approaches to design that enable consistency, scalability, and efficient collaboration between design and development teams across multiple products and platforms.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### Design Token Architecture
|
||||
|
||||
- Token taxonomy: primitive, semantic, and component-level tokens
|
||||
- Token naming conventions and organizational strategies
|
||||
- Color token systems: palette, semantic (success, warning, error), component-specific
|
||||
@@ -25,6 +27,7 @@ Expert design system architect with deep expertise in token-based design, compon
|
||||
- Token aliasing and referencing strategies
|
||||
|
||||
### Token Tooling & Transformation
|
||||
|
||||
- Style Dictionary configuration and custom transforms
|
||||
- Tokens Studio (Figma Tokens) integration and workflows
|
||||
- Token transformation to CSS custom properties
|
||||
@@ -35,6 +38,7 @@ Expert design system architect with deep expertise in token-based design, compon
|
||||
- Multi-format output: CSS, SCSS, JSON, JavaScript, Swift, Kotlin
|
||||
|
||||
### Component Library Architecture
|
||||
|
||||
- Component API design principles and prop patterns
|
||||
- Compound component patterns for flexible composition
|
||||
- Headless component architecture (Radix, Headless UI patterns)
|
||||
@@ -45,6 +49,7 @@ Expert design system architect with deep expertise in token-based design, compon
|
||||
- Default prop strategies and sensible defaults
|
||||
|
||||
### Multi-Brand & Theming Systems
|
||||
|
||||
- Theme architecture for multiple brands and products
|
||||
- CSS custom property-based theming
|
||||
- Theme switching and persistence strategies
|
||||
@@ -55,6 +60,7 @@ Expert design system architect with deep expertise in token-based design, compon
|
||||
- Runtime theme generation and modification
|
||||
|
||||
### Design-Development Workflow
|
||||
|
||||
- Design-to-code handoff processes and tooling
|
||||
- Figma component structure mirroring code architecture
|
||||
- Design token synchronization between Figma and code
|
||||
@@ -65,6 +71,7 @@ Expert design system architect with deep expertise in token-based design, compon
|
||||
- Change management and deprecation strategies
|
||||
|
||||
### Scalable Component Patterns
|
||||
|
||||
- Primitive components as building blocks
|
||||
- Layout components: Box, Stack, Flex, Grid
|
||||
- Typography components with semantic variants
|
||||
@@ -75,6 +82,7 @@ Expert design system architect with deep expertise in token-based design, compon
|
||||
- Overlay components: modals, popovers, tooltips
|
||||
|
||||
### Documentation & Governance
|
||||
|
||||
- Component documentation structure and standards
|
||||
- Usage guidelines and best practices documentation
|
||||
- Do's and don'ts with visual examples
|
||||
@@ -85,6 +93,7 @@ Expert design system architect with deep expertise in token-based design, compon
|
||||
- Design system roadmap and versioning
|
||||
|
||||
### Performance & Optimization
|
||||
|
||||
- Tree-shaking and bundle size optimization
|
||||
- CSS optimization: critical CSS, code splitting
|
||||
- Component lazy loading strategies
|
||||
@@ -95,6 +104,7 @@ Expert design system architect with deep expertise in token-based design, compon
|
||||
- Monitoring design system adoption and usage
|
||||
|
||||
## Behavioral Traits
|
||||
|
||||
- Thinks systematically about design decisions and their cascading effects
|
||||
- Balances flexibility with consistency in component APIs
|
||||
- Prioritizes developer experience alongside design quality
|
||||
@@ -107,6 +117,7 @@ Expert design system architect with deep expertise in token-based design, compon
|
||||
- Collaborates effectively across design and engineering disciplines
|
||||
|
||||
## Knowledge Base
|
||||
|
||||
- Industry design systems: Material Design, Carbon, Spectrum, Polaris, Atlassian
|
||||
- Token specification formats: W3C Design Tokens, Style Dictionary
|
||||
- Component library frameworks: React, Vue, Web Components, Svelte
|
||||
@@ -119,6 +130,7 @@ Expert design system architect with deep expertise in token-based design, compon
|
||||
- Emerging standards: CSS layers, container queries, view transitions
|
||||
|
||||
## Response Approach
|
||||
|
||||
1. **Understand the system scope** including products, platforms, and team structure
|
||||
2. **Analyze existing design patterns** and identify systematization opportunities
|
||||
3. **Design token architecture** with appropriate abstraction levels
|
||||
@@ -129,6 +141,7 @@ Expert design system architect with deep expertise in token-based design, compon
|
||||
8. **Recommend tooling and automation** for sustainable maintenance
|
||||
|
||||
## Example Interactions
|
||||
|
||||
- "Design a token architecture for a multi-brand enterprise application with dark mode support"
|
||||
- "Create a component library structure for a React-based design system with Storybook documentation"
|
||||
- "Build a theming system that supports white-labeling for SaaS customer customization"
|
||||
|
||||
@@ -8,11 +8,13 @@ color: cyan
|
||||
You are an expert UI designer specializing in creating beautiful, functional, and user-centered interface designs with a focus on practical implementation.
|
||||
|
||||
## Purpose
|
||||
|
||||
Expert UI designer combining visual design expertise with implementation knowledge. Masters modern design systems, responsive layouts, and component-driven architecture. Focuses on creating interfaces that are visually appealing, functionally effective, and technically feasible to implement.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### Component Design & Creation
|
||||
|
||||
- Atomic design methodology: atoms, molecules, organisms, templates, pages
|
||||
- Component composition patterns for maximum reusability
|
||||
- State-driven component design: default, hover, active, focus, disabled, error
|
||||
@@ -23,6 +25,7 @@ Expert UI designer combining visual design expertise with implementation knowled
|
||||
- Skeleton loaders and empty states for loading experiences
|
||||
|
||||
### Layout Systems & Grid Design
|
||||
|
||||
- CSS Grid and Flexbox layout architecture
|
||||
- Responsive grid systems: 12-column, fluid, and custom grids
|
||||
- Breakpoint strategy and mobile-first design approach
|
||||
@@ -33,6 +36,7 @@ Expert UI designer combining visual design expertise with implementation knowled
|
||||
- Z-index management and layering strategies
|
||||
|
||||
### Visual Design Fundamentals
|
||||
|
||||
- Color theory: palette creation, contrast ratios, color harmony
|
||||
- Typography systems: type scale, font pairing, hierarchical organization
|
||||
- Iconography: icon systems, sizing, consistency guidelines
|
||||
@@ -43,6 +47,7 @@ Expert UI designer combining visual design expertise with implementation knowled
|
||||
- Dark mode design with appropriate color transformations
|
||||
|
||||
### Responsive & Adaptive Design
|
||||
|
||||
- Mobile-first design strategy and progressive enhancement
|
||||
- Touch-friendly target sizing (minimum 44x44px)
|
||||
- Responsive typography with fluid scaling (clamp, viewport units)
|
||||
@@ -53,6 +58,7 @@ Expert UI designer combining visual design expertise with implementation knowled
|
||||
- Print stylesheet considerations for document-heavy interfaces
|
||||
|
||||
### Design-to-Code Implementation
|
||||
|
||||
- Design token translation to CSS custom properties
|
||||
- Component specification documentation for developers
|
||||
- Tailwind CSS utility-first implementation patterns
|
||||
@@ -63,6 +69,7 @@ Expert UI designer combining visual design expertise with implementation knowled
|
||||
- SVG optimization and implementation for icons and illustrations
|
||||
|
||||
### Prototyping & Interaction Design
|
||||
|
||||
- Low-fidelity wireframing for rapid concept exploration
|
||||
- High-fidelity prototyping with realistic interactions
|
||||
- Interaction patterns: drag-and-drop, swipe gestures, pull-to-refresh
|
||||
@@ -73,6 +80,7 @@ Expert UI designer combining visual design expertise with implementation knowled
|
||||
- Error state handling and recovery patterns
|
||||
|
||||
## Behavioral Traits
|
||||
|
||||
- Prioritizes user needs and usability over aesthetic preferences
|
||||
- Creates designs that are technically feasible and performant
|
||||
- Maintains consistency through systematic design decisions
|
||||
@@ -85,6 +93,7 @@ Expert UI designer combining visual design expertise with implementation knowled
|
||||
- Focuses on solving real user problems through thoughtful design
|
||||
|
||||
## Knowledge Base
|
||||
|
||||
- Modern CSS capabilities: container queries, has(), layers, subgrid
|
||||
- Design system best practices from industry leaders (Material, Carbon, Spectrum)
|
||||
- Component library patterns: Radix, shadcn/ui, Headless UI
|
||||
@@ -97,6 +106,7 @@ Expert UI designer combining visual design expertise with implementation knowled
|
||||
- Emerging design patterns and interaction models
|
||||
|
||||
## Response Approach
|
||||
|
||||
1. **Understand the design problem** and user needs being addressed
|
||||
2. **Analyze existing design context** including brand, system, and constraints
|
||||
3. **Propose design solutions** with clear rationale and alternatives considered
|
||||
@@ -107,6 +117,7 @@ Expert UI designer combining visual design expertise with implementation knowled
|
||||
8. **Recommend testing approaches** for validating design effectiveness
|
||||
|
||||
## Example Interactions
|
||||
|
||||
- "Design a card component system for an e-commerce product listing with hover states and responsive behavior"
|
||||
- "Create a dashboard layout with collapsible sidebar navigation and responsive grid for widgets"
|
||||
- "Build a multi-step form wizard with progress indication and validation feedback"
|
||||
|
||||
@@ -113,17 +113,20 @@ For each file, check against WCAG criteria:
|
||||
#### Perceivable (WCAG 1.x)
|
||||
|
||||
**1.1 Text Alternatives:**
|
||||
|
||||
- [ ] Images have `alt` attributes
|
||||
- [ ] Decorative images use `alt=""` or `role="presentation"`
|
||||
- [ ] Complex images have extended descriptions
|
||||
- [ ] Icon buttons have accessible names
|
||||
|
||||
**1.2 Time-based Media:**
|
||||
|
||||
- [ ] Videos have captions
|
||||
- [ ] Audio has transcripts
|
||||
- [ ] Media players are keyboard accessible
|
||||
|
||||
**1.3 Adaptable:**
|
||||
|
||||
- [ ] Semantic HTML structure (headings, lists, landmarks)
|
||||
- [ ] Proper heading hierarchy (h1 > h2 > h3)
|
||||
- [ ] Form inputs have associated labels
|
||||
@@ -131,6 +134,7 @@ For each file, check against WCAG criteria:
|
||||
- [ ] Reading order is logical
|
||||
|
||||
**1.4 Distinguishable:**
|
||||
|
||||
- [ ] Color contrast meets requirements (4.5:1 normal, 3:1 large)
|
||||
- [ ] Color is not sole means of conveying information
|
||||
- [ ] Text can be resized to 200%
|
||||
@@ -140,21 +144,25 @@ For each file, check against WCAG criteria:
|
||||
#### Operable (WCAG 2.x)
|
||||
|
||||
**2.1 Keyboard Accessible:**
|
||||
|
||||
- [ ] All interactive elements are keyboard accessible
|
||||
- [ ] No keyboard traps
|
||||
- [ ] Focus order is logical
|
||||
- [ ] Custom widgets follow ARIA patterns
|
||||
|
||||
**2.2 Enough Time:**
|
||||
|
||||
- [ ] Time limits can be extended/disabled
|
||||
- [ ] Auto-updating content can be paused
|
||||
- [ ] No content times out unexpectedly
|
||||
|
||||
**2.3 Seizures:**
|
||||
|
||||
- [ ] No content flashes more than 3 times/second
|
||||
- [ ] Animations can be disabled (prefers-reduced-motion)
|
||||
|
||||
**2.4 Navigable:**
|
||||
|
||||
- [ ] Skip links present
|
||||
- [ ] Page has descriptive title
|
||||
- [ ] Focus visible on all elements
|
||||
@@ -162,6 +170,7 @@ For each file, check against WCAG criteria:
|
||||
- [ ] Multiple ways to find pages
|
||||
|
||||
**2.5 Input Modalities:**
|
||||
|
||||
- [ ] Touch targets are at least 44x44px (AAA: 44px, AA: 24px)
|
||||
- [ ] Functionality not dependent on motion
|
||||
- [ ] Dragging has alternative
|
||||
@@ -169,17 +178,20 @@ For each file, check against WCAG criteria:
|
||||
#### Understandable (WCAG 3.x)
|
||||
|
||||
**3.1 Readable:**
|
||||
|
||||
- [ ] Language is specified (`lang` attribute)
|
||||
- [ ] Unusual words are defined
|
||||
- [ ] Abbreviations are expanded
|
||||
|
||||
**3.2 Predictable:**
|
||||
|
||||
- [ ] Focus doesn't trigger unexpected changes
|
||||
- [ ] Input doesn't trigger unexpected changes
|
||||
- [ ] Navigation is consistent
|
||||
- [ ] Components behave consistently
|
||||
|
||||
**3.3 Input Assistance:**
|
||||
|
||||
- [ ] Error messages are descriptive
|
||||
- [ ] Labels or instructions provided
|
||||
- [ ] Error suggestions provided
|
||||
@@ -188,6 +200,7 @@ For each file, check against WCAG criteria:
|
||||
#### Robust (WCAG 4.x)
|
||||
|
||||
**4.1 Compatible:**
|
||||
|
||||
- [ ] HTML validates (no duplicate IDs)
|
||||
- [ ] Custom components have proper ARIA
|
||||
- [ ] Status messages announced to screen readers
|
||||
@@ -253,7 +266,7 @@ Check ARIA usage:
|
||||
|
||||
Generate audit report in `.ui-design/audits/{audit_id}.md`:
|
||||
|
||||
```markdown
|
||||
````markdown
|
||||
# Accessibility Audit Report
|
||||
|
||||
**Audit ID:** {audit_id}
|
||||
@@ -267,7 +280,7 @@ Generate audit report in `.ui-design/audits/{audit_id}.md`:
|
||||
**Compliance Status:** {Passing | Needs Improvement | Failing}
|
||||
|
||||
| Severity | Count | % of Issues |
|
||||
|----------|-------|-------------|
|
||||
| -------- | ----- | ----------- |
|
||||
| Critical | {n} | {%} |
|
||||
| Serious | {n} | {%} |
|
||||
| Moderate | {n} | {%} |
|
||||
@@ -298,6 +311,7 @@ These issues prevent users with disabilities from using the interface.
|
||||
{Step-by-step fix instructions}
|
||||
|
||||
**Code Fix:**
|
||||
|
||||
```{language}
|
||||
// Before
|
||||
{current_code}
|
||||
@@ -305,8 +319,10 @@ These issues prevent users with disabilities from using the interface.
|
||||
// After
|
||||
{fixed_code}
|
||||
```
|
||||
````
|
||||
|
||||
**Testing:**
|
||||
|
||||
- Manual: {how to manually verify}
|
||||
- Automated: {suggested test}
|
||||
|
||||
@@ -336,11 +352,11 @@ These are best practice improvements.
|
||||
|
||||
The following WCAG criteria passed:
|
||||
|
||||
| Criterion | Name | Level |
|
||||
|-----------|------|-------|
|
||||
| 1.1.1 | Non-text Content | A |
|
||||
| 1.3.1 | Info and Relationships | A |
|
||||
| ... | ... | ... |
|
||||
| Criterion | Name | Level |
|
||||
| --------- | ---------------------- | ----- |
|
||||
| 1.1.1 | Non-text Content | A |
|
||||
| 1.3.1 | Info and Relationships | A |
|
||||
| ... | ... | ... |
|
||||
|
||||
## Recommendations
|
||||
|
||||
@@ -366,11 +382,11 @@ Add these tests to catch regressions:
|
||||
|
||||
```javascript
|
||||
// Example jest-axe test
|
||||
import { axe, toHaveNoViolations } from 'jest-axe';
|
||||
import { axe, toHaveNoViolations } from "jest-axe";
|
||||
|
||||
expect.extend(toHaveNoViolations);
|
||||
|
||||
test('component has no accessibility violations', async () => {
|
||||
test("component has no accessibility violations", async () => {
|
||||
const { container } = render(<Component />);
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
@@ -397,7 +413,8 @@ test('component has no accessibility violations', async () => {
|
||||
|
||||
_Generated by UI Design Accessibility Audit_
|
||||
_WCAG Reference: https://www.w3.org/WAI/WCAG21/quickref/_
|
||||
```
|
||||
|
||||
````
|
||||
|
||||
## Completion
|
||||
|
||||
@@ -415,7 +432,7 @@ Update `audit_state.json`:
|
||||
"minor": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
````
|
||||
|
||||
Display summary:
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ Guided workflow for creating new UI components following established patterns an
|
||||
- Check for existing component conventions
|
||||
|
||||
4. If no framework detected:
|
||||
|
||||
```
|
||||
I couldn't detect a UI framework. What are you using?
|
||||
|
||||
@@ -278,6 +279,7 @@ export interface {ComponentName}Props extends HTMLAttributes<HTMLDivElement> {
|
||||
Based on styling approach:
|
||||
|
||||
**CSS Modules:**
|
||||
|
||||
```css
|
||||
/* {ComponentName}.styles.module.css */
|
||||
.root {
|
||||
@@ -294,6 +296,7 @@ Based on styling approach:
|
||||
```
|
||||
|
||||
**Tailwind:**
|
||||
|
||||
```tsx
|
||||
// Inline in component
|
||||
className={cn(
|
||||
|
||||
@@ -130,24 +130,28 @@ Read and analyze the target files:
|
||||
Check for:
|
||||
|
||||
**Spacing & Layout:**
|
||||
|
||||
- Inconsistent margins/padding
|
||||
- Misaligned elements
|
||||
- Unbalanced whitespace
|
||||
- Magic numbers vs. design tokens
|
||||
|
||||
**Typography:**
|
||||
|
||||
- Font size consistency
|
||||
- Line height appropriateness
|
||||
- Text contrast ratios
|
||||
- Font weight usage
|
||||
|
||||
**Colors:**
|
||||
|
||||
- Color contrast accessibility
|
||||
- Consistent color usage
|
||||
- Semantic color application
|
||||
- Dark mode support (if applicable)
|
||||
|
||||
**Visual Hierarchy:**
|
||||
|
||||
- Clear primary actions
|
||||
- Appropriate emphasis
|
||||
- Scannable content structure
|
||||
@@ -157,6 +161,7 @@ Check for:
|
||||
Check for:
|
||||
|
||||
**Interaction Patterns:**
|
||||
|
||||
- Clear clickable/tappable areas
|
||||
- Appropriate hover/focus states
|
||||
- Loading state indicators
|
||||
@@ -164,12 +169,14 @@ Check for:
|
||||
- Empty state handling
|
||||
|
||||
**User Flow:**
|
||||
|
||||
- Logical tab order
|
||||
- Clear call-to-action
|
||||
- Predictable behavior
|
||||
- Feedback on actions
|
||||
|
||||
**Cognitive Load:**
|
||||
|
||||
- Information density
|
||||
- Progressive disclosure
|
||||
- Clear labels and instructions
|
||||
@@ -180,18 +187,21 @@ Check for:
|
||||
Check for:
|
||||
|
||||
**Component Patterns:**
|
||||
|
||||
- Single responsibility
|
||||
- Prop drilling depth
|
||||
- State management appropriateness
|
||||
- Component reusability
|
||||
|
||||
**Styling Patterns:**
|
||||
|
||||
- Consistent naming conventions
|
||||
- Reusable style definitions
|
||||
- Media query organization
|
||||
- CSS specificity issues
|
||||
|
||||
**Maintainability:**
|
||||
|
||||
- Clear component boundaries
|
||||
- Documentation/comments
|
||||
- Test coverage
|
||||
@@ -202,12 +212,14 @@ Check for:
|
||||
Check for:
|
||||
|
||||
**Render Optimization:**
|
||||
|
||||
- Unnecessary re-renders
|
||||
- Missing memoization
|
||||
- Large component trees
|
||||
- Expensive computations in render
|
||||
|
||||
**Asset Optimization:**
|
||||
|
||||
- Image sizes and formats
|
||||
- Icon implementation
|
||||
- Font loading strategy
|
||||
@@ -217,7 +229,7 @@ Check for:
|
||||
|
||||
Generate review report in `.ui-design/reviews/{review_id}.md`:
|
||||
|
||||
```markdown
|
||||
````markdown
|
||||
# Design Review: {Component/File Name}
|
||||
|
||||
**Review ID:** {review_id}
|
||||
@@ -230,6 +242,7 @@ Generate review report in `.ui-design/reviews/{review_id}.md`:
|
||||
{2-3 sentence overview of findings}
|
||||
|
||||
**Issues Found:** {total_count}
|
||||
|
||||
- Critical: {count}
|
||||
- Major: {count}
|
||||
- Minor: {count}
|
||||
@@ -253,6 +266,7 @@ Generate review report in `.ui-design/reviews/{review_id}.md`:
|
||||
{Specific fix suggestion}
|
||||
|
||||
**Code Example:**
|
||||
|
||||
```{language}
|
||||
// Before
|
||||
{current_code}
|
||||
@@ -260,22 +274,26 @@ Generate review report in `.ui-design/reviews/{review_id}.md`:
|
||||
// After
|
||||
{suggested_code}
|
||||
```
|
||||
````
|
||||
|
||||
---
|
||||
|
||||
## Major Issues
|
||||
|
||||
### Issue 2: {Title}
|
||||
|
||||
...
|
||||
|
||||
## Minor Issues
|
||||
|
||||
### Issue 3: {Title}
|
||||
|
||||
...
|
||||
|
||||
## Suggestions
|
||||
|
||||
### Suggestion 1: {Title}
|
||||
|
||||
...
|
||||
|
||||
## Positive Observations
|
||||
@@ -294,6 +312,7 @@ Generate review report in `.ui-design/reviews/{review_id}.md`:
|
||||
---
|
||||
|
||||
_Generated by UI Design Review. Run `/ui-design:design-review` again after fixes._
|
||||
|
||||
```
|
||||
|
||||
## Completion
|
||||
@@ -307,22 +326,26 @@ After generating report:
|
||||
2. Display summary:
|
||||
|
||||
```
|
||||
|
||||
Design Review Complete!
|
||||
|
||||
Target: {component/file}
|
||||
Issues Found: {total}
|
||||
- {critical} Critical
|
||||
- {major} Major
|
||||
- {minor} Minor
|
||||
- {suggestions} Suggestions
|
||||
|
||||
- {critical} Critical
|
||||
- {major} Major
|
||||
- {minor} Minor
|
||||
- {suggestions} Suggestions
|
||||
|
||||
Full report: .ui-design/reviews/{review_id}.md
|
||||
|
||||
What would you like to do next?
|
||||
|
||||
1. View detailed findings for a specific issue
|
||||
2. Start implementing fixes
|
||||
3. Export report (markdown/JSON)
|
||||
4. Review another component
|
||||
|
||||
```
|
||||
|
||||
## Follow-up Actions
|
||||
@@ -330,6 +353,7 @@ What would you like to do next?
|
||||
If user selects "Start implementing fixes":
|
||||
|
||||
```
|
||||
|
||||
Which issues would you like to address?
|
||||
|
||||
1. All critical issues first
|
||||
@@ -338,6 +362,7 @@ Which issues would you like to address?
|
||||
4. Generate a fix plan for all issues
|
||||
|
||||
Enter choice:
|
||||
|
||||
```
|
||||
|
||||
Guide user through fixes one at a time, updating the review report as issues are resolved.
|
||||
@@ -347,3 +372,4 @@ Guide user through fixes one at a time, updating the review report as issues are
|
||||
- If target file not found: Suggest similar files, offer to search
|
||||
- If file is not UI code: Explain and ask for correct target
|
||||
- If review fails mid-way: Save partial results, offer to resume
|
||||
```
|
||||
|
||||
@@ -25,6 +25,7 @@ Initialize a design system with design tokens, component patterns, and documenta
|
||||
- Detect TypeScript usage
|
||||
|
||||
4. If existing design system detected:
|
||||
|
||||
```
|
||||
I detected an existing design system configuration:
|
||||
|
||||
@@ -385,27 +386,27 @@ Create `.ui-design/tokens/tokens.css`:
|
||||
|
||||
:root {
|
||||
/* Colors - Primary */
|
||||
--color-primary-50: #EFF6FF;
|
||||
--color-primary-100: #DBEAFE;
|
||||
--color-primary-500: #3B82F6;
|
||||
--color-primary-600: #2563EB;
|
||||
--color-primary-700: #1D4ED8;
|
||||
--color-primary-50: #eff6ff;
|
||||
--color-primary-100: #dbeafe;
|
||||
--color-primary-500: #3b82f6;
|
||||
--color-primary-600: #2563eb;
|
||||
--color-primary-700: #1d4ed8;
|
||||
|
||||
/* Colors - Neutral */
|
||||
--color-neutral-50: #F9FAFB;
|
||||
--color-neutral-100: #F3F4F6;
|
||||
--color-neutral-500: #6B7280;
|
||||
--color-neutral-50: #f9fafb;
|
||||
--color-neutral-100: #f3f4f6;
|
||||
--color-neutral-500: #6b7280;
|
||||
--color-neutral-900: #111827;
|
||||
|
||||
/* Colors - Semantic */
|
||||
--color-success: #22C55E;
|
||||
--color-warning: #F59E0B;
|
||||
--color-error: #EF4444;
|
||||
--color-info: #3B82F6;
|
||||
--color-success: #22c55e;
|
||||
--color-warning: #f59e0b;
|
||||
--color-error: #ef4444;
|
||||
--color-info: #3b82f6;
|
||||
|
||||
/* Typography */
|
||||
--font-family-sans: Inter, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--font-family-mono: ui-monospace, 'Fira Code', monospace;
|
||||
--font-family-mono: ui-monospace, "Fira Code", monospace;
|
||||
|
||||
--font-size-xs: 0.75rem;
|
||||
--font-size-sm: 0.875rem;
|
||||
@@ -442,17 +443,17 @@ Create `.ui-design/tokens/tokens.css`:
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-neutral-50: #111827;
|
||||
--color-neutral-100: #1F2937;
|
||||
--color-neutral-500: #9CA3AF;
|
||||
--color-neutral-900: #F9FAFB;
|
||||
--color-neutral-100: #1f2937;
|
||||
--color-neutral-500: #9ca3af;
|
||||
--color-neutral-900: #f9fafb;
|
||||
}
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--color-neutral-50: #111827;
|
||||
--color-neutral-100: #1F2937;
|
||||
--color-neutral-500: #9CA3AF;
|
||||
--color-neutral-900: #F9FAFB;
|
||||
--color-neutral-100: #1f2937;
|
||||
--color-neutral-500: #9ca3af;
|
||||
--color-neutral-900: #f9fafb;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -470,23 +471,23 @@ module.exports = {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#EFF6FF',
|
||||
100: '#DBEAFE',
|
||||
200: '#BFDBFE',
|
||||
300: '#93C5FD',
|
||||
400: '#60A5FA',
|
||||
500: '#3B82F6',
|
||||
600: '#2563EB',
|
||||
700: '#1D4ED8',
|
||||
800: '#1E40AF',
|
||||
900: '#1E3A8A',
|
||||
950: '#172554',
|
||||
50: "#EFF6FF",
|
||||
100: "#DBEAFE",
|
||||
200: "#BFDBFE",
|
||||
300: "#93C5FD",
|
||||
400: "#60A5FA",
|
||||
500: "#3B82F6",
|
||||
600: "#2563EB",
|
||||
700: "#1D4ED8",
|
||||
800: "#1E40AF",
|
||||
900: "#1E3A8A",
|
||||
950: "#172554",
|
||||
},
|
||||
// ... other colors
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', '-apple-system', 'BlinkMacSystemFont', 'sans-serif'],
|
||||
mono: ['ui-monospace', 'Fira Code', 'monospace'],
|
||||
sans: ["Inter", "-apple-system", "BlinkMacSystemFont", "sans-serif"],
|
||||
mono: ["ui-monospace", "Fira Code", "monospace"],
|
||||
},
|
||||
// ... other tokens
|
||||
},
|
||||
@@ -504,7 +505,7 @@ Create `.ui-design/tokens/tokens.ts`:
|
||||
|
||||
export const colors = {
|
||||
primary: {
|
||||
50: '#EFF6FF',
|
||||
50: "#EFF6FF",
|
||||
// ... full palette
|
||||
},
|
||||
// ... other color groups
|
||||
@@ -512,17 +513,17 @@ export const colors = {
|
||||
|
||||
export const typography = {
|
||||
fontFamily: {
|
||||
sans: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif',
|
||||
sans: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
mono: "ui-monospace, 'Fira Code', monospace",
|
||||
},
|
||||
fontSize: {
|
||||
xs: '0.75rem',
|
||||
xs: "0.75rem",
|
||||
// ... full scale
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const spacing = {
|
||||
1: '0.25rem',
|
||||
1: "0.25rem",
|
||||
// ... full scale
|
||||
} as const;
|
||||
|
||||
@@ -535,7 +536,7 @@ export type SpacingToken = keyof typeof spacing;
|
||||
|
||||
Create `.ui-design/docs/design-system.md`:
|
||||
|
||||
```markdown
|
||||
````markdown
|
||||
# Design System Documentation
|
||||
|
||||
## Overview
|
||||
@@ -545,33 +546,37 @@ This design system provides the foundation for consistent UI development.
|
||||
## Colors
|
||||
|
||||
### Primary Palette
|
||||
| Token | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
|
||||
| Token | Value | Usage |
|
||||
| ----------- | ------- | ---------------------- |
|
||||
| primary-500 | #3B82F6 | Primary actions, links |
|
||||
| primary-600 | #2563EB | Hover state |
|
||||
| primary-700 | #1D4ED8 | Active state |
|
||||
| primary-600 | #2563EB | Hover state |
|
||||
| primary-700 | #1D4ED8 | Active state |
|
||||
|
||||
### Semantic Colors
|
||||
| Token | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| success | #22C55E | Success messages, positive actions |
|
||||
| warning | #F59E0B | Warning messages, caution |
|
||||
| error | #EF4444 | Error messages, destructive actions |
|
||||
|
||||
| Token | Value | Usage |
|
||||
| ------- | ------- | ----------------------------------- |
|
||||
| success | #22C55E | Success messages, positive actions |
|
||||
| warning | #F59E0B | Warning messages, caution |
|
||||
| error | #EF4444 | Error messages, destructive actions |
|
||||
|
||||
## Typography
|
||||
|
||||
### Scale
|
||||
| Name | Size | Usage |
|
||||
|------|------|-------|
|
||||
| xs | 0.75rem | Captions, labels |
|
||||
| sm | 0.875rem | Secondary text |
|
||||
| base | 1rem | Body text |
|
||||
| lg | 1.125rem | Emphasized body |
|
||||
| xl | 1.25rem | Subheadings |
|
||||
|
||||
| Name | Size | Usage |
|
||||
| ---- | -------- | ---------------- |
|
||||
| xs | 0.75rem | Captions, labels |
|
||||
| sm | 0.875rem | Secondary text |
|
||||
| base | 1rem | Body text |
|
||||
| lg | 1.125rem | Emphasized body |
|
||||
| xl | 1.25rem | Subheadings |
|
||||
|
||||
## Spacing
|
||||
|
||||
Use spacing tokens for consistent margins and padding:
|
||||
|
||||
- `spacing-1` (4px): Tight spacing
|
||||
- `spacing-2` (8px): Compact spacing
|
||||
- `spacing-4` (16px): Default spacing
|
||||
@@ -581,6 +586,7 @@ Use spacing tokens for consistent margins and padding:
|
||||
## Usage
|
||||
|
||||
### CSS Custom Properties
|
||||
|
||||
```css
|
||||
.button {
|
||||
background: var(--color-primary-500);
|
||||
@@ -588,13 +594,14 @@ Use spacing tokens for consistent margins and padding:
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
```
|
||||
````
|
||||
|
||||
### Tailwind
|
||||
|
||||
```html
|
||||
<button class="bg-primary-500 px-4 py-2 rounded-md">
|
||||
Click me
|
||||
</button>
|
||||
<button class="bg-primary-500 px-4 py-2 rounded-md">Click me</button>
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
## Completion
|
||||
@@ -602,26 +609,31 @@ Use spacing tokens for consistent margins and padding:
|
||||
Update state and display summary:
|
||||
|
||||
```
|
||||
|
||||
Design System Setup Complete!
|
||||
|
||||
Created files:
|
||||
- .ui-design/design-system.json (master configuration)
|
||||
- .ui-design/tokens/tokens.css (CSS custom properties)
|
||||
- .ui-design/tokens/tailwind.config.js (Tailwind extension)
|
||||
- .ui-design/tokens/tokens.ts (TypeScript module)
|
||||
- .ui-design/docs/design-system.md (documentation)
|
||||
|
||||
- .ui-design/design-system.json (master configuration)
|
||||
- .ui-design/tokens/tokens.css (CSS custom properties)
|
||||
- .ui-design/tokens/tailwind.config.js (Tailwind extension)
|
||||
- .ui-design/tokens/tokens.ts (TypeScript module)
|
||||
- .ui-design/docs/design-system.md (documentation)
|
||||
|
||||
Quick start:
|
||||
|
||||
1. CSS: @import '.ui-design/tokens/tokens.css';
|
||||
2. Tailwind: Spread in your tailwind.config.js
|
||||
3. TypeScript: import { colors } from '.ui-design/tokens/tokens';
|
||||
|
||||
Next steps:
|
||||
|
||||
1. Review and customize tokens as needed
|
||||
2. Run /ui-design:create-component to build with your design system
|
||||
3. Run /ui-design:design-review to validate existing UI against tokens
|
||||
|
||||
Need to modify tokens? Run /ui-design:design-system-setup --preset {preset}
|
||||
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
@@ -630,3 +642,4 @@ Need to modify tokens? Run /ui-design:design-system-setup --preset {preset}
|
||||
- If file write fails: Report error, suggest manual creation
|
||||
- If color generation fails: Provide manual palette input option
|
||||
- If tailwind not detected: Skip tailwind output, inform user
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" };
|
||||
```
|
||||
|
||||
@@ -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 = { }) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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)();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`} />
|
||||
|
||||
@@ -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[][]> = {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user