mirror of
https://github.com/wshobson/agents.git
synced 2026-03-18 17:47:16 +00:00
feat(ui-design): add comprehensive UI/UX design plugin v1.0.0
New plugin covering mobile (iOS, Android, React Native) and web applications with modern design patterns, accessibility, and design systems. Components: - 9 skills: design-system-patterns, accessibility-compliance, responsive-design, mobile-ios-design, mobile-android-design, react-native-design, web-component-design, interaction-design, visual-design-foundations - 4 commands: design-review, create-component, accessibility-audit, design-system-setup - 3 agents: ui-designer, accessibility-expert, design-system-architect Marketplace updated: - Version bumped to 1.3.4 - 102 agents (+3), 116 skills (+9)
This commit is contained in:
10
plugins/ui-design/.claude-plugin/plugin.json
Normal file
10
plugins/ui-design/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "ui-design",
|
||||
"version": "1.0.0",
|
||||
"description": "Comprehensive UI/UX design plugin for mobile (iOS, Android, React Native) and web applications. Covers design systems, accessibility, responsive design, and modern patterns.",
|
||||
"author": {
|
||||
"name": "Seth Hobson",
|
||||
"email": "seth@major7apps.com"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
117
plugins/ui-design/README.md
Normal file
117
plugins/ui-design/README.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# UI/UX Design Plugin for Claude Code
|
||||
|
||||
Comprehensive UI/UX design plugin covering mobile (iOS, Android, React Native) and web applications with modern design patterns, accessibility, and design systems.
|
||||
|
||||
## 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
|
||||
- **Mobile Design**: iOS HIG, Material Design 3, React Native patterns
|
||||
- **Web Components**: React/Vue/Svelte patterns, CSS-in-JS
|
||||
- **Interaction Design**: Microinteractions, motion, transitions
|
||||
|
||||
## 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 |
|
||||
|
||||
## 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 |
|
||||
|
||||
## 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 |
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
/plugin install ui-design
|
||||
```
|
||||
|
||||
## 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"
|
||||
```
|
||||
|
||||
## 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
|
||||
- Keyboard navigation
|
||||
|
||||
## Generated Artifacts
|
||||
|
||||
The plugin creates artifacts in `.ui-design/`:
|
||||
|
||||
```
|
||||
.ui-design/
|
||||
├── design-system.config.json # Design system configuration
|
||||
├── component_specs/ # Generated component specifications
|
||||
├── audit_reports/ # Accessibility audit reports
|
||||
└── tokens/ # Generated design tokens
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Claude Code CLI
|
||||
- Node.js 18+ (for design token generation)
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
134
plugins/ui-design/agents/accessibility-expert.md
Normal file
134
plugins/ui-design/agents/accessibility-expert.md
Normal file
@@ -0,0 +1,134 @@
|
||||
---
|
||||
name: accessibility-expert
|
||||
description: Expert accessibility specialist ensuring WCAG compliance, inclusive design, and assistive technology compatibility. Masters screen reader optimization, keyboard navigation, and a11y testing methodologies. Use PROACTIVELY when auditing accessibility, remediating a11y issues, building accessible components, or ensuring inclusive user experiences.
|
||||
model: inherit
|
||||
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
|
||||
- Section 508 compliance for government and public sector
|
||||
- ADA Title III requirements for digital accessibility
|
||||
- EN 301 549 European accessibility standard
|
||||
- CVAA requirements for communication technologies
|
||||
- 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)
|
||||
- Screen reader testing: NVDA, JAWS, VoiceOver, TalkBack
|
||||
- Semantic HTML for proper document structure and navigation
|
||||
- Heading hierarchy and landmark region organization
|
||||
- Link and button text clarity and context
|
||||
- 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
|
||||
- Custom keyboard interactions for complex widgets
|
||||
- Focus visible styling that meets contrast requirements
|
||||
- Roving tabindex patterns for composite widgets
|
||||
- Keyboard shortcuts and access keys implementation
|
||||
- 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
|
||||
- High contrast mode support and forced colors
|
||||
- Text spacing and readability requirements
|
||||
- Reduced motion preferences and vestibular considerations
|
||||
- Dark mode accessibility and color transformation
|
||||
- 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
|
||||
- Reading level considerations and plain language
|
||||
- Time limits and user control over timing
|
||||
- Distraction minimization and focus support
|
||||
- Memory load reduction through progressive disclosure
|
||||
- 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
|
||||
- Eye tracking and gaze-based navigation support
|
||||
- Screen magnification software compatibility
|
||||
- Refreshable Braille display support
|
||||
- Speech recognition and dictation software
|
||||
- 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
|
||||
- Screen reader testing methodology
|
||||
- Keyboard-only navigation testing
|
||||
- Color contrast analyzers and simulators
|
||||
- Accessibility tree inspection in browser DevTools
|
||||
- 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
|
||||
- Component-level accessibility patterns and recipes
|
||||
- Form accessibility: labels, errors, grouping, validation
|
||||
- Table accessibility: headers, captions, summaries
|
||||
- Multimedia accessibility: captions, transcripts, audio descriptions
|
||||
- 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
|
||||
- Considers the full spectrum of disabilities: visual, auditory, motor, cognitive
|
||||
- Prioritizes issues based on user impact and severity
|
||||
- Educates team members on accessibility best practices
|
||||
- Tests with real assistive technologies, not just automated tools
|
||||
- Keeps current with evolving accessibility standards and techniques
|
||||
- Recognizes that accessibility benefits all users, not just those with disabilities
|
||||
- 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
|
||||
- Browser and platform accessibility APIs
|
||||
- Legal requirements and compliance frameworks globally
|
||||
- Accessible component patterns from major design systems
|
||||
- Testing tool capabilities and limitations
|
||||
- Research on disability types and assistive technology usage
|
||||
- Inclusive design principles and universal design concepts
|
||||
- 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
|
||||
4. **Provide remediation guidance** with code examples and ARIA patterns
|
||||
5. **Explain the user impact** of accessibility issues
|
||||
6. **Recommend testing approaches** for validating fixes
|
||||
7. **Consider edge cases** across different assistive technologies
|
||||
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"
|
||||
- "Create an accessible modal dialog with proper focus management and ARIA attributes"
|
||||
- "Design an accessible data visualization that conveys information without relying solely on color"
|
||||
136
plugins/ui-design/agents/design-system-architect.md
Normal file
136
plugins/ui-design/agents/design-system-architect.md
Normal file
@@ -0,0 +1,136 @@
|
||||
---
|
||||
name: design-system-architect
|
||||
description: Expert design system architect specializing in design tokens, component libraries, theming infrastructure, and scalable design operations. Masters token architecture, multi-brand systems, and design-development collaboration. Use PROACTIVELY when building design systems, creating token architectures, implementing theming, or establishing component libraries.
|
||||
model: inherit
|
||||
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
|
||||
- Typography tokens: font families, sizes, weights, line heights, letter spacing
|
||||
- Spacing tokens: consistent scale systems (4px, 8px base units)
|
||||
- Shadow and elevation token systems
|
||||
- Border radius and shape tokens
|
||||
- Animation and timing tokens (duration, easing)
|
||||
- Breakpoint and responsive tokens
|
||||
- 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
|
||||
- Platform-specific token output: iOS, Android, web
|
||||
- Token documentation generation
|
||||
- Token versioning and change management
|
||||
- Token validation and linting rules
|
||||
- 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)
|
||||
- Component variants and size scales
|
||||
- Slot-based composition for customization
|
||||
- Polymorphic components with "as" prop patterns
|
||||
- Controlled vs. uncontrolled component design
|
||||
- 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
|
||||
- Dark mode implementation patterns
|
||||
- High contrast and accessibility themes
|
||||
- White-label and customization capabilities
|
||||
- Sub-theming and theme composition
|
||||
- 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
|
||||
- Component documentation standards and templates
|
||||
- Storybook configuration and addon ecosystem
|
||||
- Visual regression testing with Chromatic, Percy
|
||||
- Design review and approval workflows
|
||||
- Change management and deprecation strategies
|
||||
|
||||
### Scalable Component Patterns
|
||||
- Primitive components as building blocks
|
||||
- Layout components: Box, Stack, Flex, Grid
|
||||
- Typography components with semantic variants
|
||||
- Form field patterns with consistent validation
|
||||
- Feedback components: alerts, toasts, progress
|
||||
- Navigation components: tabs, breadcrumbs, menus
|
||||
- Data display: tables, lists, cards
|
||||
- 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
|
||||
- Interactive playground and code examples
|
||||
- Accessibility documentation per component
|
||||
- Migration guides for breaking changes
|
||||
- Contribution guidelines and review processes
|
||||
- Design system roadmap and versioning
|
||||
|
||||
### Performance & Optimization
|
||||
- Tree-shaking and bundle size optimization
|
||||
- CSS optimization: critical CSS, code splitting
|
||||
- Component lazy loading strategies
|
||||
- Font loading and optimization
|
||||
- Icon system optimization: sprites, individual SVGs, icon fonts
|
||||
- Style deduplication and CSS-in-JS optimization
|
||||
- Performance budgets for design system assets
|
||||
- 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
|
||||
- Documents decisions thoroughly for team alignment
|
||||
- Plans for scale and multi-platform requirements from the start
|
||||
- Advocates for design system adoption through education and tooling
|
||||
- Measures success through adoption metrics and user feedback
|
||||
- Iterates based on real-world usage patterns and pain points
|
||||
- Maintains backward compatibility while evolving the system
|
||||
- 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
|
||||
- Styling approaches: CSS Modules, CSS-in-JS, Tailwind, vanilla-extract
|
||||
- Documentation tools: Storybook, Docusaurus, custom documentation sites
|
||||
- Testing strategies: unit, integration, visual regression, accessibility
|
||||
- Versioning strategies: semantic versioning, changelogs, migration paths
|
||||
- Monorepo tooling: Turborepo, Nx, Lerna for multi-package systems
|
||||
- Design tool integrations: Figma plugins, design-to-code workflows
|
||||
- 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
|
||||
4. **Define component API patterns** that balance flexibility and consistency
|
||||
5. **Plan theming infrastructure** for current and future brand requirements
|
||||
6. **Establish documentation standards** for design and development audiences
|
||||
7. **Create governance processes** for contribution and evolution
|
||||
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"
|
||||
- "Establish a design-to-code workflow using Figma Tokens and Style Dictionary"
|
||||
- "Architect a scalable icon system with optimized delivery and consistent sizing"
|
||||
114
plugins/ui-design/agents/ui-designer.md
Normal file
114
plugins/ui-design/agents/ui-designer.md
Normal file
@@ -0,0 +1,114 @@
|
||||
---
|
||||
name: ui-designer
|
||||
description: Expert UI designer specializing in component creation, layout systems, and visual design implementation. Masters modern design patterns, responsive layouts, and design-to-code workflows. Use PROACTIVELY when building UI components, designing layouts, creating mockups, or implementing visual designs.
|
||||
model: inherit
|
||||
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
|
||||
- Interactive component patterns: buttons, inputs, cards, modals, navigation
|
||||
- Data visualization components: charts, graphs, tables, dashboards
|
||||
- Form design patterns with validation feedback and progressive disclosure
|
||||
- Animation and micro-interaction design for enhanced user feedback
|
||||
- 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
|
||||
- Container queries for component-level responsiveness
|
||||
- Layout patterns: holy grail, sidebar, dashboard, card grid, masonry
|
||||
- Whitespace and spacing systems using consistent scale (4px, 8px base)
|
||||
- Vertical rhythm and baseline grid alignment
|
||||
- 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
|
||||
- Shadow and elevation systems for depth perception
|
||||
- Border radius and shape language consistency
|
||||
- Visual hierarchy through size, color, weight, and position
|
||||
- Imagery guidelines: aspect ratios, cropping, placeholder patterns
|
||||
- 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)
|
||||
- Adaptive navigation patterns: hamburger, bottom nav, sidebar collapse
|
||||
- Image optimization strategies: srcset, picture element, lazy loading
|
||||
- Device-specific considerations: notches, safe areas, fold awareness
|
||||
- Orientation handling for tablets and foldable devices
|
||||
- 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
|
||||
- CSS-in-JS approaches: styled-components, Emotion, vanilla-extract
|
||||
- CSS Modules for scoped component styling
|
||||
- Animation implementation with CSS transitions and keyframes
|
||||
- Framer Motion and React Spring for complex animations
|
||||
- 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
|
||||
- Navigation flow design and information architecture
|
||||
- Transition design between views and states
|
||||
- Feedback mechanisms: toasts, alerts, progress indicators
|
||||
- Onboarding flow design and progressive disclosure
|
||||
- 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
|
||||
- Documents design decisions with clear rationale
|
||||
- Considers accessibility as a foundational requirement, not an afterthought
|
||||
- Balances visual appeal with functional clarity
|
||||
- Iterates based on user feedback and testing data
|
||||
- Communicates design intent clearly to development teams
|
||||
- Stays current with modern design trends while avoiding fleeting fads
|
||||
- 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
|
||||
- Animation principles and performance optimization
|
||||
- Browser compatibility and progressive enhancement strategies
|
||||
- Design tool proficiency: Figma, Sketch, Adobe XD concepts
|
||||
- Front-end framework conventions: React, Vue, Svelte
|
||||
- Performance implications of design decisions
|
||||
- Cross-platform design considerations: web, iOS, Android
|
||||
- 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
|
||||
4. **Create component specifications** with states, variants, and responsive behavior
|
||||
5. **Provide implementation guidance** with code examples when appropriate
|
||||
6. **Document design decisions** and usage guidelines
|
||||
7. **Consider edge cases** including error states, empty states, and loading
|
||||
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"
|
||||
- "Design a notification system with toast messages, banners, and in-app alerts"
|
||||
- "Create a data table component with sorting, filtering, and pagination controls"
|
||||
479
plugins/ui-design/commands/accessibility-audit.md
Normal file
479
plugins/ui-design/commands/accessibility-audit.md
Normal file
@@ -0,0 +1,479 @@
|
||||
---
|
||||
description: "Audit UI code for WCAG compliance"
|
||||
argument-hint: "[file-path|component-name|--level AA|AAA]"
|
||||
---
|
||||
|
||||
# Accessibility Audit
|
||||
|
||||
Comprehensive audit of UI code for WCAG 2.1/2.2 compliance. Identifies accessibility issues and provides actionable remediation guidance.
|
||||
|
||||
## Pre-flight Checks
|
||||
|
||||
1. Check if `.ui-design/` directory exists:
|
||||
- If not: Create `.ui-design/` directory
|
||||
- Create `.ui-design/audits/` subdirectory for audit results
|
||||
|
||||
2. Load project context:
|
||||
- Check for `conductor/tech-stack.md` for framework info
|
||||
- Check for `.ui-design/design-system.json` for color tokens
|
||||
- Detect testing framework for a11y test suggestions
|
||||
|
||||
## Target and Level Configuration
|
||||
|
||||
### If argument provided:
|
||||
|
||||
- Parse for file path or component name
|
||||
- Parse for `--level` flag (AA or AAA)
|
||||
- Default to WCAG 2.1 Level AA if not specified
|
||||
|
||||
### If no argument:
|
||||
|
||||
**Q1: Audit Target**
|
||||
|
||||
```
|
||||
What would you like to audit?
|
||||
|
||||
1. A specific component (provide name or path)
|
||||
2. A page/route (provide path)
|
||||
3. All components in a directory
|
||||
4. The entire application
|
||||
5. Recent changes only (last commit)
|
||||
|
||||
Enter number or provide a file path:
|
||||
```
|
||||
|
||||
**Q2: Compliance Level**
|
||||
|
||||
```
|
||||
What WCAG compliance level should I audit against?
|
||||
|
||||
1. Level A - Minimum accessibility (must-fix issues)
|
||||
2. Level AA - Standard compliance (recommended, most common target)
|
||||
3. Level AAA - Enhanced accessibility (highest standard)
|
||||
|
||||
Note: Each level includes all requirements from previous levels.
|
||||
|
||||
Enter number:
|
||||
```
|
||||
|
||||
**Q3: Focus Areas (optional)**
|
||||
|
||||
```
|
||||
Any specific areas to focus on? (Press enter to audit all)
|
||||
|
||||
1. Color contrast and visual presentation
|
||||
2. Keyboard navigation and focus management
|
||||
3. Screen reader compatibility
|
||||
4. Forms and input validation
|
||||
5. Dynamic content and ARIA
|
||||
6. All areas
|
||||
|
||||
Enter numbers (comma-separated) or press enter:
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
Create `.ui-design/audits/audit_state.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"audit_id": "{target}_{YYYYMMDD_HHMMSS}",
|
||||
"target": "{file_path_or_scope}",
|
||||
"wcag_level": "AA",
|
||||
"focus_areas": ["all"],
|
||||
"status": "in_progress",
|
||||
"started_at": "ISO_TIMESTAMP",
|
||||
"files_audited": 0,
|
||||
"issues_found": {
|
||||
"critical": 0,
|
||||
"serious": 0,
|
||||
"moderate": 0,
|
||||
"minor": 0
|
||||
},
|
||||
"criteria_checked": 0,
|
||||
"criteria_passed": 0
|
||||
}
|
||||
```
|
||||
|
||||
## Audit Execution
|
||||
|
||||
### 1. File Discovery
|
||||
|
||||
Identify all files to audit:
|
||||
|
||||
- If single file: Audit that file
|
||||
- If component: Find all related files (component, styles, tests)
|
||||
- If directory: Recursively find UI files (`.tsx`, `.vue`, `.svelte`, etc.)
|
||||
- If application: Audit all component and page files
|
||||
|
||||
### 2. Static Code Analysis
|
||||
|
||||
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
|
||||
- [ ] Tables have proper headers
|
||||
- [ ] 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%
|
||||
- [ ] Focus indicators are visible
|
||||
- [ ] Content reflows at 320px width (AA)
|
||||
|
||||
#### 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
|
||||
- [ ] Link purpose is clear
|
||||
- [ ] 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
|
||||
|
||||
#### 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
|
||||
- [ ] Important submissions can be reviewed
|
||||
|
||||
#### Robust (WCAG 4.x)
|
||||
|
||||
**4.1 Compatible:**
|
||||
- [ ] HTML validates (no duplicate IDs)
|
||||
- [ ] Custom components have proper ARIA
|
||||
- [ ] Status messages announced to screen readers
|
||||
|
||||
### 3. Pattern Detection
|
||||
|
||||
Identify common accessibility anti-patterns:
|
||||
|
||||
```javascript
|
||||
// Anti-patterns to detect
|
||||
const antiPatterns = [
|
||||
// Missing alt text
|
||||
/<img(?![^>]*alt=)[^>]*>/,
|
||||
|
||||
// onClick without keyboard handler
|
||||
/onClick={[^}]+}(?!.*onKeyDown)/,
|
||||
|
||||
// Div/span with click handlers (likely needs role)
|
||||
/<(?:div|span)[^>]*onClick/,
|
||||
|
||||
// Non-semantic buttons
|
||||
/<(?:div|span)[^>]*role="button"/,
|
||||
|
||||
// Missing form labels
|
||||
/<input(?![^>]*(?:aria-label|aria-labelledby|id))[^>]*>/,
|
||||
|
||||
// Positive tabindex (disrupts natural order)
|
||||
/tabIndex={[1-9]/,
|
||||
|
||||
// Empty links
|
||||
/<a[^>]*>[\s]*<\/a>/,
|
||||
|
||||
// Missing lang attribute
|
||||
/<html(?![^>]*lang=)/,
|
||||
|
||||
// Autofocus (usually bad for a11y)
|
||||
/autoFocus/,
|
||||
];
|
||||
```
|
||||
|
||||
### 4. Color Contrast Analysis
|
||||
|
||||
If design tokens or CSS available:
|
||||
|
||||
- Extract color combinations used in text/background
|
||||
- Calculate contrast ratios using WCAG formula
|
||||
- Flag combinations that fail requirements:
|
||||
- Normal text: 4.5:1 (AA), 7:1 (AAA)
|
||||
- Large text (18pt+ or 14pt bold): 3:1 (AA), 4.5:1 (AAA)
|
||||
- UI components: 3:1 (AA)
|
||||
|
||||
### 5. ARIA Validation
|
||||
|
||||
Check ARIA usage:
|
||||
|
||||
- Verify ARIA roles are valid
|
||||
- Check required ARIA attributes are present
|
||||
- Verify ARIA values are valid
|
||||
- Check for redundant ARIA (e.g., `role="button"` on `<button>`)
|
||||
- Validate ARIA references (aria-labelledby, aria-describedby)
|
||||
|
||||
## Output Format
|
||||
|
||||
Generate audit report in `.ui-design/audits/{audit_id}.md`:
|
||||
|
||||
```markdown
|
||||
# Accessibility Audit Report
|
||||
|
||||
**Audit ID:** {audit_id}
|
||||
**Date:** {YYYY-MM-DD HH:MM}
|
||||
**Target:** {target}
|
||||
**WCAG Level:** {level}
|
||||
**Standard:** WCAG 2.1
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**Compliance Status:** {Passing | Needs Improvement | Failing}
|
||||
|
||||
| Severity | Count | % of Issues |
|
||||
|----------|-------|-------------|
|
||||
| Critical | {n} | {%} |
|
||||
| Serious | {n} | {%} |
|
||||
| Moderate | {n} | {%} |
|
||||
| Minor | {n} | {%} |
|
||||
|
||||
**Criteria Checked:** {n}
|
||||
**Criteria Passed:** {n} ({%})
|
||||
**Files Audited:** {n}
|
||||
|
||||
## Critical Issues (Must Fix)
|
||||
|
||||
These issues prevent users with disabilities from using the interface.
|
||||
|
||||
### Issue 1: {Title}
|
||||
|
||||
**WCAG Criterion:** {number} - {name} (Level {A|AA|AAA})
|
||||
**Severity:** Critical
|
||||
**Location:** `{file}:{line}`
|
||||
**Element:** `{element_snippet}`
|
||||
|
||||
**Problem:**
|
||||
{Description of the issue}
|
||||
|
||||
**Impact:**
|
||||
{Who is affected and how}
|
||||
|
||||
**Remediation:**
|
||||
{Step-by-step fix instructions}
|
||||
|
||||
**Code Fix:**
|
||||
```{language}
|
||||
// Before
|
||||
{current_code}
|
||||
|
||||
// After
|
||||
{fixed_code}
|
||||
```
|
||||
|
||||
**Testing:**
|
||||
- Manual: {how to manually verify}
|
||||
- Automated: {suggested test}
|
||||
|
||||
---
|
||||
|
||||
### Issue 2: ...
|
||||
|
||||
## Serious Issues
|
||||
|
||||
These issues create significant barriers for some users.
|
||||
|
||||
### Issue 3: ...
|
||||
|
||||
## Moderate Issues
|
||||
|
||||
These issues may cause difficulty for some users.
|
||||
|
||||
### Issue 4: ...
|
||||
|
||||
## Minor Issues
|
||||
|
||||
These are best practice improvements.
|
||||
|
||||
### Issue 5: ...
|
||||
|
||||
## Passed Criteria
|
||||
|
||||
The following WCAG criteria passed:
|
||||
|
||||
| Criterion | Name | Level |
|
||||
|-----------|------|-------|
|
||||
| 1.1.1 | Non-text Content | A |
|
||||
| 1.3.1 | Info and Relationships | A |
|
||||
| ... | ... | ... |
|
||||
|
||||
## Recommendations
|
||||
|
||||
### Quick Wins (< 1 hour each)
|
||||
|
||||
1. {Quick fix 1}
|
||||
2. {Quick fix 2}
|
||||
|
||||
### Medium Effort (1-4 hours each)
|
||||
|
||||
1. {Medium fix 1}
|
||||
2. {Medium fix 2}
|
||||
|
||||
### Significant Effort (> 4 hours)
|
||||
|
||||
1. {Larger fix 1}
|
||||
|
||||
## Testing Resources
|
||||
|
||||
### Automated Testing
|
||||
|
||||
Add these tests to catch regressions:
|
||||
|
||||
```javascript
|
||||
// Example jest-axe test
|
||||
import { axe, toHaveNoViolations } from 'jest-axe';
|
||||
|
||||
expect.extend(toHaveNoViolations);
|
||||
|
||||
test('component has no accessibility violations', async () => {
|
||||
const { container } = render(<Component />);
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
```
|
||||
|
||||
### Manual Testing Checklist
|
||||
|
||||
- [ ] Navigate entire page using only keyboard
|
||||
- [ ] Test with screen reader (VoiceOver/NVDA)
|
||||
- [ ] Zoom to 200% and verify usability
|
||||
- [ ] Test with high contrast mode
|
||||
- [ ] Verify focus indicators are visible
|
||||
- [ ] Test with prefers-reduced-motion
|
||||
|
||||
### Recommended Tools
|
||||
|
||||
- axe DevTools browser extension
|
||||
- WAVE Web Accessibility Evaluator
|
||||
- Lighthouse accessibility audit
|
||||
- Color contrast analyzers
|
||||
|
||||
---
|
||||
|
||||
_Generated by UI Design Accessibility Audit_
|
||||
_WCAG Reference: https://www.w3.org/WAI/WCAG21/quickref/_
|
||||
```
|
||||
|
||||
## Completion
|
||||
|
||||
Update `audit_state.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "complete",
|
||||
"completed_at": "ISO_TIMESTAMP",
|
||||
"compliance_status": "needs_improvement",
|
||||
"issues_found": {
|
||||
"critical": 2,
|
||||
"serious": 5,
|
||||
"moderate": 8,
|
||||
"minor": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Display summary:
|
||||
|
||||
```
|
||||
Accessibility Audit Complete!
|
||||
|
||||
Target: {target}
|
||||
WCAG Level: {level}
|
||||
Compliance Status: {status}
|
||||
|
||||
Issues Found:
|
||||
- {n} Critical (must fix)
|
||||
- {n} Serious
|
||||
- {n} Moderate
|
||||
- {n} Minor
|
||||
|
||||
Full report: .ui-design/audits/{audit_id}.md
|
||||
|
||||
What would you like to do next?
|
||||
1. View details for critical issues
|
||||
2. Start fixing issues (guided)
|
||||
3. Generate automated tests
|
||||
4. Export report for stakeholders
|
||||
5. Audit another component
|
||||
|
||||
Enter number:
|
||||
```
|
||||
|
||||
## Guided Fix Mode
|
||||
|
||||
If user selects "Start fixing issues":
|
||||
|
||||
```
|
||||
Let's fix accessibility issues starting with critical ones.
|
||||
|
||||
Issue 1 of {n}: {Issue Title}
|
||||
WCAG {criterion}: {criterion_name}
|
||||
Location: {file}:{line}
|
||||
|
||||
{Show current code}
|
||||
|
||||
The fix is:
|
||||
{Explain the fix}
|
||||
|
||||
Should I:
|
||||
1. Apply this fix automatically
|
||||
2. Show me the fixed code first
|
||||
3. Skip this issue
|
||||
4. Stop fixing
|
||||
|
||||
Enter number:
|
||||
```
|
||||
|
||||
Apply fixes one at a time, re-validating after each fix.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- If file not found: Suggest alternatives, offer to search
|
||||
- If not UI code: Explain limitation, suggest correct target
|
||||
- If color extraction fails: Note in report, suggest manual check
|
||||
- If audit incomplete: Save partial results, offer to resume
|
||||
471
plugins/ui-design/commands/create-component.md
Normal file
471
plugins/ui-design/commands/create-component.md
Normal file
@@ -0,0 +1,471 @@
|
||||
---
|
||||
description: "Guided component creation with proper patterns"
|
||||
argument-hint: "[component-name]"
|
||||
---
|
||||
|
||||
# Create Component
|
||||
|
||||
Guided workflow for creating new UI components following established patterns and best practices.
|
||||
|
||||
## Pre-flight Checks
|
||||
|
||||
1. Check if `.ui-design/` directory exists:
|
||||
- If not: Create `.ui-design/` directory
|
||||
- Create `.ui-design/components/` subdirectory for component tracking
|
||||
|
||||
2. Detect project configuration:
|
||||
- Scan for framework (React, Vue, Svelte, Angular)
|
||||
- Scan for styling approach (CSS Modules, Tailwind, styled-components, etc.)
|
||||
- Check for existing component patterns in `src/components/` or similar
|
||||
- Load `.ui-design/design-system.json` if exists
|
||||
|
||||
3. Load project context:
|
||||
- Check for `conductor/tech-stack.md`
|
||||
- Check for existing component conventions
|
||||
|
||||
4. If no framework detected:
|
||||
```
|
||||
I couldn't detect a UI framework. What are you using?
|
||||
|
||||
1. React
|
||||
2. Vue 3
|
||||
3. Svelte
|
||||
4. Angular
|
||||
5. Vanilla JavaScript/HTML
|
||||
6. Other (specify)
|
||||
|
||||
Enter number:
|
||||
```
|
||||
|
||||
## Component Specification
|
||||
|
||||
**CRITICAL RULES:**
|
||||
|
||||
- Ask ONE question per turn
|
||||
- Wait for user response before proceeding
|
||||
- Build complete specification before generating code
|
||||
|
||||
### Q1: Component Name (if not provided)
|
||||
|
||||
```
|
||||
What should this component be called?
|
||||
|
||||
Guidelines:
|
||||
- Use PascalCase (e.g., UserCard, DataTable)
|
||||
- Be descriptive but concise
|
||||
- Avoid generic names like "Component" or "Widget"
|
||||
|
||||
Enter component name:
|
||||
```
|
||||
|
||||
### Q2: Component Purpose
|
||||
|
||||
```
|
||||
What is this component's primary purpose?
|
||||
|
||||
1. Display content (cards, lists, text blocks)
|
||||
2. Collect input (forms, selects, toggles)
|
||||
3. Navigation (menus, tabs, breadcrumbs)
|
||||
4. Feedback (alerts, toasts, modals)
|
||||
5. Layout (containers, grids, sections)
|
||||
6. Data visualization (charts, graphs, indicators)
|
||||
7. Other (describe)
|
||||
|
||||
Enter number or description:
|
||||
```
|
||||
|
||||
### Q3: Component Complexity
|
||||
|
||||
```
|
||||
What is the component's complexity level?
|
||||
|
||||
1. Simple - Single responsibility, minimal props, no internal state
|
||||
2. Compound - Multiple parts, some internal state, few props
|
||||
3. Complex - Multiple subcomponents, state management, many props
|
||||
4. Composite - Orchestrates other components, significant logic
|
||||
|
||||
Enter number:
|
||||
```
|
||||
|
||||
### Q4: Props/Inputs Specification
|
||||
|
||||
```
|
||||
What props/inputs should this component accept?
|
||||
|
||||
For each prop, provide:
|
||||
- Name (camelCase)
|
||||
- Type (string, number, boolean, function, object, array)
|
||||
- Required or optional
|
||||
- Default value (if optional)
|
||||
|
||||
Example format:
|
||||
title: string, required
|
||||
variant: "primary" | "secondary", optional, default: "primary"
|
||||
onClick: function, optional
|
||||
|
||||
Enter props (one per line, empty line when done):
|
||||
```
|
||||
|
||||
### Q5: State Requirements
|
||||
|
||||
```
|
||||
Does this component need internal state?
|
||||
|
||||
1. Stateless - Pure presentational, all data via props
|
||||
2. Local state - Simple internal state (open/closed, hover, etc.)
|
||||
3. Controlled - State managed by parent, component reports changes
|
||||
4. Uncontrolled - Manages own state, exposes refs for parent access
|
||||
5. Hybrid - Supports both controlled and uncontrolled modes
|
||||
|
||||
Enter number:
|
||||
```
|
||||
|
||||
### Q6: Composition Pattern (if complexity > Simple)
|
||||
|
||||
```
|
||||
How should child content be handled?
|
||||
|
||||
1. No children - Self-contained component
|
||||
2. Simple children - Accepts children prop for content
|
||||
3. Named slots - Multiple content areas (header, body, footer)
|
||||
4. Compound components - Exports subcomponents (e.g., Card.Header, Card.Body)
|
||||
5. Render props - Accepts render function for flexibility
|
||||
|
||||
Enter number:
|
||||
```
|
||||
|
||||
### Q7: Accessibility Requirements
|
||||
|
||||
```
|
||||
What accessibility features are needed?
|
||||
|
||||
1. Basic - Semantic HTML, aria-labels where needed
|
||||
2. Keyboard navigation - Full keyboard support, focus management
|
||||
3. Screen reader optimized - Live regions, announcements
|
||||
4. Full WCAG AA - All applicable success criteria
|
||||
|
||||
Enter number:
|
||||
```
|
||||
|
||||
### Q8: Styling Approach
|
||||
|
||||
```
|
||||
How should this component be styled?
|
||||
|
||||
Detected: {detected_approach}
|
||||
|
||||
1. Use detected approach ({detected_approach})
|
||||
2. CSS Modules
|
||||
3. Tailwind CSS
|
||||
4. Styled Components / Emotion
|
||||
5. Plain CSS/SCSS
|
||||
6. Other (specify)
|
||||
|
||||
Enter number:
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
Create `.ui-design/components/{component_name}.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "{ComponentName}",
|
||||
"created_at": "ISO_TIMESTAMP",
|
||||
"purpose": "{purpose}",
|
||||
"complexity": "{level}",
|
||||
"props": [
|
||||
{
|
||||
"name": "{prop_name}",
|
||||
"type": "{type}",
|
||||
"required": true,
|
||||
"default": null,
|
||||
"description": "{description}"
|
||||
}
|
||||
],
|
||||
"state_pattern": "{pattern}",
|
||||
"composition": "{pattern}",
|
||||
"accessibility_level": "{level}",
|
||||
"styling": "{approach}",
|
||||
"files_created": [],
|
||||
"status": "in_progress"
|
||||
}
|
||||
```
|
||||
|
||||
## Component Generation
|
||||
|
||||
### 1. Create Directory Structure
|
||||
|
||||
Based on detected patterns or ask user:
|
||||
|
||||
```
|
||||
Where should this component be created?
|
||||
|
||||
Detected component directories:
|
||||
1. src/components/{ComponentName}/
|
||||
2. app/components/{ComponentName}/
|
||||
3. components/{ComponentName}/
|
||||
4. Other (specify path)
|
||||
|
||||
Enter number or path:
|
||||
```
|
||||
|
||||
Create structure:
|
||||
|
||||
```
|
||||
{component_path}/
|
||||
├── index.ts # Barrel export
|
||||
├── {ComponentName}.tsx # Main component
|
||||
├── {ComponentName}.test.tsx # Tests (if testing detected)
|
||||
├── {ComponentName}.styles.{ext} # Styles (based on approach)
|
||||
└── types.ts # TypeScript types (if TS project)
|
||||
```
|
||||
|
||||
### 2. Generate Component Code
|
||||
|
||||
Generate component based on gathered specifications.
|
||||
|
||||
**For React/TypeScript example:**
|
||||
|
||||
```tsx
|
||||
// {ComponentName}.tsx
|
||||
import { forwardRef } from 'react';
|
||||
import type { {ComponentName}Props } from './types';
|
||||
import styles from './{ComponentName}.styles.module.css';
|
||||
|
||||
/**
|
||||
* {ComponentName}
|
||||
*
|
||||
* {Purpose description}
|
||||
*/
|
||||
export const {ComponentName} = forwardRef<HTML{Element}Element, {ComponentName}Props>(
|
||||
({ prop1, prop2 = 'default', children, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={styles.root}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
{ComponentName}.displayName = '{ComponentName}';
|
||||
```
|
||||
|
||||
### 3. Generate Types
|
||||
|
||||
```tsx
|
||||
// types.ts
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
export interface {ComponentName}Props extends HTMLAttributes<HTMLDivElement> {
|
||||
/** {prop1 description} */
|
||||
prop1: string;
|
||||
|
||||
/** {prop2 description} */
|
||||
prop2?: 'primary' | 'secondary';
|
||||
|
||||
/** Component children */
|
||||
children?: ReactNode;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Generate Styles
|
||||
|
||||
Based on styling approach:
|
||||
|
||||
**CSS Modules:**
|
||||
```css
|
||||
/* {ComponentName}.styles.module.css */
|
||||
.root {
|
||||
/* Base styles */
|
||||
}
|
||||
|
||||
.variant-primary {
|
||||
/* Primary variant */
|
||||
}
|
||||
|
||||
.variant-secondary {
|
||||
/* Secondary variant */
|
||||
}
|
||||
```
|
||||
|
||||
**Tailwind:**
|
||||
```tsx
|
||||
// Inline in component
|
||||
className={cn(
|
||||
'base-classes',
|
||||
variant === 'primary' && 'primary-classes',
|
||||
className
|
||||
)}
|
||||
```
|
||||
|
||||
### 5. Generate Tests (if testing framework detected)
|
||||
|
||||
```tsx
|
||||
// {ComponentName}.test.tsx
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { {ComponentName} } from './{ComponentName}';
|
||||
|
||||
describe('{ComponentName}', () => {
|
||||
it('renders without crashing', () => {
|
||||
render(<{ComponentName} prop1="test" />);
|
||||
expect(screen.getByRole('...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies variant styles correctly', () => {
|
||||
// Variant tests
|
||||
});
|
||||
|
||||
it('handles user interaction', async () => {
|
||||
const user = userEvent.setup();
|
||||
// Interaction tests
|
||||
});
|
||||
|
||||
it('meets accessibility requirements', () => {
|
||||
// A11y tests
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 6. Generate Barrel Export
|
||||
|
||||
```tsx
|
||||
// index.ts
|
||||
export { {ComponentName} } from './{ComponentName}';
|
||||
export type { {ComponentName}Props } from './types';
|
||||
```
|
||||
|
||||
## User Review
|
||||
|
||||
After generating files:
|
||||
|
||||
```
|
||||
I've created the {ComponentName} component:
|
||||
|
||||
Files created:
|
||||
- {path}/index.ts
|
||||
- {path}/{ComponentName}.tsx
|
||||
- {path}/{ComponentName}.test.tsx
|
||||
- {path}/{ComponentName}.styles.module.css
|
||||
- {path}/types.ts
|
||||
|
||||
Would you like to:
|
||||
1. Review the generated code
|
||||
2. Make modifications
|
||||
3. Add more props or features
|
||||
4. Generate Storybook stories
|
||||
5. Done, keep as-is
|
||||
|
||||
Enter number:
|
||||
```
|
||||
|
||||
### If modifications requested:
|
||||
|
||||
```
|
||||
What would you like to modify?
|
||||
|
||||
1. Add a new prop
|
||||
2. Change styling approach
|
||||
3. Add a variant
|
||||
4. Modify component structure
|
||||
5. Add accessibility features
|
||||
6. Other (describe)
|
||||
|
||||
Enter number:
|
||||
```
|
||||
|
||||
## Storybook Integration (Optional)
|
||||
|
||||
If Storybook detected or user requests:
|
||||
|
||||
```tsx
|
||||
// {ComponentName}.stories.tsx
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { {ComponentName} } from './{ComponentName}';
|
||||
|
||||
const meta: Meta<typeof {ComponentName}> = {
|
||||
title: 'Components/{ComponentName}',
|
||||
component: {ComponentName},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['primary', 'secondary'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof {ComponentName}>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
prop1: 'Example',
|
||||
},
|
||||
};
|
||||
|
||||
export const Primary: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
variant: 'primary',
|
||||
},
|
||||
};
|
||||
|
||||
export const Secondary: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
variant: 'secondary',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Completion
|
||||
|
||||
Update `.ui-design/components/{component_name}.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "complete",
|
||||
"files_created": [
|
||||
"{path}/index.ts",
|
||||
"{path}/{ComponentName}.tsx",
|
||||
"{path}/{ComponentName}.test.tsx",
|
||||
"{path}/{ComponentName}.styles.module.css",
|
||||
"{path}/types.ts"
|
||||
],
|
||||
"completed_at": "ISO_TIMESTAMP"
|
||||
}
|
||||
```
|
||||
|
||||
Display summary:
|
||||
|
||||
```
|
||||
Component Created Successfully!
|
||||
|
||||
Component: {ComponentName}
|
||||
Location: {path}/
|
||||
Files: {count} files created
|
||||
|
||||
Quick reference:
|
||||
Import: import { {ComponentName} } from '{import_path}';
|
||||
Usage: <{ComponentName} prop1="value" />
|
||||
|
||||
Next steps:
|
||||
1. Run /ui-design:design-review {path} to validate
|
||||
2. Run /ui-design:accessibility-audit {path} for a11y check
|
||||
3. Add to your page/layout
|
||||
|
||||
Need to create another component? Run /ui-design:create-component
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
- If component name conflicts: Suggest alternatives, offer to overwrite
|
||||
- If directory creation fails: Report error, suggest manual creation
|
||||
- If framework not supported: Provide generic template, explain limitations
|
||||
- If file write fails: Save to temp location, provide recovery instructions
|
||||
349
plugins/ui-design/commands/design-review.md
Normal file
349
plugins/ui-design/commands/design-review.md
Normal file
@@ -0,0 +1,349 @@
|
||||
---
|
||||
description: "Review existing UI for issues and improvements"
|
||||
argument-hint: "[file-path|component-name]"
|
||||
---
|
||||
|
||||
# Design Review
|
||||
|
||||
Review existing UI code for design issues, usability problems, and improvement opportunities. Provides actionable recommendations.
|
||||
|
||||
## Pre-flight Checks
|
||||
|
||||
1. Check if `.ui-design/` directory exists:
|
||||
- If not: Create `.ui-design/` directory
|
||||
- Create `.ui-design/reviews/` subdirectory for storing review results
|
||||
|
||||
2. Load project context if available:
|
||||
- Check for `conductor/product.md` for product context
|
||||
- Check for `conductor/tech-stack.md` for framework info
|
||||
- Check for `.ui-design/design-system.json` for design tokens
|
||||
|
||||
## Target Identification
|
||||
|
||||
### If argument provided:
|
||||
|
||||
- If file path: Validate file exists, read the file
|
||||
- If component name: Search codebase for matching component files
|
||||
- If not found: Display error with suggestions
|
||||
|
||||
### If no argument:
|
||||
|
||||
Ask user to specify target:
|
||||
|
||||
```
|
||||
What would you like me to review?
|
||||
|
||||
1. A specific component (provide name or path)
|
||||
2. A page/route (provide path)
|
||||
3. The entire UI directory
|
||||
4. Recent changes (last commit)
|
||||
|
||||
Enter number or provide a file path:
|
||||
```
|
||||
|
||||
## Interactive Review Configuration
|
||||
|
||||
**CRITICAL RULES:**
|
||||
|
||||
- Ask ONE question per turn
|
||||
- Wait for user response before proceeding
|
||||
- Gather context to provide relevant feedback
|
||||
|
||||
### Q1: Review Focus
|
||||
|
||||
```
|
||||
What aspects should I focus on?
|
||||
|
||||
1. Visual design (spacing, alignment, typography, colors)
|
||||
2. Usability (interaction patterns, accessibility basics)
|
||||
3. Code quality (patterns, maintainability, reusability)
|
||||
4. Performance (render optimization, bundle size)
|
||||
5. Comprehensive (all of the above)
|
||||
|
||||
Enter number:
|
||||
```
|
||||
|
||||
### Q2: Design Context (if visual/usability selected)
|
||||
|
||||
```
|
||||
What is this UI's primary purpose?
|
||||
|
||||
1. Data display (dashboards, tables, reports)
|
||||
2. Data entry (forms, wizards, editors)
|
||||
3. Navigation (menus, sidebars, breadcrumbs)
|
||||
4. Content consumption (articles, media, feeds)
|
||||
5. E-commerce (product display, checkout)
|
||||
6. Other (describe)
|
||||
|
||||
Enter number or description:
|
||||
```
|
||||
|
||||
### Q3: Target Platform
|
||||
|
||||
```
|
||||
What platform(s) should I consider?
|
||||
|
||||
1. Desktop only
|
||||
2. Mobile only
|
||||
3. Responsive (desktop + mobile)
|
||||
4. All platforms (desktop, tablet, mobile)
|
||||
|
||||
Enter number:
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
Create/update `.ui-design/reviews/review_state.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"review_id": "{target}_{YYYYMMDD_HHMMSS}",
|
||||
"target": "{file_path_or_component}",
|
||||
"focus_areas": ["visual", "usability", "code", "performance"],
|
||||
"context": "{purpose}",
|
||||
"platform": "{platform}",
|
||||
"status": "in_progress",
|
||||
"started_at": "ISO_TIMESTAMP",
|
||||
"issues_found": 0,
|
||||
"severity_counts": {
|
||||
"critical": 0,
|
||||
"major": 0,
|
||||
"minor": 0,
|
||||
"suggestion": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Review Execution
|
||||
|
||||
### 1. Code Analysis
|
||||
|
||||
Read and analyze the target files:
|
||||
|
||||
- Parse component structure
|
||||
- Identify styling approach (CSS, Tailwind, styled-components, etc.)
|
||||
- Detect framework (React, Vue, Svelte, etc.)
|
||||
- Note component composition patterns
|
||||
|
||||
### 2. Visual Design Review
|
||||
|
||||
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
|
||||
|
||||
### 3. Usability Review
|
||||
|
||||
Check for:
|
||||
|
||||
**Interaction Patterns:**
|
||||
- Clear clickable/tappable areas
|
||||
- Appropriate hover/focus states
|
||||
- Loading state indicators
|
||||
- Error state handling
|
||||
- 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
|
||||
- Consistent patterns
|
||||
|
||||
### 4. Code Quality Review
|
||||
|
||||
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
|
||||
- Accessibility attributes
|
||||
|
||||
### 5. Performance Review
|
||||
|
||||
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
|
||||
- Code splitting opportunities
|
||||
|
||||
## Output Format
|
||||
|
||||
Generate review report in `.ui-design/reviews/{review_id}.md`:
|
||||
|
||||
```markdown
|
||||
# Design Review: {Component/File Name}
|
||||
|
||||
**Review ID:** {review_id}
|
||||
**Reviewed:** {YYYY-MM-DD HH:MM}
|
||||
**Target:** {file_path}
|
||||
**Focus:** {focus_areas}
|
||||
|
||||
## Summary
|
||||
|
||||
{2-3 sentence overview of findings}
|
||||
|
||||
**Issues Found:** {total_count}
|
||||
- Critical: {count}
|
||||
- Major: {count}
|
||||
- Minor: {count}
|
||||
- Suggestions: {count}
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### Issue 1: {Title}
|
||||
|
||||
**Severity:** Critical
|
||||
**Location:** {file}:{line}
|
||||
**Category:** {Visual|Usability|Code|Performance}
|
||||
|
||||
**Problem:**
|
||||
{Description of the issue}
|
||||
|
||||
**Impact:**
|
||||
{Why this matters for users/maintainability}
|
||||
|
||||
**Recommendation:**
|
||||
{Specific fix suggestion}
|
||||
|
||||
**Code Example:**
|
||||
```{language}
|
||||
// Before
|
||||
{current_code}
|
||||
|
||||
// After
|
||||
{suggested_code}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Major Issues
|
||||
|
||||
### Issue 2: {Title}
|
||||
...
|
||||
|
||||
## Minor Issues
|
||||
|
||||
### Issue 3: {Title}
|
||||
...
|
||||
|
||||
## Suggestions
|
||||
|
||||
### Suggestion 1: {Title}
|
||||
...
|
||||
|
||||
## Positive Observations
|
||||
|
||||
{List things done well to reinforce good patterns}
|
||||
|
||||
- {Positive observation 1}
|
||||
- {Positive observation 2}
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. {Prioritized action 1}
|
||||
2. {Prioritized action 2}
|
||||
3. {Prioritized action 3}
|
||||
|
||||
---
|
||||
|
||||
_Generated by UI Design Review. Run `/ui-design:design-review` again after fixes._
|
||||
```
|
||||
|
||||
## Completion
|
||||
|
||||
After generating report:
|
||||
|
||||
1. Update `review_state.json`:
|
||||
- Set `status: "complete"`
|
||||
- Update issue counts
|
||||
|
||||
2. Display summary:
|
||||
|
||||
```
|
||||
Design Review Complete!
|
||||
|
||||
Target: {component/file}
|
||||
Issues Found: {total}
|
||||
- {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
|
||||
|
||||
If user selects "Start implementing fixes":
|
||||
|
||||
```
|
||||
Which issues would you like to address?
|
||||
|
||||
1. All critical issues first
|
||||
2. All issues in current file
|
||||
3. Specific issue (enter number)
|
||||
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.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- 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
|
||||
632
plugins/ui-design/commands/design-system-setup.md
Normal file
632
plugins/ui-design/commands/design-system-setup.md
Normal file
@@ -0,0 +1,632 @@
|
||||
---
|
||||
description: "Initialize a design system with tokens"
|
||||
argument-hint: "[--preset minimal|standard|comprehensive]"
|
||||
---
|
||||
|
||||
# Design System Setup
|
||||
|
||||
Initialize a design system with design tokens, component patterns, and documentation. Creates a foundation for consistent UI development.
|
||||
|
||||
## Pre-flight Checks
|
||||
|
||||
1. Check if `.ui-design/` directory exists:
|
||||
- If exists with `design-system.json`: Ask to update or reinitialize
|
||||
- If not: Create `.ui-design/` directory
|
||||
|
||||
2. Detect existing design system indicators:
|
||||
- Check for `tailwind.config.js` with custom theme
|
||||
- Check for CSS custom properties in global styles
|
||||
- Check for existing token files (tokens.json, theme.ts, etc.)
|
||||
- Check for design system packages (chakra, radix, shadcn, etc.)
|
||||
|
||||
3. Load project context:
|
||||
- Read `conductor/tech-stack.md` if exists
|
||||
- Detect styling approach (CSS, Tailwind, styled-components, etc.)
|
||||
- Detect TypeScript usage
|
||||
|
||||
4. If existing design system detected:
|
||||
```
|
||||
I detected an existing design system configuration:
|
||||
|
||||
- {detected_system}
|
||||
|
||||
Would you like to:
|
||||
1. Integrate with existing system (add missing tokens)
|
||||
2. Replace with new design system
|
||||
3. View current configuration
|
||||
4. Cancel
|
||||
|
||||
Enter number:
|
||||
```
|
||||
|
||||
## Interactive Configuration
|
||||
|
||||
**CRITICAL RULES:**
|
||||
|
||||
- Ask ONE question per turn
|
||||
- Wait for user response before proceeding
|
||||
- Build complete specification before generating files
|
||||
|
||||
### Q1: Design System Preset (if not provided)
|
||||
|
||||
```
|
||||
What level of design system do you need?
|
||||
|
||||
1. Minimal - Colors, typography, spacing only
|
||||
Best for: Small projects, rapid prototyping
|
||||
|
||||
2. Standard - Colors, typography, spacing, shadows, borders, breakpoints
|
||||
Best for: Most projects, good balance of flexibility
|
||||
|
||||
3. Comprehensive - Full token system with semantic naming, component tokens,
|
||||
animation, and documentation
|
||||
Best for: Large projects, design teams, long-term maintenance
|
||||
|
||||
Enter number:
|
||||
```
|
||||
|
||||
### Q2: Brand Colors
|
||||
|
||||
```
|
||||
Let's define your brand colors.
|
||||
|
||||
Enter your primary brand color (hex code, e.g., #3B82F6):
|
||||
```
|
||||
|
||||
After receiving primary:
|
||||
|
||||
```
|
||||
Primary color: {color}
|
||||
|
||||
Now enter your secondary/accent color (or press enter to auto-generate):
|
||||
```
|
||||
|
||||
### Q3: Color Mode Support
|
||||
|
||||
```
|
||||
What color modes should the design system support?
|
||||
|
||||
1. Light mode only
|
||||
2. Dark mode only
|
||||
3. Light and dark modes
|
||||
4. Light, dark, and system preference
|
||||
|
||||
Enter number:
|
||||
```
|
||||
|
||||
### Q4: Typography
|
||||
|
||||
```
|
||||
What font family should be used?
|
||||
|
||||
1. System fonts (fastest loading, native feel)
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', ...
|
||||
|
||||
2. Inter (modern, highly readable)
|
||||
3. Open Sans (friendly, versatile)
|
||||
4. Roboto (clean, Google standard)
|
||||
5. Custom (provide name)
|
||||
|
||||
Enter number or font name:
|
||||
```
|
||||
|
||||
### Q5: Spacing Scale
|
||||
|
||||
```
|
||||
What spacing scale philosophy?
|
||||
|
||||
1. Linear (4px base)
|
||||
4, 8, 12, 16, 20, 24, 32, 40, 48, 64
|
||||
|
||||
2. Geometric (4px base, 1.5x multiplier)
|
||||
4, 6, 9, 14, 21, 32, 48, 72
|
||||
|
||||
3. Tailwind-compatible
|
||||
0, 1, 2, 4, 6, 8, 12, 16, 20, 24, 32, 40, 48, 64
|
||||
|
||||
4. Custom (provide values)
|
||||
|
||||
Enter number:
|
||||
```
|
||||
|
||||
### Q6: Border Radius
|
||||
|
||||
```
|
||||
What corner radius style?
|
||||
|
||||
1. Sharp - 0px (no rounding)
|
||||
2. Subtle - 4px (slight rounding)
|
||||
3. Moderate - 8px (noticeable rounding)
|
||||
4. Rounded - 12px (significant rounding)
|
||||
5. Pill - 9999px for buttons, 16px for cards
|
||||
|
||||
Enter number:
|
||||
```
|
||||
|
||||
### Q7: Output Format
|
||||
|
||||
```
|
||||
How should the design tokens be output?
|
||||
|
||||
1. CSS Custom Properties (works everywhere)
|
||||
2. Tailwind config (tailwind.config.js extension)
|
||||
3. JavaScript/TypeScript module
|
||||
4. JSON tokens (Design Token Community Group format)
|
||||
5. Multiple formats (all of the above)
|
||||
|
||||
Enter number:
|
||||
```
|
||||
|
||||
### Q8: Component Guidelines (Comprehensive only)
|
||||
|
||||
If comprehensive preset selected:
|
||||
|
||||
```
|
||||
Should I generate component design guidelines?
|
||||
|
||||
These include:
|
||||
- Button variants and states
|
||||
- Form input patterns
|
||||
- Card/container patterns
|
||||
- Typography hierarchy
|
||||
- Icon usage guidelines
|
||||
|
||||
1. Yes, generate all guidelines
|
||||
2. Yes, but let me select which ones
|
||||
3. No, tokens only
|
||||
|
||||
Enter number:
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
Create `.ui-design/setup_state.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "in_progress",
|
||||
"preset": "standard",
|
||||
"colors": {
|
||||
"primary": "#3B82F6",
|
||||
"secondary": "#8B5CF6"
|
||||
},
|
||||
"color_modes": ["light", "dark"],
|
||||
"typography": {
|
||||
"family": "Inter",
|
||||
"scale": "1.25"
|
||||
},
|
||||
"spacing": "linear",
|
||||
"radius": "moderate",
|
||||
"output_formats": ["css", "tailwind"],
|
||||
"current_step": 1,
|
||||
"started_at": "ISO_TIMESTAMP"
|
||||
}
|
||||
```
|
||||
|
||||
## Token Generation
|
||||
|
||||
### 1. Generate Color Palette
|
||||
|
||||
From primary and secondary colors, generate:
|
||||
|
||||
```json
|
||||
{
|
||||
"colors": {
|
||||
"primary": {
|
||||
"50": "#EFF6FF",
|
||||
"100": "#DBEAFE",
|
||||
"200": "#BFDBFE",
|
||||
"300": "#93C5FD",
|
||||
"400": "#60A5FA",
|
||||
"500": "#3B82F6",
|
||||
"600": "#2563EB",
|
||||
"700": "#1D4ED8",
|
||||
"800": "#1E40AF",
|
||||
"900": "#1E3A8A",
|
||||
"950": "#172554"
|
||||
},
|
||||
"secondary": { ... },
|
||||
"neutral": {
|
||||
"50": "#F9FAFB",
|
||||
"100": "#F3F4F6",
|
||||
"200": "#E5E7EB",
|
||||
"300": "#D1D5DB",
|
||||
"400": "#9CA3AF",
|
||||
"500": "#6B7280",
|
||||
"600": "#4B5563",
|
||||
"700": "#374151",
|
||||
"800": "#1F2937",
|
||||
"900": "#111827",
|
||||
"950": "#030712"
|
||||
},
|
||||
"semantic": {
|
||||
"success": "#22C55E",
|
||||
"warning": "#F59E0B",
|
||||
"error": "#EF4444",
|
||||
"info": "#3B82F6"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Generate Typography Scale
|
||||
|
||||
```json
|
||||
{
|
||||
"typography": {
|
||||
"fontFamily": {
|
||||
"sans": "Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
"mono": "ui-monospace, 'Fira Code', monospace"
|
||||
},
|
||||
"fontSize": {
|
||||
"xs": "0.75rem",
|
||||
"sm": "0.875rem",
|
||||
"base": "1rem",
|
||||
"lg": "1.125rem",
|
||||
"xl": "1.25rem",
|
||||
"2xl": "1.5rem",
|
||||
"3xl": "1.875rem",
|
||||
"4xl": "2.25rem",
|
||||
"5xl": "3rem"
|
||||
},
|
||||
"fontWeight": {
|
||||
"normal": "400",
|
||||
"medium": "500",
|
||||
"semibold": "600",
|
||||
"bold": "700"
|
||||
},
|
||||
"lineHeight": {
|
||||
"tight": "1.25",
|
||||
"normal": "1.5",
|
||||
"relaxed": "1.75"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Generate Spacing Scale
|
||||
|
||||
```json
|
||||
{
|
||||
"spacing": {
|
||||
"0": "0",
|
||||
"1": "0.25rem",
|
||||
"2": "0.5rem",
|
||||
"3": "0.75rem",
|
||||
"4": "1rem",
|
||||
"5": "1.25rem",
|
||||
"6": "1.5rem",
|
||||
"8": "2rem",
|
||||
"10": "2.5rem",
|
||||
"12": "3rem",
|
||||
"16": "4rem",
|
||||
"20": "5rem",
|
||||
"24": "6rem"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Generate Additional Tokens
|
||||
|
||||
```json
|
||||
{
|
||||
"borderRadius": {
|
||||
"none": "0",
|
||||
"sm": "0.125rem",
|
||||
"base": "0.25rem",
|
||||
"md": "0.375rem",
|
||||
"lg": "0.5rem",
|
||||
"xl": "0.75rem",
|
||||
"2xl": "1rem",
|
||||
"full": "9999px"
|
||||
},
|
||||
"boxShadow": {
|
||||
"sm": "0 1px 2px 0 rgb(0 0 0 / 0.05)",
|
||||
"base": "0 1px 3px 0 rgb(0 0 0 / 0.1)",
|
||||
"md": "0 4px 6px -1px rgb(0 0 0 / 0.1)",
|
||||
"lg": "0 10px 15px -3px rgb(0 0 0 / 0.1)",
|
||||
"xl": "0 20px 25px -5px rgb(0 0 0 / 0.1)"
|
||||
},
|
||||
"breakpoints": {
|
||||
"sm": "640px",
|
||||
"md": "768px",
|
||||
"lg": "1024px",
|
||||
"xl": "1280px",
|
||||
"2xl": "1536px"
|
||||
},
|
||||
"animation": {
|
||||
"duration": {
|
||||
"fast": "150ms",
|
||||
"normal": "300ms",
|
||||
"slow": "500ms"
|
||||
},
|
||||
"easing": {
|
||||
"ease": "cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
"easeIn": "cubic-bezier(0.4, 0, 1, 1)",
|
||||
"easeOut": "cubic-bezier(0, 0, 0.2, 1)"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## File Generation
|
||||
|
||||
### Core Design System File
|
||||
|
||||
Create `.ui-design/design-system.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "{project_name} Design System",
|
||||
"version": "1.0.0",
|
||||
"created": "ISO_TIMESTAMP",
|
||||
"preset": "{preset}",
|
||||
"tokens": {
|
||||
"colors": { ... },
|
||||
"typography": { ... },
|
||||
"spacing": { ... },
|
||||
"borderRadius": { ... },
|
||||
"boxShadow": { ... },
|
||||
"breakpoints": { ... },
|
||||
"animation": { ... }
|
||||
},
|
||||
"colorModes": ["light", "dark"],
|
||||
"outputFormats": ["css", "tailwind"]
|
||||
}
|
||||
```
|
||||
|
||||
### CSS Custom Properties
|
||||
|
||||
Create `.ui-design/tokens/tokens.css`:
|
||||
|
||||
```css
|
||||
/* Design System Tokens - Generated */
|
||||
/* Do not edit directly. Regenerate with /ui-design:design-system-setup */
|
||||
|
||||
:root {
|
||||
/* Colors - Primary */
|
||||
--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-900: #111827;
|
||||
|
||||
/* Colors - Semantic */
|
||||
--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-size-xs: 0.75rem;
|
||||
--font-size-sm: 0.875rem;
|
||||
--font-size-base: 1rem;
|
||||
--font-size-lg: 1.125rem;
|
||||
--font-size-xl: 1.25rem;
|
||||
--font-size-2xl: 1.5rem;
|
||||
|
||||
/* Spacing */
|
||||
--spacing-1: 0.25rem;
|
||||
--spacing-2: 0.5rem;
|
||||
--spacing-4: 1rem;
|
||||
--spacing-6: 1.5rem;
|
||||
--spacing-8: 2rem;
|
||||
|
||||
/* Border Radius */
|
||||
--radius-sm: 0.125rem;
|
||||
--radius-base: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
--shadow-base: 0 1px 3px 0 rgb(0 0 0 / 0.1);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
|
||||
/* Animation */
|
||||
--duration-fast: 150ms;
|
||||
--duration-normal: 300ms;
|
||||
--ease-default: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Dark Mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-neutral-50: #111827;
|
||||
--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;
|
||||
}
|
||||
```
|
||||
|
||||
### Tailwind Config Extension
|
||||
|
||||
Create `.ui-design/tokens/tailwind.config.js`:
|
||||
|
||||
```javascript
|
||||
// Design System Tailwind Extension
|
||||
// Import and spread in your tailwind.config.js
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
theme: {
|
||||
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',
|
||||
},
|
||||
// ... other colors
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', '-apple-system', 'BlinkMacSystemFont', 'sans-serif'],
|
||||
mono: ['ui-monospace', 'Fira Code', 'monospace'],
|
||||
},
|
||||
// ... other tokens
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### TypeScript Module
|
||||
|
||||
Create `.ui-design/tokens/tokens.ts`:
|
||||
|
||||
```typescript
|
||||
// Design System Tokens - Generated
|
||||
// Do not edit directly.
|
||||
|
||||
export const colors = {
|
||||
primary: {
|
||||
50: '#EFF6FF',
|
||||
// ... full palette
|
||||
},
|
||||
// ... other color groups
|
||||
} as const;
|
||||
|
||||
export const typography = {
|
||||
fontFamily: {
|
||||
sans: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif',
|
||||
mono: "ui-monospace, 'Fira Code', monospace",
|
||||
},
|
||||
fontSize: {
|
||||
xs: '0.75rem',
|
||||
// ... full scale
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const spacing = {
|
||||
1: '0.25rem',
|
||||
// ... full scale
|
||||
} as const;
|
||||
|
||||
// Type exports for TypeScript consumers
|
||||
export type ColorToken = keyof typeof colors;
|
||||
export type SpacingToken = keyof typeof spacing;
|
||||
```
|
||||
|
||||
## Documentation Generation (Comprehensive preset)
|
||||
|
||||
Create `.ui-design/docs/design-system.md`:
|
||||
|
||||
```markdown
|
||||
# Design System Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
This design system provides the foundation for consistent UI development.
|
||||
|
||||
## Colors
|
||||
|
||||
### Primary Palette
|
||||
| Token | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| primary-500 | #3B82F6 | Primary actions, links |
|
||||
| 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 |
|
||||
|
||||
## 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 |
|
||||
|
||||
## Spacing
|
||||
|
||||
Use spacing tokens for consistent margins and padding:
|
||||
- `spacing-1` (4px): Tight spacing
|
||||
- `spacing-2` (8px): Compact spacing
|
||||
- `spacing-4` (16px): Default spacing
|
||||
- `spacing-6` (24px): Comfortable spacing
|
||||
- `spacing-8` (32px): Loose spacing
|
||||
|
||||
## Usage
|
||||
|
||||
### CSS Custom Properties
|
||||
```css
|
||||
.button {
|
||||
background: var(--color-primary-500);
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
```
|
||||
|
||||
### Tailwind
|
||||
```html
|
||||
<button class="bg-primary-500 px-4 py-2 rounded-md">
|
||||
Click me
|
||||
</button>
|
||||
```
|
||||
```
|
||||
|
||||
## Completion
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
- If conflicting config detected: Offer merge strategies
|
||||
- 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
|
||||
410
plugins/ui-design/skills/accessibility-compliance/SKILL.md
Normal file
410
plugins/ui-design/skills/accessibility-compliance/SKILL.md
Normal file
@@ -0,0 +1,410 @@
|
||||
---
|
||||
name: accessibility-compliance
|
||||
description: Implement WCAG 2.2 compliant interfaces with mobile accessibility, inclusive design patterns, and assistive technology support. Use when auditing accessibility, implementing ARIA patterns, building for screen readers, or ensuring inclusive user experiences.
|
||||
---
|
||||
|
||||
# Accessibility Compliance
|
||||
|
||||
Master accessibility implementation to create inclusive experiences that work for everyone, including users with disabilities.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
- Implementing WCAG 2.2 Level AA or AAA compliance
|
||||
- Building screen reader accessible interfaces
|
||||
- Adding keyboard navigation to interactive components
|
||||
- Implementing focus management and focus trapping
|
||||
- Creating accessible forms with proper labeling
|
||||
- Supporting reduced motion and high contrast preferences
|
||||
- Building mobile accessibility features (iOS VoiceOver, Android TalkBack)
|
||||
- Conducting accessibility audits and fixing violations
|
||||
|
||||
## 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
|
||||
- Dynamic Type support
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### 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 |
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### Pattern 1: Accessible Button
|
||||
|
||||
```tsx
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary';
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
function AccessibleButton({
|
||||
children,
|
||||
variant = 'primary',
|
||||
isLoading = false,
|
||||
disabled,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
// Disable when loading
|
||||
disabled={disabled || isLoading}
|
||||
// Announce loading state to screen readers
|
||||
aria-busy={isLoading}
|
||||
// Describe the button's current state
|
||||
aria-disabled={disabled || isLoading}
|
||||
className={cn(
|
||||
// Visible focus ring
|
||||
'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'
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<span className="sr-only">Loading</span>
|
||||
<Spinner aria-hidden="true" />
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Accessible Modal Dialog
|
||||
|
||||
```tsx
|
||||
import * as React from 'react';
|
||||
import { FocusTrap } from '@headlessui/react';
|
||||
|
||||
interface DialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function AccessibleDialog({ isOpen, onClose, title, children }: DialogProps) {
|
||||
const titleId = React.useId();
|
||||
const descriptionId = React.useId();
|
||||
|
||||
// Close on Escape key
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
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';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={descriptionId}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50"
|
||||
aria-hidden="true"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Focus trap container */}
|
||||
<FocusTrap>
|
||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||
<div className="bg-background rounded-lg shadow-lg max-w-md w-full p-6">
|
||||
<h2 id={titleId} className="text-lg font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
<div id={descriptionId}>{children}</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4"
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</FocusTrap>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: Accessible Form
|
||||
|
||||
```tsx
|
||||
function AccessibleForm() {
|
||||
const [errors, setErrors] = React.useState<Record<string, string>>({});
|
||||
|
||||
return (
|
||||
<form aria-describedby="form-errors" noValidate>
|
||||
{/* Error summary for screen readers */}
|
||||
{Object.keys(errors).length > 0 && (
|
||||
<div
|
||||
id="form-errors"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="bg-destructive/10 border border-destructive p-4 rounded-md mb-4"
|
||||
>
|
||||
<h2 className="font-semibold text-destructive">
|
||||
Please fix the following errors:
|
||||
</h2>
|
||||
<ul className="list-disc list-inside mt-2">
|
||||
{Object.entries(errors).map(([field, message]) => (
|
||||
<li key={field}>
|
||||
<a href={`#${field}`} className="underline">
|
||||
{message}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Required field with error */}
|
||||
<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 className="sr-only">(required)</span>
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
aria-required="true"
|
||||
aria-invalid={!!errors.email}
|
||||
aria-describedby={errors.email ? 'email-error' : 'email-hint'}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 border rounded-md',
|
||||
errors.email && 'border-destructive'
|
||||
)}
|
||||
/>
|
||||
{errors.email ? (
|
||||
<p id="email-error" className="text-sm text-destructive" role="alert">
|
||||
{errors.email}
|
||||
</p>
|
||||
) : (
|
||||
<p id="email-hint" className="text-sm text-muted-foreground">
|
||||
We'll never share your email.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button type="submit" className="mt-4">
|
||||
Submit
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 4: Skip Navigation Link
|
||||
|
||||
```tsx
|
||||
function SkipLink() {
|
||||
return (
|
||||
<a
|
||||
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'
|
||||
)}
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
// In layout
|
||||
function Layout({ children }) {
|
||||
return (
|
||||
<>
|
||||
<SkipLink />
|
||||
<header>...</header>
|
||||
<nav aria-label="Main navigation">...</nav>
|
||||
<main id="main-content" tabIndex={-1}>
|
||||
{children}
|
||||
</main>
|
||||
<footer>...</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 5: Live Region for Announcements
|
||||
|
||||
```tsx
|
||||
function useAnnounce() {
|
||||
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 Announcer = () => (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="sr-only"
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
|
||||
return { announce, Announcer };
|
||||
}
|
||||
|
||||
// Usage
|
||||
function SearchResults({ results, isLoading }) {
|
||||
const { announce, Announcer } = useAnnounce();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isLoading && results) {
|
||||
announce(`${results.length} results found`);
|
||||
}
|
||||
}, [results, isLoading, announce]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Announcer />
|
||||
<ul>{/* results */}</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Color Contrast Requirements
|
||||
|
||||
```typescript
|
||||
// Contrast ratio utilities
|
||||
function getContrastRatio(foreground: string, background: string): number {
|
||||
const fgLuminance = getLuminance(foreground);
|
||||
const bgLuminance = getLuminance(background);
|
||||
const lighter = Math.max(fgLuminance, bgLuminance);
|
||||
const darker = Math.min(fgLuminance, bgLuminance);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
// WCAG requirements
|
||||
const CONTRAST_REQUIREMENTS = {
|
||||
// Normal text (<18pt or <14pt bold)
|
||||
normalText: {
|
||||
AA: 4.5,
|
||||
AAA: 7,
|
||||
},
|
||||
// Large text (>=18pt or >=14pt bold)
|
||||
largeText: {
|
||||
AA: 3,
|
||||
AAA: 4.5,
|
||||
},
|
||||
// UI components and graphics
|
||||
uiComponents: {
|
||||
AA: 3,
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use Semantic HTML**: Prefer native elements over ARIA when possible
|
||||
2. **Test with Real Users**: Include people with disabilities in user testing
|
||||
3. **Keyboard First**: Design interactions to work without a mouse
|
||||
4. **Don't Disable Focus Styles**: Style them, don't remove them
|
||||
5. **Provide Text Alternatives**: All non-text content needs descriptions
|
||||
6. **Support Zoom**: Content should work at 200% zoom
|
||||
7. **Announce Changes**: Use live regions for dynamic content
|
||||
8. **Respect Preferences**: Honor prefers-reduced-motion and prefers-contrast
|
||||
|
||||
## Common Issues
|
||||
|
||||
- **Missing alt text**: Images without descriptions
|
||||
- **Poor color contrast**: Text hard to read against background
|
||||
- **Keyboard traps**: Focus stuck in component
|
||||
- **Missing labels**: Form inputs without associated labels
|
||||
- **Auto-playing media**: Content that plays without user initiation
|
||||
- **Inaccessible custom controls**: Recreating native functionality poorly
|
||||
- **Missing skip links**: No way to bypass repetitive content
|
||||
- **Focus order issues**: Tab order doesn't match visual order
|
||||
|
||||
## Testing Tools
|
||||
|
||||
- **Automated**: axe DevTools, WAVE, Lighthouse
|
||||
- **Manual**: VoiceOver (macOS/iOS), NVDA/JAWS (Windows), TalkBack (Android)
|
||||
- **Simulators**: NoCoffee (vision), Silktide (various disabilities)
|
||||
|
||||
## Resources
|
||||
|
||||
- [WCAG 2.2 Guidelines](https://www.w3.org/WAI/WCAG22/quickref/)
|
||||
- [WAI-ARIA Authoring Practices](https://www.w3.org/WAI/ARIA/apg/)
|
||||
- [A11y Project Checklist](https://www.a11yproject.com/checklist/)
|
||||
- [Inclusive Components](https://inclusive-components.design/)
|
||||
- [Deque University](https://dequeuniversity.com/)
|
||||
@@ -0,0 +1,567 @@
|
||||
# ARIA Patterns and Best Practices
|
||||
|
||||
## Overview
|
||||
|
||||
ARIA (Accessible Rich Internet Applications) provides attributes to enhance accessibility when native HTML semantics are insufficient. The first rule of ARIA is: don't use ARIA if native HTML can do the job.
|
||||
|
||||
## ARIA Fundamentals
|
||||
|
||||
### Roles
|
||||
|
||||
Roles define what an element is or does.
|
||||
|
||||
```tsx
|
||||
// Widget roles
|
||||
<div role="button">Click me</div>
|
||||
<div role="checkbox" aria-checked="true">Option</div>
|
||||
<div role="slider" aria-valuenow="50">Volume</div>
|
||||
|
||||
// Landmark roles (prefer semantic HTML)
|
||||
<div role="main">...</div> // Better: <main>
|
||||
<div role="navigation">...</div> // Better: <nav>
|
||||
<div role="banner">...</div> // Better: <header>
|
||||
|
||||
// Document structure roles
|
||||
<div role="region" aria-label="Featured">...</div>
|
||||
<div role="group" aria-label="Formatting options">...</div>
|
||||
```
|
||||
|
||||
### States and Properties
|
||||
|
||||
States indicate current conditions; properties describe relationships.
|
||||
|
||||
```tsx
|
||||
// States (can change)
|
||||
aria-checked="true|false|mixed"
|
||||
aria-disabled="true|false"
|
||||
aria-expanded="true|false"
|
||||
aria-hidden="true|false"
|
||||
aria-pressed="true|false"
|
||||
aria-selected="true|false"
|
||||
|
||||
// Properties (usually static)
|
||||
aria-label="Accessible name"
|
||||
aria-labelledby="id-of-label"
|
||||
aria-describedby="id-of-description"
|
||||
aria-controls="id-of-controlled-element"
|
||||
aria-owns="id-of-owned-element"
|
||||
aria-live="polite|assertive|off"
|
||||
```
|
||||
|
||||
## Common ARIA Patterns
|
||||
|
||||
### Accordion
|
||||
|
||||
```tsx
|
||||
function Accordion({ items }) {
|
||||
const [openIndex, setOpenIndex] = useState(-1);
|
||||
|
||||
return (
|
||||
<div className="accordion">
|
||||
{items.map((item, index) => {
|
||||
const isOpen = openIndex === index;
|
||||
const headingId = `accordion-heading-${index}`;
|
||||
const panelId = `accordion-panel-${index}`;
|
||||
|
||||
return (
|
||||
<div key={index}>
|
||||
<h3>
|
||||
<button
|
||||
id={headingId}
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={panelId}
|
||||
onClick={() => setOpenIndex(isOpen ? -1 : index)}
|
||||
>
|
||||
{item.title}
|
||||
<span aria-hidden="true">{isOpen ? '−' : '+'}</span>
|
||||
</button>
|
||||
</h3>
|
||||
<div
|
||||
id={panelId}
|
||||
role="region"
|
||||
aria-labelledby={headingId}
|
||||
hidden={!isOpen}
|
||||
>
|
||||
{item.content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Tabs
|
||||
|
||||
```tsx
|
||||
function Tabs({ tabs }) {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const tabListRef = useRef(null);
|
||||
|
||||
const handleKeyDown = (e, index) => {
|
||||
let newIndex = index;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
newIndex = (index + 1) % tabs.length;
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
newIndex = (index - 1 + tabs.length) % tabs.length;
|
||||
break;
|
||||
case 'Home':
|
||||
newIndex = 0;
|
||||
break;
|
||||
case 'End':
|
||||
newIndex = tabs.length - 1;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
setActiveIndex(newIndex);
|
||||
tabListRef.current?.children[newIndex]?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div role="tablist" ref={tabListRef} aria-label="Content tabs">
|
||||
{tabs.map((tab, index) => (
|
||||
<button
|
||||
key={index}
|
||||
role="tab"
|
||||
id={`tab-${index}`}
|
||||
aria-selected={index === activeIndex}
|
||||
aria-controls={`panel-${index}`}
|
||||
tabIndex={index === activeIndex ? 0 : -1}
|
||||
onClick={() => setActiveIndex(index)}
|
||||
onKeyDown={(e) => handleKeyDown(e, index)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tabs.map((tab, index) => (
|
||||
<div
|
||||
key={index}
|
||||
role="tabpanel"
|
||||
id={`panel-${index}`}
|
||||
aria-labelledby={`tab-${index}`}
|
||||
hidden={index !== activeIndex}
|
||||
tabIndex={0}
|
||||
>
|
||||
{tab.content}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Menu Button
|
||||
|
||||
```tsx
|
||||
function MenuButton({ label, items }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const buttonRef = useRef(null);
|
||||
const menuRef = useRef(null);
|
||||
const menuId = useId();
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
setActiveIndex(0);
|
||||
} else {
|
||||
setActiveIndex((prev) => Math.min(prev + 1, items.length - 1));
|
||||
}
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => Math.max(prev - 1, 0));
|
||||
break;
|
||||
case 'Escape':
|
||||
setIsOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
break;
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
if (isOpen && activeIndex >= 0) {
|
||||
e.preventDefault();
|
||||
items[activeIndex].onClick();
|
||||
setIsOpen(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Focus management
|
||||
useEffect(() => {
|
||||
if (isOpen && activeIndex >= 0) {
|
||||
menuRef.current?.children[activeIndex]?.focus();
|
||||
}
|
||||
}, [isOpen, activeIndex]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={menuId}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<ul
|
||||
ref={menuRef}
|
||||
id={menuId}
|
||||
role="menu"
|
||||
aria-label={label}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<li
|
||||
key={index}
|
||||
role="menuitem"
|
||||
tabIndex={-1}
|
||||
onClick={() => {
|
||||
item.onClick();
|
||||
setIsOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Combobox (Autocomplete)
|
||||
|
||||
```tsx
|
||||
function Combobox({ options, onSelect, placeholder }) {
|
||||
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())
|
||||
);
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setIsOpen(true);
|
||||
setActiveIndex((prev) =>
|
||||
Math.min(prev + 1, filteredOptions.length - 1)
|
||||
);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => Math.max(prev - 1, 0));
|
||||
break;
|
||||
case 'Enter':
|
||||
if (activeIndex >= 0) {
|
||||
e.preventDefault();
|
||||
selectOption(filteredOptions[activeIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
setIsOpen(false);
|
||||
setActiveIndex(-1);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const selectOption = (option) => {
|
||||
setInputValue(option);
|
||||
onSelect(option);
|
||||
setIsOpen(false);
|
||||
setActiveIndex(-1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
role="combobox"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={listboxId}
|
||||
aria-activedescendant={
|
||||
activeIndex >= 0 ? `option-${activeIndex}` : undefined
|
||||
}
|
||||
aria-autocomplete="list"
|
||||
value={inputValue}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => {
|
||||
setInputValue(e.target.value);
|
||||
setIsOpen(true);
|
||||
setActiveIndex(-1);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onBlur={() => setTimeout(() => setIsOpen(false), 200)}
|
||||
/>
|
||||
|
||||
{isOpen && filteredOptions.length > 0 && (
|
||||
<ul id={listboxId} role="listbox">
|
||||
{filteredOptions.map((option, index) => (
|
||||
<li
|
||||
key={option}
|
||||
id={`option-${index}`}
|
||||
role="option"
|
||||
aria-selected={index === activeIndex}
|
||||
onClick={() => selectOption(option)}
|
||||
onMouseEnter={() => setActiveIndex(index)}
|
||||
>
|
||||
{option}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Alert Dialog
|
||||
|
||||
```tsx
|
||||
function AlertDialog({ isOpen, onConfirm, onCancel, title, message }) {
|
||||
const confirmRef = useRef(null);
|
||||
const dialogId = useId();
|
||||
const titleId = `${dialogId}-title`;
|
||||
const descId = `${dialogId}-desc`;
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
confirmRef.current?.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<FocusTrap>
|
||||
<div
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={descId}
|
||||
>
|
||||
<div className="backdrop" onClick={onCancel} />
|
||||
|
||||
<div className="dialog">
|
||||
<h2 id={titleId}>{title}</h2>
|
||||
<p id={descId}>{message}</p>
|
||||
|
||||
<div className="actions">
|
||||
<button onClick={onCancel}>Cancel</button>
|
||||
<button ref={confirmRef} onClick={onConfirm}>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FocusTrap>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Toolbar
|
||||
|
||||
```tsx
|
||||
function Toolbar({ items }) {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const toolbarRef = useRef(null);
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
let newIndex = activeIndex;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
newIndex = (activeIndex + 1) % items.length;
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
newIndex = (activeIndex - 1 + items.length) % items.length;
|
||||
break;
|
||||
case 'Home':
|
||||
newIndex = 0;
|
||||
break;
|
||||
case 'End':
|
||||
newIndex = items.length - 1;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
setActiveIndex(newIndex);
|
||||
toolbarRef.current?.querySelectorAll('button')[newIndex]?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
role="toolbar"
|
||||
aria-label="Text formatting"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
tabIndex={index === activeIndex ? 0 : -1}
|
||||
aria-pressed={item.isActive}
|
||||
aria-label={item.label}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
{item.icon}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Live Regions
|
||||
|
||||
### Polite Announcements
|
||||
|
||||
```tsx
|
||||
// Status messages that don't interrupt
|
||||
function SearchStatus({ count, query }) {
|
||||
return (
|
||||
<div role="status" aria-live="polite" aria-atomic="true">
|
||||
{count} results found for "{query}"
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Progress indicator
|
||||
function LoadingStatus({ progress }) {
|
||||
return (
|
||||
<div role="status" aria-live="polite">
|
||||
Loading: {progress}% complete
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Assertive Announcements
|
||||
|
||||
```tsx
|
||||
// Important errors that should interrupt
|
||||
function ErrorAlert({ message }) {
|
||||
return (
|
||||
<div role="alert" aria-live="assertive">
|
||||
Error: {message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Form validation summary
|
||||
function ValidationSummary({ errors }) {
|
||||
if (errors.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div role="alert" aria-live="assertive">
|
||||
<h2>Please fix the following errors:</h2>
|
||||
<ul>
|
||||
{errors.map((error, index) => (
|
||||
<li key={index}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Log Region
|
||||
|
||||
```tsx
|
||||
// Chat messages or activity log
|
||||
function ChatLog({ messages }) {
|
||||
return (
|
||||
<div role="log" aria-live="polite" aria-relevant="additions">
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id}>
|
||||
<span className="author">{msg.author}:</span>
|
||||
<span className="text">{msg.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Common Mistakes to Avoid
|
||||
|
||||
### 1. Redundant ARIA
|
||||
|
||||
```tsx
|
||||
// Bad: role="button" on a button
|
||||
<button role="button">Click me</button>
|
||||
|
||||
// Good: just use button
|
||||
<button>Click me</button>
|
||||
|
||||
// Bad: aria-label duplicating visible text
|
||||
<button aria-label="Submit form">Submit form</button>
|
||||
|
||||
// Good: just use visible text
|
||||
<button>Submit form</button>
|
||||
```
|
||||
|
||||
### 2. Invalid ARIA
|
||||
|
||||
```tsx
|
||||
// Bad: aria-selected on non-selectable element
|
||||
<div aria-selected="true">Item</div>
|
||||
|
||||
// Good: use with proper role
|
||||
<div role="option" aria-selected="true">Item</div>
|
||||
|
||||
// Bad: aria-expanded without control relationship
|
||||
<button aria-expanded="true">Menu</button>
|
||||
<div>Menu content</div>
|
||||
|
||||
// Good: with aria-controls
|
||||
<button aria-expanded="true" aria-controls="menu">Menu</button>
|
||||
<div id="menu">Menu content</div>
|
||||
```
|
||||
|
||||
### 3. Hidden Content Still Announced
|
||||
|
||||
```tsx
|
||||
// Bad: visually hidden but still in accessibility tree
|
||||
<div style={{ display: 'none' }}>Hidden content</div>
|
||||
|
||||
// Good: properly hidden
|
||||
<div style={{ display: 'none' }} aria-hidden="true">Hidden content</div>
|
||||
|
||||
// Or just use display: none (implicitly hidden)
|
||||
<div hidden>Hidden content</div>
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [WAI-ARIA Authoring Practices](https://www.w3.org/WAI/ARIA/apg/)
|
||||
- [ARIA in HTML](https://www.w3.org/TR/html-aria/)
|
||||
- [Using ARIA](https://www.w3.org/TR/using-aria/)
|
||||
@@ -0,0 +1,539 @@
|
||||
# Mobile Accessibility
|
||||
|
||||
## Overview
|
||||
|
||||
Mobile accessibility ensures apps work for users with disabilities on iOS and Android devices. This includes support for screen readers (VoiceOver, TalkBack), motor impairments, and various visual disabilities.
|
||||
|
||||
## Touch Target Sizing
|
||||
|
||||
### Minimum Sizes
|
||||
|
||||
```css
|
||||
/* WCAG 2.2 Level AA: 24x24px minimum */
|
||||
.interactive-element {
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
/* WCAG 2.2 Level AAA / Apple HIG / Material Design: 44x44dp */
|
||||
.touch-target {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Android Material Design: 48x48dp recommended */
|
||||
.android-touch-target {
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
}
|
||||
```
|
||||
|
||||
### Touch Target Spacing
|
||||
|
||||
```tsx
|
||||
// Ensure adequate spacing between touch targets
|
||||
function ButtonGroup({ buttons }) {
|
||||
return (
|
||||
<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"
|
||||
>
|
||||
{btn.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Expanding hit area without changing visual size
|
||||
function IconButton({ icon, label, onClick }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
className="relative p-3" // Creates 44x44 touch area
|
||||
>
|
||||
<span className="block w-5 h-5">{icon}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## iOS VoiceOver
|
||||
|
||||
### React Native Accessibility Props
|
||||
|
||||
```tsx
|
||||
import { View, Text, TouchableOpacity, AccessibilityInfo } from 'react-native';
|
||||
|
||||
// Basic accessible button
|
||||
function AccessibleButton({ onPress, title, hint }) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
accessible={true}
|
||||
accessibilityLabel={title}
|
||||
accessibilityHint={hint}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Text>{title}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
// Complex component with grouped content
|
||||
function ProductCard({ product }) {
|
||||
return (
|
||||
<View
|
||||
accessible={true}
|
||||
accessibilityLabel={`${product.name}, ${product.price}, ${product.rating} stars`}
|
||||
accessibilityRole="button"
|
||||
accessibilityActions={[
|
||||
{ name: 'activate', label: 'View details' },
|
||||
{ name: 'addToCart', label: 'Add to cart' },
|
||||
]}
|
||||
onAccessibilityAction={(event) => {
|
||||
switch (event.nativeEvent.actionName) {
|
||||
case 'addToCart':
|
||||
addToCart(product);
|
||||
break;
|
||||
case 'activate':
|
||||
viewDetails(product);
|
||||
break;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Image source={product.image} accessibilityIgnoresInvertColors />
|
||||
<Text>{product.name}</Text>
|
||||
<Text>{product.price}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Announcing dynamic changes
|
||||
function Counter() {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
const increment = () => {
|
||||
setCount((prev) => prev + 1);
|
||||
AccessibilityInfo.announceForAccessibility(`Count is now ${count + 1}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Text accessibilityRole="text" accessibilityLiveRegion="polite">
|
||||
Count: {count}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={increment}
|
||||
accessibilityLabel="Increment"
|
||||
accessibilityHint="Increases the counter by one"
|
||||
>
|
||||
<Text>+</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### SwiftUI Accessibility
|
||||
|
||||
```swift
|
||||
import SwiftUI
|
||||
|
||||
struct AccessibleButton: View {
|
||||
let title: String
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text(title)
|
||||
}
|
||||
.accessibilityLabel(title)
|
||||
.accessibilityHint("Double tap to activate")
|
||||
.accessibilityAddTraits(.isButton)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProductCard: View {
|
||||
let product: Product
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
AsyncImage(url: product.imageURL)
|
||||
.accessibilityHidden(true) // Image is decorative
|
||||
|
||||
Text(product.name)
|
||||
Text(product.price.formatted(.currency(code: "USD")))
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(product.name), \(product.price.formatted(.currency(code: "USD")))")
|
||||
.accessibilityHint("Double tap to view details")
|
||||
.accessibilityAction(named: "Add to cart") {
|
||||
addToCart(product)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Custom accessibility rotor
|
||||
struct DocumentView: View {
|
||||
let sections: [Section]
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
ForEach(sections) { section in
|
||||
Text(section.title)
|
||||
.font(.headline)
|
||||
.accessibilityAddTraits(.isHeader)
|
||||
Text(section.content)
|
||||
}
|
||||
}
|
||||
.accessibilityRotor("Headings") {
|
||||
ForEach(sections) { section in
|
||||
AccessibilityRotorEntry(section.title, id: section.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Android TalkBack
|
||||
|
||||
### Jetpack Compose Accessibility
|
||||
|
||||
```kotlin
|
||||
import androidx.compose.ui.semantics.*
|
||||
|
||||
@Composable
|
||||
fun AccessibleButton(
|
||||
onClick: () -> Unit,
|
||||
text: String,
|
||||
enabled: Boolean = true
|
||||
) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription = text
|
||||
role = Role.Button
|
||||
if (!enabled) {
|
||||
disabled()
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(text)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProductCard(product: Product) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.semantics(mergeDescendants = true) {
|
||||
contentDescription = "${product.name}, ${product.formattedPrice}"
|
||||
customActions = listOf(
|
||||
CustomAccessibilityAction("Add to cart") {
|
||||
addToCart(product)
|
||||
true
|
||||
}
|
||||
)
|
||||
}
|
||||
.clickable { navigateToDetails(product) }
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(product.imageRes),
|
||||
contentDescription = null, // Decorative
|
||||
modifier = Modifier.semantics { invisibleToUser() }
|
||||
)
|
||||
Text(product.name)
|
||||
Text(product.formattedPrice)
|
||||
}
|
||||
}
|
||||
|
||||
// Live region for dynamic content
|
||||
@Composable
|
||||
fun Counter() {
|
||||
var count by remember { mutableStateOf(0) }
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = "Count: $count",
|
||||
modifier = Modifier.semantics {
|
||||
liveRegion = LiveRegionMode.Polite
|
||||
}
|
||||
)
|
||||
Button(onClick = { count++ }) {
|
||||
Text("Increment")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Heading levels
|
||||
@Composable
|
||||
fun SectionHeader(title: String, level: Int) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.semantics {
|
||||
heading()
|
||||
// Custom heading level (not built-in)
|
||||
testTag = "heading-$level"
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Android XML Views
|
||||
|
||||
```xml
|
||||
<!-- Accessible button -->
|
||||
<Button
|
||||
android:id="@+id/submit_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="48dp"
|
||||
android:minWidth="48dp"
|
||||
android:text="@string/submit"
|
||||
android:contentDescription="@string/submit_form" />
|
||||
|
||||
<!-- Grouped content -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:importantForAccessibility="yes"
|
||||
android:focusable="true"
|
||||
android:contentDescription="@string/product_description">
|
||||
|
||||
<ImageView
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/product" />
|
||||
|
||||
<TextView
|
||||
android:text="@string/product_name"
|
||||
android:importantForAccessibility="no" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Live region -->
|
||||
<TextView
|
||||
android:id="@+id/status"
|
||||
android:accessibilityLiveRegion="polite" />
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// Kotlin accessibility
|
||||
binding.submitButton.apply {
|
||||
contentDescription = getString(R.string.submit_form)
|
||||
accessibilityDelegate = object : View.AccessibilityDelegate() {
|
||||
override fun onInitializeAccessibilityNodeInfo(
|
||||
host: View,
|
||||
info: AccessibilityNodeInfo
|
||||
) {
|
||||
super.onInitializeAccessibilityNodeInfo(host, info)
|
||||
info.addAction(
|
||||
AccessibilityNodeInfo.AccessibilityAction(
|
||||
AccessibilityNodeInfo.ACTION_CLICK,
|
||||
getString(R.string.submit_action)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Announce changes
|
||||
binding.counter.announceForAccessibility("Count updated to $count")
|
||||
```
|
||||
|
||||
## Gesture Accessibility
|
||||
|
||||
### Alternative Gestures
|
||||
|
||||
```tsx
|
||||
// React Native: Provide alternatives to complex gestures
|
||||
function SwipeableCard({ item, onDelete }) {
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
|
||||
return (
|
||||
<View
|
||||
accessible={true}
|
||||
accessibilityActions={[
|
||||
{ name: 'delete', label: 'Delete item' },
|
||||
]}
|
||||
onAccessibilityAction={(event) => {
|
||||
if (event.nativeEvent.actionName === 'delete') {
|
||||
onDelete(item);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Swipeable
|
||||
renderRightActions={() => (
|
||||
<TouchableOpacity
|
||||
onPress={() => onDelete(item)}
|
||||
accessibilityLabel="Delete"
|
||||
>
|
||||
<Text>Delete</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
>
|
||||
<Text>{item.title}</Text>
|
||||
</Swipeable>
|
||||
|
||||
{/* Alternative for screen reader users */}
|
||||
<TouchableOpacity
|
||||
accessibilityLabel={`Delete ${item.title}`}
|
||||
onPress={() => onDelete(item)}
|
||||
style={{ position: 'absolute', right: 0 }}
|
||||
>
|
||||
<Text>Delete</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Motion and Animation
|
||||
|
||||
```tsx
|
||||
// Respect reduced motion preference
|
||||
import { AccessibilityInfo } from 'react-native';
|
||||
|
||||
function AnimatedComponent() {
|
||||
const [reduceMotion, setReduceMotion] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
AccessibilityInfo.isReduceMotionEnabled().then(setReduceMotion);
|
||||
|
||||
const subscription = AccessibilityInfo.addEventListener(
|
||||
'reduceMotionChanged',
|
||||
setReduceMotion
|
||||
);
|
||||
|
||||
return () => subscription.remove();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: reduceMotion
|
||||
? []
|
||||
: [{ translateX: animatedValue }],
|
||||
opacity: reduceMotion ? 1 : animatedOpacity,
|
||||
}}
|
||||
>
|
||||
<Content />
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Dynamic Type / Text Scaling
|
||||
|
||||
### iOS Dynamic Type
|
||||
|
||||
```swift
|
||||
// SwiftUI
|
||||
Text("Hello, World!")
|
||||
.font(.body) // Automatically scales with Dynamic Type
|
||||
|
||||
Text("Fixed Size")
|
||||
.font(.system(size: 16, design: .default))
|
||||
.dynamicTypeSize(.large) // Cap at large
|
||||
|
||||
// Allow unlimited scaling
|
||||
Text("Scalable")
|
||||
.font(.body)
|
||||
.minimumScaleFactor(0.5)
|
||||
.lineLimit(nil)
|
||||
```
|
||||
|
||||
### Android Text Scaling
|
||||
|
||||
```xml
|
||||
<!-- Use sp for text sizes -->
|
||||
<TextView
|
||||
android:textSize="16sp"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<!-- In styles.xml -->
|
||||
<style name="TextAppearance.Body">
|
||||
<item name="android:textSize">16sp</item>
|
||||
<item name="android:lineHeight">24sp</item>
|
||||
</style>
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// Compose: Text automatically scales
|
||||
Text(
|
||||
text = "Hello, World!",
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
|
||||
// Limit scaling if needed
|
||||
Text(
|
||||
text = "Limited scaling",
|
||||
fontSize = 16.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
```
|
||||
|
||||
### React Native Text Scaling
|
||||
|
||||
```tsx
|
||||
import { Text, PixelRatio } from 'react-native';
|
||||
|
||||
// Allow text scaling (default)
|
||||
<Text allowFontScaling={true}>Scalable text</Text>
|
||||
|
||||
// Limit maximum scale
|
||||
<Text maxFontSizeMultiplier={1.5}>Limited scaling</Text>
|
||||
|
||||
// Disable scaling (use sparingly)
|
||||
<Text allowFontScaling={false}>Fixed size</Text>
|
||||
|
||||
// Responsive font size
|
||||
const scaledFontSize = (size: number) => {
|
||||
const scale = PixelRatio.getFontScale();
|
||||
return size * Math.min(scale, 1.5); // Cap at 1.5x
|
||||
};
|
||||
```
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
```markdown
|
||||
## VoiceOver (iOS) Testing
|
||||
- [ ] All interactive elements have labels
|
||||
- [ ] Swipe navigation covers all content in logical order
|
||||
- [ ] Custom actions available for complex interactions
|
||||
- [ ] Announcements made for dynamic content
|
||||
- [ ] Headings navigable via rotor
|
||||
- [ ] Images have appropriate descriptions or are hidden
|
||||
|
||||
## TalkBack (Android) Testing
|
||||
- [ ] Focus order is logical
|
||||
- [ ] Touch exploration works correctly
|
||||
- [ ] Custom actions available
|
||||
- [ ] Live regions announce updates
|
||||
- [ ] Headings properly marked
|
||||
- [ ] 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
|
||||
- [ ] Animations respect reduced motion
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Apple Accessibility Programming Guide](https://developer.apple.com/accessibility/)
|
||||
- [Android Accessibility Developer Guide](https://developer.android.com/guide/topics/ui/accessibility)
|
||||
- [React Native Accessibility](https://reactnative.dev/docs/accessibility)
|
||||
- [Mobile Accessibility WCAG](https://www.w3.org/TR/mobile-accessibility-mapping/)
|
||||
@@ -0,0 +1,632 @@
|
||||
# WCAG 2.2 Guidelines Reference
|
||||
|
||||
## Overview
|
||||
|
||||
The Web Content Accessibility Guidelines (WCAG) 2.2 provide recommendations for making web content more accessible. They are organized into four principles (POUR): Perceivable, Operable, Understandable, and Robust.
|
||||
|
||||
## Conformance Levels
|
||||
|
||||
- **Level A**: Minimum accessibility (must satisfy)
|
||||
- **Level AA**: Standard accessibility (should satisfy)
|
||||
- **Level AAA**: Enhanced accessibility (may satisfy)
|
||||
|
||||
Most organizations target Level AA compliance.
|
||||
|
||||
## Principle 1: Perceivable
|
||||
|
||||
Content must be presentable in ways users can perceive.
|
||||
|
||||
### 1.1 Text Alternatives
|
||||
|
||||
#### 1.1.1 Non-text Content (Level A)
|
||||
|
||||
All non-text content needs text alternatives.
|
||||
|
||||
```tsx
|
||||
// Images
|
||||
<img src="chart.png" alt="Q3 sales increased 25% compared to Q2" />
|
||||
|
||||
// Decorative images
|
||||
<img src="decorative-line.svg" alt="" role="presentation" />
|
||||
|
||||
// Complex images with long descriptions
|
||||
<figure>
|
||||
<img src="org-chart.png" alt="Organization chart" aria-describedby="org-desc" />
|
||||
<figcaption id="org-desc">
|
||||
The CEO reports to the board. Three VPs report to the CEO:
|
||||
VP Engineering, VP Sales, and VP Marketing...
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
// Icons with meaning
|
||||
<button aria-label="Delete item">
|
||||
<TrashIcon aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
// Icon buttons with visible text
|
||||
<button>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
<span>Download</span>
|
||||
</button>
|
||||
```
|
||||
|
||||
### 1.2 Time-based Media
|
||||
|
||||
#### 1.2.1 Audio-only and Video-only (Level A)
|
||||
|
||||
```tsx
|
||||
// Audio with transcript
|
||||
<audio src="podcast.mp3" controls />
|
||||
<details>
|
||||
<summary>View transcript</summary>
|
||||
<p>Full transcript text here...</p>
|
||||
</details>
|
||||
|
||||
// Video with captions
|
||||
<video controls>
|
||||
<source src="tutorial.mp4" type="video/mp4" />
|
||||
<track kind="captions" src="captions-en.vtt" srclang="en" label="English" />
|
||||
<track kind="subtitles" src="subtitles-es.vtt" srclang="es" label="Spanish" />
|
||||
</video>
|
||||
```
|
||||
|
||||
### 1.3 Adaptable
|
||||
|
||||
#### 1.3.1 Info and Relationships (Level A)
|
||||
|
||||
Structure and relationships must be programmatically determinable.
|
||||
|
||||
```tsx
|
||||
// Proper heading hierarchy
|
||||
<main>
|
||||
<h1>Page Title</h1>
|
||||
<section>
|
||||
<h2>Section Title</h2>
|
||||
<h3>Subsection</h3>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
// Data tables with headers
|
||||
<table>
|
||||
<caption>Quarterly Sales Report</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Product</th>
|
||||
<th scope="col">Q1</th>
|
||||
<th scope="col">Q2</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row">Widget A</th>
|
||||
<td>$10,000</td>
|
||||
<td>$12,000</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
// Lists for grouped content
|
||||
<nav aria-label="Main navigation">
|
||||
<ul>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/about">About</a></li>
|
||||
<li><a href="/contact">Contact</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
```
|
||||
|
||||
#### 1.3.5 Identify Input Purpose (Level AA)
|
||||
|
||||
```tsx
|
||||
// Input with autocomplete for autofill
|
||||
<form>
|
||||
<label htmlFor="name">Full Name</label>
|
||||
<input id="name" name="name" autoComplete="name" />
|
||||
|
||||
<label htmlFor="email">Email</label>
|
||||
<input id="email" name="email" type="email" autoComplete="email" />
|
||||
|
||||
<label htmlFor="phone">Phone</label>
|
||||
<input id="phone" name="phone" type="tel" autoComplete="tel" />
|
||||
|
||||
<label htmlFor="address">Street Address</label>
|
||||
<input id="address" name="address" autoComplete="street-address" />
|
||||
|
||||
<label htmlFor="cc">Credit Card Number</label>
|
||||
<input id="cc" name="cc" autoComplete="cc-number" />
|
||||
</form>
|
||||
```
|
||||
|
||||
### 1.4 Distinguishable
|
||||
|
||||
#### 1.4.1 Use of Color (Level A)
|
||||
|
||||
```tsx
|
||||
// Bad: Color only indicates error
|
||||
<input className={hasError ? 'border-red-500' : ''} />
|
||||
|
||||
// Good: Color plus icon and text
|
||||
<div>
|
||||
<input
|
||||
className={hasError ? 'border-red-500' : ''}
|
||||
aria-invalid={hasError}
|
||||
aria-describedby={hasError ? 'error-message' : undefined}
|
||||
/>
|
||||
{hasError && (
|
||||
<p id="error-message" className="text-red-500 flex items-center gap-1">
|
||||
<AlertIcon aria-hidden="true" />
|
||||
This field is required
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
|
||||
#### 1.4.3 Contrast (Minimum) (Level AA)
|
||||
|
||||
```css
|
||||
/* Minimum contrast ratios */
|
||||
/* Normal text: 4.5:1 */
|
||||
/* Large text (18pt+ or 14pt bold+): 3:1 */
|
||||
|
||||
/* Good contrast examples */
|
||||
.text-on-white {
|
||||
color: #595959; /* 7:1 ratio on white */
|
||||
}
|
||||
|
||||
.text-on-dark {
|
||||
color: #ffffff;
|
||||
background: #333333; /* 12.6:1 ratio */
|
||||
}
|
||||
|
||||
/* Link must be distinguishable from surrounding text */
|
||||
.link {
|
||||
color: #0066cc; /* 4.5:1 on white */
|
||||
text-decoration: underline; /* Additional visual cue */
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.4.11 Non-text Contrast (Level AA)
|
||||
|
||||
```css
|
||||
/* UI components need 3:1 contrast */
|
||||
.button {
|
||||
border: 2px solid #767676; /* 3:1 against white */
|
||||
background: white;
|
||||
}
|
||||
|
||||
.input {
|
||||
border: 1px solid #767676;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: 2px solid #0066cc; /* Focus indicator needs 3:1 */
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Custom checkbox */
|
||||
.checkbox {
|
||||
border: 2px solid #767676;
|
||||
}
|
||||
|
||||
.checkbox:checked {
|
||||
background: #0066cc;
|
||||
border-color: #0066cc;
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.4.12 Text Spacing (Level AA)
|
||||
|
||||
Content must not be lost when user adjusts text spacing.
|
||||
|
||||
```css
|
||||
/* Allow text spacing adjustments without breaking layout */
|
||||
.content {
|
||||
/* Use relative units */
|
||||
line-height: 1.5; /* At least 1.5x font size */
|
||||
letter-spacing: 0.12em; /* Support for 0.12em */
|
||||
word-spacing: 0.16em; /* Support for 0.16em */
|
||||
|
||||
/* Don't use fixed heights on text containers */
|
||||
min-height: auto;
|
||||
|
||||
/* Allow wrapping */
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Test with these values: */
|
||||
/* Line height: 1.5x font size */
|
||||
/* Letter spacing: 0.12em */
|
||||
/* Word spacing: 0.16em */
|
||||
/* Paragraph spacing: 2x font size */
|
||||
```
|
||||
|
||||
#### 1.4.13 Content on Hover or Focus (Level AA)
|
||||
|
||||
```tsx
|
||||
// Tooltip pattern
|
||||
function Tooltip({ content, children }) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={() => setIsVisible(true)}
|
||||
onMouseLeave={() => setIsVisible(false)}
|
||||
onFocus={() => setIsVisible(true)}
|
||||
onBlur={() => setIsVisible(false)}
|
||||
>
|
||||
{children}
|
||||
{isVisible && (
|
||||
<div
|
||||
role="tooltip"
|
||||
// Dismissible: user can close without moving pointer
|
||||
onKeyDown={(e) => e.key === 'Escape' && setIsVisible(false)}
|
||||
// Hoverable: content stays visible when pointer moves to it
|
||||
onMouseEnter={() => setIsVisible(true)}
|
||||
onMouseLeave={() => setIsVisible(false)}
|
||||
// Persistent: stays until trigger loses focus/hover
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Principle 2: Operable
|
||||
|
||||
Interface components must be operable by all users.
|
||||
|
||||
### 2.1 Keyboard Accessible
|
||||
|
||||
#### 2.1.1 Keyboard (Level A)
|
||||
|
||||
All functionality must be operable via keyboard.
|
||||
|
||||
```tsx
|
||||
// Custom interactive element
|
||||
function CustomButton({ onClick, children }) {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Better: just use a button
|
||||
function BetterButton({ onClick, children }) {
|
||||
return <button onClick={onClick}>{children}</button>;
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.1.2 No Keyboard Trap (Level A)
|
||||
|
||||
```tsx
|
||||
// Modal with proper focus management
|
||||
function Modal({ isOpen, onClose, children }) {
|
||||
const closeButtonRef = useRef(null);
|
||||
|
||||
// Return focus on close
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const previousFocus = document.activeElement;
|
||||
closeButtonRef.current?.focus();
|
||||
|
||||
return () => {
|
||||
(previousFocus as HTMLElement)?.focus();
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Allow Escape to close
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<FocusTrap>
|
||||
<div role="dialog" aria-modal="true">
|
||||
<button ref={closeButtonRef} onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
</FocusTrap>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 Navigable
|
||||
|
||||
#### 2.4.1 Bypass Blocks (Level A)
|
||||
|
||||
```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>
|
||||
|
||||
<header>...</header>
|
||||
|
||||
<nav id="nav" aria-label="Main">...</nav>
|
||||
|
||||
<main id="main" tabIndex={-1}>
|
||||
{/* Main content */}
|
||||
</main>
|
||||
</body>
|
||||
```
|
||||
|
||||
#### 2.4.4 Link Purpose (In Context) (Level A)
|
||||
|
||||
```tsx
|
||||
// Bad: Ambiguous link text
|
||||
<a href="/report">Click here</a>
|
||||
<a href="/report">Read more</a>
|
||||
|
||||
// Good: Descriptive link text
|
||||
<a href="/report">View quarterly sales report</a>
|
||||
|
||||
// Good: Context provides meaning
|
||||
<article>
|
||||
<h2>Quarterly Sales Report</h2>
|
||||
<p>Sales increased by 25% this quarter...</p>
|
||||
<a href="/report">Read full report</a>
|
||||
</article>
|
||||
|
||||
// Good: Visually hidden text for context
|
||||
<a href="/report">
|
||||
Read more
|
||||
<span className="sr-only"> about quarterly sales report</span>
|
||||
</a>
|
||||
```
|
||||
|
||||
#### 2.4.7 Focus Visible (Level AA)
|
||||
|
||||
```css
|
||||
/* Always show focus indicator */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Custom focus styles */
|
||||
.button:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--color-focus);
|
||||
}
|
||||
|
||||
/* High visibility focus for links */
|
||||
.link:focus-visible {
|
||||
outline: 3px solid var(--color-focus);
|
||||
outline-offset: 2px;
|
||||
background: var(--color-focus-bg);
|
||||
}
|
||||
```
|
||||
|
||||
### 2.5 Input Modalities (New in 2.2)
|
||||
|
||||
#### 2.5.8 Target Size (Minimum) (Level AA) - NEW
|
||||
|
||||
Interactive targets must be at least 24x24 CSS pixels.
|
||||
|
||||
```css
|
||||
/* Minimum target size */
|
||||
.interactive {
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
/* Recommended size for touch (44x44) */
|
||||
.touch-target {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Inline links are exempt if they have adequate spacing */
|
||||
.link {
|
||||
/* Inline text links don't need minimum size */
|
||||
/* but should have adequate line-height */
|
||||
line-height: 1.5;
|
||||
}
|
||||
```
|
||||
|
||||
## Principle 3: Understandable
|
||||
|
||||
Content and interface must be understandable.
|
||||
|
||||
### 3.1 Readable
|
||||
|
||||
#### 3.1.1 Language of Page (Level A)
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>...</head>
|
||||
<body>...</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
#### 3.1.2 Language of Parts (Level AA)
|
||||
|
||||
```tsx
|
||||
<p>
|
||||
The French phrase <span lang="fr">c'est la vie</span> means "that's life."
|
||||
</p>
|
||||
```
|
||||
|
||||
### 3.2 Predictable
|
||||
|
||||
#### 3.2.2 On Input (Level A)
|
||||
|
||||
Don't automatically change context on input.
|
||||
|
||||
```tsx
|
||||
// Bad: Auto-submit on selection
|
||||
<select onChange={(e) => form.submit()}>
|
||||
<option>Select country</option>
|
||||
</select>
|
||||
|
||||
// Good: Explicit submit action
|
||||
<select onChange={(e) => setCountry(e.target.value)}>
|
||||
<option>Select country</option>
|
||||
</select>
|
||||
<button type="submit">Continue</button>
|
||||
```
|
||||
|
||||
### 3.3 Input Assistance
|
||||
|
||||
#### 3.3.1 Error Identification (Level A)
|
||||
|
||||
```tsx
|
||||
function FormField({ id, label, error, ...props }) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={id}>{label}</label>
|
||||
<input
|
||||
id={id}
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? `${id}-error` : undefined}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p id={`${id}-error`} role="alert" className="text-red-600">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.3.7 Redundant Entry (Level A) - NEW
|
||||
|
||||
Don't require users to re-enter previously provided information.
|
||||
|
||||
```tsx
|
||||
// Auto-fill shipping address from billing
|
||||
function CheckoutForm() {
|
||||
const [sameAsBilling, setSameAsBilling] = useState(false);
|
||||
const [billing, setBilling] = useState({});
|
||||
const [shipping, setShipping] = useState({});
|
||||
|
||||
return (
|
||||
<form>
|
||||
<fieldset>
|
||||
<legend>Billing Address</legend>
|
||||
<AddressFields value={billing} onChange={setBilling} />
|
||||
</fieldset>
|
||||
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sameAsBilling}
|
||||
onChange={(e) => {
|
||||
setSameAsBilling(e.target.checked);
|
||||
if (e.target.checked) setShipping(billing);
|
||||
}}
|
||||
/>
|
||||
Shipping same as billing
|
||||
</label>
|
||||
|
||||
{!sameAsBilling && (
|
||||
<fieldset>
|
||||
<legend>Shipping Address</legend>
|
||||
<AddressFields value={shipping} onChange={setShipping} />
|
||||
</fieldset>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Principle 4: Robust
|
||||
|
||||
Content must be robust enough for assistive technologies.
|
||||
|
||||
### 4.1 Compatible
|
||||
|
||||
#### 4.1.2 Name, Role, Value (Level A)
|
||||
|
||||
```tsx
|
||||
// Custom components must expose name, role, and value
|
||||
function CustomCheckbox({ checked, onChange, label }) {
|
||||
return (
|
||||
<button
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
onClick={() => onChange(!checked)}
|
||||
>
|
||||
{checked ? '✓' : '○'} {label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom slider
|
||||
function CustomSlider({ value, min, max, label, onChange }) {
|
||||
return (
|
||||
<div
|
||||
role="slider"
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
aria-valuenow={value}
|
||||
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));
|
||||
}}
|
||||
>
|
||||
<div style={{ width: `${((value - min) / (max - min)) * 100}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
```markdown
|
||||
## Keyboard Testing
|
||||
- [ ] All interactive elements focusable with Tab
|
||||
- [ ] Focus order matches visual order
|
||||
- [ ] Focus indicator always visible
|
||||
- [ ] No keyboard traps
|
||||
- [ ] Escape closes modals/dropdowns
|
||||
- [ ] Enter/Space activates buttons and links
|
||||
|
||||
## Screen Reader Testing
|
||||
- [ ] All images have alt text
|
||||
- [ ] Form inputs have labels
|
||||
- [ ] Headings in logical order
|
||||
- [ ] Landmarks present (main, nav, header, footer)
|
||||
- [ ] Dynamic content announced
|
||||
- [ ] Error messages announced
|
||||
|
||||
## Visual Testing
|
||||
- [ ] Text contrast at least 4.5:1
|
||||
- [ ] UI component contrast at least 3:1
|
||||
- [ ] Works at 200% zoom
|
||||
- [ ] Content readable with text spacing
|
||||
- [ ] Focus indicators visible
|
||||
- [ ] Color not sole indicator of meaning
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [WCAG 2.2 Quick Reference](https://www.w3.org/WAI/WCAG22/quickref/)
|
||||
- [Understanding WCAG 2.2](https://www.w3.org/WAI/WCAG22/Understanding/)
|
||||
- [Techniques for WCAG 2.2](https://www.w3.org/WAI/WCAG22/Techniques/)
|
||||
318
plugins/ui-design/skills/design-system-patterns/SKILL.md
Normal file
318
plugins/ui-design/skills/design-system-patterns/SKILL.md
Normal file
@@ -0,0 +1,318 @@
|
||||
---
|
||||
name: design-system-patterns
|
||||
description: Build scalable design systems with design tokens, theming infrastructure, and component architecture patterns. Use when creating design tokens, implementing theme switching, building component libraries, or establishing design system foundations.
|
||||
---
|
||||
|
||||
# Design System Patterns
|
||||
|
||||
Master design system architecture to create consistent, maintainable, and scalable UI foundations across web and mobile applications.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
- Creating design tokens for colors, typography, spacing, and shadows
|
||||
- Implementing light/dark theme switching with CSS custom properties
|
||||
- Building multi-brand theming systems
|
||||
- Architecting component libraries with consistent APIs
|
||||
- Establishing design-to-code workflows with Figma tokens
|
||||
- Creating semantic token hierarchies (primitive, semantic, component)
|
||||
- Setting up design system documentation and guidelines
|
||||
|
||||
## 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)
|
||||
- Token naming conventions and organization
|
||||
- Multi-platform token generation (CSS, iOS, Android)
|
||||
|
||||
### 2. Theming Infrastructure
|
||||
- CSS custom properties architecture
|
||||
- Theme context providers in React
|
||||
- Dynamic theme switching
|
||||
- System preference detection (prefers-color-scheme)
|
||||
- Persistent theme storage
|
||||
- Reduced motion and high contrast modes
|
||||
|
||||
### 3. Component Architecture
|
||||
- Compound component patterns
|
||||
- Polymorphic components (as prop)
|
||||
- Variant and size systems
|
||||
- Slot-based composition
|
||||
- Headless UI patterns
|
||||
- Style props and responsive variants
|
||||
|
||||
### 4. Token Pipeline
|
||||
- Figma to code synchronization
|
||||
- Style Dictionary configuration
|
||||
- Token transformation and formatting
|
||||
- CI/CD integration for token updates
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
// Design tokens with CSS custom properties
|
||||
const tokens = {
|
||||
colors: {
|
||||
// Primitive tokens
|
||||
gray: {
|
||||
50: '#fafafa',
|
||||
100: '#f5f5f5',
|
||||
900: '#171717',
|
||||
},
|
||||
blue: {
|
||||
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)',
|
||||
},
|
||||
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)',
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### Pattern 1: Token Hierarchy
|
||||
|
||||
```css
|
||||
/* Layer 1: Primitive tokens (raw values) */
|
||||
:root {
|
||||
--color-blue-500: #3b82f6;
|
||||
--color-blue-600: #2563eb;
|
||||
--color-gray-50: #fafafa;
|
||||
--color-gray-900: #171717;
|
||||
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-4: 1rem;
|
||||
|
||||
--font-size-sm: 0.875rem;
|
||||
--font-size-base: 1rem;
|
||||
--font-size-lg: 1.125rem;
|
||||
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 1rem;
|
||||
}
|
||||
|
||||
/* Layer 2: Semantic tokens (meaning) */
|
||||
:root {
|
||||
--text-primary: var(--color-gray-900);
|
||||
--text-secondary: var(--color-gray-600);
|
||||
--surface-default: white;
|
||||
--interactive-primary: var(--color-blue-500);
|
||||
--interactive-primary-hover: var(--color-blue-600);
|
||||
}
|
||||
|
||||
/* Layer 3: Component tokens (specific usage) */
|
||||
:root {
|
||||
--button-bg: var(--interactive-primary);
|
||||
--button-bg-hover: var(--interactive-primary-hover);
|
||||
--button-text: white;
|
||||
--button-radius: var(--radius-md);
|
||||
--button-padding-x: var(--space-4);
|
||||
--button-padding-y: var(--space-2);
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Theme Switching with React
|
||||
|
||||
```tsx
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme;
|
||||
resolvedTheme: 'light' | 'dark';
|
||||
setTheme: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
return 'system';
|
||||
});
|
||||
|
||||
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');
|
||||
};
|
||||
|
||||
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);
|
||||
} else {
|
||||
applyTheme(theme === 'dark');
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) throw new Error('useTheme must be used within ThemeProvider');
|
||||
return context;
|
||||
};
|
||||
```
|
||||
|
||||
### Pattern 3: Variant System with CVA
|
||||
|
||||
```tsx
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
// Base styles
|
||||
'inline-flex items-center justify-center rounded-md 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',
|
||||
},
|
||||
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',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'md',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
interface ButtonProps
|
||||
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} />
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 4: Style Dictionary Configuration
|
||||
|
||||
```javascript
|
||||
// style-dictionary.config.js
|
||||
module.exports = {
|
||||
source: ['tokens/**/*.json'],
|
||||
platforms: {
|
||||
css: {
|
||||
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',
|
||||
}],
|
||||
},
|
||||
ios: {
|
||||
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' } },
|
||||
}],
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Name Tokens by Purpose**: Use semantic names (text-primary) not visual descriptions (dark-gray)
|
||||
2. **Maintain Token Hierarchy**: Primitives > Semantic > Component tokens
|
||||
3. **Document Token Usage**: Include usage guidelines with token definitions
|
||||
4. **Version Tokens**: Treat token changes as API changes with semver
|
||||
5. **Test Theme Combinations**: Verify all themes work with all components
|
||||
6. **Automate Token Pipeline**: CI/CD for Figma-to-code synchronization
|
||||
7. **Provide Migration Paths**: Deprecate tokens gradually with clear alternatives
|
||||
|
||||
## Common Issues
|
||||
|
||||
- **Token Sprawl**: Too many tokens without clear hierarchy
|
||||
- **Inconsistent Naming**: Mixed conventions (camelCase vs kebab-case)
|
||||
- **Missing Dark Mode**: Tokens that don't adapt to theme changes
|
||||
- **Hardcoded Values**: Using raw values instead of tokens
|
||||
- **Circular References**: Tokens referencing each other in loops
|
||||
- **Platform Gaps**: Tokens missing for some platforms (web but not mobile)
|
||||
|
||||
## Resources
|
||||
|
||||
- [Style Dictionary Documentation](https://amzn.github.io/style-dictionary/)
|
||||
- [Tokens Studio for Figma](https://tokens.studio/)
|
||||
- [Design Tokens W3C Spec](https://design-tokens.github.io/community-group/format/)
|
||||
- [Radix UI Themes](https://www.radix-ui.com/themes)
|
||||
- [shadcn/ui Theming](https://ui.shadcn.com/docs/theming)
|
||||
@@ -0,0 +1,603 @@
|
||||
# Component Architecture Patterns
|
||||
|
||||
## Overview
|
||||
|
||||
Well-architected components are reusable, composable, and maintainable. This guide covers patterns for building flexible component APIs that scale across design systems.
|
||||
|
||||
## Compound Components
|
||||
|
||||
Compound components share implicit state through React context, allowing flexible composition.
|
||||
|
||||
```tsx
|
||||
// Compound component pattern
|
||||
import * as React from 'react';
|
||||
|
||||
interface AccordionContextValue {
|
||||
openItems: Set<string>;
|
||||
toggle: (id: string) => void;
|
||||
type: 'single' | 'multiple';
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
// Root component
|
||||
interface AccordionProps {
|
||||
children: React.ReactNode;
|
||||
type?: 'single' | 'multiple';
|
||||
defaultOpen?: string[];
|
||||
}
|
||||
|
||||
function Accordion({ children, type = 'single', defaultOpen = [] }: AccordionProps) {
|
||||
const [openItems, setOpenItems] = React.useState<Set<string>>(
|
||||
new Set(defaultOpen)
|
||||
);
|
||||
|
||||
const toggle = React.useCallback(
|
||||
(id: string) => {
|
||||
setOpenItems((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
if (type === 'single') {
|
||||
next.clear();
|
||||
}
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[type]
|
||||
);
|
||||
|
||||
return (
|
||||
<AccordionContext.Provider value={{ openItems, toggle, type }}>
|
||||
<div className="divide-y divide-border">{children}</div>
|
||||
</AccordionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// Item component
|
||||
interface AccordionItemProps {
|
||||
children: React.ReactNode;
|
||||
id: string;
|
||||
}
|
||||
|
||||
function AccordionItem({ children, id }: AccordionItemProps) {
|
||||
return (
|
||||
<AccordionItemContext.Provider value={{ id }}>
|
||||
<div className="py-2">{children}</div>
|
||||
</AccordionItemContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// Trigger component
|
||||
function AccordionTrigger({ children }: { children: React.ReactNode }) {
|
||||
const { toggle, openItems } = useAccordionContext();
|
||||
const { id } = useAccordionItemContext();
|
||||
const isOpen = openItems.has(id);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => toggle(id)}
|
||||
className="flex w-full items-center justify-between py-2 font-medium"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Content component
|
||||
function AccordionContent({ children }: { children: React.ReactNode }) {
|
||||
const { openItems } = useAccordionContext();
|
||||
const { id } = useAccordionItemContext();
|
||||
const isOpen = openItems.has(id);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return <div className="pb-4 text-muted-foreground">{children}</div>;
|
||||
}
|
||||
|
||||
// Export compound component
|
||||
export const AccordionCompound = Object.assign(Accordion, {
|
||||
Item: AccordionItem,
|
||||
Trigger: AccordionTrigger,
|
||||
Content: AccordionContent,
|
||||
});
|
||||
|
||||
// Usage
|
||||
function Example() {
|
||||
return (
|
||||
<AccordionCompound type="single" defaultOpen={['item-1']}>
|
||||
<AccordionCompound.Item id="item-1">
|
||||
<AccordionCompound.Trigger>Is it accessible?</AccordionCompound.Trigger>
|
||||
<AccordionCompound.Content>
|
||||
Yes. It follows WAI-ARIA patterns.
|
||||
</AccordionCompound.Content>
|
||||
</AccordionCompound.Item>
|
||||
<AccordionCompound.Item id="item-2">
|
||||
<AccordionCompound.Trigger>Is it styled?</AccordionCompound.Trigger>
|
||||
<AccordionCompound.Content>
|
||||
Yes. It uses Tailwind CSS.
|
||||
</AccordionCompound.Content>
|
||||
</AccordionCompound.Item>
|
||||
</AccordionCompound>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Polymorphic Components
|
||||
|
||||
Polymorphic components can render as different HTML elements or other components.
|
||||
|
||||
```tsx
|
||||
// Polymorphic component with proper TypeScript support
|
||||
import * as React from 'react';
|
||||
|
||||
type AsProp<C extends React.ElementType> = {
|
||||
as?: C;
|
||||
};
|
||||
|
||||
type PropsToOmit<C extends React.ElementType, P> = keyof (AsProp<C> & P);
|
||||
|
||||
type PolymorphicComponentProp<
|
||||
C extends React.ElementType,
|
||||
Props = {}
|
||||
> = React.PropsWithChildren<Props & AsProp<C>> &
|
||||
Omit<React.ComponentPropsWithoutRef<C>, PropsToOmit<C, Props>>;
|
||||
|
||||
type PolymorphicRef<C extends React.ElementType> =
|
||||
React.ComponentPropsWithRef<C>['ref'];
|
||||
|
||||
type PolymorphicComponentPropWithRef<
|
||||
C extends React.ElementType,
|
||||
Props = {}
|
||||
> = PolymorphicComponentProp<C, Props> & { ref?: PolymorphicRef<C> };
|
||||
|
||||
// Button component
|
||||
interface ButtonOwnProps {
|
||||
variant?: 'default' | 'outline' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
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>
|
||||
) => {
|
||||
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',
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
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',
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
// Usage
|
||||
function Example() {
|
||||
return (
|
||||
<>
|
||||
{/* As button (default) */}
|
||||
<Button variant="default" onClick={() => {}}>
|
||||
Click me
|
||||
</Button>
|
||||
|
||||
{/* As anchor link */}
|
||||
<Button as="a" href="/page" variant="outline">
|
||||
Go to page
|
||||
</Button>
|
||||
|
||||
{/* As Next.js Link */}
|
||||
<Button as={Link} href="/dashboard" variant="ghost">
|
||||
Dashboard
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Slot Pattern
|
||||
|
||||
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';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
asChild?: boolean;
|
||||
variant?: 'default' | 'outline';
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ 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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// Usage - Button styles applied to child element
|
||||
function Example() {
|
||||
return (
|
||||
<Button asChild variant="outline">
|
||||
<a href="/link">I'm a link that looks like a button</a>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Headless Components
|
||||
|
||||
Headless components provide behavior without styling, enabling complete visual customization.
|
||||
|
||||
```tsx
|
||||
// Headless toggle hook
|
||||
import * as React from 'react';
|
||||
|
||||
interface UseToggleProps {
|
||||
defaultPressed?: boolean;
|
||||
pressed?: boolean;
|
||||
onPressedChange?: (pressed: boolean) => void;
|
||||
}
|
||||
|
||||
function useToggle({
|
||||
defaultPressed = false,
|
||||
pressed: controlledPressed,
|
||||
onPressedChange,
|
||||
}: UseToggleProps = {}) {
|
||||
const [uncontrolledPressed, setUncontrolledPressed] =
|
||||
React.useState(defaultPressed);
|
||||
|
||||
const isControlled = controlledPressed !== undefined;
|
||||
const pressed = isControlled ? controlledPressed : uncontrolledPressed;
|
||||
|
||||
const toggle = React.useCallback(() => {
|
||||
if (!isControlled) {
|
||||
setUncontrolledPressed((prev) => !prev);
|
||||
}
|
||||
onPressedChange?.(!pressed);
|
||||
}, [isControlled, pressed, onPressedChange]);
|
||||
|
||||
return {
|
||||
pressed,
|
||||
toggle,
|
||||
buttonProps: {
|
||||
role: 'switch' as const,
|
||||
'aria-checked': pressed,
|
||||
onClick: toggle,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Headless listbox hook
|
||||
interface UseListboxProps<T> {
|
||||
items: T[];
|
||||
defaultSelectedIndex?: number;
|
||||
onSelect?: (item: T, index: number) => void;
|
||||
}
|
||||
|
||||
function useListbox<T>({
|
||||
items,
|
||||
defaultSelectedIndex = -1,
|
||||
onSelect,
|
||||
}: UseListboxProps<T>) {
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(defaultSelectedIndex);
|
||||
const [highlightedIndex, setHighlightedIndex] = React.useState(-1);
|
||||
|
||||
const select = React.useCallback(
|
||||
(index: number) => {
|
||||
setSelectedIndex(index);
|
||||
onSelect?.(items[index], index);
|
||||
},
|
||||
[items, onSelect]
|
||||
);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
setHighlightedIndex((prev) =>
|
||||
prev < items.length - 1 ? prev + 1 : prev
|
||||
);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : prev));
|
||||
break;
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
event.preventDefault();
|
||||
if (highlightedIndex >= 0) {
|
||||
select(highlightedIndex);
|
||||
}
|
||||
break;
|
||||
case 'Home':
|
||||
event.preventDefault();
|
||||
setHighlightedIndex(0);
|
||||
break;
|
||||
case 'End':
|
||||
event.preventDefault();
|
||||
setHighlightedIndex(items.length - 1);
|
||||
break;
|
||||
}
|
||||
},
|
||||
[items.length, highlightedIndex, select]
|
||||
);
|
||||
|
||||
return {
|
||||
selectedIndex,
|
||||
highlightedIndex,
|
||||
select,
|
||||
setHighlightedIndex,
|
||||
listboxProps: {
|
||||
role: 'listbox' as const,
|
||||
tabIndex: 0,
|
||||
onKeyDown: handleKeyDown,
|
||||
},
|
||||
getOptionProps: (index: number) => ({
|
||||
role: 'option' as const,
|
||||
'aria-selected': index === selectedIndex,
|
||||
onClick: () => select(index),
|
||||
onMouseEnter: () => setHighlightedIndex(index),
|
||||
}),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Variant System with CVA
|
||||
|
||||
Class Variance Authority (CVA) provides type-safe variant management.
|
||||
|
||||
```tsx
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Define variants
|
||||
const badgeVariants = cva(
|
||||
// Base classes
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground',
|
||||
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
||||
destructive: 'border-transparent bg-destructive text-destructive-foreground',
|
||||
outline: 'text-foreground',
|
||||
success: 'border-transparent bg-green-500 text-white',
|
||||
warning: 'border-transparent bg-amber-500 text-white',
|
||||
},
|
||||
size: {
|
||||
sm: 'text-xs px-2 py-0.5',
|
||||
md: 'text-sm px-2.5 py-0.5',
|
||||
lg: 'text-sm px-3 py-1',
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
// Outline variant with sizes
|
||||
{
|
||||
variant: 'outline',
|
||||
size: 'lg',
|
||||
className: 'border-2',
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'md',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Component with variants
|
||||
interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, size, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant, size, className }))} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
// Usage
|
||||
<Badge variant="success" size="lg">Active</Badge>
|
||||
<Badge variant="destructive">Error</Badge>
|
||||
<Badge variant="outline">Draft</Badge>
|
||||
```
|
||||
|
||||
## Responsive Variants
|
||||
|
||||
```tsx
|
||||
import { cva } from 'class-variance-authority';
|
||||
|
||||
// Responsive variant configuration
|
||||
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',
|
||||
},
|
||||
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',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'lg',
|
||||
padding: 'md',
|
||||
},
|
||||
});
|
||||
|
||||
// Responsive prop pattern
|
||||
interface ResponsiveValue<T> {
|
||||
base?: T;
|
||||
sm?: T;
|
||||
md?: T;
|
||||
lg?: T;
|
||||
xl?: T;
|
||||
}
|
||||
|
||||
function getResponsiveClasses<T extends string>(
|
||||
prop: T | ResponsiveValue<T> | undefined,
|
||||
classMap: Record<T, string>,
|
||||
responsiveClassMap: Record<string, Record<T, string>>
|
||||
): string {
|
||||
if (!prop) return '';
|
||||
|
||||
if (typeof prop === 'string') {
|
||||
return classMap[prop];
|
||||
}
|
||||
|
||||
return Object.entries(prop)
|
||||
.map(([breakpoint, value]) => {
|
||||
if (breakpoint === 'base') {
|
||||
return classMap[value as T];
|
||||
}
|
||||
return responsiveClassMap[breakpoint]?.[value as T];
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
```
|
||||
|
||||
## Composition Patterns
|
||||
|
||||
### Render Props
|
||||
|
||||
```tsx
|
||||
interface DataListProps<T> {
|
||||
items: T[];
|
||||
renderItem: (item: T, index: number) => React.ReactNode;
|
||||
renderEmpty?: () => React.ReactNode;
|
||||
keyExtractor: (item: T) => string;
|
||||
}
|
||||
|
||||
function DataList<T>({
|
||||
items,
|
||||
renderItem,
|
||||
renderEmpty,
|
||||
keyExtractor,
|
||||
}: DataListProps<T>) {
|
||||
if (items.length === 0 && renderEmpty) {
|
||||
return <>{renderEmpty()}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="space-y-2">
|
||||
{items.map((item, index) => (
|
||||
<li key={keyExtractor(item)}>{renderItem(item, index)}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage
|
||||
<DataList
|
||||
items={users}
|
||||
keyExtractor={(user) => user.id}
|
||||
renderItem={(user) => <UserCard user={user} />}
|
||||
renderEmpty={() => <EmptyState message="No users found" />}
|
||||
/>
|
||||
```
|
||||
|
||||
### Children as Function
|
||||
|
||||
```tsx
|
||||
interface DisclosureProps {
|
||||
children: (props: { isOpen: boolean; toggle: () => void }) => React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
function Disclosure({ children, defaultOpen = false }: DisclosureProps) {
|
||||
const [isOpen, setIsOpen] = React.useState(defaultOpen);
|
||||
const toggle = () => setIsOpen((prev) => !prev);
|
||||
|
||||
return <>{children({ isOpen, toggle })}</>;
|
||||
}
|
||||
|
||||
// Usage
|
||||
<Disclosure>
|
||||
{({ isOpen, toggle }) => (
|
||||
<>
|
||||
<button onClick={toggle}>{isOpen ? 'Close' : 'Open'}</button>
|
||||
{isOpen && <div>Content</div>}
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Prefer Composition**: Build complex components from simple primitives
|
||||
2. **Use Controlled/Uncontrolled Pattern**: Support both modes for flexibility
|
||||
3. **Forward Refs**: Always forward refs to root elements
|
||||
4. **Spread Props**: Allow custom props to pass through
|
||||
5. **Provide Defaults**: Set sensible defaults for optional props
|
||||
6. **Type Everything**: Use TypeScript for prop validation
|
||||
7. **Document Variants**: Show all variant combinations in Storybook
|
||||
8. **Test Accessibility**: Verify keyboard navigation and screen reader support
|
||||
|
||||
## Resources
|
||||
|
||||
- [Radix UI Primitives](https://www.radix-ui.com/primitives)
|
||||
- [Headless UI](https://headlessui.com/)
|
||||
- [Class Variance Authority](https://cva.style/docs)
|
||||
- [React Aria](https://react-spectrum.adobe.com/react-aria/)
|
||||
@@ -0,0 +1,414 @@
|
||||
# Design Tokens Deep Dive
|
||||
|
||||
## Overview
|
||||
|
||||
Design tokens are the atomic values of a design system - the smallest pieces that define visual style. They bridge the gap between design and development by providing a single source of truth for colors, typography, spacing, and other design decisions.
|
||||
|
||||
## Token Categories
|
||||
|
||||
### Color Tokens
|
||||
|
||||
```json
|
||||
{
|
||||
"color": {
|
||||
"primitive": {
|
||||
"gray": {
|
||||
"0": { "value": "#ffffff" },
|
||||
"50": { "value": "#fafafa" },
|
||||
"100": { "value": "#f5f5f5" },
|
||||
"200": { "value": "#e5e5e5" },
|
||||
"300": { "value": "#d4d4d4" },
|
||||
"400": { "value": "#a3a3a3" },
|
||||
"500": { "value": "#737373" },
|
||||
"600": { "value": "#525252" },
|
||||
"700": { "value": "#404040" },
|
||||
"800": { "value": "#262626" },
|
||||
"900": { "value": "#171717" },
|
||||
"950": { "value": "#0a0a0a" }
|
||||
},
|
||||
"blue": {
|
||||
"50": { "value": "#eff6ff" },
|
||||
"100": { "value": "#dbeafe" },
|
||||
"200": { "value": "#bfdbfe" },
|
||||
"300": { "value": "#93c5fd" },
|
||||
"400": { "value": "#60a5fa" },
|
||||
"500": { "value": "#3b82f6" },
|
||||
"600": { "value": "#2563eb" },
|
||||
"700": { "value": "#1d4ed8" },
|
||||
"800": { "value": "#1e40af" },
|
||||
"900": { "value": "#1e3a8a" }
|
||||
},
|
||||
"red": {
|
||||
"500": { "value": "#ef4444" },
|
||||
"600": { "value": "#dc2626" }
|
||||
},
|
||||
"green": {
|
||||
"500": { "value": "#22c55e" },
|
||||
"600": { "value": "#16a34a" }
|
||||
},
|
||||
"amber": {
|
||||
"500": { "value": "#f59e0b" },
|
||||
"600": { "value": "#d97706" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Typography Tokens
|
||||
|
||||
```json
|
||||
{
|
||||
"typography": {
|
||||
"fontFamily": {
|
||||
"sans": { "value": "Inter, system-ui, sans-serif" },
|
||||
"mono": { "value": "JetBrains Mono, Menlo, monospace" }
|
||||
},
|
||||
"fontSize": {
|
||||
"xs": { "value": "0.75rem" },
|
||||
"sm": { "value": "0.875rem" },
|
||||
"base": { "value": "1rem" },
|
||||
"lg": { "value": "1.125rem" },
|
||||
"xl": { "value": "1.25rem" },
|
||||
"2xl": { "value": "1.5rem" },
|
||||
"3xl": { "value": "1.875rem" },
|
||||
"4xl": { "value": "2.25rem" }
|
||||
},
|
||||
"fontWeight": {
|
||||
"normal": { "value": "400" },
|
||||
"medium": { "value": "500" },
|
||||
"semibold": { "value": "600" },
|
||||
"bold": { "value": "700" }
|
||||
},
|
||||
"lineHeight": {
|
||||
"tight": { "value": "1.25" },
|
||||
"normal": { "value": "1.5" },
|
||||
"relaxed": { "value": "1.75" }
|
||||
},
|
||||
"letterSpacing": {
|
||||
"tight": { "value": "-0.025em" },
|
||||
"normal": { "value": "0" },
|
||||
"wide": { "value": "0.025em" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Spacing Tokens
|
||||
|
||||
```json
|
||||
{
|
||||
"spacing": {
|
||||
"0": { "value": "0" },
|
||||
"0.5": { "value": "0.125rem" },
|
||||
"1": { "value": "0.25rem" },
|
||||
"1.5": { "value": "0.375rem" },
|
||||
"2": { "value": "0.5rem" },
|
||||
"2.5": { "value": "0.625rem" },
|
||||
"3": { "value": "0.75rem" },
|
||||
"3.5": { "value": "0.875rem" },
|
||||
"4": { "value": "1rem" },
|
||||
"5": { "value": "1.25rem" },
|
||||
"6": { "value": "1.5rem" },
|
||||
"7": { "value": "1.75rem" },
|
||||
"8": { "value": "2rem" },
|
||||
"9": { "value": "2.25rem" },
|
||||
"10": { "value": "2.5rem" },
|
||||
"12": { "value": "3rem" },
|
||||
"14": { "value": "3.5rem" },
|
||||
"16": { "value": "4rem" },
|
||||
"20": { "value": "5rem" },
|
||||
"24": { "value": "6rem" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Effects Tokens
|
||||
|
||||
```json
|
||||
{
|
||||
"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)" }
|
||||
},
|
||||
"radius": {
|
||||
"none": { "value": "0" },
|
||||
"sm": { "value": "0.125rem" },
|
||||
"md": { "value": "0.375rem" },
|
||||
"lg": { "value": "0.5rem" },
|
||||
"xl": { "value": "0.75rem" },
|
||||
"2xl": { "value": "1rem" },
|
||||
"full": { "value": "9999px" }
|
||||
},
|
||||
"opacity": {
|
||||
"0": { "value": "0" },
|
||||
"25": { "value": "0.25" },
|
||||
"50": { "value": "0.5" },
|
||||
"75": { "value": "0.75" },
|
||||
"100": { "value": "1" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Semantic Token Mapping
|
||||
|
||||
### Light Theme
|
||||
|
||||
```json
|
||||
{
|
||||
"semantic": {
|
||||
"light": {
|
||||
"background": {
|
||||
"default": { "value": "{color.primitive.gray.0}" },
|
||||
"subtle": { "value": "{color.primitive.gray.50}" },
|
||||
"muted": { "value": "{color.primitive.gray.100}" },
|
||||
"emphasis": { "value": "{color.primitive.gray.900}" }
|
||||
},
|
||||
"foreground": {
|
||||
"default": { "value": "{color.primitive.gray.900}" },
|
||||
"muted": { "value": "{color.primitive.gray.600}" },
|
||||
"subtle": { "value": "{color.primitive.gray.400}" },
|
||||
"onEmphasis": { "value": "{color.primitive.gray.0}" }
|
||||
},
|
||||
"border": {
|
||||
"default": { "value": "{color.primitive.gray.200}" },
|
||||
"muted": { "value": "{color.primitive.gray.100}" },
|
||||
"emphasis": { "value": "{color.primitive.gray.900}" }
|
||||
},
|
||||
"accent": {
|
||||
"default": { "value": "{color.primitive.blue.500}" },
|
||||
"emphasis": { "value": "{color.primitive.blue.600}" },
|
||||
"muted": { "value": "{color.primitive.blue.100}" },
|
||||
"subtle": { "value": "{color.primitive.blue.50}" }
|
||||
},
|
||||
"success": {
|
||||
"default": { "value": "{color.primitive.green.500}" },
|
||||
"emphasis": { "value": "{color.primitive.green.600}" }
|
||||
},
|
||||
"warning": {
|
||||
"default": { "value": "{color.primitive.amber.500}" },
|
||||
"emphasis": { "value": "{color.primitive.amber.600}" }
|
||||
},
|
||||
"danger": {
|
||||
"default": { "value": "{color.primitive.red.500}" },
|
||||
"emphasis": { "value": "{color.primitive.red.600}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dark Theme
|
||||
|
||||
```json
|
||||
{
|
||||
"semantic": {
|
||||
"dark": {
|
||||
"background": {
|
||||
"default": { "value": "{color.primitive.gray.950}" },
|
||||
"subtle": { "value": "{color.primitive.gray.900}" },
|
||||
"muted": { "value": "{color.primitive.gray.800}" },
|
||||
"emphasis": { "value": "{color.primitive.gray.50}" }
|
||||
},
|
||||
"foreground": {
|
||||
"default": { "value": "{color.primitive.gray.50}" },
|
||||
"muted": { "value": "{color.primitive.gray.400}" },
|
||||
"subtle": { "value": "{color.primitive.gray.500}" },
|
||||
"onEmphasis": { "value": "{color.primitive.gray.950}" }
|
||||
},
|
||||
"border": {
|
||||
"default": { "value": "{color.primitive.gray.800}" },
|
||||
"muted": { "value": "{color.primitive.gray.900}" },
|
||||
"emphasis": { "value": "{color.primitive.gray.50}" }
|
||||
},
|
||||
"accent": {
|
||||
"default": { "value": "{color.primitive.blue.400}" },
|
||||
"emphasis": { "value": "{color.primitive.blue.300}" },
|
||||
"muted": { "value": "{color.primitive.blue.900}" },
|
||||
"subtle": { "value": "{color.primitive.blue.950}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Token Naming Conventions
|
||||
|
||||
### Recommended Structure
|
||||
|
||||
```
|
||||
[category]-[property]-[variant]-[state]
|
||||
|
||||
Examples:
|
||||
- color-background-default
|
||||
- color-text-primary
|
||||
- color-border-input-focus
|
||||
- spacing-component-padding
|
||||
- typography-heading-lg
|
||||
```
|
||||
|
||||
### Naming Guidelines
|
||||
|
||||
1. **Use kebab-case**: `text-primary` not `textPrimary`
|
||||
2. **Be descriptive**: `button-padding-horizontal` not `btn-px`
|
||||
3. **Use semantic names**: `danger` not `red`
|
||||
4. **Include scale info**: `spacing-4` or `font-size-lg`
|
||||
5. **State suffixes**: `-hover`, `-focus`, `-active`, `-disabled`
|
||||
|
||||
## CSS Custom Properties Output
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* Primitives */
|
||||
--color-gray-50: #fafafa;
|
||||
--color-gray-100: #f5f5f5;
|
||||
--color-gray-900: #171717;
|
||||
--color-blue-500: #3b82f6;
|
||||
|
||||
--spacing-1: 0.25rem;
|
||||
--spacing-2: 0.5rem;
|
||||
--spacing-4: 1rem;
|
||||
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
|
||||
/* Semantic - Light Theme */
|
||||
--background-default: var(--color-white);
|
||||
--background-subtle: var(--color-gray-50);
|
||||
--foreground-default: var(--color-gray-900);
|
||||
--foreground-muted: var(--color-gray-600);
|
||||
--border-default: var(--color-gray-200);
|
||||
--accent-default: var(--color-blue-500);
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Semantic - Dark Theme Overrides */
|
||||
--background-default: var(--color-gray-950);
|
||||
--background-subtle: var(--color-gray-900);
|
||||
--foreground-default: var(--color-gray-50);
|
||||
--foreground-muted: var(--color-gray-400);
|
||||
--border-default: var(--color-gray-800);
|
||||
--accent-default: var(--color-blue-400);
|
||||
}
|
||||
```
|
||||
|
||||
## Token Transformations
|
||||
|
||||
### Style Dictionary Transforms
|
||||
|
||||
```javascript
|
||||
const StyleDictionary = require('style-dictionary');
|
||||
|
||||
// Custom transform for px to rem
|
||||
StyleDictionary.registerTransform({
|
||||
name: 'size/pxToRem',
|
||||
type: 'value',
|
||||
matcher: (token) => token.attributes.category === 'size',
|
||||
transformer: (token) => {
|
||||
const value = parseFloat(token.value);
|
||||
return `${value / 16}rem`;
|
||||
},
|
||||
});
|
||||
|
||||
// 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, '-');
|
||||
return ` --${name}: ${token.value};`;
|
||||
});
|
||||
|
||||
return `:root {\n${tokens.join('\n')}\n}`;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Platform-Specific Outputs
|
||||
|
||||
```javascript
|
||||
// iOS Swift output
|
||||
public enum DesignTokens {
|
||||
public enum Color {
|
||||
public static let gray50 = UIColor(hex: "#fafafa")
|
||||
public static let gray900 = UIColor(hex: "#171717")
|
||||
public static let blue500 = UIColor(hex: "#3b82f6")
|
||||
}
|
||||
|
||||
public enum Spacing {
|
||||
public static let space1: CGFloat = 4
|
||||
public static let space2: CGFloat = 8
|
||||
public static let space4: CGFloat = 16
|
||||
}
|
||||
}
|
||||
|
||||
// Android XML output
|
||||
<resources>
|
||||
<color name="gray_50">#fafafa</color>
|
||||
<color name="gray_900">#171717</color>
|
||||
<color name="blue_500">#3b82f6</color>
|
||||
|
||||
<dimen name="spacing_1">4dp</dimen>
|
||||
<dimen name="spacing_2">8dp</dimen>
|
||||
<dimen name="spacing_4">16dp</dimen>
|
||||
</resources>
|
||||
```
|
||||
|
||||
## Token Governance
|
||||
|
||||
### Change Management
|
||||
|
||||
1. **Propose**: Document the change and rationale
|
||||
2. **Review**: Design and engineering review
|
||||
3. **Test**: Validate across all platforms
|
||||
4. **Communicate**: Announce changes to consumers
|
||||
5. **Deprecate**: Mark old tokens, provide migration path
|
||||
6. **Remove**: After deprecation period
|
||||
|
||||
### Deprecation Pattern
|
||||
|
||||
```json
|
||||
{
|
||||
"color": {
|
||||
"primary": {
|
||||
"value": "{color.primitive.blue.500}",
|
||||
"deprecated": true,
|
||||
"deprecatedMessage": "Use accent.default instead",
|
||||
"replacedBy": "semantic.accent.default"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Token Validation
|
||||
|
||||
```typescript
|
||||
interface TokenValidation {
|
||||
checkContrastRatios(): ContrastReport;
|
||||
validateReferences(): ReferenceReport;
|
||||
detectCircularDeps(): CircularDepReport;
|
||||
auditNaming(): NamingReport;
|
||||
}
|
||||
|
||||
// Contrast validation
|
||||
function validateContrast(
|
||||
foreground: string,
|
||||
background: string,
|
||||
level: 'AA' | 'AAA' = 'AA'
|
||||
): boolean {
|
||||
const ratio = getContrastRatio(foreground, background);
|
||||
return level === 'AA' ? ratio >= 4.5 : ratio >= 7;
|
||||
}
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Design Tokens W3C Community Group](https://design-tokens.github.io/community-group/)
|
||||
- [Style Dictionary](https://amzn.github.io/style-dictionary/)
|
||||
- [Tokens Studio](https://tokens.studio/)
|
||||
- [Open Props](https://open-props.style/)
|
||||
@@ -0,0 +1,520 @@
|
||||
# Theming Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
A robust theming system enables applications to support multiple visual appearances (light/dark modes, brand themes) while maintaining consistency and developer experience.
|
||||
|
||||
## CSS Custom Properties Architecture
|
||||
|
||||
### Base Setup
|
||||
|
||||
```css
|
||||
/* 1. Define the token contract */
|
||||
:root {
|
||||
/* Color scheme */
|
||||
color-scheme: light dark;
|
||||
|
||||
/* Base tokens that don't change */
|
||||
--font-sans: Inter, system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
|
||||
/* Animation tokens */
|
||||
--duration-fast: 150ms;
|
||||
--duration-normal: 250ms;
|
||||
--duration-slow: 400ms;
|
||||
--ease-default: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* Z-index scale */
|
||||
--z-dropdown: 100;
|
||||
--z-sticky: 200;
|
||||
--z-modal: 300;
|
||||
--z-popover: 400;
|
||||
--z-tooltip: 500;
|
||||
}
|
||||
|
||||
/* 2. Light theme (default) */
|
||||
:root,
|
||||
[data-theme='light'] {
|
||||
--color-bg: #ffffff;
|
||||
--color-bg-subtle: #f8fafc;
|
||||
--color-bg-muted: #f1f5f9;
|
||||
--color-bg-emphasis: #0f172a;
|
||||
|
||||
--color-text: #0f172a;
|
||||
--color-text-muted: #475569;
|
||||
--color-text-subtle: #94a3b8;
|
||||
|
||||
--color-border: #e2e8f0;
|
||||
--color-border-muted: #f1f5f9;
|
||||
|
||||
--color-accent: #3b82f6;
|
||||
--color-accent-hover: #2563eb;
|
||||
--color-accent-muted: #dbeafe;
|
||||
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
/* 3. Dark theme */
|
||||
[data-theme='dark'] {
|
||||
--color-bg: #0f172a;
|
||||
--color-bg-subtle: #1e293b;
|
||||
--color-bg-muted: #334155;
|
||||
--color-bg-emphasis: #f8fafc;
|
||||
|
||||
--color-text: #f8fafc;
|
||||
--color-text-muted: #94a3b8;
|
||||
--color-text-subtle: #64748b;
|
||||
|
||||
--color-border: #334155;
|
||||
--color-border-muted: #1e293b;
|
||||
|
||||
--color-accent: #60a5fa;
|
||||
--color-accent-hover: #93c5fd;
|
||||
--color-accent-muted: #1e3a5f;
|
||||
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5);
|
||||
}
|
||||
|
||||
/* 4. System preference detection */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme='light']) {
|
||||
/* Inherit dark theme values */
|
||||
--color-bg: #0f172a;
|
||||
/* ... other dark values */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using Tokens in Components
|
||||
|
||||
```css
|
||||
.card {
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-description {
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
transition: background var(--duration-fast) var(--ease-default);
|
||||
}
|
||||
|
||||
.button-primary:hover {
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
```
|
||||
|
||||
## React Theme Provider
|
||||
|
||||
### Complete Implementation
|
||||
|
||||
```tsx
|
||||
// theme-provider.tsx
|
||||
import * as React from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
type ResolvedTheme = 'light' | 'dark';
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: Theme;
|
||||
storageKey?: string;
|
||||
attribute?: 'class' | 'data-theme';
|
||||
enableSystem?: boolean;
|
||||
disableTransitionOnChange?: boolean;
|
||||
}
|
||||
|
||||
interface ThemeProviderState {
|
||||
theme: Theme;
|
||||
resolvedTheme: ResolvedTheme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
const ThemeProviderContext = React.createContext<ThemeProviderState | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = 'system',
|
||||
storageKey = 'theme',
|
||||
attribute = 'data-theme',
|
||||
enableSystem = true,
|
||||
disableTransitionOnChange = false,
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setThemeState] = React.useState<Theme>(() => {
|
||||
if (typeof window === 'undefined') return defaultTheme;
|
||||
return (localStorage.getItem(storageKey) as Theme) || defaultTheme;
|
||||
});
|
||||
|
||||
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';
|
||||
}, []);
|
||||
|
||||
// Apply theme to DOM
|
||||
const applyTheme = React.useCallback(
|
||||
(newTheme: ResolvedTheme) => {
|
||||
const root = document.documentElement;
|
||||
|
||||
// Disable transitions temporarily
|
||||
if (disableTransitionOnChange) {
|
||||
const css = document.createElement('style');
|
||||
css.appendChild(
|
||||
document.createTextNode(
|
||||
`*,*::before,*::after{transition:none!important}`
|
||||
)
|
||||
);
|
||||
document.head.appendChild(css);
|
||||
|
||||
// Force repaint
|
||||
(() => window.getComputedStyle(document.body))();
|
||||
|
||||
// Remove after a tick
|
||||
setTimeout(() => {
|
||||
document.head.removeChild(css);
|
||||
}, 1);
|
||||
}
|
||||
|
||||
// Apply attribute
|
||||
if (attribute === 'class') {
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(newTheme);
|
||||
} else {
|
||||
root.setAttribute(attribute, newTheme);
|
||||
}
|
||||
|
||||
// Update color-scheme for native elements
|
||||
root.style.colorScheme = newTheme;
|
||||
|
||||
setResolvedTheme(newTheme);
|
||||
},
|
||||
[attribute, disableTransitionOnChange]
|
||||
);
|
||||
|
||||
// Handle theme changes
|
||||
React.useEffect(() => {
|
||||
const resolved = theme === 'system' ? getSystemTheme() : theme;
|
||||
applyTheme(resolved);
|
||||
}, [theme, applyTheme, getSystemTheme]);
|
||||
|
||||
// Listen for system theme changes
|
||||
React.useEffect(() => {
|
||||
if (!enableSystem || theme !== 'system') return;
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
const handleChange = () => {
|
||||
applyTheme(getSystemTheme());
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}, [theme, enableSystem, applyTheme, getSystemTheme]);
|
||||
|
||||
// Persist to localStorage
|
||||
const setTheme = React.useCallback(
|
||||
(newTheme: Theme) => {
|
||||
localStorage.setItem(storageKey, newTheme);
|
||||
setThemeState(newTheme);
|
||||
},
|
||||
[storageKey]
|
||||
);
|
||||
|
||||
const toggleTheme = React.useCallback(() => {
|
||||
setTheme(resolvedTheme === 'light' ? 'dark' : 'light');
|
||||
}, [resolvedTheme, setTheme]);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
theme,
|
||||
resolvedTheme,
|
||||
setTheme,
|
||||
toggleTheme,
|
||||
}),
|
||||
[theme, resolvedTheme, setTheme, toggleTheme]
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = React.useContext(ThemeProviderContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
```
|
||||
|
||||
### Theme Toggle Component
|
||||
|
||||
```tsx
|
||||
// theme-toggle.tsx
|
||||
import { Moon, Sun, Monitor } from 'lucide-react';
|
||||
import { useTheme } from './theme-provider';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded-lg bg-muted p-1">
|
||||
<button
|
||||
onClick={() => setTheme('light')}
|
||||
className={`rounded-md p-2 ${
|
||||
theme === 'light' ? 'bg-background shadow-sm' : ''
|
||||
}`}
|
||||
aria-label="Light theme"
|
||||
>
|
||||
<Sun className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTheme('dark')}
|
||||
className={`rounded-md p-2 ${
|
||||
theme === 'dark' ? 'bg-background shadow-sm' : ''
|
||||
}`}
|
||||
aria-label="Dark theme"
|
||||
>
|
||||
<Moon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTheme('system')}
|
||||
className={`rounded-md p-2 ${
|
||||
theme === 'system' ? 'bg-background shadow-sm' : ''
|
||||
}`}
|
||||
aria-label="System theme"
|
||||
>
|
||||
<Monitor className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Multi-Brand Theming
|
||||
|
||||
### Brand Token Structure
|
||||
|
||||
```css
|
||||
/* Brand A - Corporate Blue */
|
||||
[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-radius: 0.25rem;
|
||||
--brand-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Brand B - Modern 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-radius: 1rem;
|
||||
--brand-shadow: 0 4px 12px rgba(124, 58, 237, 0.15);
|
||||
}
|
||||
|
||||
/* Brand C - 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-radius: 0;
|
||||
--brand-shadow: none;
|
||||
}
|
||||
```
|
||||
|
||||
## Accessibility Considerations
|
||||
|
||||
### Reduced Motion
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root {
|
||||
--duration-fast: 0ms;
|
||||
--duration-normal: 0ms;
|
||||
--duration-slow: 0ms;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### High Contrast Mode
|
||||
|
||||
```css
|
||||
@media (prefers-contrast: high) {
|
||||
:root {
|
||||
--color-text: #000000;
|
||||
--color-text-muted: #000000;
|
||||
--color-bg: #ffffff;
|
||||
--color-border: #000000;
|
||||
--color-accent: #0000ee;
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
--color-text: #ffffff;
|
||||
--color-text-muted: #ffffff;
|
||||
--color-bg: #000000;
|
||||
--color-border: #ffffff;
|
||||
--color-accent: #ffff00;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Forced Colors
|
||||
|
||||
```css
|
||||
@media (forced-colors: active) {
|
||||
.button {
|
||||
border: 2px solid currentColor;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid CanvasText;
|
||||
}
|
||||
|
||||
.link {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Server-Side Rendering
|
||||
|
||||
### Preventing Flash of Unstyled Content
|
||||
|
||||
```tsx
|
||||
// Inline script to prevent FOUC
|
||||
const themeScript = `
|
||||
(function() {
|
||||
const theme = localStorage.getItem('theme') || 'system';
|
||||
const isDark = theme === 'dark' ||
|
||||
(theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
|
||||
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
|
||||
document.documentElement.style.colorScheme = isDark ? 'dark' : 'light';
|
||||
})();
|
||||
`;
|
||||
|
||||
// In Next.js layout - note: inline scripts should be properly sanitized in production
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
// Only use for trusted, static content
|
||||
// For dynamic content, use a sanitization library
|
||||
dangerouslySetInnerHTML={{ __html: themeScript }}
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Themes
|
||||
|
||||
```tsx
|
||||
// theme.test.tsx
|
||||
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();
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="theme">{theme}</span>
|
||||
<span data-testid="resolved">{resolvedTheme}</span>
|
||||
<button onClick={() => setTheme('dark')}>Set Dark</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('ThemeProvider', () => {
|
||||
it('should default to system theme', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('theme')).toHaveTextContent('system');
|
||||
});
|
||||
|
||||
it('should switch to dark theme', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Set Dark'));
|
||||
expect(screen.getByTestId('theme')).toHaveTextContent('dark');
|
||||
expect(document.documentElement).toHaveAttribute('data-theme', 'dark');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Web.dev: prefers-color-scheme](https://web.dev/prefers-color-scheme/)
|
||||
- [CSS Color Scheme](https://developer.mozilla.org/en-US/docs/Web/CSS/color-scheme)
|
||||
- [next-themes](https://github.com/pacocoursey/next-themes)
|
||||
- [Radix UI Colors](https://www.radix-ui.com/colors)
|
||||
297
plugins/ui-design/skills/interaction-design/SKILL.md
Normal file
297
plugins/ui-design/skills/interaction-design/SKILL.md
Normal file
@@ -0,0 +1,297 @@
|
||||
---
|
||||
name: interaction-design
|
||||
description: Design and implement microinteractions, motion design, transitions, and user feedback patterns. Use when adding polish to UI interactions, implementing loading states, or creating delightful user experiences.
|
||||
---
|
||||
|
||||
# Interaction Design
|
||||
|
||||
Create engaging, intuitive interactions through motion, feedback, and thoughtful state transitions that enhance usability and delight users.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
- Adding microinteractions to enhance user feedback
|
||||
- Implementing smooth page and component transitions
|
||||
- Designing loading states and skeleton screens
|
||||
- Creating gesture-based interactions
|
||||
- Building notification and toast systems
|
||||
- Implementing drag-and-drop interfaces
|
||||
- Adding scroll-triggered animations
|
||||
- Designing hover and focus states
|
||||
|
||||
## Core Principles
|
||||
|
||||
### 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
|
||||
- **Continuity**: Maintain context during transitions
|
||||
|
||||
### 2. Timing Guidelines
|
||||
|
||||
| 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 |
|
||||
|
||||
### 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 */
|
||||
```
|
||||
|
||||
## Quick Start: Button Microinteraction
|
||||
|
||||
```tsx
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export function InteractiveButton({ children, onClick }) {
|
||||
return (
|
||||
<motion.button
|
||||
onClick={onClick}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg"
|
||||
>
|
||||
{children}
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Interaction Patterns
|
||||
|
||||
### 1. Loading States
|
||||
|
||||
**Skeleton Screens**: Preserve layout while loading
|
||||
```tsx
|
||||
function CardSkeleton() {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
<div className="h-48 bg-gray-200 rounded-lg" />
|
||||
<div className="mt-4 h-4 bg-gray-200 rounded w-3/4" />
|
||||
<div className="mt-2 h-4 bg-gray-200 rounded w-1/2" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Progress Indicators**: Show determinate progress
|
||||
```tsx
|
||||
function ProgressBar({ progress }: { progress: number }) {
|
||||
return (
|
||||
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full bg-blue-600"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ ease: 'easeOut' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. State Transitions
|
||||
|
||||
**Toggle with smooth transition**:
|
||||
```tsx
|
||||
function Toggle({ checked, onChange }) {
|
||||
return (
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`
|
||||
relative w-12 h-6 rounded-full transition-colors duration-200
|
||||
${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 }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Page Transitions
|
||||
|
||||
**Framer Motion layout animations**:
|
||||
```tsx
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
|
||||
function PageTransition({ children, key }) {
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={key}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Feedback Patterns
|
||||
|
||||
**Ripple effect on click**:
|
||||
```tsx
|
||||
function RippleButton({ children, onClick }) {
|
||||
const [ripples, setRipples] = useState([]);
|
||||
|
||||
const handleClick = (e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const ripple = {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
id: Date.now(),
|
||||
};
|
||||
setRipples(prev => [...prev, ripple]);
|
||||
setTimeout(() => {
|
||||
setRipples(prev => prev.filter(r => r.id !== ripple.id));
|
||||
}, 600);
|
||||
onClick?.(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<button onClick={handleClick} className="relative overflow-hidden">
|
||||
{children}
|
||||
{ripples.map(ripple => (
|
||||
<span
|
||||
key={ripple.id}
|
||||
className="absolute bg-white/30 rounded-full animate-ripple"
|
||||
style={{ left: ripple.x, top: ripple.y }}
|
||||
/>
|
||||
))}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Gesture Interactions
|
||||
|
||||
**Swipe to dismiss**:
|
||||
```tsx
|
||||
function SwipeCard({ children, onDismiss }) {
|
||||
return (
|
||||
<motion.div
|
||||
drag="x"
|
||||
dragConstraints={{ left: 0, right: 0 }}
|
||||
onDragEnd={(_, info) => {
|
||||
if (Math.abs(info.offset.x) > 100) {
|
||||
onDismiss();
|
||||
}
|
||||
}}
|
||||
className="cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## CSS Animation Patterns
|
||||
|
||||
### Keyframe Animations
|
||||
|
||||
```css
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
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; }
|
||||
```
|
||||
|
||||
### CSS Transitions
|
||||
|
||||
```css
|
||||
.card {
|
||||
transition: transform 0.2s ease-out, box-shadow 0.2s ease-out;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
```
|
||||
|
||||
## Accessibility Considerations
|
||||
|
||||
```css
|
||||
/* Respect user motion preferences */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
function AnimatedComponent() {
|
||||
const prefersReducedMotion = window.matchMedia(
|
||||
'(prefers-reduced-motion: reduce)'
|
||||
).matches;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: prefersReducedMotion ? 0 : 0.3 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Performance First**: Use `transform` and `opacity` for smooth 60fps
|
||||
2. **Reduce Motion Support**: Always respect `prefers-reduced-motion`
|
||||
3. **Consistent Timing**: Use a timing scale across the app
|
||||
4. **Natural Physics**: Prefer spring animations over linear
|
||||
5. **Interruptible**: Allow users to cancel long animations
|
||||
6. **Progressive Enhancement**: Work without JS animations
|
||||
7. **Test on Devices**: Performance varies significantly
|
||||
|
||||
## Common Issues
|
||||
|
||||
- **Janky Animations**: Avoid animating `width`, `height`, `top`, `left`
|
||||
- **Over-animation**: Too much motion causes fatigue
|
||||
- **Blocking Interactions**: Never prevent user input during animations
|
||||
- **Memory Leaks**: Clean up animation listeners on unmount
|
||||
- **Flash of Content**: Use `will-change` sparingly for optimization
|
||||
|
||||
## Resources
|
||||
|
||||
- [Framer Motion Documentation](https://www.framer.com/motion/)
|
||||
- [CSS Animation Guide](https://web.dev/animations-guide/)
|
||||
- [Material Design Motion](https://m3.material.io/styles/motion/overview)
|
||||
- [GSAP Animation Library](https://greensock.com/gsap/)
|
||||
@@ -0,0 +1,500 @@
|
||||
# Animation Libraries Reference
|
||||
|
||||
## Framer Motion
|
||||
|
||||
The most popular React animation library with declarative API.
|
||||
|
||||
### Basic Animations
|
||||
|
||||
```tsx
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
// Simple animation
|
||||
function FadeIn({ children }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// Gesture animations
|
||||
function InteractiveCard() {
|
||||
return (
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.02, y: -4 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
|
||||
className="p-6 bg-white rounded-lg shadow"
|
||||
>
|
||||
Hover or tap me
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// Keyframes animation
|
||||
function PulseButton() {
|
||||
return (
|
||||
<motion.button
|
||||
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)',
|
||||
],
|
||||
}}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded"
|
||||
>
|
||||
Click me
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Layout Animations
|
||||
|
||||
```tsx
|
||||
import { motion, LayoutGroup } from 'framer-motion';
|
||||
|
||||
// Shared layout animation
|
||||
function TabIndicator({ activeTab, tabs }) {
|
||||
return (
|
||||
<div className="flex border-b">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className="relative px-4 py-2"
|
||||
>
|
||||
{tab.label}
|
||||
{activeTab === tab.id && (
|
||||
<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 }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-layout reordering
|
||||
function ReorderableList({ items, setItems }) {
|
||||
return (
|
||||
<Reorder.Group axis="y" values={items} onReorder={setItems}>
|
||||
{items.map((item) => (
|
||||
<Reorder.Item
|
||||
key={item.id}
|
||||
value={item}
|
||||
className="bg-white p-4 rounded-lg shadow mb-2 cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
{item.title}
|
||||
</Reorder.Item>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Orchestration
|
||||
|
||||
```tsx
|
||||
// Staggered children
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
delayChildren: 0.2,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.3 },
|
||||
},
|
||||
};
|
||||
|
||||
function StaggeredList({ items }) {
|
||||
return (
|
||||
<motion.ul
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<motion.li key={item.id} variants={itemVariants}>
|
||||
{item.content}
|
||||
</motion.li>
|
||||
))}
|
||||
</motion.ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Page Transitions
|
||||
|
||||
```tsx
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
const pageVariants = {
|
||||
initial: { opacity: 0, x: -20 },
|
||||
enter: { opacity: 1, x: 0 },
|
||||
exit: { opacity: 0, x: 20 },
|
||||
};
|
||||
|
||||
function PageTransition({ children }) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={router.pathname}
|
||||
variants={pageVariants}
|
||||
initial="initial"
|
||||
animate="enter"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## GSAP (GreenSock)
|
||||
|
||||
Industry-standard animation library for complex, performant animations.
|
||||
|
||||
### Basic Timeline
|
||||
|
||||
```tsx
|
||||
import { useRef, useLayoutEffect } from 'react';
|
||||
import gsap from 'gsap';
|
||||
|
||||
function AnimatedHero() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const titleRef = useRef<HTMLHeadingElement>(null);
|
||||
const subtitleRef = useRef<HTMLParagraphElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const ctx = gsap.context(() => {
|
||||
const tl = gsap.timeline({ defaults: { ease: 'power3.out' } });
|
||||
|
||||
tl.from(titleRef.current, {
|
||||
y: 50,
|
||||
opacity: 0,
|
||||
duration: 0.8,
|
||||
})
|
||||
.from(
|
||||
subtitleRef.current,
|
||||
{
|
||||
y: 30,
|
||||
opacity: 0,
|
||||
duration: 0.6,
|
||||
},
|
||||
'-=0.4' // Start 0.4s before previous ends
|
||||
)
|
||||
.from('.cta-button', {
|
||||
scale: 0.8,
|
||||
opacity: 0,
|
||||
duration: 0.4,
|
||||
});
|
||||
}, containerRef);
|
||||
|
||||
return () => ctx.revert(); // Cleanup
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<h1 ref={titleRef}>Welcome</h1>
|
||||
<p ref={subtitleRef}>Discover amazing things</p>
|
||||
<button className="cta-button">Get Started</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### ScrollTrigger
|
||||
|
||||
```tsx
|
||||
import { useLayoutEffect, useRef } from 'react';
|
||||
import gsap from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
function ParallaxSection() {
|
||||
const sectionRef = useRef<HTMLDivElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const ctx = gsap.context(() => {
|
||||
// Parallax image
|
||||
gsap.to(imageRef.current, {
|
||||
yPercent: -20,
|
||||
ease: 'none',
|
||||
scrollTrigger: {
|
||||
trigger: sectionRef.current,
|
||||
start: 'top bottom',
|
||||
end: 'bottom top',
|
||||
scrub: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Fade in content
|
||||
gsap.from('.content-block', {
|
||||
opacity: 0,
|
||||
y: 50,
|
||||
stagger: 0.2,
|
||||
scrollTrigger: {
|
||||
trigger: sectionRef.current,
|
||||
start: 'top 80%',
|
||||
end: 'top 20%',
|
||||
scrub: 1,
|
||||
},
|
||||
});
|
||||
}, sectionRef);
|
||||
|
||||
return () => ctx.revert();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section ref={sectionRef} className="relative overflow-hidden">
|
||||
<img ref={imageRef} src="/hero.jpg" alt="" className="absolute inset-0" />
|
||||
<div className="relative z-10">
|
||||
<div className="content-block">Block 1</div>
|
||||
<div className="content-block">Block 2</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Text Animation
|
||||
|
||||
```tsx
|
||||
import { useLayoutEffect, useRef } from 'react';
|
||||
import gsap from 'gsap';
|
||||
import { SplitText } from 'gsap/SplitText';
|
||||
|
||||
gsap.registerPlugin(SplitText);
|
||||
|
||||
function AnimatedHeadline({ text }) {
|
||||
const textRef = useRef<HTMLHeadingElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const split = new SplitText(textRef.current, {
|
||||
type: 'chars,words',
|
||||
charsClass: 'char',
|
||||
});
|
||||
|
||||
gsap.from(split.chars, {
|
||||
opacity: 0,
|
||||
y: 50,
|
||||
rotateX: -90,
|
||||
stagger: 0.02,
|
||||
duration: 0.8,
|
||||
ease: 'back.out(1.7)',
|
||||
});
|
||||
|
||||
return () => split.revert();
|
||||
}, [text]);
|
||||
|
||||
return <h1 ref={textRef}>{text}</h1>;
|
||||
}
|
||||
```
|
||||
|
||||
## CSS Spring Physics
|
||||
|
||||
```tsx
|
||||
// spring.ts - Custom spring physics
|
||||
interface SpringConfig {
|
||||
stiffness: number; // Higher = snappier
|
||||
damping: number; // Higher = less bouncy
|
||||
mass: number;
|
||||
}
|
||||
|
||||
const presets: Record<string, SpringConfig> = {
|
||||
default: { stiffness: 170, damping: 26, mass: 1 },
|
||||
gentle: { stiffness: 120, damping: 14, mass: 1 },
|
||||
wobbly: { stiffness: 180, damping: 12, mass: 1 },
|
||||
stiff: { stiffness: 210, damping: 20, mass: 1 },
|
||||
slow: { stiffness: 280, damping: 60, mass: 1 },
|
||||
molasses: { stiffness: 280, damping: 120, mass: 1 },
|
||||
};
|
||||
|
||||
function springToCss(config: SpringConfig): string {
|
||||
// Convert spring parameters to CSS timing function approximation
|
||||
const { stiffness, damping } = config;
|
||||
const duration = Math.sqrt(stiffness) / damping;
|
||||
const bounce = 1 - damping / (2 * Math.sqrt(stiffness));
|
||||
|
||||
// Map to cubic-bezier (approximation)
|
||||
if (bounce <= 0) {
|
||||
return `cubic-bezier(0.25, 0.1, 0.25, 1)`;
|
||||
}
|
||||
return `cubic-bezier(0.34, 1.56, 0.64, 1)`;
|
||||
}
|
||||
```
|
||||
|
||||
## Web Animations API
|
||||
|
||||
Native browser animation API for simple animations.
|
||||
|
||||
```tsx
|
||||
function useWebAnimation(
|
||||
ref: RefObject<HTMLElement>,
|
||||
keyframes: Keyframe[],
|
||||
options: KeyframeAnimationOptions
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
|
||||
const animation = ref.current.animate(keyframes, options);
|
||||
|
||||
return () => animation.cancel();
|
||||
}, [ref, keyframes, options]);
|
||||
}
|
||||
|
||||
// Usage
|
||||
function SlideIn({ children }) {
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useWebAnimation(
|
||||
elementRef,
|
||||
[
|
||||
{ transform: 'translateX(-100%)', opacity: 0 },
|
||||
{ transform: 'translateX(0)', opacity: 1 },
|
||||
],
|
||||
{
|
||||
duration: 300,
|
||||
easing: 'cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
fill: 'forwards',
|
||||
}
|
||||
);
|
||||
|
||||
return <div ref={elementRef}>{children}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## View Transitions API
|
||||
|
||||
Native browser API for page transitions.
|
||||
|
||||
```tsx
|
||||
// Check support
|
||||
const supportsViewTransitions = 'startViewTransition' in document;
|
||||
|
||||
// Simple page transition
|
||||
async function navigateTo(url: string) {
|
||||
if (!document.startViewTransition) {
|
||||
window.location.href = url;
|
||||
return;
|
||||
}
|
||||
|
||||
document.startViewTransition(async () => {
|
||||
await fetch(url);
|
||||
// Update DOM
|
||||
});
|
||||
}
|
||||
|
||||
// Named elements for morphing
|
||||
function ProductCard({ product }) {
|
||||
return (
|
||||
<Link href={`/product/${product.id}`}>
|
||||
<img
|
||||
src={product.image}
|
||||
style={{ viewTransitionName: `product-${product.id}` }}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// CSS for view transitions
|
||||
/*
|
||||
::view-transition-old(root) {
|
||||
animation: fade-out 0.25s ease-out;
|
||||
}
|
||||
|
||||
::view-transition-new(root) {
|
||||
animation: fade-in 0.25s ease-in;
|
||||
}
|
||||
|
||||
::view-transition-group(product-*) {
|
||||
animation-duration: 0.3s;
|
||||
}
|
||||
*/
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
### GPU Acceleration
|
||||
|
||||
```css
|
||||
/* Properties that trigger GPU acceleration */
|
||||
.animated-element {
|
||||
transform: translateZ(0); /* Force GPU layer */
|
||||
will-change: transform, opacity; /* Hint to browser */
|
||||
}
|
||||
|
||||
/* Only animate transform and opacity for 60fps */
|
||||
.smooth {
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
/* Avoid animating these (cause reflow) */
|
||||
.avoid {
|
||||
/* Don't animate: width, height, top, left, margin, padding */
|
||||
}
|
||||
```
|
||||
|
||||
### Reduced Motion
|
||||
|
||||
```tsx
|
||||
function useReducedMotion() {
|
||||
const [prefersReduced, setPrefersReduced] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
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);
|
||||
}, []);
|
||||
|
||||
return prefersReduced;
|
||||
}
|
||||
|
||||
// Usage
|
||||
function AnimatedComponent() {
|
||||
const prefersReduced = useReducedMotion();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: prefersReduced ? 0 : 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: prefersReduced ? 0 : 0.3 }}
|
||||
>
|
||||
Content
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,518 @@
|
||||
# Microinteraction Patterns Reference
|
||||
|
||||
## Button States
|
||||
|
||||
### Loading Button
|
||||
|
||||
```tsx
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
interface LoadingButtonProps {
|
||||
isLoading: boolean;
|
||||
children: React.ReactNode;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function LoadingButton({ isLoading, children, onClick }: LoadingButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={isLoading}
|
||||
className="relative px-4 py-2 bg-blue-600 text-white rounded-lg overflow-hidden"
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{isLoading ? (
|
||||
<motion.span
|
||||
key="loading"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Spinner className="w-4 h-4" />
|
||||
Processing...
|
||||
</motion.span>
|
||||
) : (
|
||||
<motion.span
|
||||
key="idle"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
>
|
||||
{children}
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Spinner component
|
||||
function Spinner({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={`animate-spin ${className}`} viewBox="0 0 24 24">
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
strokeDasharray="62.83"
|
||||
strokeDashoffset="15"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Success/Error State
|
||||
|
||||
```tsx
|
||||
function SubmitButton({ onSubmit }: { onSubmit: () => Promise<void> }) {
|
||||
const [state, setState] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
|
||||
const handleClick = async () => {
|
||||
setState('loading');
|
||||
try {
|
||||
await onSubmit();
|
||||
setState('success');
|
||||
setTimeout(() => setState('idle'), 2000);
|
||||
} catch {
|
||||
setState('error');
|
||||
setTimeout(() => setState('idle'), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const icons = {
|
||||
idle: null,
|
||||
loading: <Spinner className="w-5 h-5" />,
|
||||
success: <CheckIcon className="w-5 h-5" />,
|
||||
error: <XIcon className="w-5 h-5" />,
|
||||
};
|
||||
|
||||
const colors = {
|
||||
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'}
|
||||
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 }}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{icons[state] && (
|
||||
<motion.span
|
||||
key={state}
|
||||
initial={{ scale: 0, rotate: -180 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
exit={{ scale: 0, rotate: 180 }}
|
||||
>
|
||||
{icons[state]}
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{state === 'idle' && 'Submit'}
|
||||
{state === 'loading' && 'Submitting...'}
|
||||
{state === 'success' && 'Done!'}
|
||||
{state === 'error' && 'Failed'}
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Form Interactions
|
||||
|
||||
### Floating Label Input
|
||||
|
||||
```tsx
|
||||
import { useState, useId } from 'react';
|
||||
|
||||
function FloatingInput({ label, type = 'text' }: { label: string; type?: string }) {
|
||||
const [value, setValue] = useState('');
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const id = useId();
|
||||
|
||||
const isFloating = isFocused || value.length > 0;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
id={id}
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
className="peer w-full px-4 py-3 border rounded-lg outline-none transition-colors
|
||||
focus:border-blue-600 focus:ring-2 focus:ring-blue-100"
|
||||
/>
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Shake on Error
|
||||
|
||||
```tsx
|
||||
import { motion, useAnimation } from 'framer-motion';
|
||||
|
||||
function ShakeInput({ error, ...props }: InputProps & { error?: string }) {
|
||||
const controls = useAnimation();
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
controls.start({
|
||||
x: [0, -10, 10, -10, 10, 0],
|
||||
transition: { duration: 0.4 },
|
||||
});
|
||||
}
|
||||
}, [error, controls]);
|
||||
|
||||
return (
|
||||
<motion.div animate={controls}>
|
||||
<input
|
||||
{...props}
|
||||
className={`w-full px-4 py-2 border rounded-lg ${
|
||||
error ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
{error && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mt-1 text-sm text-red-500"
|
||||
>
|
||||
{error}
|
||||
</motion.p>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Character Count
|
||||
|
||||
```tsx
|
||||
function TextareaWithCount({ maxLength = 280 }: { maxLength?: number }) {
|
||||
const [value, setValue] = useState('');
|
||||
const remaining = maxLength - value.length;
|
||||
const isNearLimit = remaining <= 20;
|
||||
const isOverLimit = remaining < 0;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
className="w-full px-4 py-3 border rounded-lg resize-none"
|
||||
rows={4}
|
||||
/>
|
||||
<motion.span
|
||||
className={`absolute bottom-2 right-2 text-sm ${
|
||||
isOverLimit ? 'text-red-500' : isNearLimit ? 'text-yellow-500' : 'text-gray-400'
|
||||
}`}
|
||||
animate={{ scale: isNearLimit ? [1, 1.1, 1] : 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{remaining}
|
||||
</motion.span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Feedback Patterns
|
||||
|
||||
### Toast Notifications
|
||||
|
||||
```tsx
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { createContext, useContext, useState, useCallback } from 'react';
|
||||
|
||||
interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'info';
|
||||
}
|
||||
|
||||
const ToastContext = createContext<{
|
||||
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 id = Date.now().toString();
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, 3000);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ addToast }}>
|
||||
{children}
|
||||
<div className="fixed bottom-4 right-4 space-y-2 z-50">
|
||||
<AnimatePresence>
|
||||
{toasts.map((toast) => (
|
||||
<motion.div
|
||||
key={toast.id}
|
||||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
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'
|
||||
} text-white`}
|
||||
>
|
||||
{toast.message}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) throw new Error('useToast must be within ToastProvider');
|
||||
return context;
|
||||
}
|
||||
```
|
||||
|
||||
### Confirmation Dialog
|
||||
|
||||
```tsx
|
||||
function ConfirmButton({
|
||||
onConfirm,
|
||||
confirmText = 'Click again to confirm',
|
||||
children,
|
||||
}: {
|
||||
onConfirm: () => void;
|
||||
confirmText?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending) {
|
||||
const timer = setTimeout(() => setIsPending(false), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isPending]);
|
||||
|
||||
const handleClick = () => {
|
||||
if (isPending) {
|
||||
onConfirm();
|
||||
setIsPending(false);
|
||||
} else {
|
||||
setIsPending(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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'
|
||||
}`}
|
||||
animate={{ scale: isPending ? [1, 1.02, 1] : 1 }}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.span
|
||||
key={isPending ? 'confirm' : 'idle'}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
>
|
||||
{isPending ? confirmText : children}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Navigation Patterns
|
||||
|
||||
### Active Link Indicator
|
||||
|
||||
```tsx
|
||||
import { motion } from 'framer-motion';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
function Navigation({ items }: { items: { href: string; label: string }[] }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav className="flex gap-1 p-1 bg-gray-100 rounded-lg">
|
||||
{items.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
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 && (
|
||||
<motion.div
|
||||
layoutId="activeNav"
|
||||
className="absolute inset-0 bg-blue-600 rounded-md"
|
||||
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10">{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Hamburger Menu Icon
|
||||
|
||||
```tsx
|
||||
function MenuIcon({ isOpen }: { isOpen: boolean }) {
|
||||
return (
|
||||
<button className="relative w-6 h-6" aria-label="Toggle menu">
|
||||
<motion.span
|
||||
className="absolute left-0 h-0.5 w-6 bg-current"
|
||||
animate={{
|
||||
top: isOpen ? '50%' : '25%',
|
||||
rotate: isOpen ? 45 : 0,
|
||||
translateY: isOpen ? '-50%' : 0,
|
||||
}}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
<motion.span
|
||||
className="absolute left-0 top-1/2 h-0.5 w-6 bg-current -translate-y-1/2"
|
||||
animate={{ opacity: isOpen ? 0 : 1, scaleX: isOpen ? 0 : 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
<motion.span
|
||||
className="absolute left-0 h-0.5 w-6 bg-current"
|
||||
animate={{
|
||||
bottom: isOpen ? '50%' : '25%',
|
||||
rotate: isOpen ? -45 : 0,
|
||||
translateY: isOpen ? '50%' : 0,
|
||||
}}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Data Interactions
|
||||
|
||||
### Optimistic Updates
|
||||
|
||||
```tsx
|
||||
function LikeButton({ postId, initialLiked, initialCount }) {
|
||||
const [liked, setLiked] = useState(initialLiked);
|
||||
const [count, setCount] = useState(initialCount);
|
||||
|
||||
const handleLike = async () => {
|
||||
// Optimistic update
|
||||
const newLiked = !liked;
|
||||
setLiked(newLiked);
|
||||
setCount((c) => c + (newLiked ? 1 : -1));
|
||||
|
||||
try {
|
||||
await api.toggleLike(postId);
|
||||
} catch {
|
||||
// Rollback on error
|
||||
setLiked(!newLiked);
|
||||
setCount((c) => c + (newLiked ? -1 : 1));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
onClick={handleLike}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className={`flex items-center gap-2 ${liked ? 'text-red-500' : 'text-gray-500'}`}
|
||||
>
|
||||
<motion.span
|
||||
animate={{ scale: liked ? [1, 1.3, 1] : 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{liked ? <HeartFilledIcon /> : <HeartIcon />}
|
||||
</motion.span>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.span
|
||||
key={count}
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
>
|
||||
{count}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Pull to Refresh
|
||||
|
||||
```tsx
|
||||
import { motion, useMotionValue, useTransform } from 'framer-motion';
|
||||
|
||||
function PullToRefresh({ onRefresh, children }) {
|
||||
const y = useMotionValue(0);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const opacity = useTransform(y, [0, 60], [0, 1]);
|
||||
const rotate = useTransform(y, [0, 60], [0, 180]);
|
||||
|
||||
const handleDragEnd = async (_, info) => {
|
||||
if (info.offset.y > 60 && !isRefreshing) {
|
||||
setIsRefreshing(true);
|
||||
await onRefresh();
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden">
|
||||
<motion.div style={{ opacity }} className="flex justify-center py-4">
|
||||
<motion.div style={{ rotate }}>
|
||||
{isRefreshing ? <Spinner /> : <ArrowDownIcon />}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
drag="y"
|
||||
dragConstraints={{ top: 0, bottom: 0 }}
|
||||
dragElastic={{ top: 0.5, bottom: 0 }}
|
||||
style={{ y }}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,416 @@
|
||||
# Scroll Animations Reference
|
||||
|
||||
## Intersection Observer Hook
|
||||
|
||||
```tsx
|
||||
import { useEffect, useRef, useState, type RefObject } from 'react';
|
||||
|
||||
interface UseInViewOptions {
|
||||
threshold?: number | number[];
|
||||
rootMargin?: string;
|
||||
triggerOnce?: boolean;
|
||||
}
|
||||
|
||||
function useInView<T extends HTMLElement>({
|
||||
threshold = 0,
|
||||
rootMargin = '0px',
|
||||
triggerOnce = false,
|
||||
}: UseInViewOptions = {}): [RefObject<T>, boolean] {
|
||||
const ref = useRef<T>(null);
|
||||
const [isInView, setIsInView] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
const inView = entry.isIntersecting;
|
||||
setIsInView(inView);
|
||||
if (inView && triggerOnce) {
|
||||
observer.unobserve(element);
|
||||
}
|
||||
},
|
||||
{ threshold, rootMargin }
|
||||
);
|
||||
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [threshold, rootMargin, triggerOnce]);
|
||||
|
||||
return [ref, isInView];
|
||||
}
|
||||
|
||||
// Usage
|
||||
function FadeInSection({ children }) {
|
||||
const [ref, isInView] = useInView({ threshold: 0.2, triggerOnce: true });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`transition-all duration-700 ${
|
||||
isInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Scroll Progress Indicator
|
||||
|
||||
```tsx
|
||||
import { motion, useScroll, useSpring } from 'framer-motion';
|
||||
|
||||
function ScrollProgress() {
|
||||
const { scrollYProgress } = useScroll();
|
||||
const scaleX = useSpring(scrollYProgress, {
|
||||
stiffness: 100,
|
||||
damping: 30,
|
||||
restDelta: 0.001,
|
||||
});
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="fixed top-0 left-0 right-0 h-1 bg-blue-600 origin-left z-50"
|
||||
style={{ scaleX }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Parallax Scrolling
|
||||
|
||||
### Simple CSS Parallax
|
||||
|
||||
```css
|
||||
.parallax-container {
|
||||
height: 100vh;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
perspective: 10px;
|
||||
}
|
||||
|
||||
.parallax-layer-back {
|
||||
transform: translateZ(-10px) scale(2);
|
||||
}
|
||||
|
||||
.parallax-layer-base {
|
||||
transform: translateZ(0);
|
||||
}
|
||||
```
|
||||
|
||||
### Framer Motion Parallax
|
||||
|
||||
```tsx
|
||||
import { motion, useScroll, useTransform } from 'framer-motion';
|
||||
|
||||
function ParallaxHero() {
|
||||
const ref = useRef(null);
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: ref,
|
||||
offset: ['start start', 'end start'],
|
||||
});
|
||||
|
||||
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"
|
||||
>
|
||||
<img src="/hero-bg.jpg" alt="" className="w-full h-full object-cover" />
|
||||
</motion.div>
|
||||
|
||||
{/* Content fades out on scroll */}
|
||||
<motion.div
|
||||
style={{ opacity }}
|
||||
className="relative z-10 flex items-center justify-center h-full"
|
||||
>
|
||||
<h1 className="text-6xl font-bold text-white">Welcome</h1>
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Scroll-Linked Animations
|
||||
|
||||
### Progress-Based Animation
|
||||
|
||||
```tsx
|
||||
function ScrollAnimation() {
|
||||
const containerRef = useRef(null);
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: containerRef,
|
||||
offset: ['start end', 'end start'],
|
||||
});
|
||||
|
||||
// Different transformations based on scroll progress
|
||||
const x = useTransform(scrollYProgress, [0, 1], [-200, 200]);
|
||||
const rotate = useTransform(scrollYProgress, [0, 1], [0, 360]);
|
||||
const backgroundColor = useTransform(
|
||||
scrollYProgress,
|
||||
[0, 0.5, 1],
|
||||
['#3b82f6', '#8b5cf6', '#ec4899']
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="h-[200vh] py-20">
|
||||
<div className="sticky top-1/2 -translate-y-1/2 flex justify-center">
|
||||
<motion.div
|
||||
style={{ x, rotate, backgroundColor }}
|
||||
className="w-32 h-32 rounded-2xl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Horizontal Scroll Section
|
||||
|
||||
```tsx
|
||||
function HorizontalScroll({ items }) {
|
||||
const containerRef = useRef(null);
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: containerRef,
|
||||
offset: ['start start', 'end end'],
|
||||
});
|
||||
|
||||
const x = useTransform(
|
||||
scrollYProgress,
|
||||
[0, 1],
|
||||
['0%', `-${(items.length - 1) * 100}%`]
|
||||
);
|
||||
|
||||
return (
|
||||
<section ref={containerRef} className="relative h-[300vh]">
|
||||
<div className="sticky top-0 h-screen overflow-hidden">
|
||||
<motion.div style={{ x }} className="flex h-full">
|
||||
{items.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-shrink-0 w-screen h-full flex items-center justify-center"
|
||||
>
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Reveal Animations
|
||||
|
||||
### Staggered List Reveal
|
||||
|
||||
```tsx
|
||||
function StaggeredList({ items }) {
|
||||
const [ref, isInView] = useInView({ threshold: 0.1, triggerOnce: true });
|
||||
|
||||
return (
|
||||
<ul ref={ref} className="space-y-4">
|
||||
{items.map((item, i) => (
|
||||
<motion.li
|
||||
key={item.id}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ delay: i * 0.1, duration: 0.5 }}
|
||||
className="p-4 bg-white rounded-lg shadow"
|
||||
>
|
||||
{item.content}
|
||||
</motion.li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Text Reveal
|
||||
|
||||
```tsx
|
||||
function TextReveal({ text }) {
|
||||
const [ref, isInView] = useInView({ threshold: 0.5, triggerOnce: true });
|
||||
const words = text.split(' ');
|
||||
|
||||
return (
|
||||
<p ref={ref} className="text-4xl font-bold">
|
||||
{words.map((word, i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ delay: i * 0.05, duration: 0.3 }}
|
||||
className="inline-block mr-2"
|
||||
>
|
||||
{word}
|
||||
</motion.span>
|
||||
))}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Clip Path Reveal
|
||||
|
||||
```tsx
|
||||
function ClipReveal({ children }) {
|
||||
const [ref, isInView] = useInView({ threshold: 0.3, triggerOnce: true });
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
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}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Sticky Scroll Sections
|
||||
|
||||
```tsx
|
||||
function StickySection({ title, content, image }) {
|
||||
const ref = useRef(null);
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: ref,
|
||||
offset: ['start start', 'end start'],
|
||||
});
|
||||
|
||||
const opacity = useTransform(scrollYProgress, [0, 0.5, 1], [1, 1, 0]);
|
||||
const scale = useTransform(scrollYProgress, [0, 0.5, 1], [1, 1, 0.8]);
|
||||
|
||||
return (
|
||||
<section ref={ref} className="relative h-[200vh]">
|
||||
<motion.div
|
||||
style={{ opacity, scale }}
|
||||
className="sticky top-0 h-screen flex items-center"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-16 container mx-auto">
|
||||
<div>
|
||||
<h2 className="text-4xl font-bold">{title}</h2>
|
||||
<p className="mt-4 text-lg text-gray-600">{content}</p>
|
||||
</div>
|
||||
<div>
|
||||
<img src={image} alt="" className="rounded-2xl shadow-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Smooth Scroll
|
||||
|
||||
```tsx
|
||||
// Using CSS
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
// Using Lenis for butter-smooth scrolling
|
||||
import Lenis from '@studio-freight/lenis';
|
||||
|
||||
function SmoothScrollProvider({ children }) {
|
||||
useEffect(() => {
|
||||
const lenis = new Lenis({
|
||||
duration: 1.2,
|
||||
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
|
||||
direction: 'vertical',
|
||||
smooth: true,
|
||||
});
|
||||
|
||||
function raf(time) {
|
||||
lenis.raf(time);
|
||||
requestAnimationFrame(raf);
|
||||
}
|
||||
requestAnimationFrame(raf);
|
||||
|
||||
return () => lenis.destroy();
|
||||
}, []);
|
||||
|
||||
return children;
|
||||
}
|
||||
```
|
||||
|
||||
## Scroll Snap
|
||||
|
||||
```css
|
||||
/* Scroll snap container */
|
||||
.snap-container {
|
||||
scroll-snap-type: y mandatory;
|
||||
overflow-y: scroll;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.snap-section {
|
||||
scroll-snap-align: start;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Smooth scrolling with snap */
|
||||
@supports (scroll-snap-type: y mandatory) {
|
||||
.snap-container {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
function FullPageScroll({ sections }) {
|
||||
return (
|
||||
<div className="snap-container">
|
||||
{sections.map((section, i) => (
|
||||
<section key={i} className="snap-section flex items-center justify-center">
|
||||
{section}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
```tsx
|
||||
// Use will-change sparingly
|
||||
const AnimatedElement = styled(motion.div)`
|
||||
will-change: transform;
|
||||
`;
|
||||
|
||||
// Debounce scroll handlers
|
||||
function useThrottledScroll(callback, delay = 16) {
|
||||
const lastRun = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
const now = Date.now();
|
||||
if (now - lastRun.current >= delay) {
|
||||
lastRun.current = now;
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
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)' };
|
||||
// Bad (causes reflow)
|
||||
const badAnimation = { top: '100px' };
|
||||
```
|
||||
431
plugins/ui-design/skills/mobile-android-design/SKILL.md
Normal file
431
plugins/ui-design/skills/mobile-android-design/SKILL.md
Normal file
@@ -0,0 +1,431 @@
|
||||
---
|
||||
name: mobile-android-design
|
||||
description: Master Material Design 3 and Jetpack Compose patterns for building native Android apps. Use when designing Android interfaces, implementing Compose UI, or following Google's Material Design guidelines.
|
||||
---
|
||||
|
||||
# Android Mobile Design
|
||||
|
||||
Master Material Design 3 (Material You) and Jetpack Compose to build modern, adaptive Android applications that integrate seamlessly with the Android ecosystem.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
- Designing Android app interfaces following Material Design 3
|
||||
- Building Jetpack Compose UI and layouts
|
||||
- Implementing Android navigation patterns (Navigation Compose)
|
||||
- Creating adaptive layouts for phones, tablets, and foldables
|
||||
- Using Material 3 theming with dynamic colors
|
||||
- Building accessible Android interfaces
|
||||
- Implementing Android-specific gestures and interactions
|
||||
- Designing for different screen configurations
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### 1. Material Design 3 Principles
|
||||
|
||||
**Personalization**: Dynamic color adapts UI to user's wallpaper
|
||||
**Accessibility**: Tonal palettes ensure sufficient color contrast
|
||||
**Large Screens**: Responsive layouts for tablets and foldables
|
||||
|
||||
**Material Components:**
|
||||
- Cards, Buttons, FABs, Chips
|
||||
- Navigation (rail, drawer, bottom nav)
|
||||
- Text fields, Dialogs, Sheets
|
||||
- Lists, Menus, Progress indicators
|
||||
|
||||
### 2. Jetpack Compose Layout System
|
||||
|
||||
**Column and Row:**
|
||||
```kotlin
|
||||
// Vertical arrangement with alignment
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Text(
|
||||
text = "Title",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
Text(
|
||||
text = "Subtitle",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// Horizontal arrangement with weight
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Default.Star, contentDescription = null)
|
||||
Text("Featured")
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
TextButton(onClick = {}) {
|
||||
Text("View All")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Lazy Lists and Grids:**
|
||||
```kotlin
|
||||
// Lazy column with sticky headers
|
||||
LazyColumn {
|
||||
items.groupBy { it.category }.forEach { (category, categoryItems) ->
|
||||
stickyHeader {
|
||||
Text(
|
||||
text = category,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(16.dp),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
}
|
||||
items(categoryItems) { item ->
|
||||
ItemRow(item = item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Adaptive grid
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 150.dp),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(items) { item ->
|
||||
ItemCard(item = item)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Navigation Patterns
|
||||
|
||||
**Bottom Navigation:**
|
||||
```kotlin
|
||||
@Composable
|
||||
fun MainScreen() {
|
||||
val navController = rememberNavController()
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentDestination = navBackStackEntry?.destination
|
||||
|
||||
NavigationDestination.entries.forEach { destination ->
|
||||
NavigationBarItem(
|
||||
icon = { Icon(destination.icon, contentDescription = null) },
|
||||
label = { Text(destination.label) },
|
||||
selected = currentDestination?.hierarchy?.any {
|
||||
it.route == destination.route
|
||||
} == true,
|
||||
onClick = {
|
||||
navController.navigate(destination.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = NavigationDestination.Home.route,
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
) {
|
||||
composable(NavigationDestination.Home.route) { HomeScreen() }
|
||||
composable(NavigationDestination.Search.route) { SearchScreen() }
|
||||
composable(NavigationDestination.Profile.route) { ProfileScreen() }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Navigation Drawer:**
|
||||
```kotlin
|
||||
@Composable
|
||||
fun DrawerNavigation() {
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
ModalDrawerSheet {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
"App Name",
|
||||
modifier = Modifier.padding(16.dp),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
HorizontalDivider()
|
||||
|
||||
NavigationDrawerItem(
|
||||
icon = { Icon(Icons.Default.Home, null) },
|
||||
label = { Text("Home") },
|
||||
selected = true,
|
||||
onClick = { scope.launch { drawerState.close() } }
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
icon = { Icon(Icons.Default.Settings, null) },
|
||||
label = { Text("Settings") },
|
||||
selected = false,
|
||||
onClick = { }
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Home") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
||||
Icon(Icons.Default.Menu, contentDescription = "Menu")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Content(modifier = Modifier.padding(innerPadding))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Material 3 Theming
|
||||
|
||||
**Color Scheme:**
|
||||
```kotlin
|
||||
// Dynamic color (Android 12+)
|
||||
val dynamicColorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context)
|
||||
else dynamicLightColorScheme(context)
|
||||
} else {
|
||||
if (darkTheme) DarkColorScheme else LightColorScheme
|
||||
}
|
||||
|
||||
// Custom color scheme
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Color(0xFF6750A4),
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFEADDFF),
|
||||
onPrimaryContainer = Color(0xFF21005D),
|
||||
secondary = Color(0xFF625B71),
|
||||
onSecondary = Color.White,
|
||||
tertiary = Color(0xFF7D5260),
|
||||
onTertiary = Color.White,
|
||||
surface = Color(0xFFFFFBFE),
|
||||
onSurface = Color(0xFF1C1B1F),
|
||||
)
|
||||
```
|
||||
|
||||
**Typography:**
|
||||
```kotlin
|
||||
val AppTypography = Typography(
|
||||
displayLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 57.sp,
|
||||
lineHeight = 64.sp
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 36.sp
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp
|
||||
),
|
||||
labelMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### 5. Component Examples
|
||||
|
||||
**Cards:**
|
||||
```kotlin
|
||||
@Composable
|
||||
fun FeatureCard(
|
||||
title: String,
|
||||
description: String,
|
||||
imageUrl: String,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(180.dp),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Buttons:**
|
||||
```kotlin
|
||||
// Filled button (primary action)
|
||||
Button(onClick = { }) {
|
||||
Text("Continue")
|
||||
}
|
||||
|
||||
// Filled tonal button (secondary action)
|
||||
FilledTonalButton(onClick = { }) {
|
||||
Icon(Icons.Default.Add, null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Add Item")
|
||||
}
|
||||
|
||||
// Outlined button
|
||||
OutlinedButton(onClick = { }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
|
||||
// Text button
|
||||
TextButton(onClick = { }) {
|
||||
Text("Learn More")
|
||||
}
|
||||
|
||||
// FAB
|
||||
FloatingActionButton(
|
||||
onClick = { },
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Add")
|
||||
}
|
||||
```
|
||||
|
||||
## Quick Start Component
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ItemListCard(
|
||||
item: Item,
|
||||
onItemClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Card(
|
||||
onClick = onItemClick,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Star,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = item.title,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
text = item.subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Default.ChevronRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use Material Theme**: Access colors via `MaterialTheme.colorScheme` for automatic dark mode support
|
||||
2. **Support Dynamic Color**: Enable dynamic color on Android 12+ for personalization
|
||||
3. **Adaptive Layouts**: Use `WindowSizeClass` for responsive designs
|
||||
4. **Content Descriptions**: Add `contentDescription` to all interactive elements
|
||||
5. **Touch Targets**: Minimum 48dp touch targets for accessibility
|
||||
6. **State Hoisting**: Hoist state to make components reusable and testable
|
||||
7. **Remember Properly**: Use `remember` and `rememberSaveable` appropriately
|
||||
8. **Preview Annotations**: Add `@Preview` with different configurations
|
||||
|
||||
## Common Issues
|
||||
|
||||
- **Recomposition Issues**: Avoid passing unstable lambdas; use `remember`
|
||||
- **State Loss**: Use `rememberSaveable` for configuration changes
|
||||
- **Performance**: Use `LazyColumn` instead of `Column` for long lists
|
||||
- **Theme Leaks**: Ensure `MaterialTheme` wraps all composables
|
||||
- **Navigation Crashes**: Handle back press and deep links properly
|
||||
- **Memory Leaks**: Cancel coroutines in `DisposableEffect`
|
||||
|
||||
## Resources
|
||||
|
||||
- [Material Design 3](https://m3.material.io/)
|
||||
- [Jetpack Compose Documentation](https://developer.android.com/jetpack/compose)
|
||||
- [Compose Samples](https://github.com/android/compose-samples)
|
||||
- [Material 3 Compose](https://developer.android.com/jetpack/compose/designsystems/material3)
|
||||
@@ -0,0 +1,698 @@
|
||||
# Android Navigation Patterns
|
||||
|
||||
## Navigation Compose Basics
|
||||
|
||||
### Setup and Dependencies
|
||||
|
||||
```kotlin
|
||||
// build.gradle.kts
|
||||
dependencies {
|
||||
implementation("androidx.navigation:navigation-compose:2.7.7")
|
||||
// For type-safe navigation (recommended)
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
|
||||
}
|
||||
```
|
||||
|
||||
### Basic Navigation
|
||||
|
||||
```kotlin
|
||||
@Serializable
|
||||
object Home
|
||||
|
||||
@Serializable
|
||||
data class Detail(val itemId: String)
|
||||
|
||||
@Serializable
|
||||
object Settings
|
||||
|
||||
@Composable
|
||||
fun AppNavigation() {
|
||||
val navController = rememberNavController()
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Home
|
||||
) {
|
||||
composable<Home> {
|
||||
HomeScreen(
|
||||
onItemClick = { itemId ->
|
||||
navController.navigate(Detail(itemId))
|
||||
},
|
||||
onSettingsClick = {
|
||||
navController.navigate(Settings)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable<Detail> { backStackEntry ->
|
||||
val detail: Detail = backStackEntry.toRoute()
|
||||
DetailScreen(
|
||||
itemId = detail.itemId,
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
composable<Settings> {
|
||||
SettingsScreen(
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Navigation with Arguments
|
||||
|
||||
```kotlin
|
||||
// Type-safe routes with arguments
|
||||
@Serializable
|
||||
data class ProductDetail(
|
||||
val productId: String,
|
||||
val category: String,
|
||||
val fromSearch: Boolean = false
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UserProfile(
|
||||
val userId: Long
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun NavigationWithArgs() {
|
||||
val navController = rememberNavController()
|
||||
|
||||
NavHost(navController = navController, startDestination = Home) {
|
||||
composable<Home> {
|
||||
HomeScreen(
|
||||
onProductClick = { productId, category ->
|
||||
navController.navigate(
|
||||
ProductDetail(
|
||||
productId = productId,
|
||||
category = category,
|
||||
fromSearch = false
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable<ProductDetail> { backStackEntry ->
|
||||
val args: ProductDetail = backStackEntry.toRoute()
|
||||
ProductDetailScreen(
|
||||
productId = args.productId,
|
||||
category = args.category,
|
||||
showBackToSearch = args.fromSearch
|
||||
)
|
||||
}
|
||||
|
||||
composable<UserProfile> { backStackEntry ->
|
||||
val args: UserProfile = backStackEntry.toRoute()
|
||||
UserProfileScreen(userId = args.userId)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Bottom Navigation
|
||||
|
||||
### Standard Implementation
|
||||
|
||||
```kotlin
|
||||
enum class BottomNavDestination(
|
||||
val route: Any,
|
||||
val icon: ImageVector,
|
||||
val label: String
|
||||
) {
|
||||
HOME(Home, Icons.Default.Home, "Home"),
|
||||
SEARCH(Search, Icons.Default.Search, "Search"),
|
||||
FAVORITES(Favorites, Icons.Default.Favorite, "Favorites"),
|
||||
PROFILE(Profile, Icons.Default.Person, "Profile")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainScreenWithBottomNav() {
|
||||
val navController = rememberNavController()
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentDestination = navBackStackEntry?.destination
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
BottomNavDestination.entries.forEach { destination ->
|
||||
NavigationBarItem(
|
||||
icon = {
|
||||
Icon(destination.icon, contentDescription = destination.label)
|
||||
},
|
||||
label = { Text(destination.label) },
|
||||
selected = currentDestination?.hasRoute(destination.route::class) == true,
|
||||
onClick = {
|
||||
navController.navigate(destination.route) {
|
||||
// Pop up to start destination to avoid building up stack
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
// Avoid multiple copies of same destination
|
||||
launchSingleTop = true
|
||||
// Restore state when reselecting
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Home,
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
) {
|
||||
composable<Home> { HomeScreen() }
|
||||
composable<Search> { SearchScreen() }
|
||||
composable<Favorites> { FavoritesScreen() }
|
||||
composable<Profile> { ProfileScreen() }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Bottom Nav with Badges
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun BottomNavWithBadges(
|
||||
cartCount: Int,
|
||||
notificationCount: Int
|
||||
) {
|
||||
NavigationBar {
|
||||
NavigationBarItem(
|
||||
icon = { Icon(Icons.Default.Home, null) },
|
||||
label = { Text("Home") },
|
||||
selected = true,
|
||||
onClick = { }
|
||||
)
|
||||
|
||||
NavigationBarItem(
|
||||
icon = {
|
||||
BadgedBox(
|
||||
badge = {
|
||||
if (cartCount > 0) {
|
||||
Badge { Text("$cartCount") }
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(Icons.Default.ShoppingCart, null)
|
||||
}
|
||||
},
|
||||
label = { Text("Cart") },
|
||||
selected = false,
|
||||
onClick = { }
|
||||
)
|
||||
|
||||
NavigationBarItem(
|
||||
icon = {
|
||||
BadgedBox(
|
||||
badge = {
|
||||
if (notificationCount > 0) {
|
||||
Badge {
|
||||
Text(
|
||||
if (notificationCount > 99) "99+"
|
||||
else "$notificationCount"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(Icons.Default.Notifications, null)
|
||||
}
|
||||
},
|
||||
label = { Text("Alerts") },
|
||||
selected = false,
|
||||
onClick = { }
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Navigation Drawer
|
||||
|
||||
### Modal Navigation Drawer
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ModalDrawerNavigation() {
|
||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
var selectedItem by remember { mutableStateOf(0) }
|
||||
|
||||
val items = listOf(
|
||||
DrawerItem(Icons.Default.Home, "Home"),
|
||||
DrawerItem(Icons.Default.Settings, "Settings"),
|
||||
DrawerItem(Icons.Default.Info, "About"),
|
||||
DrawerItem(Icons.Default.Help, "Help")
|
||||
)
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
ModalDrawerSheet {
|
||||
// Header
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(180.dp)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.BottomStart
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
AsyncImage(
|
||||
model = "avatar_url",
|
||||
contentDescription = "Profile",
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.clip(CircleShape)
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"John Doe",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
"john@example.com",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// Navigation items
|
||||
items.forEachIndexed { index, item ->
|
||||
NavigationDrawerItem(
|
||||
icon = { Icon(item.icon, contentDescription = null) },
|
||||
label = { Text(item.label) },
|
||||
selected = index == selectedItem,
|
||||
onClick = {
|
||||
selectedItem = index
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
// Footer
|
||||
HorizontalDivider()
|
||||
NavigationDrawerItem(
|
||||
icon = { Icon(Icons.Default.Logout, null) },
|
||||
label = { Text("Sign Out") },
|
||||
selected = false,
|
||||
onClick = { },
|
||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(items[selectedItem].label) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
||||
Icon(Icons.Default.Menu, "Open drawer")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Content(modifier = Modifier.padding(padding))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class DrawerItem(val icon: ImageVector, val label: String)
|
||||
```
|
||||
|
||||
### Permanent Navigation Drawer (Tablets)
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun PermanentDrawerLayout() {
|
||||
PermanentNavigationDrawer(
|
||||
drawerContent = {
|
||||
PermanentDrawerSheet(
|
||||
modifier = Modifier.width(240.dp)
|
||||
) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
"App Name",
|
||||
modifier = Modifier.padding(16.dp),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
HorizontalDivider()
|
||||
|
||||
drawerItems.forEach { item ->
|
||||
NavigationDrawerItem(
|
||||
icon = { Icon(item.icon, null) },
|
||||
label = { Text(item.label) },
|
||||
selected = item == selectedItem,
|
||||
onClick = { selectedItem = item },
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
// Main content takes remaining space
|
||||
MainContent()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Navigation Rail
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun NavigationRailLayout() {
|
||||
var selectedItem by remember { mutableStateOf(0) }
|
||||
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
NavigationRail(
|
||||
header = {
|
||||
FloatingActionButton(
|
||||
onClick = { },
|
||||
elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation()
|
||||
) {
|
||||
Icon(Icons.Default.Add, "Create")
|
||||
}
|
||||
}
|
||||
) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
railItems.forEachIndexed { index, item ->
|
||||
NavigationRailItem(
|
||||
icon = { Icon(item.icon, null) },
|
||||
label = { Text(item.label) },
|
||||
selected = selectedItem == index,
|
||||
onClick = { selectedItem = index }
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
|
||||
// Main content
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
when (selectedItem) {
|
||||
0 -> HomeContent()
|
||||
1 -> SearchContent()
|
||||
2 -> ProfileContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Deep Linking
|
||||
|
||||
### Basic Deep Link Setup
|
||||
|
||||
```kotlin
|
||||
// In AndroidManifest.xml
|
||||
// <intent-filter>
|
||||
// <action android:name="android.intent.action.VIEW" />
|
||||
// <category android:name="android.intent.category.DEFAULT" />
|
||||
// <category android:name="android.intent.category.BROWSABLE" />
|
||||
// <data android:scheme="myapp" />
|
||||
// <data android:scheme="https" android:host="myapp.com" />
|
||||
// </intent-filter>
|
||||
|
||||
@Composable
|
||||
fun DeepLinkNavigation() {
|
||||
val navController = rememberNavController()
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Home
|
||||
) {
|
||||
composable<Home> {
|
||||
HomeScreen()
|
||||
}
|
||||
|
||||
composable<ProductDetail>(
|
||||
deepLinks = listOf(
|
||||
navDeepLink<ProductDetail>(
|
||||
basePath = "https://myapp.com/product"
|
||||
),
|
||||
navDeepLink<ProductDetail>(
|
||||
basePath = "myapp://product"
|
||||
)
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val args: ProductDetail = backStackEntry.toRoute()
|
||||
ProductDetailScreen(productId = args.productId)
|
||||
}
|
||||
|
||||
composable<UserProfile>(
|
||||
deepLinks = listOf(
|
||||
navDeepLink<UserProfile>(
|
||||
basePath = "https://myapp.com/user"
|
||||
)
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val args: UserProfile = backStackEntry.toRoute()
|
||||
UserProfileScreen(userId = args.userId)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Handling Intent in Activity
|
||||
|
||||
```kotlin
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContent {
|
||||
AppTheme {
|
||||
val navController = rememberNavController()
|
||||
|
||||
// Handle deep link from intent
|
||||
LaunchedEffect(Unit) {
|
||||
intent?.data?.let { uri ->
|
||||
navController.handleDeepLink(intent)
|
||||
}
|
||||
}
|
||||
|
||||
AppNavigation(navController = navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
// Handle new intents when activity is already running
|
||||
setIntent(intent)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Nested Navigation
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun NestedNavigation() {
|
||||
val navController = rememberNavController()
|
||||
|
||||
NavHost(navController = navController, startDestination = MainGraph) {
|
||||
// Main graph with bottom navigation
|
||||
navigation<MainGraph>(startDestination = Home) {
|
||||
composable<Home> {
|
||||
HomeScreen(
|
||||
onItemClick = { navController.navigate(Detail(it)) }
|
||||
)
|
||||
}
|
||||
composable<Search> { SearchScreen() }
|
||||
composable<Profile> {
|
||||
ProfileScreen(
|
||||
onSettingsClick = { navController.navigate(SettingsGraph) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Nested detail graph
|
||||
composable<Detail> { backStackEntry ->
|
||||
val args: Detail = backStackEntry.toRoute()
|
||||
DetailScreen(itemId = args.itemId)
|
||||
}
|
||||
|
||||
// Separate settings graph (full screen, no bottom nav)
|
||||
navigation<SettingsGraph>(startDestination = SettingsMain) {
|
||||
composable<SettingsMain> {
|
||||
SettingsScreen(
|
||||
onAccountClick = { navController.navigate(AccountSettings) },
|
||||
onNotificationsClick = { navController.navigate(NotificationSettings) }
|
||||
)
|
||||
}
|
||||
composable<AccountSettings> { AccountSettingsScreen() }
|
||||
composable<NotificationSettings> { NotificationSettingsScreen() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable object MainGraph
|
||||
@Serializable object SettingsGraph
|
||||
@Serializable object SettingsMain
|
||||
@Serializable object AccountSettings
|
||||
@Serializable object NotificationSettings
|
||||
```
|
||||
|
||||
## Navigation State Management
|
||||
|
||||
### ViewModel Integration
|
||||
|
||||
```kotlin
|
||||
@HiltViewModel
|
||||
class NavigationViewModel @Inject constructor(
|
||||
private val savedStateHandle: SavedStateHandle
|
||||
) : ViewModel() {
|
||||
|
||||
private val _navigationEvents = MutableSharedFlow<NavigationEvent>()
|
||||
val navigationEvents = _navigationEvents.asSharedFlow()
|
||||
|
||||
fun navigateToDetail(itemId: String) {
|
||||
viewModelScope.launch {
|
||||
_navigationEvents.emit(NavigationEvent.NavigateToDetail(itemId))
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateBack() {
|
||||
viewModelScope.launch {
|
||||
_navigationEvents.emit(NavigationEvent.NavigateBack)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class NavigationEvent {
|
||||
data class NavigateToDetail(val itemId: String) : NavigationEvent()
|
||||
object NavigateBack : NavigationEvent()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NavigationHandler(
|
||||
navController: NavHostController,
|
||||
viewModel: NavigationViewModel = hiltViewModel()
|
||||
) {
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.navigationEvents.collect { event ->
|
||||
when (event) {
|
||||
is NavigationEvent.NavigateToDetail -> {
|
||||
navController.navigate(Detail(event.itemId))
|
||||
}
|
||||
NavigationEvent.NavigateBack -> {
|
||||
navController.popBackStack()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Back Handler
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ScreenWithBackHandler(
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
var showExitDialog by remember { mutableStateOf(false) }
|
||||
|
||||
// Intercept back press
|
||||
BackHandler {
|
||||
showExitDialog = true
|
||||
}
|
||||
|
||||
if (showExitDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showExitDialog = false },
|
||||
title = { Text("Exit App?") },
|
||||
text = { Text("Are you sure you want to exit?") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onBack) {
|
||||
Text("Exit")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showExitDialog = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Screen content
|
||||
Content()
|
||||
}
|
||||
```
|
||||
|
||||
## Navigation Animations
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun AnimatedNavigation() {
|
||||
val navController = rememberNavController()
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Home,
|
||||
enterTransition = {
|
||||
slideIntoContainer(
|
||||
towards = AnimatedContentTransitionScope.SlideDirection.Left,
|
||||
animationSpec = tween(300)
|
||||
)
|
||||
},
|
||||
exitTransition = {
|
||||
slideOutOfContainer(
|
||||
towards = AnimatedContentTransitionScope.SlideDirection.Left,
|
||||
animationSpec = tween(300)
|
||||
)
|
||||
},
|
||||
popEnterTransition = {
|
||||
slideIntoContainer(
|
||||
towards = AnimatedContentTransitionScope.SlideDirection.Right,
|
||||
animationSpec = tween(300)
|
||||
)
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOutOfContainer(
|
||||
towards = AnimatedContentTransitionScope.SlideDirection.Right,
|
||||
animationSpec = tween(300)
|
||||
)
|
||||
}
|
||||
) {
|
||||
composable<Home> {
|
||||
HomeScreen()
|
||||
}
|
||||
|
||||
composable<Detail>(
|
||||
// Custom transition for specific route
|
||||
enterTransition = {
|
||||
fadeIn(animationSpec = tween(500)) +
|
||||
scaleIn(initialScale = 0.9f, animationSpec = tween(500))
|
||||
},
|
||||
exitTransition = {
|
||||
fadeOut(animationSpec = tween(500))
|
||||
}
|
||||
) {
|
||||
DetailScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,796 @@
|
||||
# Jetpack Compose Component Library
|
||||
|
||||
## Lists and Collections
|
||||
|
||||
### Basic LazyColumn
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ItemList(
|
||||
items: List<Item>,
|
||||
onItemClick: (Item) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(
|
||||
items = items,
|
||||
key = { it.id }
|
||||
) { item ->
|
||||
ItemRow(
|
||||
item = item,
|
||||
onClick = { onItemClick(item) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pull to Refresh
|
||||
|
||||
```kotlin
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RefreshableList(
|
||||
items: List<Item>,
|
||||
isRefreshing: Boolean,
|
||||
onRefresh: () -> Unit
|
||||
) {
|
||||
val pullToRefreshState = rememberPullToRefreshState()
|
||||
|
||||
PullToRefreshBox(
|
||||
state = pullToRefreshState,
|
||||
isRefreshing = isRefreshing,
|
||||
onRefresh = onRefresh
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
items(items) { item ->
|
||||
ItemRow(item = item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Swipe to Dismiss
|
||||
|
||||
```kotlin
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SwipeableItem(
|
||||
item: Item,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
val dismissState = rememberSwipeToDismissBoxState(
|
||||
confirmValueChange = { value ->
|
||||
if (value == SwipeToDismissBoxValue.EndToStart) {
|
||||
onDelete()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
SwipeToDismissBox(
|
||||
state = dismissState,
|
||||
backgroundContent = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.errorContainer)
|
||||
.padding(horizontal = 20.dp),
|
||||
contentAlignment = Alignment.CenterEnd
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Delete",
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
ItemRow(item = item)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Sticky Headers
|
||||
|
||||
```kotlin
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun GroupedList(
|
||||
groups: Map<String, List<Item>>
|
||||
) {
|
||||
LazyColumn {
|
||||
groups.forEach { (header, items) ->
|
||||
stickyHeader {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant
|
||||
) {
|
||||
Text(
|
||||
text = header,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
items(items, key = { it.id }) { item ->
|
||||
ItemRow(item = item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Forms and Input
|
||||
|
||||
### Text Fields
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun LoginForm(
|
||||
onLogin: (email: String, password: String) -> Unit
|
||||
) {
|
||||
var email by rememberSaveable { mutableStateOf("") }
|
||||
var password by rememberSaveable { mutableStateOf("") }
|
||||
var passwordVisible by rememberSaveable { mutableStateOf(false) }
|
||||
var emailError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
onValueChange = {
|
||||
email = it
|
||||
emailError = if (it.isValidEmail()) null else "Invalid email"
|
||||
},
|
||||
label = { Text("Email") },
|
||||
placeholder = { Text("name@example.com") },
|
||||
leadingIcon = { Icon(Icons.Default.Email, null) },
|
||||
isError = emailError != null,
|
||||
supportingText = emailError?.let { { Text(it) } },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Email,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("Password") },
|
||||
leadingIcon = { Icon(Icons.Default.Lock, null) },
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(
|
||||
if (passwordVisible) Icons.Default.VisibilityOff
|
||||
else Icons.Default.Visibility,
|
||||
contentDescription = "Toggle password visibility"
|
||||
)
|
||||
}
|
||||
},
|
||||
visualTransformation = if (passwordVisible)
|
||||
VisualTransformation.None
|
||||
else
|
||||
PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = { onLogin(email, password) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = email.isNotEmpty() && password.isNotEmpty() && emailError == null
|
||||
) {
|
||||
Text("Sign In")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Search Bar
|
||||
|
||||
```kotlin
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SearchableScreen(
|
||||
items: List<Item>,
|
||||
onItemClick: (Item) -> Unit
|
||||
) {
|
||||
var query by rememberSaveable { mutableStateOf("") }
|
||||
var expanded by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val filteredItems = remember(query, items) {
|
||||
if (query.isEmpty()) items
|
||||
else items.filter { it.name.contains(query, ignoreCase = true) }
|
||||
}
|
||||
|
||||
SearchBar(
|
||||
query = query,
|
||||
onQueryChange = { query = it },
|
||||
onSearch = { expanded = false },
|
||||
active = expanded,
|
||||
onActiveChange = { expanded = it },
|
||||
placeholder = { Text("Search items") },
|
||||
leadingIcon = { Icon(Icons.Default.Search, null) },
|
||||
trailingIcon = {
|
||||
if (query.isNotEmpty()) {
|
||||
IconButton(onClick = { query = "" }) {
|
||||
Icon(Icons.Default.Clear, "Clear search")
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = if (expanded) 0.dp else 16.dp)
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(16.dp)
|
||||
) {
|
||||
items(filteredItems) { item ->
|
||||
ListItem(
|
||||
headlineContent = { Text(item.name) },
|
||||
supportingContent = { Text(item.description) },
|
||||
modifier = Modifier.clickable {
|
||||
onItemClick(item)
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Selection Controls
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun SettingsScreen() {
|
||||
var notificationsEnabled by rememberSaveable { mutableStateOf(true) }
|
||||
var selectedOption by rememberSaveable { mutableStateOf(0) }
|
||||
var expandedDropdown by remember { mutableStateOf(false) }
|
||||
var selectedLanguage by rememberSaveable { mutableStateOf("English") }
|
||||
val languages = listOf("English", "Spanish", "French", "German")
|
||||
|
||||
Column {
|
||||
// Switch
|
||||
ListItem(
|
||||
headlineContent = { Text("Enable Notifications") },
|
||||
supportingContent = { Text("Receive push notifications") },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = notificationsEnabled,
|
||||
onCheckedChange = { notificationsEnabled = it }
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Radio buttons
|
||||
Column {
|
||||
Text(
|
||||
"Theme",
|
||||
modifier = Modifier.padding(16.dp),
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
listOf("System", "Light", "Dark").forEachIndexed { index, option ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = selectedOption == index,
|
||||
onClick = { selectedOption = index },
|
||||
role = Role.RadioButton
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = selectedOption == index,
|
||||
onClick = null
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Text(option)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Dropdown
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expandedDropdown,
|
||||
onExpandedChange = { expandedDropdown = it },
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = selectedLanguage,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Language") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedDropdown) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expandedDropdown,
|
||||
onDismissRequest = { expandedDropdown = false }
|
||||
) {
|
||||
languages.forEach { language ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(language) },
|
||||
onClick = {
|
||||
selectedLanguage = language
|
||||
expandedDropdown = false
|
||||
},
|
||||
contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Dialogs and Bottom Sheets
|
||||
|
||||
### Alert Dialog
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun DeleteConfirmationDialog(
|
||||
itemName: String,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text("Delete Item?")
|
||||
},
|
||||
text = {
|
||||
Text("Are you sure you want to delete \"$itemName\"? This action cannot be undone.")
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = onConfirm,
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text("Delete")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Modal Bottom Sheet
|
||||
|
||||
```kotlin
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun OptionsBottomSheet(
|
||||
onDismiss: () -> Unit,
|
||||
onOptionSelected: (String) -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
dragHandle = { BottomSheetDefaults.DragHandle() }
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 32.dp)
|
||||
) {
|
||||
Text(
|
||||
"Options",
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
listOf(
|
||||
Triple(Icons.Default.Share, "Share", "share"),
|
||||
Triple(Icons.Default.Edit, "Edit", "edit"),
|
||||
Triple(Icons.Default.FileCopy, "Duplicate", "duplicate"),
|
||||
Triple(Icons.Default.Delete, "Delete", "delete")
|
||||
).forEach { (icon, label, action) ->
|
||||
ListItem(
|
||||
headlineContent = { Text(label) },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
tint = if (action == "delete")
|
||||
MaterialTheme.colorScheme.error
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable { onOptionSelected(action) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Date and Time Pickers
|
||||
|
||||
```kotlin
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DateTimePickerExample() {
|
||||
var showDatePicker by remember { mutableStateOf(false) }
|
||||
var showTimePicker by remember { mutableStateOf(false) }
|
||||
val datePickerState = rememberDatePickerState()
|
||||
val timePickerState = rememberTimePickerState()
|
||||
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
OutlinedButton(onClick = { showDatePicker = true }) {
|
||||
Icon(Icons.Default.CalendarToday, null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
datePickerState.selectedDateMillis?.let {
|
||||
SimpleDateFormat("MMM dd, yyyy", Locale.getDefault())
|
||||
.format(Date(it))
|
||||
} ?: "Select Date"
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
OutlinedButton(onClick = { showTimePicker = true }) {
|
||||
Icon(Icons.Default.Schedule, null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
String.format("%02d:%02d", timePickerState.hour, timePickerState.minute)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showDatePicker) {
|
||||
DatePickerDialog(
|
||||
onDismissRequest = { showDatePicker = false },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showDatePicker = false }) {
|
||||
Text("OK")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDatePicker = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
) {
|
||||
DatePicker(state = datePickerState)
|
||||
}
|
||||
}
|
||||
|
||||
if (showTimePicker) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showTimePicker = false },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showTimePicker = false }) {
|
||||
Text("OK")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showTimePicker = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
text = {
|
||||
TimePicker(state = timePickerState)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Loading States
|
||||
|
||||
### Progress Indicators
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun LoadingStates() {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||
) {
|
||||
// Indeterminate circular
|
||||
CircularProgressIndicator()
|
||||
|
||||
// Determinate circular
|
||||
CircularProgressIndicator(
|
||||
progress = { 0.7f },
|
||||
strokeWidth = 4.dp
|
||||
)
|
||||
|
||||
// Indeterminate linear
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
|
||||
// Determinate linear
|
||||
LinearProgressIndicator(
|
||||
progress = { 0.7f },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Skeleton Loading
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun SkeletonLoader(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "skeleton")
|
||||
val alpha by infiniteTransition.animateFloat(
|
||||
initialValue = 0.3f,
|
||||
targetValue = 0.7f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(1000, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "alpha"
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
repeat(5) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = alpha))
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(16.dp)
|
||||
.fillMaxWidth(0.7f)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = alpha))
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(12.dp)
|
||||
.fillMaxWidth(0.5f)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = alpha))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Content Loading Pattern
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun <T> AsyncContent(
|
||||
state: AsyncState<T>,
|
||||
onRetry: () -> Unit,
|
||||
content: @Composable (T) -> Unit
|
||||
) {
|
||||
when (state) {
|
||||
is AsyncState.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
is AsyncState.Success -> {
|
||||
content(state.data)
|
||||
}
|
||||
is AsyncState.Error -> {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
"Something went wrong",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
state.message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Button(onClick = onRetry) {
|
||||
Text("Try Again")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class AsyncState<out T> {
|
||||
object Loading : AsyncState<Nothing>()
|
||||
data class Success<T>(val data: T) : AsyncState<T>()
|
||||
data class Error(val message: String) : AsyncState<Nothing>()
|
||||
}
|
||||
```
|
||||
|
||||
## Animations
|
||||
|
||||
### Animated Visibility
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ExpandableCard(
|
||||
title: String,
|
||||
content: String
|
||||
) {
|
||||
var expanded by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clickable { expanded = !expanded }
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||
Icon(
|
||||
if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
contentDescription = if (expanded) "Collapse" else "Expand"
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = expanded,
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut()
|
||||
) {
|
||||
Text(
|
||||
text = content,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Animated Content
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun AnimatedCounter(count: Int) {
|
||||
AnimatedContent(
|
||||
targetState = count,
|
||||
transitionSpec = {
|
||||
if (targetState > initialState) {
|
||||
slideInVertically { -it } + fadeIn() togetherWith
|
||||
slideOutVertically { it } + fadeOut()
|
||||
} else {
|
||||
slideInVertically { it } + fadeIn() togetherWith
|
||||
slideOutVertically { -it } + fadeOut()
|
||||
}.using(SizeTransform(clip = false))
|
||||
},
|
||||
label = "counter"
|
||||
) { targetCount ->
|
||||
Text(
|
||||
text = "$targetCount",
|
||||
style = MaterialTheme.typography.displayMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Gesture-Based Animation
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun SwipeableCard(
|
||||
onSwipeLeft: () -> Unit,
|
||||
onSwipeRight: () -> Unit,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
var offsetX by remember { mutableFloatStateOf(0f) }
|
||||
val animatedOffset by animateFloatAsState(
|
||||
targetValue = offsetX,
|
||||
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
|
||||
label = "offset"
|
||||
)
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.offset { IntOffset(animatedOffset.roundToInt(), 0) }
|
||||
.pointerInput(Unit) {
|
||||
detectHorizontalDragGestures(
|
||||
onDragEnd = {
|
||||
when {
|
||||
offsetX > 200f -> {
|
||||
onSwipeRight()
|
||||
offsetX = 0f
|
||||
}
|
||||
offsetX < -200f -> {
|
||||
onSwipeLeft()
|
||||
offsetX = 0f
|
||||
}
|
||||
else -> offsetX = 0f
|
||||
}
|
||||
},
|
||||
onHorizontalDrag = { _, dragAmount ->
|
||||
offsetX += dragAmount
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,604 @@
|
||||
# Material Design 3 Theming
|
||||
|
||||
## Color System
|
||||
|
||||
### Dynamic Color (Material You)
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun AppTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context)
|
||||
else dynamicLightColorScheme(context)
|
||||
}
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = AppTypography,
|
||||
shapes = AppShapes,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Color Scheme
|
||||
|
||||
```kotlin
|
||||
// Define color palette
|
||||
val md_theme_light_primary = Color(0xFF6750A4)
|
||||
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
|
||||
val md_theme_light_primaryContainer = Color(0xFFEADDFF)
|
||||
val md_theme_light_onPrimaryContainer = Color(0xFF21005D)
|
||||
val md_theme_light_secondary = Color(0xFF625B71)
|
||||
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
|
||||
val md_theme_light_secondaryContainer = Color(0xFFE8DEF8)
|
||||
val md_theme_light_onSecondaryContainer = Color(0xFF1D192B)
|
||||
val md_theme_light_tertiary = Color(0xFF7D5260)
|
||||
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
|
||||
val md_theme_light_tertiaryContainer = Color(0xFFFFD8E4)
|
||||
val md_theme_light_onTertiaryContainer = Color(0xFF31111D)
|
||||
val md_theme_light_error = Color(0xFFB3261E)
|
||||
val md_theme_light_onError = Color(0xFFFFFFFF)
|
||||
val md_theme_light_errorContainer = Color(0xFFF9DEDC)
|
||||
val md_theme_light_onErrorContainer = Color(0xFF410E0B)
|
||||
val md_theme_light_background = Color(0xFFFFFBFE)
|
||||
val md_theme_light_onBackground = Color(0xFF1C1B1F)
|
||||
val md_theme_light_surface = Color(0xFFFFFBFE)
|
||||
val md_theme_light_onSurface = Color(0xFF1C1B1F)
|
||||
val md_theme_light_surfaceVariant = Color(0xFFE7E0EC)
|
||||
val md_theme_light_onSurfaceVariant = Color(0xFF49454F)
|
||||
val md_theme_light_outline = Color(0xFF79747E)
|
||||
val md_theme_light_outlineVariant = Color(0xFFCAC4D0)
|
||||
|
||||
val LightColorScheme = lightColorScheme(
|
||||
primary = md_theme_light_primary,
|
||||
onPrimary = md_theme_light_onPrimary,
|
||||
primaryContainer = md_theme_light_primaryContainer,
|
||||
onPrimaryContainer = md_theme_light_onPrimaryContainer,
|
||||
secondary = md_theme_light_secondary,
|
||||
onSecondary = md_theme_light_onSecondary,
|
||||
secondaryContainer = md_theme_light_secondaryContainer,
|
||||
onSecondaryContainer = md_theme_light_onSecondaryContainer,
|
||||
tertiary = md_theme_light_tertiary,
|
||||
onTertiary = md_theme_light_onTertiary,
|
||||
tertiaryContainer = md_theme_light_tertiaryContainer,
|
||||
onTertiaryContainer = md_theme_light_onTertiaryContainer,
|
||||
error = md_theme_light_error,
|
||||
onError = md_theme_light_onError,
|
||||
errorContainer = md_theme_light_errorContainer,
|
||||
onErrorContainer = md_theme_light_onErrorContainer,
|
||||
background = md_theme_light_background,
|
||||
onBackground = md_theme_light_onBackground,
|
||||
surface = md_theme_light_surface,
|
||||
onSurface = md_theme_light_onSurface,
|
||||
surfaceVariant = md_theme_light_surfaceVariant,
|
||||
onSurfaceVariant = md_theme_light_onSurfaceVariant,
|
||||
outline = md_theme_light_outline,
|
||||
outlineVariant = md_theme_light_outlineVariant
|
||||
)
|
||||
|
||||
// Dark colors follow the same pattern
|
||||
val DarkColorScheme = darkColorScheme(
|
||||
primary = md_theme_dark_primary,
|
||||
// ... other colors
|
||||
)
|
||||
```
|
||||
|
||||
### Color Roles Usage
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ColorRolesExample() {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Primary - Key actions, FABs
|
||||
Button(onClick = { }) {
|
||||
Text("Primary Action")
|
||||
}
|
||||
|
||||
// Primary Container - Less prominent containers
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Text(
|
||||
"Primary Container",
|
||||
modifier = Modifier.padding(16.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
|
||||
// Secondary - Less prominent actions
|
||||
FilledTonalButton(onClick = { }) {
|
||||
Text("Secondary Action")
|
||||
}
|
||||
|
||||
// Tertiary - Contrast accents
|
||||
Badge(
|
||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onTertiaryContainer
|
||||
) {
|
||||
Text("New")
|
||||
}
|
||||
|
||||
// Error - Destructive actions
|
||||
Button(
|
||||
onClick = { },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text("Delete")
|
||||
}
|
||||
|
||||
// Surface variants
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"Surface Variant",
|
||||
modifier = Modifier.padding(16.dp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Extended Colors
|
||||
|
||||
```kotlin
|
||||
// Custom semantic colors beyond M3 defaults
|
||||
data class ExtendedColors(
|
||||
val success: Color,
|
||||
val onSuccess: Color,
|
||||
val successContainer: Color,
|
||||
val onSuccessContainer: Color,
|
||||
val warning: Color,
|
||||
val onWarning: Color,
|
||||
val warningContainer: Color,
|
||||
val onWarningContainer: Color
|
||||
)
|
||||
|
||||
val LocalExtendedColors = staticCompositionLocalOf {
|
||||
ExtendedColors(
|
||||
success = Color(0xFF4CAF50),
|
||||
onSuccess = Color.White,
|
||||
successContainer = Color(0xFFE8F5E9),
|
||||
onSuccessContainer = Color(0xFF1B5E20),
|
||||
warning = Color(0xFFFF9800),
|
||||
onWarning = Color.White,
|
||||
warningContainer = Color(0xFFFFF3E0),
|
||||
onWarningContainer = Color(0xFFE65100)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppTheme(
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val extendedColors = ExtendedColors(
|
||||
// ... define colors based on light/dark theme
|
||||
)
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalExtendedColors provides extendedColors
|
||||
) {
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
@Composable
|
||||
fun SuccessBanner() {
|
||||
val extendedColors = LocalExtendedColors.current
|
||||
|
||||
Surface(
|
||||
color = extendedColors.successContainer,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = extendedColors.success
|
||||
)
|
||||
Text(
|
||||
"Operation successful!",
|
||||
color = extendedColors.onSuccessContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Typography
|
||||
|
||||
### Material 3 Type Scale
|
||||
|
||||
```kotlin
|
||||
val AppTypography = Typography(
|
||||
// Display styles - Hero text, large numerals
|
||||
displayLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 57.sp,
|
||||
lineHeight = 64.sp,
|
||||
letterSpacing = (-0.25).sp
|
||||
),
|
||||
displayMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 45.sp,
|
||||
lineHeight = 52.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
displaySmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 36.sp,
|
||||
lineHeight = 44.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
|
||||
// Headline styles - High emphasis, short text
|
||||
headlineLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 32.sp,
|
||||
lineHeight = 40.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 36.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
headlineSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 32.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
|
||||
// Title styles - Medium emphasis headers
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.15.sp
|
||||
),
|
||||
titleSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.1.sp
|
||||
),
|
||||
|
||||
// Body styles - Long-form text
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.25.sp
|
||||
),
|
||||
bodySmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.4.sp
|
||||
),
|
||||
|
||||
// Label styles - Buttons, chips, navigation
|
||||
labelLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.1.sp
|
||||
),
|
||||
labelMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### Custom Fonts
|
||||
|
||||
```kotlin
|
||||
// Load custom fonts
|
||||
val Inter = FontFamily(
|
||||
Font(R.font.inter_regular, FontWeight.Normal),
|
||||
Font(R.font.inter_medium, FontWeight.Medium),
|
||||
Font(R.font.inter_semibold, FontWeight.SemiBold),
|
||||
Font(R.font.inter_bold, FontWeight.Bold)
|
||||
)
|
||||
|
||||
val AppTypography = Typography(
|
||||
displayLarge = TextStyle(
|
||||
fontFamily = Inter,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 57.sp,
|
||||
lineHeight = 64.sp
|
||||
),
|
||||
// Apply to all styles...
|
||||
)
|
||||
|
||||
// Variable fonts (Android 12+)
|
||||
val InterVariable = FontFamily(
|
||||
Font(
|
||||
R.font.inter_variable,
|
||||
variationSettings = FontVariation.Settings(
|
||||
FontVariation.weight(400)
|
||||
)
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
## Shape System
|
||||
|
||||
### Material 3 Shapes
|
||||
|
||||
```kotlin
|
||||
val AppShapes = Shapes(
|
||||
// Extra small - Chips, small buttons
|
||||
extraSmall = RoundedCornerShape(4.dp),
|
||||
|
||||
// Small - Text fields, small cards
|
||||
small = RoundedCornerShape(8.dp),
|
||||
|
||||
// Medium - Cards, dialogs
|
||||
medium = RoundedCornerShape(12.dp),
|
||||
|
||||
// Large - Large cards, bottom sheets
|
||||
large = RoundedCornerShape(16.dp),
|
||||
|
||||
// Extra large - Full-screen dialogs
|
||||
extraLarge = RoundedCornerShape(28.dp)
|
||||
)
|
||||
```
|
||||
|
||||
### Custom Shape Usage
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ShapedComponents() {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Small shape for text field
|
||||
OutlinedTextField(
|
||||
value = "",
|
||||
onValueChange = {},
|
||||
shape = MaterialTheme.shapes.small,
|
||||
label = { Text("Input") }
|
||||
)
|
||||
|
||||
// Medium shape for cards
|
||||
Card(
|
||||
shape = MaterialTheme.shapes.medium
|
||||
) {
|
||||
Text("Card content", modifier = Modifier.padding(16.dp))
|
||||
}
|
||||
|
||||
// Large shape for prominent containers
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.primaryContainer
|
||||
) {
|
||||
Text("Featured", modifier = Modifier.padding(24.dp))
|
||||
}
|
||||
|
||||
// Custom asymmetric shape
|
||||
Surface(
|
||||
shape = RoundedCornerShape(
|
||||
topStart = 24.dp,
|
||||
topEnd = 24.dp,
|
||||
bottomStart = 0.dp,
|
||||
bottomEnd = 0.dp
|
||||
),
|
||||
color = MaterialTheme.colorScheme.surface
|
||||
) {
|
||||
Text("Bottom sheet style", modifier = Modifier.padding(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Elevation and Shadows
|
||||
|
||||
### Tonal Elevation
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ElevationExample() {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Level 0 - No elevation
|
||||
Surface(
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp
|
||||
) {
|
||||
Text("Level 0", modifier = Modifier.padding(16.dp))
|
||||
}
|
||||
|
||||
// Level 1 - Low emphasis surfaces
|
||||
Surface(
|
||||
tonalElevation = 1.dp,
|
||||
shadowElevation = 1.dp
|
||||
) {
|
||||
Text("Level 1", modifier = Modifier.padding(16.dp))
|
||||
}
|
||||
|
||||
// Level 2 - Cards, switches
|
||||
Surface(
|
||||
tonalElevation = 3.dp,
|
||||
shadowElevation = 2.dp
|
||||
) {
|
||||
Text("Level 2", modifier = Modifier.padding(16.dp))
|
||||
}
|
||||
|
||||
// Level 3 - Navigation components
|
||||
Surface(
|
||||
tonalElevation = 6.dp,
|
||||
shadowElevation = 4.dp
|
||||
) {
|
||||
Text("Level 3", modifier = Modifier.padding(16.dp))
|
||||
}
|
||||
|
||||
// Level 4 - Navigation rail
|
||||
Surface(
|
||||
tonalElevation = 8.dp,
|
||||
shadowElevation = 6.dp
|
||||
) {
|
||||
Text("Level 4", modifier = Modifier.padding(16.dp))
|
||||
}
|
||||
|
||||
// Level 5 - FAB
|
||||
Surface(
|
||||
tonalElevation = 12.dp,
|
||||
shadowElevation = 8.dp
|
||||
) {
|
||||
Text("Level 5", modifier = Modifier.padding(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Responsive Design
|
||||
|
||||
### Window Size Classes
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun AdaptiveLayout() {
|
||||
val windowSizeClass = calculateWindowSizeClass(LocalContext.current as Activity)
|
||||
|
||||
when (windowSizeClass.widthSizeClass) {
|
||||
WindowWidthSizeClass.Compact -> {
|
||||
// Phone portrait - Single column, bottom nav
|
||||
CompactLayout()
|
||||
}
|
||||
WindowWidthSizeClass.Medium -> {
|
||||
// Tablet portrait, phone landscape - Navigation rail
|
||||
MediumLayout()
|
||||
}
|
||||
WindowWidthSizeClass.Expanded -> {
|
||||
// Tablet landscape, desktop - Navigation drawer, multi-pane
|
||||
ExpandedLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CompactLayout() {
|
||||
Scaffold(
|
||||
bottomBar = { NavigationBar { /* items */ } }
|
||||
) { padding ->
|
||||
Content(modifier = Modifier.padding(padding))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MediumLayout() {
|
||||
Row {
|
||||
NavigationRail { /* items */ }
|
||||
Content(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ExpandedLayout() {
|
||||
PermanentNavigationDrawer(
|
||||
drawerContent = {
|
||||
PermanentDrawerSheet { /* items */ }
|
||||
}
|
||||
) {
|
||||
Row {
|
||||
ListPane(modifier = Modifier.weight(0.4f))
|
||||
DetailPane(modifier = Modifier.weight(0.6f))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Foldable Support
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun FoldableAwareLayout() {
|
||||
val foldingFeature = LocalFoldingFeature.current
|
||||
|
||||
when {
|
||||
foldingFeature?.state == FoldingFeature.State.HALF_OPENED -> {
|
||||
// Device is half-folded (tabletop mode)
|
||||
TwoHingeLayout(
|
||||
top = { CameraPreview() },
|
||||
bottom = { CameraControls() }
|
||||
)
|
||||
}
|
||||
foldingFeature?.orientation == FoldingFeature.Orientation.VERTICAL -> {
|
||||
// Vertical fold (book mode)
|
||||
TwoPaneLayout(
|
||||
first = { NavigationPane() },
|
||||
second = { ContentPane() }
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
// Regular or fully opened
|
||||
SinglePaneLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
263
plugins/ui-design/skills/mobile-ios-design/SKILL.md
Normal file
263
plugins/ui-design/skills/mobile-ios-design/SKILL.md
Normal file
@@ -0,0 +1,263 @@
|
||||
---
|
||||
name: mobile-ios-design
|
||||
description: Master iOS Human Interface Guidelines and SwiftUI patterns for building native iOS apps. Use when designing iOS interfaces, implementing SwiftUI views, or ensuring apps follow Apple's design principles.
|
||||
---
|
||||
|
||||
# iOS Mobile Design
|
||||
|
||||
Master iOS Human Interface Guidelines (HIG) and SwiftUI patterns to build polished, native iOS applications that feel at home on Apple platforms.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
- Designing iOS app interfaces following Apple HIG
|
||||
- Building SwiftUI views and layouts
|
||||
- Implementing iOS navigation patterns (NavigationStack, TabView, sheets)
|
||||
- Creating adaptive layouts for iPhone and iPad
|
||||
- Using SF Symbols and system typography
|
||||
- Building accessible iOS interfaces
|
||||
- Implementing iOS-specific gestures and interactions
|
||||
- Designing for Dynamic Type and Dark Mode
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### 1. Human Interface Guidelines Principles
|
||||
|
||||
**Clarity**: Content is legible, icons are precise, adornments are subtle
|
||||
**Deference**: UI helps users understand content without competing with it
|
||||
**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
|
||||
|
||||
### 2. SwiftUI Layout System
|
||||
|
||||
**Stack-Based Layouts:**
|
||||
```swift
|
||||
// Vertical stack with alignment
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Title")
|
||||
.font(.headline)
|
||||
Text("Subtitle")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
// Horizontal stack with flexible spacing
|
||||
HStack {
|
||||
Image(systemName: "star.fill")
|
||||
Text("Featured")
|
||||
Spacer()
|
||||
Text("View All")
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
```
|
||||
|
||||
**Grid Layouts:**
|
||||
```swift
|
||||
// Adaptive grid that fills available width
|
||||
LazyVGrid(columns: [
|
||||
GridItem(.adaptive(minimum: 150, maximum: 200))
|
||||
], spacing: 16) {
|
||||
ForEach(items) { item in
|
||||
ItemCard(item: item)
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed column grid
|
||||
LazyVGrid(columns: [
|
||||
GridItem(.flexible()),
|
||||
GridItem(.flexible()),
|
||||
GridItem(.flexible())
|
||||
], spacing: 12) {
|
||||
ForEach(items) { item in
|
||||
ItemThumbnail(item: item)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Navigation Patterns
|
||||
|
||||
**NavigationStack (iOS 16+):**
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
@State private var path = NavigationPath()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: $path) {
|
||||
List(items) { item in
|
||||
NavigationLink(value: item) {
|
||||
ItemRow(item: item)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Items")
|
||||
.navigationDestination(for: Item.self) { item in
|
||||
ItemDetailView(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**TabView:**
|
||||
```swift
|
||||
struct MainTabView: View {
|
||||
@State private var selectedTab = 0
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
HomeView()
|
||||
.tabItem {
|
||||
Label("Home", systemImage: "house")
|
||||
}
|
||||
.tag(0)
|
||||
|
||||
SearchView()
|
||||
.tabItem {
|
||||
Label("Search", systemImage: "magnifyingglass")
|
||||
}
|
||||
.tag(1)
|
||||
|
||||
ProfileView()
|
||||
.tabItem {
|
||||
Label("Profile", systemImage: "person")
|
||||
}
|
||||
.tag(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. System Integration
|
||||
|
||||
**SF Symbols:**
|
||||
```swift
|
||||
// Basic symbol
|
||||
Image(systemName: "heart.fill")
|
||||
.foregroundStyle(.red)
|
||||
|
||||
// Symbol with rendering mode
|
||||
Image(systemName: "cloud.sun.fill")
|
||||
.symbolRenderingMode(.multicolor)
|
||||
|
||||
// Variable symbol (iOS 16+)
|
||||
Image(systemName: "speaker.wave.3.fill", variableValue: volume)
|
||||
|
||||
// Symbol effect (iOS 17+)
|
||||
Image(systemName: "bell.fill")
|
||||
.symbolEffect(.bounce, value: notificationCount)
|
||||
```
|
||||
|
||||
**Dynamic Type:**
|
||||
```swift
|
||||
// Use semantic fonts
|
||||
Text("Headline")
|
||||
.font(.headline)
|
||||
|
||||
Text("Body text that scales with user preferences")
|
||||
.font(.body)
|
||||
|
||||
// Custom font that respects Dynamic Type
|
||||
Text("Custom")
|
||||
.font(.custom("Avenir", size: 17, relativeTo: .body))
|
||||
```
|
||||
|
||||
### 5. Visual Design
|
||||
|
||||
**Colors and Materials:**
|
||||
```swift
|
||||
// Semantic colors that adapt to light/dark mode
|
||||
Text("Primary")
|
||||
.foregroundStyle(.primary)
|
||||
Text("Secondary")
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
// System materials for blur effects
|
||||
Rectangle()
|
||||
.fill(.ultraThinMaterial)
|
||||
.frame(height: 100)
|
||||
|
||||
// Vibrant materials for overlays
|
||||
Text("Overlay")
|
||||
.padding()
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
|
||||
```
|
||||
|
||||
**Shadows and Depth:**
|
||||
```swift
|
||||
// Standard card shadow
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(.background)
|
||||
.shadow(color: .black.opacity(0.1), radius: 8, y: 4)
|
||||
|
||||
// Elevated appearance
|
||||
.shadow(radius: 2, y: 1)
|
||||
.shadow(radius: 8, y: 4)
|
||||
```
|
||||
|
||||
## Quick Start Component
|
||||
|
||||
```swift
|
||||
import SwiftUI
|
||||
|
||||
struct FeatureCard: View {
|
||||
let title: String
|
||||
let description: String
|
||||
let systemImage: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 16) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.title)
|
||||
.foregroundStyle(.blue)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(.blue.opacity(0.1), in: Circle())
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
Text(description)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding()
|
||||
.background(.background, in: RoundedRectangle(cornerRadius: 12))
|
||||
.shadow(color: .black.opacity(0.05), radius: 4, y: 2)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use Semantic Colors**: Always use `.primary`, `.secondary`, `.background` for automatic light/dark mode support
|
||||
2. **Embrace SF Symbols**: Use system symbols for consistency and automatic accessibility
|
||||
3. **Support Dynamic Type**: Use semantic fonts (`.body`, `.headline`) instead of fixed sizes
|
||||
4. **Add Accessibility**: Include `.accessibilityLabel()` and `.accessibilityHint()` modifiers
|
||||
5. **Use Safe Areas**: Respect `safeAreaInset` and avoid hardcoded padding at screen edges
|
||||
6. **Implement State Restoration**: Use `@SceneStorage` for preserving user state
|
||||
7. **Support iPad Multitasking**: Design for split view and slide over
|
||||
8. **Test on Device**: Simulator doesn't capture full haptic and performance experience
|
||||
|
||||
## Common Issues
|
||||
|
||||
- **Layout Breaking**: Use `.fixedSize()` sparingly; prefer flexible layouts
|
||||
- **Performance Issues**: Use `LazyVStack`/`LazyHStack` for long scrolling lists
|
||||
- **Navigation Bugs**: Ensure `NavigationLink` values are `Hashable`
|
||||
- **Dark Mode Problems**: Avoid hardcoded colors; use semantic or asset catalog colors
|
||||
- **Accessibility Failures**: Test with VoiceOver enabled
|
||||
- **Memory Leaks**: Watch for strong reference cycles in closures
|
||||
|
||||
## Resources
|
||||
|
||||
- [Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/)
|
||||
- [SwiftUI Documentation](https://developer.apple.com/documentation/swiftui)
|
||||
- [SF Symbols App](https://developer.apple.com/sf-symbols/)
|
||||
- [WWDC SwiftUI Sessions](https://developer.apple.com/videos/swiftui/)
|
||||
@@ -0,0 +1,529 @@
|
||||
# iOS Human Interface Guidelines Patterns
|
||||
|
||||
## Layout and Spacing
|
||||
|
||||
### Standard Margins and Padding
|
||||
|
||||
```swift
|
||||
// System standard margins
|
||||
private let standardMargin: CGFloat = 16
|
||||
private let compactMargin: CGFloat = 8
|
||||
private let largeMargin: CGFloat = 24
|
||||
|
||||
// Content insets following HIG
|
||||
extension EdgeInsets {
|
||||
static let standard = EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16)
|
||||
static let listRow = EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)
|
||||
static let card = EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16)
|
||||
}
|
||||
```
|
||||
|
||||
### Safe Area Handling
|
||||
|
||||
```swift
|
||||
struct SafeAreaAwareView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 16) {
|
||||
ForEach(items) { item in
|
||||
ItemRow(item: item)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
// Floating action area
|
||||
HStack {
|
||||
Button("Cancel") { }
|
||||
.buttonStyle(.bordered)
|
||||
Spacer()
|
||||
Button("Confirm") { }
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding()
|
||||
.background(.regularMaterial)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Adaptive Layouts
|
||||
|
||||
```swift
|
||||
struct AdaptiveGridView: View {
|
||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||
|
||||
private var columns: [GridItem] {
|
||||
switch sizeClass {
|
||||
case .compact:
|
||||
return [GridItem(.flexible())]
|
||||
case .regular:
|
||||
return [
|
||||
GridItem(.flexible()),
|
||||
GridItem(.flexible()),
|
||||
GridItem(.flexible())
|
||||
]
|
||||
default:
|
||||
return [GridItem(.flexible())]
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 16) {
|
||||
ForEach(items) { item in
|
||||
ItemCard(item: item)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Typography Hierarchy
|
||||
|
||||
### System Font Styles
|
||||
|
||||
```swift
|
||||
// HIG-compliant typography scale
|
||||
struct Typography {
|
||||
// Titles
|
||||
static let largeTitle = Font.largeTitle.weight(.bold) // 34pt bold
|
||||
static let title = Font.title.weight(.semibold) // 28pt semibold
|
||||
static let title2 = Font.title2.weight(.semibold) // 22pt semibold
|
||||
static let title3 = Font.title3.weight(.semibold) // 20pt semibold
|
||||
|
||||
// Headlines and body
|
||||
static let headline = Font.headline // 17pt semibold
|
||||
static let body = Font.body // 17pt regular
|
||||
static let callout = Font.callout // 16pt regular
|
||||
|
||||
// Supporting text
|
||||
static let subheadline = Font.subheadline // 15pt regular
|
||||
static let footnote = Font.footnote // 13pt regular
|
||||
static let caption = Font.caption // 12pt regular
|
||||
static let caption2 = Font.caption2 // 11pt regular
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Font with Dynamic Type
|
||||
|
||||
```swift
|
||||
extension Font {
|
||||
static func customBody(_ name: String) -> Font {
|
||||
.custom(name, size: 17, relativeTo: .body)
|
||||
}
|
||||
|
||||
static func customHeadline(_ name: String) -> Font {
|
||||
.custom(name, size: 17, relativeTo: .headline)
|
||||
.weight(.semibold)
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
Text("Custom styled text")
|
||||
.font(.customBody("Avenir Next"))
|
||||
```
|
||||
|
||||
## Color System
|
||||
|
||||
### Semantic Colors
|
||||
|
||||
```swift
|
||||
// Use semantic colors for automatic light/dark mode support
|
||||
extension Color {
|
||||
// Labels
|
||||
static let primaryLabel = Color.primary
|
||||
static let secondaryLabel = Color.secondary
|
||||
static let tertiaryLabel = Color(uiColor: .tertiaryLabel)
|
||||
|
||||
// Backgrounds
|
||||
static let systemBackground = Color(uiColor: .systemBackground)
|
||||
static let secondaryBackground = Color(uiColor: .secondarySystemBackground)
|
||||
static let groupedBackground = Color(uiColor: .systemGroupedBackground)
|
||||
|
||||
// Fills
|
||||
static let primaryFill = Color(uiColor: .systemFill)
|
||||
static let secondaryFill = Color(uiColor: .secondarySystemFill)
|
||||
|
||||
// Separators
|
||||
static let separator = Color(uiColor: .separator)
|
||||
static let opaqueSeparator = Color(uiColor: .opaqueSeparator)
|
||||
}
|
||||
```
|
||||
|
||||
### Tint Colors
|
||||
|
||||
```swift
|
||||
// App-wide tint color
|
||||
struct AppColors {
|
||||
static let primary = Color.blue
|
||||
static let secondary = Color.purple
|
||||
static let success = Color.green
|
||||
static let warning = Color.orange
|
||||
static let error = Color.red
|
||||
|
||||
// Semantic tints
|
||||
static let interactive = Color.accentColor
|
||||
static let destructive = Color.red
|
||||
}
|
||||
|
||||
// Apply tint to views
|
||||
ContentView()
|
||||
.tint(AppColors.primary)
|
||||
```
|
||||
|
||||
## Navigation Patterns
|
||||
|
||||
### Hierarchical Navigation
|
||||
|
||||
```swift
|
||||
struct MasterDetailView: View {
|
||||
@State private var selectedItem: Item?
|
||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
// Sidebar
|
||||
List(items, selection: $selectedItem) { item in
|
||||
NavigationLink(value: item) {
|
||||
ItemRow(item: item)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Items")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button("Add", systemImage: "plus") { }
|
||||
}
|
||||
}
|
||||
} detail: {
|
||||
// Detail view
|
||||
if let item = selectedItem {
|
||||
ItemDetailView(item: item)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"Select an Item",
|
||||
systemImage: "sidebar.leading"
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tab-Based Navigation
|
||||
|
||||
```swift
|
||||
struct MainTabView: View {
|
||||
@State private var selectedTab: Tab = .home
|
||||
|
||||
enum Tab: String, CaseIterable {
|
||||
case home, explore, notifications, profile
|
||||
|
||||
var title: String {
|
||||
rawValue.capitalized
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .home: return "house"
|
||||
case .explore: return "magnifyingglass"
|
||||
case .notifications: return "bell"
|
||||
case .profile: return "person"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
ForEach(Tab.allCases, id: \.self) { tab in
|
||||
NavigationStack {
|
||||
tabContent(for: tab)
|
||||
}
|
||||
.tabItem {
|
||||
Label(tab.title, systemImage: tab.systemImage)
|
||||
}
|
||||
.tag(tab)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func tabContent(for tab: Tab) -> some View {
|
||||
switch tab {
|
||||
case .home:
|
||||
HomeView()
|
||||
case .explore:
|
||||
ExploreView()
|
||||
case .notifications:
|
||||
NotificationsView()
|
||||
case .profile:
|
||||
ProfileView()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Toolbar Patterns
|
||||
|
||||
### Standard Toolbar Items
|
||||
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
@State private var isEditing = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List { /* content */ }
|
||||
.navigationTitle("Items")
|
||||
.toolbar {
|
||||
// Leading items
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
EditButton()
|
||||
}
|
||||
|
||||
// Trailing items
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
Button("Filter", systemImage: "line.3.horizontal.decrease.circle") { }
|
||||
Button("Add", systemImage: "plus") { }
|
||||
}
|
||||
|
||||
// Bottom bar
|
||||
ToolbarItemGroup(placement: .bottomBar) {
|
||||
Button("Archive", systemImage: "archivebox") { }
|
||||
Spacer()
|
||||
Text("\(itemCount) items")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("Share", systemImage: "square.and.arrow.up") { }
|
||||
}
|
||||
}
|
||||
.toolbarBackground(.visible, for: .bottomBar)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Search Integration
|
||||
|
||||
```swift
|
||||
struct SearchableView: View {
|
||||
@State private var searchText = ""
|
||||
@State private var searchScope: SearchScope = .all
|
||||
@State private var isSearching = false
|
||||
|
||||
enum SearchScope: String, CaseIterable {
|
||||
case all, titles, content
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List(filteredItems) { item in
|
||||
ItemRow(item: item)
|
||||
}
|
||||
.navigationTitle("Library")
|
||||
.searchable(
|
||||
text: $searchText,
|
||||
isPresented: $isSearching,
|
||||
placement: .navigationBarDrawer(displayMode: .always)
|
||||
)
|
||||
.searchScopes($searchScope) {
|
||||
ForEach(SearchScope.allCases, id: \.self) { scope in
|
||||
Text(scope.rawValue.capitalized).tag(scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Feedback Patterns
|
||||
|
||||
### Haptic Feedback
|
||||
|
||||
```swift
|
||||
struct HapticFeedback {
|
||||
static func impact(_ style: UIImpactFeedbackGenerator.FeedbackStyle = .medium) {
|
||||
let generator = UIImpactFeedbackGenerator(style: style)
|
||||
generator.impactOccurred()
|
||||
}
|
||||
|
||||
static func notification(_ type: UINotificationFeedbackGenerator.FeedbackType) {
|
||||
let generator = UINotificationFeedbackGenerator()
|
||||
generator.notificationOccurred(type)
|
||||
}
|
||||
|
||||
static func selection() {
|
||||
let generator = UISelectionFeedbackGenerator()
|
||||
generator.selectionChanged()
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
Button("Submit") {
|
||||
HapticFeedback.notification(.success)
|
||||
submit()
|
||||
}
|
||||
```
|
||||
|
||||
### Visual Feedback
|
||||
|
||||
```swift
|
||||
struct FeedbackButton: View {
|
||||
let title: String
|
||||
let action: () -> Void
|
||||
|
||||
@State private var showSuccess = false
|
||||
|
||||
var body: some View {
|
||||
Button(title) {
|
||||
action()
|
||||
withAnimation {
|
||||
showSuccess = true
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||||
withAnimation {
|
||||
showSuccess = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .trailing) {
|
||||
if showSuccess {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Accessibility
|
||||
|
||||
### VoiceOver Support
|
||||
|
||||
```swift
|
||||
struct AccessibleCard: View {
|
||||
let item: Item
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(item.title)
|
||||
.font(.headline)
|
||||
Text(item.subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
HStack {
|
||||
Image(systemName: "star.fill")
|
||||
Text("\(item.rating, specifier: "%.1f")")
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(item.title), \(item.subtitle)")
|
||||
.accessibilityValue("Rating: \(item.rating) stars")
|
||||
.accessibilityHint("Double tap to view details")
|
||||
.accessibilityAddTraits(.isButton)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dynamic Type Support
|
||||
|
||||
```swift
|
||||
struct DynamicTypeView: View {
|
||||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if dynamicTypeSize.isAccessibilitySize {
|
||||
// Stack vertically for accessibility sizes
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
leadingContent
|
||||
trailingContent
|
||||
}
|
||||
} else {
|
||||
// Side-by-side for standard sizes
|
||||
HStack {
|
||||
leadingContent
|
||||
Spacer()
|
||||
trailingContent
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var leadingContent: some View {
|
||||
Label("Items", systemImage: "folder")
|
||||
}
|
||||
|
||||
var trailingContent: some View {
|
||||
Text("12")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling UI
|
||||
|
||||
### Error States
|
||||
|
||||
```swift
|
||||
struct ErrorView: View {
|
||||
let error: Error
|
||||
let retryAction: () async -> Void
|
||||
|
||||
var body: some View {
|
||||
ContentUnavailableView {
|
||||
Label("Unable to Load", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(error.localizedDescription)
|
||||
} actions: {
|
||||
Button("Try Again") {
|
||||
Task {
|
||||
await retryAction()
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Empty States
|
||||
|
||||
```swift
|
||||
struct EmptyStateView: View {
|
||||
let title: String
|
||||
let description: String
|
||||
let systemImage: String
|
||||
let action: (() -> Void)?
|
||||
let actionTitle: String?
|
||||
|
||||
var body: some View {
|
||||
ContentUnavailableView {
|
||||
Label(title, systemImage: systemImage)
|
||||
} description: {
|
||||
Text(description)
|
||||
} actions: {
|
||||
if let action, let actionTitle {
|
||||
Button(actionTitle, action: action)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
EmptyStateView(
|
||||
title: "No Photos",
|
||||
description: "Take your first photo to get started.",
|
||||
systemImage: "camera",
|
||||
action: { showCamera = true },
|
||||
actionTitle: "Take Photo"
|
||||
)
|
||||
```
|
||||
@@ -0,0 +1,564 @@
|
||||
# iOS Navigation Patterns
|
||||
|
||||
## NavigationStack (iOS 16+)
|
||||
|
||||
### Basic Navigation
|
||||
|
||||
```swift
|
||||
struct BasicNavigationView: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List(items) { item in
|
||||
NavigationLink(item.title, value: item)
|
||||
}
|
||||
.navigationTitle("Items")
|
||||
.navigationDestination(for: Item.self) { item in
|
||||
ItemDetailView(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Programmatic Navigation
|
||||
|
||||
```swift
|
||||
struct ProgrammaticNavigationView: View {
|
||||
@State private var path = NavigationPath()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: $path) {
|
||||
VStack(spacing: 20) {
|
||||
Button("Go to Settings") {
|
||||
path.append(Destination.settings)
|
||||
}
|
||||
|
||||
Button("Go to Profile") {
|
||||
path.append(Destination.profile)
|
||||
}
|
||||
|
||||
Button("Deep Link to Item 123") {
|
||||
path.append(Destination.settings)
|
||||
path.append(Destination.itemDetail(id: 123))
|
||||
}
|
||||
}
|
||||
.navigationTitle("Home")
|
||||
.navigationDestination(for: Destination.self) { destination in
|
||||
switch destination {
|
||||
case .settings:
|
||||
SettingsView()
|
||||
case .profile:
|
||||
ProfileView()
|
||||
case .itemDetail(let id):
|
||||
ItemDetailView(itemId: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Destination: Hashable {
|
||||
case settings
|
||||
case profile
|
||||
case itemDetail(id: Int)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Navigation State Persistence
|
||||
|
||||
```swift
|
||||
struct PersistentNavigationView: View {
|
||||
@SceneStorage("navigationPath") private var pathData: Data?
|
||||
@State private var path = NavigationPath()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: $path) {
|
||||
ContentView()
|
||||
.navigationDestination(for: Item.self) { item in
|
||||
ItemDetailView(item: item)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
restorePath()
|
||||
}
|
||||
.onChange(of: path) { _, newPath in
|
||||
savePath(newPath)
|
||||
}
|
||||
}
|
||||
|
||||
private func savePath(_ path: NavigationPath) {
|
||||
guard let representation = path.codable else { return }
|
||||
pathData = try? JSONEncoder().encode(representation)
|
||||
}
|
||||
|
||||
private func restorePath() {
|
||||
guard let data = pathData,
|
||||
let representation = try? JSONDecoder().decode(
|
||||
NavigationPath.CodableRepresentation.self,
|
||||
from: data
|
||||
) else { return }
|
||||
path = NavigationPath(representation)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## NavigationSplitView
|
||||
|
||||
### Two-Column Layout
|
||||
|
||||
```swift
|
||||
struct TwoColumnView: View {
|
||||
@State private var selectedCategory: Category?
|
||||
@State private var columnVisibility: NavigationSplitViewVisibility = .all
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
// Sidebar
|
||||
List(categories, selection: $selectedCategory) { category in
|
||||
NavigationLink(value: category) {
|
||||
Label(category.name, systemImage: category.icon)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Categories")
|
||||
} detail: {
|
||||
// Detail
|
||||
if let category = selectedCategory {
|
||||
CategoryDetailView(category: category)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"Select a Category",
|
||||
systemImage: "sidebar.leading"
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Three-Column Layout
|
||||
|
||||
```swift
|
||||
struct ThreeColumnView: View {
|
||||
@State private var selectedFolder: Folder?
|
||||
@State private var selectedDocument: Document?
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
// Sidebar
|
||||
List(folders, selection: $selectedFolder) { folder in
|
||||
NavigationLink(value: folder) {
|
||||
Label(folder.name, systemImage: "folder")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Folders")
|
||||
} content: {
|
||||
// Content column
|
||||
if let folder = selectedFolder {
|
||||
List(folder.documents, selection: $selectedDocument) { document in
|
||||
NavigationLink(value: document) {
|
||||
DocumentRow(document: document)
|
||||
}
|
||||
}
|
||||
.navigationTitle(folder.name)
|
||||
} else {
|
||||
Text("Select a folder")
|
||||
}
|
||||
} detail: {
|
||||
// Detail column
|
||||
if let document = selectedDocument {
|
||||
DocumentDetailView(document: document)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"Select a Document",
|
||||
systemImage: "doc"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Sheet Navigation
|
||||
|
||||
### Modal Sheets
|
||||
|
||||
```swift
|
||||
struct SheetNavigationView: View {
|
||||
@State private var showSettings = false
|
||||
@State private var showNewItem = false
|
||||
@State private var editingItem: Item?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ContentView()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button("Add", systemImage: "plus") {
|
||||
showNewItem = true
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Settings", systemImage: "gear") {
|
||||
showSettings = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Boolean-based sheet
|
||||
.sheet(isPresented: $showSettings) {
|
||||
SettingsSheet()
|
||||
}
|
||||
// Boolean-based fullscreen cover
|
||||
.fullScreenCover(isPresented: $showNewItem) {
|
||||
NewItemView()
|
||||
}
|
||||
// Item-based sheet
|
||||
.sheet(item: $editingItem) { item in
|
||||
EditItemSheet(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Sheet with Navigation
|
||||
|
||||
```swift
|
||||
struct NavigableSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("General") {
|
||||
NavigationLink("Account") {
|
||||
AccountSettingsView()
|
||||
}
|
||||
NavigationLink("Notifications") {
|
||||
NotificationSettingsView()
|
||||
}
|
||||
}
|
||||
|
||||
Section("Advanced") {
|
||||
NavigationLink("Privacy") {
|
||||
PrivacySettingsView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Sheet Customization
|
||||
|
||||
```swift
|
||||
struct CustomSheetView: View {
|
||||
@State private var showSheet = false
|
||||
|
||||
var body: some View {
|
||||
Button("Show Sheet") {
|
||||
showSheet = true
|
||||
}
|
||||
.sheet(isPresented: $showSheet) {
|
||||
SheetContent()
|
||||
// Available detents
|
||||
.presentationDetents([
|
||||
.medium,
|
||||
.large,
|
||||
.height(200),
|
||||
.fraction(0.75)
|
||||
])
|
||||
// Selected detent binding
|
||||
.presentationDetents([.medium, .large], selection: $selectedDetent)
|
||||
// Drag indicator visibility
|
||||
.presentationDragIndicator(.visible)
|
||||
// Corner radius
|
||||
.presentationCornerRadius(24)
|
||||
// Background interaction
|
||||
.presentationBackgroundInteraction(.enabled(upThrough: .medium))
|
||||
// Prevent interactive dismiss
|
||||
.interactiveDismissDisabled(hasUnsavedChanges)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tab Navigation
|
||||
|
||||
### Basic TabView
|
||||
|
||||
```swift
|
||||
struct MainTabView: View {
|
||||
@State private var selectedTab = 0
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
HomeView()
|
||||
.tabItem {
|
||||
Label("Home", systemImage: "house")
|
||||
}
|
||||
.tag(0)
|
||||
|
||||
SearchView()
|
||||
.tabItem {
|
||||
Label("Search", systemImage: "magnifyingglass")
|
||||
}
|
||||
.tag(1)
|
||||
|
||||
ProfileView()
|
||||
.tabItem {
|
||||
Label("Profile", systemImage: "person")
|
||||
}
|
||||
.tag(2)
|
||||
.badge(unreadCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tab with Custom Badge
|
||||
|
||||
```swift
|
||||
struct BadgedTabView: View {
|
||||
@State private var selectedTab: Tab = .home
|
||||
@State private var cartCount = 3
|
||||
|
||||
enum Tab: String, CaseIterable {
|
||||
case home, search, cart, profile
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .home: return "house"
|
||||
case .search: return "magnifyingglass"
|
||||
case .cart: return "cart"
|
||||
case .profile: return "person"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
ForEach(Tab.allCases, id: \.self) { tab in
|
||||
NavigationStack {
|
||||
contentView(for: tab)
|
||||
}
|
||||
.tabItem {
|
||||
Label(tab.rawValue.capitalized, systemImage: tab.icon)
|
||||
}
|
||||
.tag(tab)
|
||||
.badge(tab == .cart ? cartCount : 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Deep Linking
|
||||
|
||||
### URL-Based Navigation
|
||||
|
||||
```swift
|
||||
struct DeepLinkableApp: App {
|
||||
@StateObject private var router = NavigationRouter()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environmentObject(router)
|
||||
.onOpenURL { url in
|
||||
router.handle(url: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NavigationRouter: ObservableObject {
|
||||
@Published var path = NavigationPath()
|
||||
@Published var selectedTab: Tab = .home
|
||||
|
||||
func handle(url: URL) {
|
||||
guard url.scheme == "myapp" else { return }
|
||||
|
||||
switch url.host {
|
||||
case "item":
|
||||
if let id = Int(url.lastPathComponent) {
|
||||
selectedTab = .home
|
||||
path = NavigationPath()
|
||||
path.append(Destination.itemDetail(id: id))
|
||||
}
|
||||
case "settings":
|
||||
selectedTab = .profile
|
||||
path = NavigationPath()
|
||||
path.append(Destination.settings)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Universal Links
|
||||
|
||||
```swift
|
||||
struct UniversalLinkHandler: View {
|
||||
@EnvironmentObject private var router: NavigationRouter
|
||||
|
||||
var body: some View {
|
||||
ContentView()
|
||||
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
|
||||
guard let url = activity.webpageURL else { return }
|
||||
handleUniversalLink(url)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleUniversalLink(_ url: URL) {
|
||||
// Parse URL path and navigate accordingly
|
||||
let pathComponents = url.pathComponents
|
||||
|
||||
if pathComponents.contains("product"),
|
||||
let idString = pathComponents.last,
|
||||
let id = Int(idString) {
|
||||
router.navigate(to: .product(id: id))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Navigation Coordinator Pattern
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
class NavigationCoordinator: ObservableObject {
|
||||
@Published var path = NavigationPath()
|
||||
@Published var sheet: Sheet?
|
||||
@Published var fullScreenCover: FullScreenCover?
|
||||
|
||||
enum Sheet: Identifiable {
|
||||
case settings
|
||||
case newItem
|
||||
case editItem(Item)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .settings: return "settings"
|
||||
case .newItem: return "newItem"
|
||||
case .editItem(let item): return "editItem-\(item.id)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum FullScreenCover: Identifiable {
|
||||
case onboarding
|
||||
case camera
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .onboarding: return "onboarding"
|
||||
case .camera: return "camera"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func push(_ destination: Destination) {
|
||||
path.append(destination)
|
||||
}
|
||||
|
||||
func pop() {
|
||||
guard !path.isEmpty else { return }
|
||||
path.removeLast()
|
||||
}
|
||||
|
||||
func popToRoot() {
|
||||
path = NavigationPath()
|
||||
}
|
||||
|
||||
func present(_ sheet: Sheet) {
|
||||
self.sheet = sheet
|
||||
}
|
||||
|
||||
func presentFullScreen(_ cover: FullScreenCover) {
|
||||
self.fullScreenCover = cover
|
||||
}
|
||||
|
||||
func dismiss() {
|
||||
if fullScreenCover != nil {
|
||||
fullScreenCover = nil
|
||||
} else if sheet != nil {
|
||||
sheet = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Navigation Transitions (iOS 18+)
|
||||
|
||||
### Custom Navigation Transitions
|
||||
|
||||
```swift
|
||||
struct CustomTransitionView: View {
|
||||
@Namespace private var namespace
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List(items) { item in
|
||||
NavigationLink(value: item) {
|
||||
ItemRow(item: item)
|
||||
.matchedTransitionSource(id: item.id, in: namespace)
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: Item.self) { item in
|
||||
ItemDetailView(item: item)
|
||||
.navigationTransition(.zoom(sourceID: item.id, in: namespace))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Hero Transitions
|
||||
|
||||
```swift
|
||||
struct HeroTransitionView: View {
|
||||
@Namespace private var animation
|
||||
@State private var selectedItem: Item?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns) {
|
||||
ForEach(items) { item in
|
||||
if selectedItem?.id != item.id {
|
||||
ItemCard(item: item)
|
||||
.matchedGeometryEffect(id: item.id, in: animation)
|
||||
.onTapGesture {
|
||||
withAnimation(.spring(response: 0.3)) {
|
||||
selectedItem = item
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let item = selectedItem {
|
||||
ItemDetailView(item: item)
|
||||
.matchedGeometryEffect(id: item.id, in: animation)
|
||||
.onTapGesture {
|
||||
withAnimation(.spring(response: 0.3)) {
|
||||
selectedItem = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,557 @@
|
||||
# SwiftUI Component Library
|
||||
|
||||
## Lists and Collections
|
||||
|
||||
### Basic List
|
||||
```swift
|
||||
struct ItemListView: View {
|
||||
@State private var items: [Item] = []
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(items) { item in
|
||||
ItemRow(item: item)
|
||||
}
|
||||
.onDelete(perform: deleteItems)
|
||||
.onMove(perform: moveItems)
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.refreshable {
|
||||
await loadItems()
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteItems(at offsets: IndexSet) {
|
||||
items.remove(atOffsets: offsets)
|
||||
}
|
||||
|
||||
private func moveItems(from source: IndexSet, to destination: Int) {
|
||||
items.move(fromOffsets: source, toOffset: destination)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Sectioned List
|
||||
```swift
|
||||
struct SectionedListView: View {
|
||||
let groupedItems: [String: [Item]]
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(groupedItems.keys.sorted(), id: \.self) { key in
|
||||
Section(header: Text(key)) {
|
||||
ForEach(groupedItems[key] ?? []) { item in
|
||||
ItemRow(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Search Integration
|
||||
```swift
|
||||
struct SearchableListView: View {
|
||||
@State private var searchText = ""
|
||||
@State private var items: [Item] = []
|
||||
|
||||
var filteredItems: [Item] {
|
||||
if searchText.isEmpty {
|
||||
return items
|
||||
}
|
||||
return items.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List(filteredItems) { item in
|
||||
ItemRow(item: item)
|
||||
}
|
||||
.searchable(text: $searchText, prompt: "Search items")
|
||||
.searchSuggestions {
|
||||
ForEach(searchSuggestions, id: \.self) { suggestion in
|
||||
Text(suggestion)
|
||||
.searchCompletion(suggestion)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Items")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Forms and Input
|
||||
|
||||
### Settings Form
|
||||
```swift
|
||||
struct SettingsView: View {
|
||||
@AppStorage("notifications") private var notificationsEnabled = true
|
||||
@AppStorage("soundEnabled") private var soundEnabled = true
|
||||
@State private var selectedTheme = Theme.system
|
||||
@State private var username = ""
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Account") {
|
||||
TextField("Username", text: $username)
|
||||
.textContentType(.username)
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
|
||||
Section("Preferences") {
|
||||
Toggle("Enable Notifications", isOn: $notificationsEnabled)
|
||||
Toggle("Sound Effects", isOn: $soundEnabled)
|
||||
|
||||
Picker("Theme", selection: $selectedTheme) {
|
||||
ForEach(Theme.allCases) { theme in
|
||||
Text(theme.rawValue).tag(theme)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("About") {
|
||||
LabeledContent("Version", value: "1.0.0")
|
||||
|
||||
Link(destination: URL(string: "https://example.com/privacy")!) {
|
||||
Text("Privacy Policy")
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Input Fields
|
||||
```swift
|
||||
struct ValidatedTextField: View {
|
||||
let title: String
|
||||
@Binding var text: String
|
||||
let validation: (String) -> Bool
|
||||
|
||||
@State private var isValid = true
|
||||
@FocusState private var isFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
TextField(title, text: $text)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.focused($isFocused)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(borderColor, lineWidth: 1)
|
||||
)
|
||||
.onChange(of: text) { _, newValue in
|
||||
isValid = validation(newValue)
|
||||
}
|
||||
|
||||
if !isValid && !text.isEmpty {
|
||||
Text("Invalid input")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var borderColor: Color {
|
||||
if isFocused {
|
||||
return isValid ? .blue : .red
|
||||
}
|
||||
return .clear
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Buttons and Actions
|
||||
|
||||
### Button Styles
|
||||
```swift
|
||||
// Primary filled button
|
||||
Button("Continue") {
|
||||
// action
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
|
||||
// Secondary button
|
||||
Button("Cancel") {
|
||||
// action
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
// Destructive button
|
||||
Button("Delete", role: .destructive) {
|
||||
// action
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
// Custom button style
|
||||
struct ScaleButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.95 : 1.0)
|
||||
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Menu and Context Menu
|
||||
```swift
|
||||
// Menu button
|
||||
Menu {
|
||||
Button("Edit", systemImage: "pencil") { }
|
||||
Button("Duplicate", systemImage: "doc.on.doc") { }
|
||||
Divider()
|
||||
Button("Delete", systemImage: "trash", role: .destructive) { }
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
|
||||
// Context menu on any view
|
||||
Text("Long press me")
|
||||
.contextMenu {
|
||||
Button("Copy", systemImage: "doc.on.doc") { }
|
||||
Button("Share", systemImage: "square.and.arrow.up") { }
|
||||
} preview: {
|
||||
ItemPreviewView()
|
||||
}
|
||||
```
|
||||
|
||||
## Sheets and Modals
|
||||
|
||||
### Sheet Presentation
|
||||
```swift
|
||||
struct ParentView: View {
|
||||
@State private var showSettings = false
|
||||
@State private var selectedItem: Item?
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Button("Settings") {
|
||||
showSettings = true
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showSettings) {
|
||||
SettingsSheet()
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.sheet(item: $selectedItem) { item in
|
||||
ItemDetailSheet(item: item)
|
||||
.presentationDetents([.height(300), .large])
|
||||
.presentationCornerRadius(24)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
SettingsContent()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Confirmation Dialog
|
||||
```swift
|
||||
struct DeleteConfirmationView: View {
|
||||
@State private var showConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
Button("Delete Account", role: .destructive) {
|
||||
showConfirmation = true
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete Account",
|
||||
isPresented: $showConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
deleteAccount()
|
||||
}
|
||||
Button("Cancel", role: .cancel) { }
|
||||
} message: {
|
||||
Text("This action cannot be undone.")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Loading and Progress
|
||||
|
||||
### Progress Indicators
|
||||
```swift
|
||||
// Indeterminate spinner
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
|
||||
// Determinate progress
|
||||
ProgressView(value: downloadProgress, total: 1.0) {
|
||||
Text("Downloading...")
|
||||
} currentValueLabel: {
|
||||
Text("\(Int(downloadProgress * 100))%")
|
||||
}
|
||||
|
||||
// Custom loading view
|
||||
struct LoadingOverlay: View {
|
||||
let message: String
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black.opacity(0.4)
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
.scaleEffect(1.5)
|
||||
.tint(.white)
|
||||
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
.padding(24)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Skeleton Loading
|
||||
```swift
|
||||
struct SkeletonRow: View {
|
||||
@State private var isAnimating = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Circle()
|
||||
.fill(.gray.opacity(0.3))
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(.gray.opacity(0.3))
|
||||
.frame(height: 14)
|
||||
.frame(maxWidth: 200)
|
||||
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(.gray.opacity(0.2))
|
||||
.frame(height: 12)
|
||||
.frame(maxWidth: 150)
|
||||
}
|
||||
}
|
||||
.opacity(isAnimating ? 0.5 : 1.0)
|
||||
.animation(.easeInOut(duration: 0.8).repeatForever(), value: isAnimating)
|
||||
.onAppear { isAnimating = true }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Async Content Loading
|
||||
|
||||
### AsyncImage
|
||||
```swift
|
||||
AsyncImage(url: imageURL) { phase in
|
||||
switch phase {
|
||||
case .empty:
|
||||
ProgressView()
|
||||
case .success(let image):
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
case .failure:
|
||||
Image(systemName: "photo")
|
||||
.foregroundStyle(.secondary)
|
||||
@unknown default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.frame(width: 100, height: 100)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
```
|
||||
|
||||
### Task-Based Loading
|
||||
```swift
|
||||
struct AsyncContentView: View {
|
||||
@State private var items: [Item] = []
|
||||
@State private var isLoading = true
|
||||
@State private var error: Error?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView("Loading...")
|
||||
} else if let error {
|
||||
ContentUnavailableView(
|
||||
"Failed to Load",
|
||||
systemImage: "exclamationmark.triangle",
|
||||
description: Text(error.localizedDescription)
|
||||
)
|
||||
} else if items.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Items",
|
||||
systemImage: "tray",
|
||||
description: Text("Add your first item to get started.")
|
||||
)
|
||||
} else {
|
||||
List(items) { item in
|
||||
ItemRow(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await loadItems()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadItems() async {
|
||||
do {
|
||||
items = try await api.fetchItems()
|
||||
isLoading = false
|
||||
} catch {
|
||||
self.error = error
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Animations
|
||||
|
||||
### Implicit Animations
|
||||
```swift
|
||||
struct AnimatedCard: View {
|
||||
@State private var isExpanded = false
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Text("Tap to expand")
|
||||
|
||||
if isExpanded {
|
||||
Text("Additional content here")
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.blue.opacity(0.1), in: RoundedRectangle(cornerRadius: 12))
|
||||
.onTapGesture {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
|
||||
isExpanded.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Transitions
|
||||
```swift
|
||||
extension AnyTransition {
|
||||
static var slideAndFade: AnyTransition {
|
||||
.asymmetric(
|
||||
insertion: .move(edge: .trailing).combined(with: .opacity),
|
||||
removal: .move(edge: .leading).combined(with: .opacity)
|
||||
)
|
||||
}
|
||||
|
||||
static var scaleAndFade: AnyTransition {
|
||||
.scale(scale: 0.8).combined(with: .opacity)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase Animator (iOS 17+)
|
||||
```swift
|
||||
struct PulsingButton: View {
|
||||
var body: some View {
|
||||
Button("Tap Me") { }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.phaseAnimator([false, true]) { content, phase in
|
||||
content
|
||||
.scaleEffect(phase ? 1.05 : 1.0)
|
||||
} animation: { _ in
|
||||
.easeInOut(duration: 0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Gestures
|
||||
|
||||
### Drag Gesture
|
||||
```swift
|
||||
struct DraggableCard: View {
|
||||
@State private var offset = CGSize.zero
|
||||
@State private var isDragging = false
|
||||
|
||||
var body: some View {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(.blue)
|
||||
.frame(width: 200, height: 150)
|
||||
.offset(offset)
|
||||
.scaleEffect(isDragging ? 1.05 : 1.0)
|
||||
.gesture(
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
offset = value.translation
|
||||
isDragging = true
|
||||
}
|
||||
.onEnded { _ in
|
||||
withAnimation(.spring()) {
|
||||
offset = .zero
|
||||
isDragging = false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Simultaneous Gestures
|
||||
```swift
|
||||
struct ZoomableImage: View {
|
||||
@State private var scale: CGFloat = 1.0
|
||||
@State private var lastScale: CGFloat = 1.0
|
||||
|
||||
var body: some View {
|
||||
Image("photo")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.scaleEffect(scale)
|
||||
.gesture(
|
||||
MagnificationGesture()
|
||||
.onChanged { value in
|
||||
scale = lastScale * value
|
||||
}
|
||||
.onEnded { _ in
|
||||
lastScale = scale
|
||||
}
|
||||
)
|
||||
.gesture(
|
||||
TapGesture(count: 2)
|
||||
.onEnded {
|
||||
withAnimation {
|
||||
scale = 1.0
|
||||
lastScale = 1.0
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
433
plugins/ui-design/skills/react-native-design/SKILL.md
Normal file
433
plugins/ui-design/skills/react-native-design/SKILL.md
Normal file
@@ -0,0 +1,433 @@
|
||||
---
|
||||
name: react-native-design
|
||||
description: Master React Native styling, navigation, and Reanimated animations for cross-platform mobile development. Use when building React Native apps, implementing navigation patterns, or creating performant animations.
|
||||
---
|
||||
|
||||
# React Native Design
|
||||
|
||||
Master React Native styling patterns, React Navigation, and Reanimated 3 to build performant, cross-platform mobile applications with native-quality user experiences.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
- Building cross-platform mobile apps with React Native
|
||||
- Implementing navigation with React Navigation 6+
|
||||
- Creating performant animations with Reanimated 3
|
||||
- Styling components with StyleSheet and styled-components
|
||||
- Building responsive layouts for different screen sizes
|
||||
- Implementing platform-specific designs (iOS/Android)
|
||||
- Creating gesture-driven interactions with Gesture Handler
|
||||
- Optimizing React Native performance
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### 1. StyleSheet and Styling
|
||||
|
||||
**Basic StyleSheet:**
|
||||
```typescript
|
||||
import { StyleSheet, View, Text } from 'react-native';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: '600',
|
||||
color: '#1a1a1a',
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#666666',
|
||||
lineHeight: 24,
|
||||
},
|
||||
});
|
||||
|
||||
function Card() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Title</Text>
|
||||
<Text style={styles.subtitle}>Subtitle text</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Dynamic Styles:**
|
||||
```typescript
|
||||
interface CardProps {
|
||||
variant: 'primary' | 'secondary';
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function Card({ variant, disabled }: CardProps) {
|
||||
return (
|
||||
<View style={[
|
||||
styles.card,
|
||||
variant === 'primary' ? styles.primary : styles.secondary,
|
||||
disabled && styles.disabled,
|
||||
]}>
|
||||
<Text style={styles.text}>Content</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
},
|
||||
primary: {
|
||||
backgroundColor: '#6366f1',
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: '#f3f4f6',
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e7eb',
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
text: {
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Flexbox Layout
|
||||
|
||||
**Row and Column Layouts:**
|
||||
```typescript
|
||||
const styles = StyleSheet.create({
|
||||
// Vertical stack (column)
|
||||
column: {
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
},
|
||||
// Horizontal stack (row)
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
// Space between items
|
||||
spaceBetween: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
// Centered content
|
||||
centered: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
// Fill remaining space
|
||||
fill: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 3. React Navigation Setup
|
||||
|
||||
**Stack Navigator:**
|
||||
```typescript
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
|
||||
type RootStackParamList = {
|
||||
Home: undefined;
|
||||
Detail: { itemId: string };
|
||||
Settings: undefined;
|
||||
};
|
||||
|
||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
|
||||
function AppNavigator() {
|
||||
return (
|
||||
<NavigationContainer>
|
||||
<Stack.Navigator
|
||||
initialRouteName="Home"
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: '#6366f1' },
|
||||
headerTintColor: '#ffffff',
|
||||
headerTitleStyle: { fontWeight: '600' },
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="Home"
|
||||
component={HomeScreen}
|
||||
options={{ title: 'Home' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Detail"
|
||||
component={DetailScreen}
|
||||
options={({ route }) => ({
|
||||
title: `Item ${route.params.itemId}`,
|
||||
})}
|
||||
/>
|
||||
<Stack.Screen name="Settings" component={SettingsScreen} />
|
||||
</Stack.Navigator>
|
||||
</NavigationContainer>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Tab Navigator:**
|
||||
```typescript
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
type TabParamList = {
|
||||
Home: undefined;
|
||||
Search: undefined;
|
||||
Profile: undefined;
|
||||
};
|
||||
|
||||
const Tab = createBottomTabNavigator<TabParamList>();
|
||||
|
||||
function TabNavigator() {
|
||||
return (
|
||||
<Tab.Navigator
|
||||
screenOptions={({ route }) => ({
|
||||
tabBarIcon: ({ focused, color, size }) => {
|
||||
const icons: Record<string, keyof typeof Ionicons.glyphMap> = {
|
||||
Home: focused ? 'home' : 'home-outline',
|
||||
Search: focused ? 'search' : 'search-outline',
|
||||
Profile: focused ? 'person' : 'person-outline',
|
||||
};
|
||||
return <Ionicons name={icons[route.name]} size={size} color={color} />;
|
||||
},
|
||||
tabBarActiveTintColor: '#6366f1',
|
||||
tabBarInactiveTintColor: '#9ca3af',
|
||||
})}
|
||||
>
|
||||
<Tab.Screen name="Home" component={HomeScreen} />
|
||||
<Tab.Screen name="Search" component={SearchScreen} />
|
||||
<Tab.Screen name="Profile" component={ProfileScreen} />
|
||||
</Tab.Navigator>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Reanimated 3 Basics
|
||||
|
||||
**Animated Values:**
|
||||
```typescript
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
function AnimatedBox() {
|
||||
const scale = useSharedValue(1);
|
||||
const opacity = useSharedValue(1);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
opacity: opacity.value,
|
||||
}));
|
||||
|
||||
const handlePress = () => {
|
||||
scale.value = withSpring(1.2, {}, () => {
|
||||
scale.value = withSpring(1);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable onPress={handlePress}>
|
||||
<Animated.View style={[styles.box, animatedStyle]} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Gesture Handler Integration:**
|
||||
```typescript
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
function DraggableCard() {
|
||||
const translateX = useSharedValue(0);
|
||||
const translateY = useSharedValue(0);
|
||||
|
||||
const gesture = Gesture.Pan()
|
||||
.onUpdate((event) => {
|
||||
translateX.value = event.translationX;
|
||||
translateY.value = event.translationY;
|
||||
})
|
||||
.onEnd(() => {
|
||||
translateX.value = withSpring(0);
|
||||
translateY.value = withSpring(0);
|
||||
});
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{ translateX: translateX.value },
|
||||
{ translateY: translateY.value },
|
||||
],
|
||||
}));
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={gesture}>
|
||||
<Animated.View style={[styles.card, animatedStyle]}>
|
||||
<Text>Drag me!</Text>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Platform-Specific Styling
|
||||
|
||||
```typescript
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 16,
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
},
|
||||
android: {
|
||||
elevation: 4,
|
||||
},
|
||||
}),
|
||||
},
|
||||
text: {
|
||||
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;
|
||||
```
|
||||
|
||||
## Quick Start Component
|
||||
|
||||
```typescript
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
interface ItemCardProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
imageUrl: string;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
|
||||
|
||||
export function ItemCard({ title, subtitle, imageUrl, onPress }: ItemCardProps) {
|
||||
const scale = useSharedValue(1);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<AnimatedPressable
|
||||
style={[styles.card, animatedStyle]}
|
||||
onPress={onPress}
|
||||
onPressIn={() => { scale.value = withSpring(0.97); }}
|
||||
onPressOut={() => { scale.value = withSpring(1); }}
|
||||
>
|
||||
<Image source={{ uri: imageUrl }} style={styles.image} />
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={styles.subtitle} numberOfLines={2}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
</AnimatedPressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
image: {
|
||||
width: '100%',
|
||||
height: 160,
|
||||
backgroundColor: '#f3f4f6',
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
gap: 4,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#1f2937',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
color: '#6b7280',
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use TypeScript**: Define navigation and prop types for type safety
|
||||
2. **Memoize Components**: Use `React.memo` and `useCallback` to prevent unnecessary rerenders
|
||||
3. **Run Animations on UI Thread**: Use Reanimated worklets for 60fps animations
|
||||
4. **Avoid Inline Styles**: Use StyleSheet.create for performance
|
||||
5. **Handle Safe Areas**: Use `SafeAreaView` or `useSafeAreaInsets`
|
||||
6. **Test on Real Devices**: Simulator/emulator performance differs from real devices
|
||||
7. **Use FlatList for Lists**: Never use ScrollView with map for long lists
|
||||
8. **Platform-Specific Code**: Use Platform.select for iOS/Android differences
|
||||
|
||||
## Common Issues
|
||||
|
||||
- **Gesture Conflicts**: Wrap gestures with `GestureDetector` and use `simultaneousHandlers`
|
||||
- **Navigation Type Errors**: Define `ParamList` types for all navigators
|
||||
- **Animation Jank**: Move animations to UI thread with `runOnUI` worklets
|
||||
- **Memory Leaks**: Cancel animations and cleanup in useEffect
|
||||
- **Font Loading**: Use `expo-font` or `react-native-asset` for custom fonts
|
||||
- **Safe Area Issues**: Test on notched devices (iPhone, Android with cutouts)
|
||||
|
||||
## Resources
|
||||
|
||||
- [React Native Documentation](https://reactnative.dev/)
|
||||
- [React Navigation](https://reactnavigation.org/)
|
||||
- [Reanimated Documentation](https://docs.swmansion.com/react-native-reanimated/)
|
||||
- [Gesture Handler](https://docs.swmansion.com/react-native-gesture-handler/)
|
||||
- [Expo Documentation](https://docs.expo.dev/)
|
||||
@@ -0,0 +1,829 @@
|
||||
# React Navigation Patterns
|
||||
|
||||
## Setup and Configuration
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Core packages
|
||||
npm install @react-navigation/native
|
||||
npm install @react-navigation/native-stack
|
||||
npm install @react-navigation/bottom-tabs
|
||||
|
||||
# Required peer dependencies
|
||||
npm install react-native-screens react-native-safe-area-context
|
||||
```
|
||||
|
||||
### Type-Safe Navigation Setup
|
||||
|
||||
```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';
|
||||
|
||||
// Define param lists for each navigator
|
||||
export type RootStackParamList = {
|
||||
Main: NavigatorScreenParams<MainTabParamList>;
|
||||
Modal: { title: string };
|
||||
Auth: NavigatorScreenParams<AuthStackParamList>;
|
||||
};
|
||||
|
||||
export type MainTabParamList = {
|
||||
Home: undefined;
|
||||
Search: { query?: string };
|
||||
Profile: { userId: string };
|
||||
};
|
||||
|
||||
export type AuthStackParamList = {
|
||||
Login: undefined;
|
||||
Register: undefined;
|
||||
ForgotPassword: { email?: string };
|
||||
};
|
||||
|
||||
// Screen props helpers
|
||||
export type RootStackScreenProps<T extends keyof RootStackParamList> =
|
||||
NativeStackScreenProps<RootStackParamList, T>;
|
||||
|
||||
export type MainTabScreenProps<T extends keyof MainTabParamList> =
|
||||
CompositeScreenProps<
|
||||
BottomTabScreenProps<MainTabParamList, T>,
|
||||
RootStackScreenProps<keyof RootStackParamList>
|
||||
>;
|
||||
|
||||
// Global type declaration
|
||||
declare global {
|
||||
namespace ReactNavigation {
|
||||
interface RootParamList extends RootStackParamList {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Navigation Hooks
|
||||
|
||||
```typescript
|
||||
// hooks/useAppNavigation.ts
|
||||
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>>();
|
||||
}
|
||||
|
||||
export function useTypedRoute<T extends keyof RootStackParamList>() {
|
||||
return useRoute<RouteProp<RootStackParamList, T>>();
|
||||
}
|
||||
```
|
||||
|
||||
## Stack Navigation
|
||||
|
||||
### Basic Stack Navigator
|
||||
|
||||
```typescript
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import { RootStackParamList } from './types';
|
||||
|
||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
|
||||
function RootNavigator() {
|
||||
return (
|
||||
<Stack.Navigator
|
||||
initialRouteName="Main"
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: '#6366f1' },
|
||||
headerTintColor: '#ffffff',
|
||||
headerTitleStyle: { fontWeight: '600' },
|
||||
headerBackTitleVisible: false,
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="Main"
|
||||
component={MainTabNavigator}
|
||||
options={{ headerShown: false }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Modal"
|
||||
component={ModalScreen}
|
||||
options={{
|
||||
presentation: 'modal',
|
||||
animation: 'slide_from_bottom',
|
||||
}}
|
||||
/>
|
||||
<Stack.Group screenOptions={{ presentation: 'fullScreenModal' }}>
|
||||
<Stack.Screen
|
||||
name="Auth"
|
||||
component={AuthNavigator}
|
||||
options={{ headerShown: false }}
|
||||
/>
|
||||
</Stack.Group>
|
||||
</Stack.Navigator>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Screen with Dynamic Options
|
||||
|
||||
```typescript
|
||||
function DetailScreen({ route, navigation }: DetailScreenProps) {
|
||||
const { itemId } = route.params;
|
||||
const [item, setItem] = useState<Item | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Update header when data loads
|
||||
if (item) {
|
||||
navigation.setOptions({
|
||||
title: item.title,
|
||||
headerRight: () => (
|
||||
<TouchableOpacity onPress={() => shareItem(item)}>
|
||||
<Ionicons name="share-outline" size={24} color="#ffffff" />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
}
|
||||
}, [item, navigation]);
|
||||
|
||||
// Prevent going back with unsaved changes
|
||||
useEffect(() => {
|
||||
const unsubscribe = navigation.addListener('beforeRemove', (e) => {
|
||||
if (!hasUnsavedChanges) return;
|
||||
|
||||
e.preventDefault();
|
||||
Alert.alert(
|
||||
'Discard changes?',
|
||||
'You have unsaved changes. Are you sure you want to leave?',
|
||||
[
|
||||
{ text: "Don't leave", style: 'cancel' },
|
||||
{
|
||||
text: 'Discard',
|
||||
style: 'destructive',
|
||||
onPress: () => navigation.dispatch(e.data.action),
|
||||
},
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [navigation, hasUnsavedChanges]);
|
||||
|
||||
return <View>{/* Content */}</View>;
|
||||
}
|
||||
```
|
||||
|
||||
## Tab Navigation
|
||||
|
||||
### Bottom Tab Navigator
|
||||
|
||||
```typescript
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { MainTabParamList } from './types';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
const Tab = createBottomTabNavigator<MainTabParamList>();
|
||||
|
||||
function MainTabNavigator() {
|
||||
return (
|
||||
<Tab.Navigator
|
||||
screenOptions={({ route }) => ({
|
||||
tabBarIcon: ({ focused, color, size }) => {
|
||||
const icons: Record<keyof MainTabParamList, string> = {
|
||||
Home: focused ? 'home' : 'home-outline',
|
||||
Search: focused ? 'search' : 'search-outline',
|
||||
Profile: focused ? 'person' : 'person-outline',
|
||||
};
|
||||
return (
|
||||
<Ionicons
|
||||
name={icons[route.name] as any}
|
||||
size={size}
|
||||
color={color}
|
||||
/>
|
||||
);
|
||||
},
|
||||
tabBarActiveTintColor: '#6366f1',
|
||||
tabBarInactiveTintColor: '#9ca3af',
|
||||
tabBarStyle: {
|
||||
backgroundColor: '#ffffff',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#e5e7eb',
|
||||
paddingBottom: 8,
|
||||
paddingTop: 8,
|
||||
height: 60,
|
||||
},
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
headerStyle: { backgroundColor: '#ffffff' },
|
||||
headerTitleStyle: { fontWeight: '600' },
|
||||
})}
|
||||
>
|
||||
<Tab.Screen
|
||||
name="Home"
|
||||
component={HomeScreen}
|
||||
options={{
|
||||
tabBarLabel: 'Home',
|
||||
tabBarBadge: 3,
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Search"
|
||||
component={SearchScreen}
|
||||
options={{ tabBarLabel: 'Search' }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Profile"
|
||||
component={ProfileScreen}
|
||||
options={{ tabBarLabel: 'Profile' }}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Tab Bar
|
||||
|
||||
```typescript
|
||||
import { View, Pressable, StyleSheet } from 'react-native';
|
||||
import { BottomTabBarProps } from '@react-navigation/bottom-tabs';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
function CustomTabBar({ state, descriptors, navigation }: BottomTabBarProps) {
|
||||
return (
|
||||
<View style={styles.tabBar}>
|
||||
{state.routes.map((route, index) => {
|
||||
const { options } = descriptors[route.key];
|
||||
const label = options.tabBarLabel ?? route.name;
|
||||
const isFocused = state.index === index;
|
||||
|
||||
const onPress = () => {
|
||||
const event = navigation.emit({
|
||||
type: 'tabPress',
|
||||
target: route.key,
|
||||
canPreventDefault: true,
|
||||
});
|
||||
|
||||
if (!isFocused && !event.defaultPrevented) {
|
||||
navigation.navigate(route.name);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TabBarButton
|
||||
key={route.key}
|
||||
label={label as string}
|
||||
isFocused={isFocused}
|
||||
onPress={onPress}
|
||||
icon={options.tabBarIcon}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function TabBarButton({ label, isFocused, onPress, icon }) {
|
||||
const scale = useSharedValue(1);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onPressIn={() => { scale.value = withSpring(0.9); }}
|
||||
onPressOut={() => { scale.value = withSpring(1); }}
|
||||
style={styles.tabButton}
|
||||
>
|
||||
<Animated.View style={animatedStyle}>
|
||||
{icon?.({
|
||||
focused: isFocused,
|
||||
color: isFocused ? '#6366f1' : '#9ca3af',
|
||||
size: 24,
|
||||
})}
|
||||
<Text
|
||||
style={[
|
||||
styles.tabLabel,
|
||||
{ color: isFocused ? '#6366f1' : '#9ca3af' },
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
tabBar: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#ffffff',
|
||||
paddingBottom: 20,
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#e5e7eb',
|
||||
},
|
||||
tabButton: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
tabLabel: {
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
|
||||
// Usage
|
||||
<Tab.Navigator tabBar={(props) => <CustomTabBar {...props} />}>
|
||||
{/* screens */}
|
||||
</Tab.Navigator>
|
||||
```
|
||||
|
||||
## Drawer Navigation
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createDrawerNavigator,
|
||||
DrawerContentScrollView,
|
||||
DrawerItemList,
|
||||
DrawerContentComponentProps,
|
||||
} from '@react-navigation/drawer';
|
||||
|
||||
const Drawer = createDrawerNavigator();
|
||||
|
||||
function CustomDrawerContent(props: DrawerContentComponentProps) {
|
||||
return (
|
||||
<DrawerContentScrollView {...props}>
|
||||
<View style={styles.drawerHeader}>
|
||||
<Image source={{ uri: user.avatar }} style={styles.avatar} />
|
||||
<Text style={styles.userName}>{user.name}</Text>
|
||||
<Text style={styles.userEmail}>{user.email}</Text>
|
||||
</View>
|
||||
<DrawerItemList {...props} />
|
||||
<View style={styles.drawerFooter}>
|
||||
<TouchableOpacity
|
||||
onPress={handleLogout}
|
||||
style={styles.logoutButton}
|
||||
>
|
||||
<Ionicons name="log-out-outline" size={24} color="#ef4444" />
|
||||
<Text style={styles.logoutText}>Log Out</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</DrawerContentScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerNavigator() {
|
||||
return (
|
||||
<Drawer.Navigator
|
||||
drawerContent={(props) => <CustomDrawerContent {...props} />}
|
||||
screenOptions={{
|
||||
drawerActiveBackgroundColor: '#ede9fe',
|
||||
drawerActiveTintColor: '#6366f1',
|
||||
drawerInactiveTintColor: '#4b5563',
|
||||
drawerLabelStyle: { marginLeft: -20, fontSize: 15, fontWeight: '500' },
|
||||
drawerStyle: { width: 280 },
|
||||
}}
|
||||
>
|
||||
<Drawer.Screen
|
||||
name="Home"
|
||||
component={HomeScreen}
|
||||
options={{
|
||||
drawerIcon: ({ color }) => (
|
||||
<Ionicons name="home-outline" size={22} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Drawer.Screen
|
||||
name="Settings"
|
||||
component={SettingsScreen}
|
||||
options={{
|
||||
drawerIcon: ({ color }) => (
|
||||
<Ionicons name="settings-outline" size={22} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Drawer.Navigator>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Deep Linking
|
||||
|
||||
### Configuration
|
||||
|
||||
```typescript
|
||||
// navigation/linking.ts
|
||||
import { LinkingOptions } from '@react-navigation/native';
|
||||
import { RootStackParamList } from './types';
|
||||
|
||||
export const linking: LinkingOptions<RootStackParamList> = {
|
||||
prefixes: ['myapp://', 'https://myapp.com'],
|
||||
config: {
|
||||
screens: {
|
||||
Main: {
|
||||
screens: {
|
||||
Home: 'home',
|
||||
Search: 'search',
|
||||
Profile: 'profile/:userId',
|
||||
},
|
||||
},
|
||||
Modal: 'modal/:title',
|
||||
Auth: {
|
||||
screens: {
|
||||
Login: 'login',
|
||||
Register: 'register',
|
||||
ForgotPassword: 'forgot-password',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// Custom URL parsing
|
||||
getStateFromPath: (path, config) => {
|
||||
// Handle custom URL patterns
|
||||
return getStateFromPath(path, config);
|
||||
},
|
||||
};
|
||||
|
||||
// App.tsx
|
||||
function App() {
|
||||
return (
|
||||
<NavigationContainer linking={linking} fallback={<LoadingScreen />}>
|
||||
<RootNavigator />
|
||||
</NavigationContainer>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Handling Deep Links
|
||||
|
||||
```typescript
|
||||
import { useEffect } from 'react';
|
||||
import { Linking } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
|
||||
function useDeepLinkHandler() {
|
||||
const navigation = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
// Handle initial URL
|
||||
const handleInitialUrl = async () => {
|
||||
const url = await Linking.getInitialURL();
|
||||
if (url) {
|
||||
handleDeepLink(url);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle URL changes
|
||||
const subscription = Linking.addEventListener('url', ({ url }) => {
|
||||
handleDeepLink(url);
|
||||
});
|
||||
|
||||
handleInitialUrl();
|
||||
|
||||
return () => subscription.remove();
|
||||
}, []);
|
||||
|
||||
const handleDeepLink = (url: string) => {
|
||||
// Parse URL and navigate
|
||||
const route = parseUrl(url);
|
||||
if (route) {
|
||||
navigation.navigate(route.name, route.params);
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Navigation State Management
|
||||
|
||||
### Auth Flow
|
||||
|
||||
```typescript
|
||||
import { createContext, useContext, useState, useEffect } from 'react';
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
isLoading: boolean;
|
||||
signIn: (email: string, password: string) => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType>(null!);
|
||||
|
||||
function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Check for existing session
|
||||
checkAuthState();
|
||||
}, []);
|
||||
|
||||
const checkAuthState = async () => {
|
||||
try {
|
||||
const token = await AsyncStorage.getItem('token');
|
||||
if (token) {
|
||||
const user = await fetchUser(token);
|
||||
setUser(user);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const signIn = async (email: string, password: string) => {
|
||||
const { user, token } = await loginApi(email, password);
|
||||
await AsyncStorage.setItem('token', token);
|
||||
setUser(user);
|
||||
};
|
||||
|
||||
const signOut = async () => {
|
||||
await AsyncStorage.removeItem('token');
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, isLoading, signIn, signOut }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function RootNavigator() {
|
||||
const { user, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return <SplashScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
||||
{user ? (
|
||||
<Stack.Screen name="Main" component={MainNavigator} />
|
||||
) : (
|
||||
<Stack.Screen name="Auth" component={AuthNavigator} />
|
||||
)}
|
||||
</Stack.Navigator>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Navigation State Persistence
|
||||
|
||||
```typescript
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { NavigationContainer, NavigationState } from '@react-navigation/native';
|
||||
|
||||
const PERSISTENCE_KEY = 'NAVIGATION_STATE';
|
||||
|
||||
function App() {
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [initialState, setInitialState] = useState<NavigationState | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
const restoreState = async () => {
|
||||
try {
|
||||
const savedState = await AsyncStorage.getItem(PERSISTENCE_KEY);
|
||||
if (savedState) {
|
||||
setInitialState(JSON.parse(savedState));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to restore navigation state:', e);
|
||||
} finally {
|
||||
setIsReady(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isReady) {
|
||||
restoreState();
|
||||
}
|
||||
}, [isReady]);
|
||||
|
||||
if (!isReady) {
|
||||
return <SplashScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<NavigationContainer
|
||||
initialState={initialState}
|
||||
onStateChange={(state) => {
|
||||
AsyncStorage.setItem(PERSISTENCE_KEY, JSON.stringify(state));
|
||||
}}
|
||||
>
|
||||
<RootNavigator />
|
||||
</NavigationContainer>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Screen Transitions
|
||||
|
||||
### Custom Animations
|
||||
|
||||
```typescript
|
||||
import { TransitionPresets } from '@react-navigation/native-stack';
|
||||
|
||||
<Stack.Navigator
|
||||
screenOptions={{
|
||||
...TransitionPresets.SlideFromRightIOS,
|
||||
gestureEnabled: true,
|
||||
gestureDirection: 'horizontal',
|
||||
}}
|
||||
>
|
||||
{/* Standard slide transition */}
|
||||
<Stack.Screen name="List" component={ListScreen} />
|
||||
|
||||
{/* Modal with custom animation */}
|
||||
<Stack.Screen
|
||||
name="Modal"
|
||||
component={ModalScreen}
|
||||
options={{
|
||||
presentation: 'transparentModal',
|
||||
animation: 'fade',
|
||||
cardOverlayEnabled: true,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Full screen modal */}
|
||||
<Stack.Screen
|
||||
name="FullScreenModal"
|
||||
component={FullScreenModalScreen}
|
||||
options={{
|
||||
presentation: 'fullScreenModal',
|
||||
animation: 'slide_from_bottom',
|
||||
}}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
```
|
||||
|
||||
### Shared Element Transitions
|
||||
|
||||
```typescript
|
||||
import { SharedElement } from 'react-navigation-shared-element';
|
||||
import { createSharedElementStackNavigator } from 'react-navigation-shared-element';
|
||||
|
||||
const Stack = createSharedElementStackNavigator();
|
||||
|
||||
function ListScreen({ navigation }) {
|
||||
return (
|
||||
<FlatList
|
||||
data={items}
|
||||
renderItem={({ item }) => (
|
||||
<Pressable onPress={() => navigation.navigate('Detail', { item })}>
|
||||
<SharedElement id={`item.${item.id}.photo`}>
|
||||
<Image source={{ uri: item.imageUrl }} style={styles.image} />
|
||||
</SharedElement>
|
||||
<SharedElement id={`item.${item.id}.title`}>
|
||||
<Text style={styles.title}>{item.title}</Text>
|
||||
</SharedElement>
|
||||
</Pressable>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailScreen({ route }) {
|
||||
const { item } = route.params;
|
||||
|
||||
return (
|
||||
<View>
|
||||
<SharedElement id={`item.${item.id}.photo`}>
|
||||
<Image source={{ uri: item.imageUrl }} style={styles.heroImage} />
|
||||
</SharedElement>
|
||||
<SharedElement id={`item.${item.id}.title`}>
|
||||
<Text style={styles.title}>{item.title}</Text>
|
||||
</SharedElement>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Navigator configuration
|
||||
<Stack.Navigator>
|
||||
<Stack.Screen name="List" component={ListScreen} />
|
||||
<Stack.Screen
|
||||
name="Detail"
|
||||
component={DetailScreen}
|
||||
sharedElements={(route) => {
|
||||
const { item } = route.params;
|
||||
return [
|
||||
{ id: `item.${item.id}.photo`, animation: 'move' },
|
||||
{ id: `item.${item.id}.title`, animation: 'fade' },
|
||||
];
|
||||
}}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
```
|
||||
|
||||
## Header Customization
|
||||
|
||||
### Custom Header Component
|
||||
|
||||
```typescript
|
||||
import { getHeaderTitle } from '@react-navigation/elements';
|
||||
import { NativeStackHeaderProps } from '@react-navigation/native-stack';
|
||||
|
||||
function CustomHeader({ navigation, route, options, back }: NativeStackHeaderProps) {
|
||||
const title = getHeaderTitle(options, route.name);
|
||||
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
{back && (
|
||||
<TouchableOpacity
|
||||
onPress={navigation.goBack}
|
||||
style={styles.backButton}
|
||||
>
|
||||
<Ionicons name="arrow-back" size={24} color="#1f2937" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{options.headerRight && (
|
||||
<View style={styles.rightActions}>
|
||||
{options.headerRight({ canGoBack: !!back })}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage
|
||||
<Stack.Navigator
|
||||
screenOptions={{
|
||||
header: (props) => <CustomHeader {...props} />,
|
||||
}}
|
||||
>
|
||||
{/* screens */}
|
||||
</Stack.Navigator>
|
||||
```
|
||||
|
||||
### Collapsible Header
|
||||
|
||||
```typescript
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedScrollHandler,
|
||||
useAnimatedStyle,
|
||||
interpolate,
|
||||
Extrapolation,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
const HEADER_HEIGHT = 200;
|
||||
const COLLAPSED_HEIGHT = 60;
|
||||
|
||||
function CollapsibleHeaderScreen() {
|
||||
const scrollY = useSharedValue(0);
|
||||
|
||||
const scrollHandler = useAnimatedScrollHandler({
|
||||
onScroll: (event) => {
|
||||
scrollY.value = event.contentOffset.y;
|
||||
},
|
||||
});
|
||||
|
||||
const headerStyle = useAnimatedStyle(() => {
|
||||
const height = interpolate(
|
||||
scrollY.value,
|
||||
[0, HEADER_HEIGHT - COLLAPSED_HEIGHT],
|
||||
[HEADER_HEIGHT, COLLAPSED_HEIGHT],
|
||||
Extrapolation.CLAMP
|
||||
);
|
||||
|
||||
return { height };
|
||||
});
|
||||
|
||||
const titleStyle = useAnimatedStyle(() => {
|
||||
const fontSize = interpolate(
|
||||
scrollY.value,
|
||||
[0, HEADER_HEIGHT - COLLAPSED_HEIGHT],
|
||||
[32, 18],
|
||||
Extrapolation.CLAMP
|
||||
);
|
||||
|
||||
return { fontSize };
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Animated.View style={[styles.header, headerStyle]}>
|
||||
<Animated.Text style={[styles.title, titleStyle]}>
|
||||
Title
|
||||
</Animated.Text>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.ScrollView
|
||||
onScroll={scrollHandler}
|
||||
scrollEventThrottle={16}
|
||||
contentContainerStyle={{ paddingTop: HEADER_HEIGHT }}
|
||||
>
|
||||
{/* Content */}
|
||||
</Animated.ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,772 @@
|
||||
# React Native Reanimated 3 Patterns
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Shared Values and Animated Styles
|
||||
|
||||
```typescript
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
withTiming,
|
||||
withDelay,
|
||||
withSequence,
|
||||
withRepeat,
|
||||
Easing,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
function BasicAnimations() {
|
||||
// Shared value - can be modified from JS or UI thread
|
||||
const opacity = useSharedValue(0);
|
||||
const scale = useSharedValue(1);
|
||||
const rotation = useSharedValue(0);
|
||||
|
||||
// Animated style - runs on UI thread
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: opacity.value,
|
||||
transform: [
|
||||
{ scale: scale.value },
|
||||
{ rotate: `${rotation.value}deg` },
|
||||
],
|
||||
}));
|
||||
|
||||
const animate = () => {
|
||||
// Spring animation
|
||||
scale.value = withSpring(1.2, {
|
||||
damping: 10,
|
||||
stiffness: 100,
|
||||
});
|
||||
|
||||
// Timing animation with easing
|
||||
opacity.value = withTiming(1, {
|
||||
duration: 500,
|
||||
easing: Easing.bezier(0.25, 0.1, 0.25, 1),
|
||||
});
|
||||
|
||||
// Sequence of animations
|
||||
rotation.value = withSequence(
|
||||
withTiming(15, { duration: 100 }),
|
||||
withTiming(-15, { duration: 100 }),
|
||||
withTiming(0, { duration: 100 })
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.box, animatedStyle]} />
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Animation Callbacks
|
||||
|
||||
```typescript
|
||||
import { runOnJS, runOnUI } from 'react-native-reanimated';
|
||||
|
||||
function AnimationWithCallbacks() {
|
||||
const translateX = useSharedValue(0);
|
||||
const [status, setStatus] = useState('idle');
|
||||
|
||||
const updateStatus = (newStatus: string) => {
|
||||
setStatus(newStatus);
|
||||
};
|
||||
|
||||
const animate = () => {
|
||||
translateX.value = withTiming(
|
||||
200,
|
||||
{ duration: 1000 },
|
||||
(finished) => {
|
||||
'worklet';
|
||||
if (finished) {
|
||||
// Call JS function from worklet
|
||||
runOnJS(updateStatus)('completed');
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.box,
|
||||
useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: translateX.value }],
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Gesture Handler Integration
|
||||
|
||||
### Pan Gesture
|
||||
|
||||
```typescript
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
clamp,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
function DraggableBox() {
|
||||
const translateX = useSharedValue(0);
|
||||
const translateY = useSharedValue(0);
|
||||
const context = useSharedValue({ x: 0, y: 0 });
|
||||
|
||||
const panGesture = Gesture.Pan()
|
||||
.onStart(() => {
|
||||
context.value = { x: translateX.value, y: translateY.value };
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
translateX.value = event.translationX + context.value.x;
|
||||
translateY.value = event.translationY + context.value.y;
|
||||
})
|
||||
.onEnd((event) => {
|
||||
// Apply velocity decay
|
||||
translateX.value = withSpring(
|
||||
clamp(translateX.value + event.velocityX / 10, -100, 100)
|
||||
);
|
||||
translateY.value = withSpring(
|
||||
clamp(translateY.value + event.velocityY / 10, -100, 100)
|
||||
);
|
||||
});
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{ translateX: translateX.value },
|
||||
{ translateY: translateY.value },
|
||||
],
|
||||
}));
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={panGesture}>
|
||||
<Animated.View style={[styles.box, animatedStyle]} />
|
||||
</GestureDetector>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Pinch and Rotate Gestures
|
||||
|
||||
```typescript
|
||||
function ZoomableImage() {
|
||||
const scale = useSharedValue(1);
|
||||
const rotation = useSharedValue(0);
|
||||
const savedScale = useSharedValue(1);
|
||||
const savedRotation = useSharedValue(0);
|
||||
|
||||
const pinchGesture = Gesture.Pinch()
|
||||
.onUpdate((event) => {
|
||||
scale.value = savedScale.value * event.scale;
|
||||
})
|
||||
.onEnd(() => {
|
||||
savedScale.value = scale.value;
|
||||
// Snap back if too small
|
||||
if (scale.value < 1) {
|
||||
scale.value = withSpring(1);
|
||||
savedScale.value = 1;
|
||||
}
|
||||
});
|
||||
|
||||
const rotateGesture = Gesture.Rotation()
|
||||
.onUpdate((event) => {
|
||||
rotation.value = savedRotation.value + event.rotation;
|
||||
})
|
||||
.onEnd(() => {
|
||||
savedRotation.value = rotation.value;
|
||||
});
|
||||
|
||||
const composed = Gesture.Simultaneous(pinchGesture, rotateGesture);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{ scale: scale.value },
|
||||
{ rotate: `${rotation.value}rad` },
|
||||
],
|
||||
}));
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={composed}>
|
||||
<Animated.Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={[styles.image, animatedStyle]}
|
||||
/>
|
||||
</GestureDetector>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Tap Gesture with Feedback
|
||||
|
||||
```typescript
|
||||
function TappableCard({ onPress, children }: TappableCardProps) {
|
||||
const scale = useSharedValue(1);
|
||||
const opacity = useSharedValue(1);
|
||||
|
||||
const tapGesture = Gesture.Tap()
|
||||
.onBegin(() => {
|
||||
scale.value = withSpring(0.97);
|
||||
opacity.value = withTiming(0.8, { duration: 100 });
|
||||
})
|
||||
.onFinalize(() => {
|
||||
scale.value = withSpring(1);
|
||||
opacity.value = withTiming(1, { duration: 100 });
|
||||
})
|
||||
.onEnd(() => {
|
||||
runOnJS(onPress)();
|
||||
});
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
opacity: opacity.value,
|
||||
}));
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={tapGesture}>
|
||||
<Animated.View style={[styles.card, animatedStyle]}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Common Animation Patterns
|
||||
|
||||
### Fade In/Out
|
||||
|
||||
```typescript
|
||||
function FadeInView({ visible, children }: FadeInViewProps) {
|
||||
const opacity = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
opacity.value = withTiming(visible ? 1 : 0, { duration: 300 });
|
||||
}, [visible]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: opacity.value,
|
||||
display: opacity.value === 0 ? 'none' : 'flex',
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View style={animatedStyle}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Slide In/Out
|
||||
|
||||
```typescript
|
||||
function SlideInView({ visible, direction = 'right', children }) {
|
||||
const translateX = useSharedValue(direction === 'right' ? 100 : -100);
|
||||
const opacity = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
translateX.value = withSpring(0);
|
||||
opacity.value = withTiming(1);
|
||||
} else {
|
||||
translateX.value = withSpring(direction === 'right' ? 100 : -100);
|
||||
opacity.value = withTiming(0);
|
||||
}
|
||||
}, [visible, direction]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: translateX.value }],
|
||||
opacity: opacity.value,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View style={animatedStyle}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Staggered List Animation
|
||||
|
||||
```typescript
|
||||
function StaggeredList({ items }: { items: Item[] }) {
|
||||
return (
|
||||
<FlatList
|
||||
data={items}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item, index }) => (
|
||||
<StaggeredItem item={item} index={index} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function StaggeredItem({ item, index }: { item: Item; index: number }) {
|
||||
const translateY = useSharedValue(50);
|
||||
const opacity = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
translateY.value = withDelay(
|
||||
index * 100,
|
||||
withSpring(0, { damping: 15 })
|
||||
);
|
||||
opacity.value = withDelay(
|
||||
index * 100,
|
||||
withTiming(1, { duration: 300 })
|
||||
);
|
||||
}, [index]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: translateY.value }],
|
||||
opacity: opacity.value,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.listItem, animatedStyle]}>
|
||||
<Text>{item.title}</Text>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Pulse Animation
|
||||
|
||||
```typescript
|
||||
function PulseView({ children }: { children: React.ReactNode }) {
|
||||
const scale = useSharedValue(1);
|
||||
|
||||
useEffect(() => {
|
||||
scale.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(1.05, { duration: 500 }),
|
||||
withTiming(1, { duration: 500 })
|
||||
),
|
||||
-1, // infinite
|
||||
false // no reverse
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelAnimation(scale);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View style={animatedStyle}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Shake Animation
|
||||
|
||||
```typescript
|
||||
function ShakeView({ trigger, children }) {
|
||||
const translateX = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (trigger) {
|
||||
translateX.value = withSequence(
|
||||
withTiming(-10, { duration: 50 }),
|
||||
withTiming(10, { duration: 50 }),
|
||||
withTiming(-10, { duration: 50 }),
|
||||
withTiming(10, { duration: 50 }),
|
||||
withTiming(0, { duration: 50 })
|
||||
);
|
||||
}
|
||||
}, [trigger]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: translateX.value }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View style={animatedStyle}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
### Interpolation
|
||||
|
||||
```typescript
|
||||
import { interpolate, Extrapolation } from 'react-native-reanimated';
|
||||
|
||||
function ParallaxHeader() {
|
||||
const scrollY = useSharedValue(0);
|
||||
|
||||
const headerStyle = useAnimatedStyle(() => {
|
||||
const height = interpolate(
|
||||
scrollY.value,
|
||||
[0, 200],
|
||||
[300, 100],
|
||||
Extrapolation.CLAMP
|
||||
);
|
||||
|
||||
const opacity = interpolate(
|
||||
scrollY.value,
|
||||
[0, 150, 200],
|
||||
[1, 0.5, 0],
|
||||
Extrapolation.CLAMP
|
||||
);
|
||||
|
||||
const translateY = interpolate(
|
||||
scrollY.value,
|
||||
[0, 200],
|
||||
[0, -50],
|
||||
Extrapolation.CLAMP
|
||||
);
|
||||
|
||||
return {
|
||||
height,
|
||||
opacity,
|
||||
transform: [{ translateY }],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Animated.View style={[styles.header, headerStyle]}>
|
||||
<Text style={styles.headerTitle}>Header</Text>
|
||||
</Animated.View>
|
||||
<Animated.ScrollView
|
||||
onScroll={useAnimatedScrollHandler({
|
||||
onScroll: (event) => {
|
||||
scrollY.value = event.contentOffset.y;
|
||||
},
|
||||
})}
|
||||
scrollEventThrottle={16}
|
||||
>
|
||||
{/* Content */}
|
||||
</Animated.ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Color Interpolation
|
||||
|
||||
```typescript
|
||||
import { interpolateColor } from 'react-native-reanimated';
|
||||
|
||||
function ColorTransition() {
|
||||
const progress = useSharedValue(0);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
const backgroundColor = interpolateColor(
|
||||
progress.value,
|
||||
[0, 0.5, 1],
|
||||
['#6366f1', '#8b5cf6', '#ec4899']
|
||||
);
|
||||
|
||||
return { backgroundColor };
|
||||
});
|
||||
|
||||
const toggleColor = () => {
|
||||
progress.value = withTiming(progress.value === 0 ? 1 : 0, {
|
||||
duration: 1000,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable onPress={toggleColor}>
|
||||
<Animated.View style={[styles.box, animatedStyle]} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Derived Values
|
||||
|
||||
```typescript
|
||||
import { useDerivedValue } from 'react-native-reanimated';
|
||||
|
||||
function DerivedValueExample() {
|
||||
const x = useSharedValue(0);
|
||||
const y = useSharedValue(0);
|
||||
|
||||
// Derived value computed from other shared values
|
||||
const distance = useDerivedValue(() => {
|
||||
return Math.sqrt(x.value ** 2 + y.value ** 2);
|
||||
});
|
||||
|
||||
const angle = useDerivedValue(() => {
|
||||
return Math.atan2(y.value, x.value) * (180 / Math.PI);
|
||||
});
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{ translateX: x.value },
|
||||
{ translateY: y.value },
|
||||
{ rotate: `${angle.value}deg` },
|
||||
],
|
||||
opacity: interpolate(distance.value, [0, 200], [1, 0.5]),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.box, animatedStyle]} />
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Layout Animations
|
||||
|
||||
```typescript
|
||||
import Animated, {
|
||||
Layout,
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
SlideInLeft,
|
||||
SlideOutRight,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
function AnimatedList() {
|
||||
const [items, setItems] = useState([1, 2, 3, 4, 5]);
|
||||
|
||||
const addItem = () => {
|
||||
setItems([...items, items.length + 1]);
|
||||
};
|
||||
|
||||
const removeItem = (id: number) => {
|
||||
setItems(items.filter((item) => item !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Button title="Add Item" onPress={addItem} />
|
||||
{items.map((item) => (
|
||||
<Animated.View
|
||||
key={item}
|
||||
style={styles.item}
|
||||
entering={FadeIn.duration(300).springify()}
|
||||
exiting={SlideOutRight.duration(300)}
|
||||
layout={Layout.springify()}
|
||||
>
|
||||
<Text>Item {item}</Text>
|
||||
<Pressable onPress={() => removeItem(item)}>
|
||||
<Text>Remove</Text>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Swipeable Card
|
||||
|
||||
```typescript
|
||||
function SwipeableCard({ onSwipeLeft, onSwipeRight }) {
|
||||
const translateX = useSharedValue(0);
|
||||
const rotateZ = useSharedValue(0);
|
||||
const context = useSharedValue({ x: 0 });
|
||||
|
||||
const SWIPE_THRESHOLD = 120;
|
||||
|
||||
const panGesture = Gesture.Pan()
|
||||
.onStart(() => {
|
||||
context.value = { x: translateX.value };
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
translateX.value = event.translationX + context.value.x;
|
||||
rotateZ.value = interpolate(
|
||||
translateX.value,
|
||||
[-200, 0, 200],
|
||||
[-15, 0, 15]
|
||||
);
|
||||
})
|
||||
.onEnd((event) => {
|
||||
if (translateX.value > SWIPE_THRESHOLD) {
|
||||
translateX.value = withTiming(500, { duration: 200 }, () => {
|
||||
runOnJS(onSwipeRight)();
|
||||
});
|
||||
} else if (translateX.value < -SWIPE_THRESHOLD) {
|
||||
translateX.value = withTiming(-500, { duration: 200 }, () => {
|
||||
runOnJS(onSwipeLeft)();
|
||||
});
|
||||
} else {
|
||||
translateX.value = withSpring(0);
|
||||
rotateZ.value = withSpring(0);
|
||||
}
|
||||
});
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{ translateX: translateX.value },
|
||||
{ rotate: `${rotateZ.value}deg` },
|
||||
],
|
||||
}));
|
||||
|
||||
const leftIndicatorStyle = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(
|
||||
translateX.value,
|
||||
[0, SWIPE_THRESHOLD],
|
||||
[0, 1],
|
||||
Extrapolation.CLAMP
|
||||
),
|
||||
}));
|
||||
|
||||
const rightIndicatorStyle = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(
|
||||
translateX.value,
|
||||
[-SWIPE_THRESHOLD, 0],
|
||||
[1, 0],
|
||||
Extrapolation.CLAMP
|
||||
),
|
||||
}));
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={panGesture}>
|
||||
<Animated.View style={[styles.card, animatedStyle]}>
|
||||
<Animated.View style={[styles.likeIndicator, leftIndicatorStyle]}>
|
||||
<Text>LIKE</Text>
|
||||
</Animated.View>
|
||||
<Animated.View style={[styles.nopeIndicator, rightIndicatorStyle]}>
|
||||
<Text>NOPE</Text>
|
||||
</Animated.View>
|
||||
{/* Card content */}
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Bottom Sheet
|
||||
|
||||
```typescript
|
||||
const MAX_TRANSLATE_Y = -SCREEN_HEIGHT + 50;
|
||||
const MIN_TRANSLATE_Y = 0;
|
||||
const SNAP_POINTS = [-SCREEN_HEIGHT * 0.5, -SCREEN_HEIGHT * 0.25, 0];
|
||||
|
||||
function BottomSheet({ children }) {
|
||||
const translateY = useSharedValue(0);
|
||||
const context = useSharedValue({ y: 0 });
|
||||
|
||||
const panGesture = Gesture.Pan()
|
||||
.onStart(() => {
|
||||
context.value = { y: translateY.value };
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
translateY.value = clamp(
|
||||
context.value.y + event.translationY,
|
||||
MAX_TRANSLATE_Y,
|
||||
MIN_TRANSLATE_Y
|
||||
);
|
||||
})
|
||||
.onEnd((event) => {
|
||||
// Find closest snap point
|
||||
const destination = SNAP_POINTS.reduce((prev, curr) =>
|
||||
Math.abs(curr - translateY.value) < Math.abs(prev - translateY.value)
|
||||
? curr
|
||||
: prev
|
||||
);
|
||||
|
||||
translateY.value = withSpring(destination, {
|
||||
damping: 50,
|
||||
stiffness: 300,
|
||||
});
|
||||
});
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: translateY.value }],
|
||||
}));
|
||||
|
||||
const backdropStyle = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(
|
||||
translateY.value,
|
||||
[MIN_TRANSLATE_Y, MAX_TRANSLATE_Y],
|
||||
[0, 0.5]
|
||||
),
|
||||
pointerEvents: translateY.value < -50 ? 'auto' : 'none',
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Animated.View style={[styles.backdrop, backdropStyle]} />
|
||||
<GestureDetector gesture={panGesture}>
|
||||
<Animated.View style={[styles.bottomSheet, animatedStyle]}>
|
||||
<View style={styles.handle} />
|
||||
{children}
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
### Memoization
|
||||
|
||||
```typescript
|
||||
// Memoize animated style when dependencies don't change
|
||||
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]);
|
||||
```
|
||||
|
||||
### Worklet Best Practices
|
||||
|
||||
```typescript
|
||||
// Do: Keep worklets simple
|
||||
const simpleWorklet = () => {
|
||||
'worklet';
|
||||
return scale.value * 2;
|
||||
};
|
||||
|
||||
// Don't: Complex logic in worklets
|
||||
// Move complex logic to JS with runOnJS
|
||||
|
||||
// Do: Use runOnJS for callbacks
|
||||
const onComplete = () => {
|
||||
setIsAnimating(false);
|
||||
};
|
||||
|
||||
opacity.value = withTiming(1, {}, (finished) => {
|
||||
'worklet';
|
||||
if (finished) {
|
||||
runOnJS(onComplete)();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Cancel Animations
|
||||
|
||||
```typescript
|
||||
import { cancelAnimation } from 'react-native-reanimated';
|
||||
|
||||
function AnimatedComponent() {
|
||||
const translateX = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
// Start animation
|
||||
translateX.value = withRepeat(
|
||||
withTiming(100, { duration: 1000 }),
|
||||
-1,
|
||||
true
|
||||
);
|
||||
|
||||
// Cleanup: cancel animation on unmount
|
||||
return () => {
|
||||
cancelAnimation(translateX);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <Animated.View style={animatedStyle} />;
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,874 @@
|
||||
# React Native Styling Patterns
|
||||
|
||||
## StyleSheet Fundamentals
|
||||
|
||||
### Creating Styles
|
||||
|
||||
```typescript
|
||||
import { StyleSheet, ViewStyle, TextStyle, ImageStyle } from 'react-native';
|
||||
|
||||
// Typed styles for better IDE support
|
||||
interface Styles {
|
||||
container: ViewStyle;
|
||||
title: TextStyle;
|
||||
image: ImageStyle;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create<Styles>({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
color: '#1f2937',
|
||||
},
|
||||
image: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 8,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Combining Styles
|
||||
|
||||
```typescript
|
||||
import { StyleProp, ViewStyle } from 'react-native';
|
||||
|
||||
interface BoxProps {
|
||||
style?: StyleProp<ViewStyle>;
|
||||
variant?: 'default' | 'primary' | 'danger';
|
||||
}
|
||||
|
||||
function Box({ style, variant = 'default' }: BoxProps) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.base,
|
||||
variant === 'primary' && styles.primary,
|
||||
variant === 'danger' && styles.danger,
|
||||
style, // Allow external style overrides
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
base: {
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#f3f4f6',
|
||||
},
|
||||
primary: {
|
||||
backgroundColor: '#6366f1',
|
||||
},
|
||||
danger: {
|
||||
backgroundColor: '#ef4444',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Theme System
|
||||
|
||||
### Theme Context
|
||||
|
||||
```typescript
|
||||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import { useColorScheme } from 'react-native';
|
||||
|
||||
interface Theme {
|
||||
colors: {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
background: string;
|
||||
surface: string;
|
||||
text: string;
|
||||
textSecondary: string;
|
||||
border: string;
|
||||
error: string;
|
||||
success: string;
|
||||
};
|
||||
spacing: {
|
||||
xs: number;
|
||||
sm: number;
|
||||
md: number;
|
||||
lg: number;
|
||||
xl: number;
|
||||
};
|
||||
borderRadius: {
|
||||
sm: number;
|
||||
md: number;
|
||||
lg: number;
|
||||
full: number;
|
||||
};
|
||||
typography: {
|
||||
h1: { fontSize: number; fontWeight: string; lineHeight: number };
|
||||
h2: { fontSize: number; fontWeight: string; lineHeight: number };
|
||||
body: { fontSize: number; fontWeight: string; lineHeight: number };
|
||||
caption: { fontSize: number; fontWeight: string; lineHeight: number };
|
||||
};
|
||||
}
|
||||
|
||||
const lightTheme: Theme = {
|
||||
colors: {
|
||||
primary: '#6366f1',
|
||||
secondary: '#8b5cf6',
|
||||
background: '#ffffff',
|
||||
surface: '#f9fafb',
|
||||
text: '#1f2937',
|
||||
textSecondary: '#6b7280',
|
||||
border: '#e5e7eb',
|
||||
error: '#ef4444',
|
||||
success: '#10b981',
|
||||
},
|
||||
spacing: {
|
||||
xs: 4,
|
||||
sm: 8,
|
||||
md: 16,
|
||||
lg: 24,
|
||||
xl: 32,
|
||||
},
|
||||
borderRadius: {
|
||||
sm: 4,
|
||||
md: 8,
|
||||
lg: 16,
|
||||
full: 9999,
|
||||
},
|
||||
typography: {
|
||||
h1: { fontSize: 32, fontWeight: '700', lineHeight: 40 },
|
||||
h2: { fontSize: 24, fontWeight: '600', lineHeight: 32 },
|
||||
body: { fontSize: 16, fontWeight: '400', lineHeight: 24 },
|
||||
caption: { fontSize: 12, fontWeight: '400', lineHeight: 16 },
|
||||
},
|
||||
};
|
||||
|
||||
const darkTheme: Theme = {
|
||||
...lightTheme,
|
||||
colors: {
|
||||
primary: '#818cf8',
|
||||
secondary: '#a78bfa',
|
||||
background: '#111827',
|
||||
surface: '#1f2937',
|
||||
text: '#f9fafb',
|
||||
textSecondary: '#9ca3af',
|
||||
border: '#374151',
|
||||
error: '#f87171',
|
||||
success: '#34d399',
|
||||
},
|
||||
};
|
||||
|
||||
const ThemeContext = createContext<Theme>(lightTheme);
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const colorScheme = useColorScheme();
|
||||
const theme = useMemo(
|
||||
() => (colorScheme === 'dark' ? darkTheme : lightTheme),
|
||||
[colorScheme]
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={theme}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
return useContext(ThemeContext);
|
||||
}
|
||||
```
|
||||
|
||||
### Using Theme
|
||||
|
||||
```typescript
|
||||
import { useTheme } from './theme';
|
||||
|
||||
function ThemedCard() {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: theme.colors.surface,
|
||||
padding: theme.spacing.md,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
...theme.typography.h2,
|
||||
color: theme.colors.text,
|
||||
marginBottom: theme.spacing.sm,
|
||||
}}
|
||||
>
|
||||
Card Title
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
...theme.typography.body,
|
||||
color: theme.colors.textSecondary,
|
||||
}}
|
||||
>
|
||||
Card description text
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Responsive Design
|
||||
|
||||
### Screen Dimensions
|
||||
|
||||
```typescript
|
||||
import { Dimensions, useWindowDimensions, PixelRatio } from 'react-native';
|
||||
|
||||
// Get dimensions once (may be stale after rotation)
|
||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||
|
||||
// Responsive scaling
|
||||
const guidelineBaseWidth = 375;
|
||||
const guidelineBaseHeight = 812;
|
||||
|
||||
export const scale = (size: number) =>
|
||||
(SCREEN_WIDTH / guidelineBaseWidth) * size;
|
||||
|
||||
export const verticalScale = (size: number) =>
|
||||
(SCREEN_HEIGHT / guidelineBaseHeight) * size;
|
||||
|
||||
export const moderateScale = (size: number, factor = 0.5) =>
|
||||
size + (scale(size) - size) * factor;
|
||||
|
||||
// Hook for dynamic dimensions (handles rotation)
|
||||
function ResponsiveComponent() {
|
||||
const { width, height } = useWindowDimensions();
|
||||
const isLandscape = width > height;
|
||||
const isTablet = width >= 768;
|
||||
|
||||
return (
|
||||
<View style={{ flexDirection: isLandscape ? 'row' : 'column' }}>
|
||||
{/* Content */}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Breakpoint System
|
||||
|
||||
```typescript
|
||||
import { useWindowDimensions } from 'react-native';
|
||||
|
||||
type Breakpoint = 'sm' | 'md' | 'lg' | 'xl';
|
||||
|
||||
const breakpoints = {
|
||||
sm: 0,
|
||||
md: 768,
|
||||
lg: 1024,
|
||||
xl: 1280,
|
||||
};
|
||||
|
||||
export function useBreakpoint(): Breakpoint {
|
||||
const { width } = useWindowDimensions();
|
||||
|
||||
if (width >= breakpoints.xl) return 'xl';
|
||||
if (width >= breakpoints.lg) return 'lg';
|
||||
if (width >= breakpoints.md) return 'md';
|
||||
return 'sm';
|
||||
}
|
||||
|
||||
export function useResponsiveValue<T>(values: Partial<Record<Breakpoint, T>>): T | undefined {
|
||||
const breakpoint = useBreakpoint();
|
||||
const breakpointOrder: Breakpoint[] = ['xl', 'lg', 'md', 'sm'];
|
||||
const currentIndex = breakpointOrder.indexOf(breakpoint);
|
||||
|
||||
for (let i = currentIndex; i < breakpointOrder.length; i++) {
|
||||
const bp = breakpointOrder[i];
|
||||
if (values[bp] !== undefined) {
|
||||
return values[bp];
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Usage
|
||||
function ResponsiveGrid() {
|
||||
const columns = useResponsiveValue({ sm: 1, md: 2, lg: 3, xl: 4 }) ?? 1;
|
||||
|
||||
return (
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
|
||||
{items.map((item) => (
|
||||
<View key={item.id} style={{ width: `${100 / columns}%` }}>
|
||||
<ItemCard item={item} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Layout Components
|
||||
|
||||
### Container
|
||||
|
||||
```typescript
|
||||
import { View, ViewStyle, StyleProp } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useTheme } from './theme';
|
||||
|
||||
interface ContainerProps {
|
||||
children: React.ReactNode;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
edges?: ('top' | 'bottom' | 'left' | 'right')[];
|
||||
}
|
||||
|
||||
export function Container({ children, style, edges = ['top', 'bottom'] }: ContainerProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
paddingTop: edges.includes('top') ? insets.top : 0,
|
||||
paddingBottom: edges.includes('bottom') ? insets.bottom : 0,
|
||||
paddingLeft: edges.includes('left') ? insets.left : 0,
|
||||
paddingRight: edges.includes('right') ? insets.right : 0,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Stack Components
|
||||
|
||||
```typescript
|
||||
import { View, ViewStyle, StyleProp } from 'react-native';
|
||||
|
||||
interface StackProps {
|
||||
children: React.ReactNode;
|
||||
spacing?: number;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
export function VStack({ children, spacing = 8, style }: StackProps) {
|
||||
return (
|
||||
<View style={[{ gap: spacing }, style]}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function HStack({ children, spacing = 8, style }: StackProps) {
|
||||
return (
|
||||
<View style={[{ flexDirection: 'row', alignItems: 'center', gap: spacing }, style]}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage
|
||||
function Example() {
|
||||
return (
|
||||
<VStack spacing={16}>
|
||||
<HStack spacing={8}>
|
||||
<Avatar />
|
||||
<VStack spacing={2}>
|
||||
<Text style={styles.name}>John Doe</Text>
|
||||
<Text style={styles.email}>john@example.com</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<Button title="Edit Profile" />
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Spacer
|
||||
|
||||
```typescript
|
||||
import { View } from 'react-native';
|
||||
|
||||
interface SpacerProps {
|
||||
size?: number;
|
||||
flex?: number;
|
||||
}
|
||||
|
||||
export function Spacer({ size, flex }: SpacerProps) {
|
||||
if (flex) {
|
||||
return <View style={{ flex }} />;
|
||||
}
|
||||
return <View style={{ height: size, width: size }} />;
|
||||
}
|
||||
|
||||
// Usage
|
||||
<HStack>
|
||||
<Text>Left</Text>
|
||||
<Spacer flex={1} />
|
||||
<Text>Right</Text>
|
||||
</HStack>
|
||||
```
|
||||
|
||||
## Shadow Styles
|
||||
|
||||
### Cross-Platform Shadows
|
||||
|
||||
```typescript
|
||||
import { Platform, ViewStyle } from 'react-native';
|
||||
|
||||
export function createShadow(
|
||||
elevation: number,
|
||||
color = '#000000'
|
||||
): ViewStyle {
|
||||
if (Platform.OS === 'android') {
|
||||
return { elevation };
|
||||
}
|
||||
|
||||
// iOS shadow mapping based on elevation
|
||||
const shadowMap: Record<number, ViewStyle> = {
|
||||
1: {
|
||||
shadowColor: color,
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.18,
|
||||
shadowRadius: 1,
|
||||
},
|
||||
2: {
|
||||
shadowColor: color,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 2,
|
||||
},
|
||||
4: {
|
||||
shadowColor: color,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.22,
|
||||
shadowRadius: 4,
|
||||
},
|
||||
8: {
|
||||
shadowColor: color,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 8,
|
||||
},
|
||||
16: {
|
||||
shadowColor: color,
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 16,
|
||||
},
|
||||
};
|
||||
|
||||
return shadowMap[elevation] || shadowMap[4];
|
||||
}
|
||||
|
||||
// Predefined shadow styles
|
||||
export const shadows = {
|
||||
sm: createShadow(2),
|
||||
md: createShadow(4),
|
||||
lg: createShadow(8),
|
||||
xl: createShadow(16),
|
||||
};
|
||||
|
||||
// Usage
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
...shadows.md,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Typography System
|
||||
|
||||
### Text Components
|
||||
|
||||
```typescript
|
||||
import { Text as RNText, TextStyle, StyleProp, TextProps as RNTextProps } from 'react-native';
|
||||
import { useTheme } from './theme';
|
||||
|
||||
type Variant = 'h1' | 'h2' | 'h3' | 'body' | 'bodySmall' | 'caption' | 'label';
|
||||
type Color = 'primary' | 'secondary' | 'text' | 'textSecondary' | 'error' | 'success';
|
||||
|
||||
interface TextProps extends RNTextProps {
|
||||
variant?: Variant;
|
||||
color?: Color;
|
||||
weight?: 'normal' | 'medium' | 'semibold' | 'bold';
|
||||
align?: 'left' | 'center' | 'right';
|
||||
}
|
||||
|
||||
const variantStyles: Record<Variant, TextStyle> = {
|
||||
h1: { fontSize: 32, lineHeight: 40, fontWeight: '700' },
|
||||
h2: { fontSize: 24, lineHeight: 32, fontWeight: '600' },
|
||||
h3: { fontSize: 20, lineHeight: 28, fontWeight: '600' },
|
||||
body: { fontSize: 16, lineHeight: 24, fontWeight: '400' },
|
||||
bodySmall: { fontSize: 14, lineHeight: 20, fontWeight: '400' },
|
||||
caption: { fontSize: 12, lineHeight: 16, fontWeight: '400' },
|
||||
label: { fontSize: 14, lineHeight: 20, fontWeight: '500' },
|
||||
};
|
||||
|
||||
const weightStyles: Record<string, TextStyle> = {
|
||||
normal: { fontWeight: '400' },
|
||||
medium: { fontWeight: '500' },
|
||||
semibold: { fontWeight: '600' },
|
||||
bold: { fontWeight: '700' },
|
||||
};
|
||||
|
||||
export function Text({
|
||||
variant = 'body',
|
||||
color = 'text',
|
||||
weight,
|
||||
align,
|
||||
style,
|
||||
...props
|
||||
}: TextProps) {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<RNText
|
||||
style={[
|
||||
variantStyles[variant],
|
||||
{ color: theme.colors[color] },
|
||||
weight && weightStyles[weight],
|
||||
align && { textAlign: align },
|
||||
style,
|
||||
]}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage
|
||||
<Text variant="h1">Heading</Text>
|
||||
<Text variant="body" color="textSecondary">Body text</Text>
|
||||
<Text variant="label" weight="semibold">Label</Text>
|
||||
```
|
||||
|
||||
## Button Styles
|
||||
|
||||
### Customizable Button
|
||||
|
||||
```typescript
|
||||
import { Pressable, StyleSheet, ActivityIndicator } from 'react-native';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
} from 'react-native-reanimated';
|
||||
import { useTheme } from './theme';
|
||||
|
||||
type Variant = 'filled' | 'outlined' | 'ghost';
|
||||
type Size = 'sm' | 'md' | 'lg';
|
||||
|
||||
interface ButtonProps {
|
||||
title: string;
|
||||
onPress: () => void;
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
|
||||
|
||||
export function Button({
|
||||
title,
|
||||
onPress,
|
||||
variant = 'filled',
|
||||
size = 'md',
|
||||
disabled = false,
|
||||
loading = false,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
}: ButtonProps) {
|
||||
const theme = useTheme();
|
||||
const scale = useSharedValue(1);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
}));
|
||||
|
||||
const sizeStyles = {
|
||||
sm: { paddingVertical: 8, paddingHorizontal: 12, fontSize: 14 },
|
||||
md: { paddingVertical: 12, paddingHorizontal: 16, fontSize: 16 },
|
||||
lg: { paddingVertical: 16, paddingHorizontal: 24, fontSize: 18 },
|
||||
};
|
||||
|
||||
const variantStyles = {
|
||||
filled: {
|
||||
backgroundColor: theme.colors.primary,
|
||||
textColor: '#ffffff',
|
||||
},
|
||||
outlined: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.primary,
|
||||
textColor: theme.colors.primary,
|
||||
},
|
||||
ghost: {
|
||||
backgroundColor: 'transparent',
|
||||
textColor: theme.colors.primary,
|
||||
},
|
||||
};
|
||||
|
||||
const currentVariant = variantStyles[variant];
|
||||
const currentSize = sizeStyles[size];
|
||||
|
||||
return (
|
||||
<AnimatedPressable
|
||||
style={[
|
||||
styles.base,
|
||||
{
|
||||
backgroundColor: currentVariant.backgroundColor,
|
||||
borderWidth: currentVariant.borderWidth,
|
||||
borderColor: currentVariant.borderColor,
|
||||
paddingVertical: currentSize.paddingVertical,
|
||||
paddingHorizontal: currentSize.paddingHorizontal,
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
},
|
||||
animatedStyle,
|
||||
]}
|
||||
onPress={onPress}
|
||||
onPressIn={() => { scale.value = withSpring(0.97); }}
|
||||
onPressOut={() => { scale.value = withSpring(1); }}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={currentVariant.textColor} />
|
||||
) : (
|
||||
<>
|
||||
{leftIcon}
|
||||
<Text
|
||||
style={{
|
||||
color: currentVariant.textColor,
|
||||
fontSize: currentSize.fontSize,
|
||||
fontWeight: '600',
|
||||
marginHorizontal: leftIcon || rightIcon ? 8 : 0,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{rightIcon}
|
||||
</>
|
||||
)}
|
||||
</AnimatedPressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
base: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 8,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Form Styles
|
||||
|
||||
### Input Component
|
||||
|
||||
```typescript
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
TextInput,
|
||||
StyleSheet,
|
||||
TextInputProps,
|
||||
Pressable,
|
||||
} from 'react-native';
|
||||
import { useTheme } from './theme';
|
||||
|
||||
interface InputProps extends TextInputProps {
|
||||
label?: string;
|
||||
error?: string;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Input({
|
||||
label,
|
||||
error,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
style,
|
||||
...props
|
||||
}: InputProps) {
|
||||
const theme = useTheme();
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const borderColor = error
|
||||
? theme.colors.error
|
||||
: isFocused
|
||||
? theme.colors.primary
|
||||
: theme.colors.border;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{label && (
|
||||
<Text style={[styles.label, { color: theme.colors.text }]}>
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
<View
|
||||
style={[
|
||||
styles.inputContainer,
|
||||
{
|
||||
borderColor,
|
||||
backgroundColor: theme.colors.surface,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{leftIcon && <View style={styles.icon}>{leftIcon}</View>}
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
{ color: theme.colors.text },
|
||||
style,
|
||||
]}
|
||||
placeholderTextColor={theme.colors.textSecondary}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
{...props}
|
||||
/>
|
||||
{rightIcon && <View style={styles.icon}>{rightIcon}</View>}
|
||||
</View>
|
||||
{error && (
|
||||
<Text style={[styles.error, { color: theme.colors.error }]}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
marginBottom: 6,
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
},
|
||||
icon: {
|
||||
marginHorizontal: 4,
|
||||
},
|
||||
error: {
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## List Styles
|
||||
|
||||
### FlatList with Styling
|
||||
|
||||
```typescript
|
||||
import { FlatList, View, StyleSheet } from 'react-native';
|
||||
|
||||
interface Item {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
function StyledList({ items }: { items: Item[] }) {
|
||||
return (
|
||||
<FlatList
|
||||
data={items}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item, index }) => (
|
||||
<View
|
||||
style={[
|
||||
styles.item,
|
||||
index === 0 && styles.firstItem,
|
||||
index === items.length - 1 && styles.lastItem,
|
||||
]}
|
||||
>
|
||||
<Text style={styles.itemTitle}>{item.title}</Text>
|
||||
<Text style={styles.itemSubtitle}>{item.subtitle}</Text>
|
||||
</View>
|
||||
)}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
ListHeaderComponent={() => (
|
||||
<Text style={styles.header}>List Header</Text>
|
||||
)}
|
||||
ListEmptyComponent={() => (
|
||||
<View style={styles.empty}>
|
||||
<Text>No items found</Text>
|
||||
</View>
|
||||
)}
|
||||
contentContainerStyle={styles.listContent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
listContent: {
|
||||
padding: 16,
|
||||
},
|
||||
item: {
|
||||
backgroundColor: '#ffffff',
|
||||
padding: 16,
|
||||
},
|
||||
firstItem: {
|
||||
borderTopLeftRadius: 12,
|
||||
borderTopRightRadius: 12,
|
||||
},
|
||||
lastItem: {
|
||||
borderBottomLeftRadius: 12,
|
||||
borderBottomRightRadius: 12,
|
||||
},
|
||||
itemTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
},
|
||||
itemSubtitle: {
|
||||
fontSize: 14,
|
||||
color: '#6b7280',
|
||||
},
|
||||
separator: {
|
||||
height: 1,
|
||||
backgroundColor: '#e5e7eb',
|
||||
},
|
||||
header: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
marginBottom: 16,
|
||||
},
|
||||
empty: {
|
||||
alignItems: 'center',
|
||||
padding: 32,
|
||||
},
|
||||
});
|
||||
```
|
||||
493
plugins/ui-design/skills/responsive-design/SKILL.md
Normal file
493
plugins/ui-design/skills/responsive-design/SKILL.md
Normal file
@@ -0,0 +1,493 @@
|
||||
---
|
||||
name: responsive-design
|
||||
description: Implement modern responsive layouts using container queries, fluid typography, CSS Grid, and mobile-first breakpoint strategies. Use when building adaptive interfaces, implementing fluid layouts, or creating component-level responsive behavior.
|
||||
---
|
||||
|
||||
# Responsive Design
|
||||
|
||||
Master modern responsive design techniques to create interfaces that adapt seamlessly across all screen sizes and device contexts.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
- Implementing mobile-first responsive layouts
|
||||
- Using container queries for component-based responsiveness
|
||||
- Creating fluid typography and spacing scales
|
||||
- Building complex layouts with CSS Grid and Flexbox
|
||||
- Designing breakpoint strategies for design systems
|
||||
- Implementing responsive images and media
|
||||
- Creating adaptive navigation patterns
|
||||
- Building responsive tables and data displays
|
||||
|
||||
## 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
|
||||
- Feature queries (@supports)
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Modern Breakpoint Scale
|
||||
|
||||
```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 */ }
|
||||
|
||||
/* Tailwind CSS equivalent */
|
||||
/* sm: @media (min-width: 640px) */
|
||||
/* md: @media (min-width: 768px) */
|
||||
/* lg: @media (min-width: 1024px) */
|
||||
/* xl: @media (min-width: 1280px) */
|
||||
/* 2xl: @media (min-width: 1536px) */
|
||||
```
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### Pattern 1: Container Queries
|
||||
|
||||
```css
|
||||
/* Define a containment context */
|
||||
.card-container {
|
||||
container-type: inline-size;
|
||||
container-name: card;
|
||||
}
|
||||
|
||||
/* Query the container, not the viewport */
|
||||
@container card (min-width: 400px) {
|
||||
.card {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.card-image {
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@container card (min-width: 600px) {
|
||||
.card {
|
||||
grid-template-columns: 250px 1fr;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Container query units */
|
||||
.card-title {
|
||||
/* 5% of container width, clamped between 1rem and 2rem */
|
||||
font-size: clamp(1rem, 5cqi, 2rem);
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// React component with container queries
|
||||
function ResponsiveCard({ title, image, description }) {
|
||||
return (
|
||||
<div className="@container">
|
||||
<article className="flex flex-col @md:flex-row @md:gap-4">
|
||||
<img
|
||||
src={image}
|
||||
alt=""
|
||||
className="w-full @md:w-48 @lg:w-64 aspect-video @md:aspect-square object-cover"
|
||||
/>
|
||||
<div className="p-4 @md:p-0">
|
||||
<h2 className="text-lg @md:text-xl @lg:text-2xl font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="mt-2 text-muted-foreground @md:line-clamp-3">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Fluid Typography
|
||||
|
||||
```css
|
||||
/* Fluid type scale using clamp() */
|
||||
:root {
|
||||
/* Min size, preferred (fluid), max size */
|
||||
--text-xs: clamp(0.75rem, 0.7rem + 0.25vw, 0.875rem);
|
||||
--text-sm: clamp(0.875rem, 0.8rem + 0.375vw, 1rem);
|
||||
--text-base: clamp(1rem, 0.9rem + 0.5vw, 1.125rem);
|
||||
--text-lg: clamp(1.125rem, 1rem + 0.625vw, 1.25rem);
|
||||
--text-xl: clamp(1.25rem, 1rem + 1.25vw, 1.5rem);
|
||||
--text-2xl: clamp(1.5rem, 1.25rem + 1.25vw, 2rem);
|
||||
--text-3xl: clamp(1.875rem, 1.5rem + 1.875vw, 2.5rem);
|
||||
--text-4xl: clamp(2.25rem, 1.75rem + 2.5vw, 3.5rem);
|
||||
}
|
||||
|
||||
/* 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); }
|
||||
|
||||
/* Fluid spacing scale */
|
||||
:root {
|
||||
--space-xs: clamp(0.25rem, 0.2rem + 0.25vw, 0.5rem);
|
||||
--space-sm: clamp(0.5rem, 0.4rem + 0.5vw, 0.75rem);
|
||||
--space-md: clamp(1rem, 0.8rem + 1vw, 1.5rem);
|
||||
--space-lg: clamp(1.5rem, 1.2rem + 1.5vw, 2.5rem);
|
||||
--space-xl: clamp(2rem, 1.5rem + 2.5vw, 4rem);
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Utility function for fluid values
|
||||
function fluidValue(minSize: number, maxSize: number, minWidth = 320, maxWidth = 1280) {
|
||||
const slope = (maxSize - minSize) / (maxWidth - minWidth);
|
||||
const yAxisIntersection = -minWidth * slope + minSize;
|
||||
|
||||
return `clamp(${minSize}rem, ${yAxisIntersection.toFixed(4)}rem + ${(slope * 100).toFixed(4)}vw, ${maxSize}rem)`;
|
||||
}
|
||||
|
||||
// Generate fluid type scale
|
||||
const fluidTypeScale = {
|
||||
sm: fluidValue(0.875, 1),
|
||||
base: fluidValue(1, 1.125),
|
||||
lg: fluidValue(1.25, 1.5),
|
||||
xl: fluidValue(1.5, 2),
|
||||
'2xl': fluidValue(2, 3),
|
||||
};
|
||||
```
|
||||
|
||||
### Pattern 3: CSS Grid Responsive Layout
|
||||
|
||||
```css
|
||||
/* Auto-fit grid - items wrap automatically */
|
||||
.grid-auto {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
/* Auto-fill grid - maintains empty columns */
|
||||
.grid-auto-fill {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Responsive grid with named areas */
|
||||
.page-layout {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"header"
|
||||
"main"
|
||||
"sidebar"
|
||||
"footer";
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.page-layout {
|
||||
grid-template-columns: 1fr 300px;
|
||||
grid-template-areas:
|
||||
"header header"
|
||||
"main sidebar"
|
||||
"footer footer";
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.page-layout {
|
||||
grid-template-columns: 250px 1fr 300px;
|
||||
grid-template-areas:
|
||||
"header header header"
|
||||
"nav main sidebar"
|
||||
"footer footer 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',
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="grid"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(auto-fit, minmax(min(${minItemWidth}, 100%), 1fr))`,
|
||||
gap,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage with Tailwind
|
||||
function ProductGrid({ products }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 md:gap-6">
|
||||
{products.map((product) => (
|
||||
<ProductCard key={product.id} product={product} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 4: Responsive Navigation
|
||||
|
||||
```tsx
|
||||
function ResponsiveNav({ items }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<nav className="relative">
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
className="lg:hidden p-2"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-expanded={isOpen}
|
||||
aria-controls="nav-menu"
|
||||
>
|
||||
<span className="sr-only">Toggle navigation</span>
|
||||
{isOpen ? <X /> : <Menu />}
|
||||
</button>
|
||||
|
||||
{/* Navigation links */}
|
||||
<ul
|
||||
id="nav-menu"
|
||||
className={cn(
|
||||
// Base: hidden on mobile
|
||||
'absolute top-full left-0 right-0 bg-background border-b',
|
||||
'flex flex-col',
|
||||
// Mobile: slide down
|
||||
isOpen ? 'flex' : 'hidden',
|
||||
// Desktop: always visible, horizontal
|
||||
'lg:static lg:flex lg:flex-row lg:border-0 lg:bg-transparent'
|
||||
)}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<li key={item.href}>
|
||||
<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'
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 5: Responsive Images
|
||||
|
||||
```tsx
|
||||
// Responsive image with art direction
|
||||
function ResponsiveHero() {
|
||||
return (
|
||||
<picture>
|
||||
{/* Art direction: different crops for different screens */}
|
||||
<source
|
||||
media="(min-width: 1024px)"
|
||||
srcSet="/hero-wide.webp"
|
||||
type="image/webp"
|
||||
/>
|
||||
<source
|
||||
media="(min-width: 768px)"
|
||||
srcSet="/hero-medium.webp"
|
||||
type="image/webp"
|
||||
/>
|
||||
<source srcSet="/hero-mobile.webp" type="image/webp" />
|
||||
|
||||
{/* Fallback */}
|
||||
<img
|
||||
src="/hero-mobile.jpg"
|
||||
alt="Hero image description"
|
||||
className="w-full h-auto"
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
/>
|
||||
</picture>
|
||||
);
|
||||
}
|
||||
|
||||
// Responsive image with srcset for resolution switching
|
||||
function ProductImage({ product }) {
|
||||
return (
|
||||
<img
|
||||
src={product.image}
|
||||
srcSet={`
|
||||
${product.image}?w=400 400w,
|
||||
${product.image}?w=800 800w,
|
||||
${product.image}?w=1200 1200w
|
||||
`}
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
alt={product.name}
|
||||
className="w-full h-auto object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 6: Responsive Tables
|
||||
|
||||
```tsx
|
||||
// Responsive table with horizontal scroll
|
||||
function ResponsiveTable({ data, columns }) {
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<table className="w-full min-w-[600px]">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th key={col.key} className="text-left p-3">
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row, i) => (
|
||||
<tr key={i} className="border-t">
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className="p-3">
|
||||
{row[col.key]}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Card-based table for mobile
|
||||
function ResponsiveDataTable({ data, columns }) {
|
||||
return (
|
||||
<>
|
||||
{/* Desktop table */}
|
||||
<table className="hidden md:table w-full">
|
||||
{/* ... standard table */}
|
||||
</table>
|
||||
|
||||
{/* Mobile cards */}
|
||||
<div className="md:hidden space-y-4">
|
||||
{data.map((row, i) => (
|
||||
<div key={i} className="border rounded-lg p-4 space-y-2">
|
||||
{columns.map((col) => (
|
||||
<div key={col.key} className="flex justify-between">
|
||||
<span className="font-medium text-muted-foreground">
|
||||
{col.label}
|
||||
</span>
|
||||
<span>{row[col.key]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Viewport Units
|
||||
|
||||
```css
|
||||
/* Standard viewport units */
|
||||
.full-height {
|
||||
height: 100vh; /* May cause issues on mobile */
|
||||
}
|
||||
|
||||
/* Dynamic viewport units (recommended for mobile) */
|
||||
.full-height-dynamic {
|
||||
height: 100dvh; /* Accounts for mobile browser UI */
|
||||
}
|
||||
|
||||
/* Small viewport (minimum) */
|
||||
.min-full-height {
|
||||
min-height: 100svh;
|
||||
}
|
||||
|
||||
/* Large viewport (maximum) */
|
||||
.max-full-height {
|
||||
max-height: 100lvh;
|
||||
}
|
||||
|
||||
/* Viewport-relative font sizing */
|
||||
.hero-title {
|
||||
/* 5vw with min/max bounds */
|
||||
font-size: clamp(2rem, 5vw, 4rem);
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Mobile-First**: Start with mobile styles, enhance for larger screens
|
||||
2. **Content Breakpoints**: Set breakpoints based on content, not devices
|
||||
3. **Fluid Over Fixed**: Use fluid values for typography and spacing
|
||||
4. **Container Queries**: Use for component-level responsiveness
|
||||
5. **Test Real Devices**: Simulators don't catch all issues
|
||||
6. **Performance**: Optimize images, lazy load off-screen content
|
||||
7. **Touch Targets**: Maintain 44x44px minimum on mobile
|
||||
8. **Logical Properties**: Use inline/block for internationalization
|
||||
|
||||
## Common Issues
|
||||
|
||||
- **Horizontal Overflow**: Content breaking out of viewport
|
||||
- **Fixed Widths**: Using px instead of relative units
|
||||
- **Viewport Height**: 100vh issues on mobile browsers
|
||||
- **Font Size**: Text too small on mobile
|
||||
- **Touch Targets**: Buttons too small to tap accurately
|
||||
- **Aspect Ratio**: Images squishing or stretching
|
||||
- **Z-Index Stacking**: Overlays breaking on different screens
|
||||
|
||||
## Resources
|
||||
|
||||
- [CSS Container Queries](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_container_queries)
|
||||
- [Utopia Fluid Type Calculator](https://utopia.fyi/type/calculator/)
|
||||
- [Every Layout](https://every-layout.dev/)
|
||||
- [Responsive Images Guide](https://web.dev/responsive-images/)
|
||||
- [CSS Grid Garden](https://cssgridgarden.com/)
|
||||
@@ -0,0 +1,551 @@
|
||||
# Breakpoint Strategies
|
||||
|
||||
## Overview
|
||||
|
||||
Effective breakpoint strategies focus on content needs rather than device sizes. Modern responsive design uses fewer, content-driven breakpoints combined with fluid techniques.
|
||||
|
||||
## Mobile-First Approach
|
||||
|
||||
### Core Philosophy
|
||||
|
||||
Start with the smallest screen, then progressively enhance for larger screens.
|
||||
|
||||
```css
|
||||
/* Base styles (mobile first) */
|
||||
.component {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Enhance for larger screens */
|
||||
@media (min-width: 640px) {
|
||||
.component {
|
||||
flex-direction: row;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.component {
|
||||
padding: 2rem;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Benefits
|
||||
|
||||
1. **Performance**: Mobile devices load only necessary CSS
|
||||
2. **Progressive Enhancement**: Features add rather than subtract
|
||||
3. **Content Priority**: Forces focus on essential content first
|
||||
4. **Simplicity**: Easier to reason about cascading styles
|
||||
|
||||
## Common Breakpoint Scales
|
||||
|
||||
### Tailwind CSS Default
|
||||
|
||||
```css
|
||||
/* Tailwind breakpoints */
|
||||
/* sm: 640px - Landscape phones */
|
||||
/* md: 768px - Tablets */
|
||||
/* lg: 1024px - Laptops */
|
||||
/* 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 */ }
|
||||
```
|
||||
|
||||
### Bootstrap 5
|
||||
|
||||
```css
|
||||
/* Bootstrap breakpoints */
|
||||
/* sm: 576px */
|
||||
/* md: 768px */
|
||||
/* lg: 992px */
|
||||
/* 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 */ }
|
||||
```
|
||||
|
||||
### Minimalist Scale
|
||||
|
||||
```css
|
||||
/* Simplified 3-breakpoint system */
|
||||
/* Base: Mobile (< 600px) */
|
||||
/* Medium: Tablets and small laptops (600px - 1024px) */
|
||||
/* Large: Desktops (> 1024px) */
|
||||
|
||||
:root {
|
||||
--bp-md: 600px;
|
||||
--bp-lg: 1024px;
|
||||
}
|
||||
|
||||
@media (min-width: 600px) { /* Medium */ }
|
||||
@media (min-width: 1024px) { /* Large */ }
|
||||
```
|
||||
|
||||
## Content-Based Breakpoints
|
||||
|
||||
### Finding Natural Breakpoints
|
||||
|
||||
Instead of using device-based breakpoints, identify where your content naturally needs to change.
|
||||
|
||||
```css
|
||||
/* Bad: Device-based thinking */
|
||||
@media (min-width: 768px) { /* iPad breakpoint */ }
|
||||
|
||||
/* Good: Content-based thinking */
|
||||
/* Breakpoint where sidebar fits comfortably next to content */
|
||||
@media (min-width: 50rem) {
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 300px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Breakpoint where cards can show 3 across without crowding */
|
||||
@media (min-width: 65rem) {
|
||||
.card-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Content Breakpoints
|
||||
|
||||
```javascript
|
||||
// Find where content breaks
|
||||
function findBreakpoints(selector) {
|
||||
const element = document.querySelector(selector);
|
||||
const breakpoints = [];
|
||||
|
||||
for (let width = 320; width <= 1920; width += 10) {
|
||||
element.style.width = `${width}px`;
|
||||
|
||||
// Check for overflow, wrapping, or layout issues
|
||||
if (element.scrollWidth > element.clientWidth) {
|
||||
breakpoints.push({ width, issue: 'overflow' });
|
||||
}
|
||||
}
|
||||
|
||||
return breakpoints;
|
||||
}
|
||||
```
|
||||
|
||||
## Design Token Integration
|
||||
|
||||
### Breakpoint Tokens
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* Breakpoint values */
|
||||
--breakpoint-sm: 640px;
|
||||
--breakpoint-md: 768px;
|
||||
--breakpoint-lg: 1024px;
|
||||
--breakpoint-xl: 1280px;
|
||||
--breakpoint-2xl: 1536px;
|
||||
|
||||
/* Container widths for each breakpoint */
|
||||
--container-sm: 640px;
|
||||
--container-md: 768px;
|
||||
--container-lg: 1024px;
|
||||
--container-xl: 1280px;
|
||||
--container-2xl: 1536px;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: var(--container-lg);
|
||||
margin-inline: auto;
|
||||
padding-inline: var(--space-4);
|
||||
}
|
||||
```
|
||||
|
||||
### JavaScript Integration
|
||||
|
||||
```typescript
|
||||
// Breakpoint constants
|
||||
export const breakpoints = {
|
||||
sm: 640,
|
||||
md: 768,
|
||||
lg: 1024,
|
||||
xl: 1280,
|
||||
'2xl': 1536,
|
||||
} as const;
|
||||
|
||||
// Media query hook
|
||||
function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia(query);
|
||||
setMatches(media.matches);
|
||||
|
||||
const listener = () => setMatches(media.matches);
|
||||
media.addEventListener('change', listener);
|
||||
return () => media.removeEventListener('change', listener);
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
// Breakpoint hook
|
||||
function useBreakpoint() {
|
||||
const isSmall = useMediaQuery(`(min-width: ${breakpoints.sm}px)`);
|
||||
const isMedium = useMediaQuery(`(min-width: ${breakpoints.md}px)`);
|
||||
const isLarge = useMediaQuery(`(min-width: ${breakpoints.lg}px)`);
|
||||
const isXLarge = useMediaQuery(`(min-width: ${breakpoints.xl}px)`);
|
||||
|
||||
return {
|
||||
isMobile: !isSmall,
|
||||
isTablet: isSmall && !isLarge,
|
||||
isDesktop: isLarge,
|
||||
current: isXLarge ? 'xl' : isLarge ? 'lg' : isMedium ? 'md' : isSmall ? 'sm' : 'base',
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Feature Queries
|
||||
|
||||
### @supports for Progressive Enhancement
|
||||
|
||||
```css
|
||||
/* Feature detection instead of browser detection */
|
||||
@supports (display: grid) {
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@supports (container-type: inline-size) {
|
||||
.card-container {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
@container (min-width: 400px) {
|
||||
.card {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@supports (aspect-ratio: 16/9) {
|
||||
.video-container {
|
||||
aspect-ratio: 16/9;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fallback for older browsers */
|
||||
@supports not (gap: 1rem) {
|
||||
.flex-container > * + * {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Combining Feature and Size Queries
|
||||
|
||||
```css
|
||||
/* Only apply grid layout if supported and screen is large enough */
|
||||
@supports (display: grid) {
|
||||
@media (min-width: 768px) {
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 250px 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Responsive Patterns by Component
|
||||
|
||||
### Navigation
|
||||
|
||||
```css
|
||||
.nav {
|
||||
/* Mobile: vertical stack */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.nav {
|
||||
/* Tablet+: horizontal */
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Or with container queries */
|
||||
.nav-container {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
@container (min-width: 600px) {
|
||||
.nav {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Cards Grid
|
||||
|
||||
```css
|
||||
.cards {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.cards {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.cards {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.cards {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Better: auto-fit with minimum size */
|
||||
.cards-auto {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
|
||||
}
|
||||
```
|
||||
|
||||
### Hero Section
|
||||
|
||||
```css
|
||||
.hero {
|
||||
min-height: 50vh;
|
||||
padding: var(--space-lg) var(--space-md);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: clamp(2rem, 5vw + 1rem, 4rem);
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: clamp(1rem, 2vw + 0.5rem, 1.5rem);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.hero {
|
||||
min-height: 70vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
max-width: 60%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.hero {
|
||||
min-height: 80vh;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
max-width: 50%;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tables
|
||||
|
||||
```css
|
||||
/* Mobile: cards or horizontal scroll */
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.responsive-table {
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
/* Alternative: transform to cards on mobile */
|
||||
@media (max-width: 639px) {
|
||||
.responsive-table {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.responsive-table thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.responsive-table tr {
|
||||
display: block;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.responsive-table td {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.responsive-table td::before {
|
||||
content: attr(data-label);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Print Styles
|
||||
|
||||
```css
|
||||
@media print {
|
||||
/* Remove non-essential elements */
|
||||
.nav,
|
||||
.sidebar,
|
||||
.footer,
|
||||
.ads {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Reset colors and backgrounds */
|
||||
* {
|
||||
background: white !important;
|
||||
color: black !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Ensure content fits on page */
|
||||
.container {
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Handle page breaks */
|
||||
h1, h2, h3 {
|
||||
page-break-after: avoid;
|
||||
}
|
||||
|
||||
img, table {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
/* Show URLs for links */
|
||||
a[href^="http"]::after {
|
||||
content: " (" attr(href) ")";
|
||||
font-size: 0.8em;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Preference Queries
|
||||
|
||||
```css
|
||||
/* Dark mode preference */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #1a1a1a;
|
||||
--text: #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion preference */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* High contrast preference */
|
||||
@media (prefers-contrast: high) {
|
||||
:root {
|
||||
--text: #000;
|
||||
--bg: #fff;
|
||||
--border: #000;
|
||||
}
|
||||
|
||||
.button {
|
||||
border: 2px solid currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced data preference */
|
||||
@media (prefers-reduced-data: reduce) {
|
||||
.hero-video {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hero-image {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Breakpoints
|
||||
|
||||
```javascript
|
||||
// Automated breakpoint testing
|
||||
async function testBreakpoints(page, breakpoints) {
|
||||
const results = [];
|
||||
|
||||
for (const [name, width] of Object.entries(breakpoints)) {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
|
||||
// Check for horizontal overflow
|
||||
const hasOverflow = await page.evaluate(() => {
|
||||
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 rect = el.getBoundingClientRect();
|
||||
return rect.right > window.innerWidth || rect.left < 0;
|
||||
}).length;
|
||||
});
|
||||
|
||||
results.push({
|
||||
breakpoint: name,
|
||||
width,
|
||||
hasOverflow,
|
||||
offscreenElements,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Tailwind CSS Breakpoints](https://tailwindcss.com/docs/responsive-design)
|
||||
- [The 100% Correct Way to Do CSS Breakpoints](https://www.freecodecamp.org/news/the-100-correct-way-to-do-css-breakpoints-88d6a5ba1862/)
|
||||
- [Modern CSS Solutions](https://moderncss.dev/)
|
||||
- [Defensive CSS](https://defensivecss.dev/)
|
||||
@@ -0,0 +1,548 @@
|
||||
# Container Queries Deep Dive
|
||||
|
||||
## Overview
|
||||
|
||||
Container queries enable component-based responsive design by allowing elements to respond to their container's size rather than the viewport. This paradigm shift makes truly reusable components possible.
|
||||
|
||||
## Browser Support
|
||||
|
||||
Container queries have excellent modern browser support (Chrome 105+, Firefox 110+, Safari 16+). For older browsers, provide graceful fallbacks.
|
||||
|
||||
## Containment Basics
|
||||
|
||||
### Container Types
|
||||
|
||||
```css
|
||||
/* Size containment - queries based on inline and block size */
|
||||
.container {
|
||||
container-type: size;
|
||||
}
|
||||
|
||||
/* Inline-size containment - queries based on inline (width) size only */
|
||||
/* Most common and recommended */
|
||||
.container {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
/* Normal - style queries only, no size queries */
|
||||
.container {
|
||||
container-type: normal;
|
||||
}
|
||||
```
|
||||
|
||||
### Named Containers
|
||||
|
||||
```css
|
||||
/* Named container for targeted queries */
|
||||
.card-wrapper {
|
||||
container-type: inline-size;
|
||||
container-name: card;
|
||||
}
|
||||
|
||||
/* Shorthand */
|
||||
.card-wrapper {
|
||||
container: card / inline-size;
|
||||
}
|
||||
|
||||
/* Query specific container */
|
||||
@container card (min-width: 400px) {
|
||||
.card-content {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Container Query Syntax
|
||||
|
||||
### Width-Based Queries
|
||||
|
||||
```css
|
||||
.container {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
/* Minimum width */
|
||||
@container (min-width: 300px) {
|
||||
.element { /* styles */ }
|
||||
}
|
||||
|
||||
/* Maximum width */
|
||||
@container (max-width: 500px) {
|
||||
.element { /* styles */ }
|
||||
}
|
||||
|
||||
/* Range syntax */
|
||||
@container (300px <= width <= 600px) {
|
||||
.element { /* styles */ }
|
||||
}
|
||||
|
||||
/* Exact width */
|
||||
@container (width: 400px) {
|
||||
.element { /* styles */ }
|
||||
}
|
||||
```
|
||||
|
||||
### Combining Conditions
|
||||
|
||||
```css
|
||||
/* AND condition */
|
||||
@container (min-width: 400px) and (max-width: 800px) {
|
||||
.element { /* styles */ }
|
||||
}
|
||||
|
||||
/* OR condition */
|
||||
@container (max-width: 300px) or (min-width: 800px) {
|
||||
.element { /* styles */ }
|
||||
}
|
||||
|
||||
/* NOT condition */
|
||||
@container not (min-width: 400px) {
|
||||
.element { /* styles */ }
|
||||
}
|
||||
```
|
||||
|
||||
### Named Container Queries
|
||||
|
||||
```css
|
||||
/* Multiple named containers */
|
||||
.page-wrapper {
|
||||
container: page / inline-size;
|
||||
}
|
||||
|
||||
.sidebar-wrapper {
|
||||
container: sidebar / inline-size;
|
||||
}
|
||||
|
||||
/* Target specific containers */
|
||||
@container page (min-width: 1024px) {
|
||||
.main-content {
|
||||
max-width: 800px;
|
||||
}
|
||||
}
|
||||
|
||||
@container sidebar (min-width: 300px) {
|
||||
.sidebar-widget {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Container Query Units
|
||||
|
||||
```css
|
||||
/* Container query length units */
|
||||
.element {
|
||||
/* Container query width - 1cqw = 1% of container width */
|
||||
width: 50cqw;
|
||||
|
||||
/* Container query height - 1cqh = 1% of container height */
|
||||
height: 50cqh;
|
||||
|
||||
/* Container query inline - 1cqi = 1% of container inline size */
|
||||
padding-inline: 5cqi;
|
||||
|
||||
/* Container query block - 1cqb = 1% of container block size */
|
||||
padding-block: 3cqb;
|
||||
|
||||
/* Container query min - smaller of cqi and cqb */
|
||||
font-size: 5cqmin;
|
||||
|
||||
/* Container query max - larger of cqi and cqb */
|
||||
margin: 2cqmax;
|
||||
}
|
||||
|
||||
/* Practical example: fluid typography based on container */
|
||||
.card-title {
|
||||
font-size: clamp(1rem, 4cqi, 2rem);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: clamp(0.75rem, 4cqi, 1.5rem);
|
||||
}
|
||||
```
|
||||
|
||||
## Style Queries
|
||||
|
||||
Style queries allow querying CSS custom property values. Currently limited support.
|
||||
|
||||
```css
|
||||
/* Define a custom property */
|
||||
.card {
|
||||
--layout: stack;
|
||||
}
|
||||
|
||||
/* Query the property value */
|
||||
@container style(--layout: stack) {
|
||||
.card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@container style(--layout: inline) {
|
||||
.card-content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
/* Toggle layout via custom property */
|
||||
.card.horizontal {
|
||||
--layout: inline;
|
||||
}
|
||||
```
|
||||
|
||||
## Practical Patterns
|
||||
|
||||
### Responsive Card Component
|
||||
|
||||
```css
|
||||
.card-container {
|
||||
container: card / inline-size;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: clamp(1rem, 4cqi, 2rem);
|
||||
}
|
||||
|
||||
.card-image {
|
||||
aspect-ratio: 16/9;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: clamp(1rem, 4cqi, 1.5rem);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Medium container: side-by-side layout */
|
||||
@container card (min-width: 400px) {
|
||||
.card {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.card-image {
|
||||
width: 40%;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Large container: enhanced layout */
|
||||
@container card (min-width: 600px) {
|
||||
.card-image {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Responsive Grid Items
|
||||
|
||||
```css
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.grid-item {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.item-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Item adapts to its own size, not the viewport */
|
||||
@container (min-width: 350px) {
|
||||
.item-content {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@container (min-width: 500px) {
|
||||
.item-content {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dashboard Widget
|
||||
|
||||
```css
|
||||
.widget-container {
|
||||
container: widget / inline-size;
|
||||
}
|
||||
|
||||
.widget {
|
||||
--chart-height: 150px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.widget-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.widget-chart {
|
||||
height: var(--chart-height);
|
||||
}
|
||||
|
||||
.widget-stats {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@container widget (min-width: 300px) {
|
||||
.widget {
|
||||
--chart-height: 200px;
|
||||
}
|
||||
|
||||
.widget-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@container widget (min-width: 500px) {
|
||||
.widget {
|
||||
--chart-height: 250px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.widget-stats {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
.widget-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Navigation Component
|
||||
|
||||
```css
|
||||
.nav-container {
|
||||
container: nav / inline-size;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-link-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-link-icon {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
/* Show text when container is wide enough */
|
||||
@container nav (min-width: 200px) {
|
||||
.nav-link-text {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* Horizontal layout for wider containers */
|
||||
@container nav (min-width: 600px) {
|
||||
.nav {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tailwind CSS Integration
|
||||
|
||||
```tsx
|
||||
// Tailwind v3.2+ supports container queries
|
||||
// tailwind.config.js
|
||||
module.exports = {
|
||||
plugins: [
|
||||
require('@tailwindcss/container-queries'),
|
||||
],
|
||||
};
|
||||
|
||||
// Component usage
|
||||
function Card({ title, image, description }) {
|
||||
return (
|
||||
// @container creates containment context
|
||||
<div className="@container">
|
||||
<article className="flex flex-col @md:flex-row @md:gap-4">
|
||||
<img
|
||||
src={image}
|
||||
alt=""
|
||||
className="w-full @md:w-48 @lg:w-64 aspect-video @md:aspect-square object-cover rounded-lg"
|
||||
/>
|
||||
<div className="p-4 @md:p-0">
|
||||
<h2 className="text-lg @md:text-xl @lg:text-2xl font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="mt-2 text-muted-foreground @lg:text-lg">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Named containers
|
||||
function Dashboard() {
|
||||
return (
|
||||
<div className="@container/main">
|
||||
<aside className="@container/sidebar">
|
||||
<nav className="flex flex-col @lg/sidebar:flex-row">
|
||||
{/* ... */}
|
||||
</nav>
|
||||
</aside>
|
||||
<main className="@lg/main:grid @lg/main:grid-cols-2">
|
||||
{/* ... */}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Fallback Strategies
|
||||
|
||||
```css
|
||||
/* Provide fallbacks for browsers without support */
|
||||
.card {
|
||||
/* Default (fallback) styles */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Feature query for container support */
|
||||
@supports (container-type: inline-size) {
|
||||
.card-container {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
@container (min-width: 400px) {
|
||||
.card {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Alternative: media query fallback */
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Viewport-based fallback */
|
||||
@media (min-width: 768px) {
|
||||
.card {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced with container queries when supported */
|
||||
@supports (container-type: inline-size) {
|
||||
@media (min-width: 768px) {
|
||||
.card {
|
||||
flex-direction: column; /* Reset */
|
||||
}
|
||||
}
|
||||
|
||||
@container (min-width: 400px) {
|
||||
.card {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
```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; }
|
||||
|
||||
/* Good: Strategic container placement */
|
||||
.component-wrapper {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
/* Use inline-size instead of size when possible */
|
||||
/* size containment is more expensive */
|
||||
.container {
|
||||
container-type: inline-size; /* Preferred */
|
||||
/* container-type: size; */ /* Only when needed */
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Container Queries
|
||||
|
||||
```javascript
|
||||
// Test container query support
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.querySelector('.container'));
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [MDN Container Queries](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_container_queries)
|
||||
- [CSS Container Queries Spec](https://www.w3.org/TR/css-contain-3/)
|
||||
- [Una Kravets: Container Queries](https://web.dev/cq-stable/)
|
||||
- [Ahmad Shadeed: Container Queries Guide](https://ishadeed.com/article/container-queries-are-finally-here/)
|
||||
@@ -0,0 +1,498 @@
|
||||
# Fluid Layouts and Typography
|
||||
|
||||
## Overview
|
||||
|
||||
Fluid design creates smooth scaling experiences by using relative units and mathematical functions instead of fixed breakpoints. This approach reduces the need for media queries and creates more natural-feeling interfaces.
|
||||
|
||||
## Fluid Typography
|
||||
|
||||
### The clamp() Function
|
||||
|
||||
```css
|
||||
/* clamp(minimum, preferred, maximum) */
|
||||
.heading {
|
||||
/* Never smaller than 1.5rem, never larger than 3rem */
|
||||
/* Scales at 5vw between those values */
|
||||
font-size: clamp(1.5rem, 5vw, 3rem);
|
||||
}
|
||||
```
|
||||
|
||||
### Calculating Fluid Values
|
||||
|
||||
The preferred value in `clamp()` typically combines a base size with a viewport-relative portion:
|
||||
|
||||
```css
|
||||
/* Formula: clamp(min, base + scale * vw, max) */
|
||||
|
||||
/* For text that scales from 16px (320px viewport) to 24px (1200px viewport): */
|
||||
/* slope = (24 - 16) / (1200 - 320) = 8 / 880 = 0.00909 */
|
||||
/* y-intercept = 16 - 0.00909 * 320 = 13.09px = 0.818rem */
|
||||
|
||||
.text {
|
||||
font-size: clamp(1rem, 0.818rem + 0.909vw, 1.5rem);
|
||||
}
|
||||
```
|
||||
|
||||
### Type Scale Generator
|
||||
|
||||
```javascript
|
||||
// Generate a fluid type scale
|
||||
function fluidType({
|
||||
minFontSize,
|
||||
maxFontSize,
|
||||
minViewport = 320,
|
||||
maxViewport = 1200,
|
||||
}) {
|
||||
const minFontRem = minFontSize / 16;
|
||||
const maxFontRem = maxFontSize / 16;
|
||||
const minViewportRem = minViewport / 16;
|
||||
const maxViewportRem = maxViewport / 16;
|
||||
|
||||
const slope = (maxFontRem - minFontRem) / (maxViewportRem - minViewportRem);
|
||||
const yAxisIntersection = minFontRem - slope * minViewportRem;
|
||||
|
||||
return `clamp(${minFontRem}rem, ${yAxisIntersection.toFixed(4)}rem + ${(slope * 100).toFixed(4)}vw, ${maxFontRem}rem)`;
|
||||
}
|
||||
|
||||
// Usage
|
||||
const typeScale = {
|
||||
xs: fluidType({ minFontSize: 12, maxFontSize: 14 }),
|
||||
sm: fluidType({ minFontSize: 14, maxFontSize: 16 }),
|
||||
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 }),
|
||||
};
|
||||
```
|
||||
|
||||
### Complete Type Scale
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* Base: 16-18px */
|
||||
--text-base: clamp(1rem, 0.9rem + 0.5vw, 1.125rem);
|
||||
|
||||
/* Smaller sizes */
|
||||
--text-sm: clamp(0.875rem, 0.8rem + 0.375vw, 1rem);
|
||||
--text-xs: clamp(0.75rem, 0.7rem + 0.25vw, 0.875rem);
|
||||
|
||||
/* Larger sizes */
|
||||
--text-lg: clamp(1.125rem, 1rem + 0.625vw, 1.25rem);
|
||||
--text-xl: clamp(1.25rem, 1.1rem + 0.75vw, 1.5rem);
|
||||
--text-2xl: clamp(1.5rem, 1.2rem + 1.5vw, 2rem);
|
||||
--text-3xl: clamp(1.875rem, 1.4rem + 2.375vw, 2.5rem);
|
||||
--text-4xl: clamp(2.25rem, 1.5rem + 3.75vw, 3.5rem);
|
||||
--text-5xl: clamp(3rem, 1.8rem + 6vw, 5rem);
|
||||
|
||||
/* Line heights scale inversely */
|
||||
--leading-tight: 1.25;
|
||||
--leading-normal: 1.5;
|
||||
--leading-relaxed: 1.75;
|
||||
}
|
||||
|
||||
/* Apply to elements */
|
||||
body {
|
||||
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); }
|
||||
```
|
||||
|
||||
## Fluid Spacing
|
||||
|
||||
### Spacing Scale
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* Spacing tokens that scale with viewport */
|
||||
--space-3xs: clamp(0.25rem, 0.2rem + 0.25vw, 0.375rem);
|
||||
--space-2xs: clamp(0.375rem, 0.3rem + 0.375vw, 0.5rem);
|
||||
--space-xs: clamp(0.5rem, 0.4rem + 0.5vw, 0.75rem);
|
||||
--space-sm: clamp(0.75rem, 0.6rem + 0.75vw, 1rem);
|
||||
--space-md: clamp(1rem, 0.8rem + 1vw, 1.5rem);
|
||||
--space-lg: clamp(1.5rem, 1.2rem + 1.5vw, 2rem);
|
||||
--space-xl: clamp(2rem, 1.5rem + 2.5vw, 3rem);
|
||||
--space-2xl: clamp(3rem, 2rem + 5vw, 5rem);
|
||||
--space-3xl: clamp(4rem, 2.5rem + 7.5vw, 8rem);
|
||||
|
||||
/* One-up pairs (for asymmetric spacing) */
|
||||
--space-xs-sm: clamp(0.5rem, 0.3rem + 1vw, 1rem);
|
||||
--space-sm-md: clamp(0.75rem, 0.5rem + 1.25vw, 1.5rem);
|
||||
--space-md-lg: clamp(1rem, 0.6rem + 2vw, 2rem);
|
||||
--space-lg-xl: clamp(1.5rem, 1rem + 2.5vw, 3rem);
|
||||
}
|
||||
|
||||
/* Usage examples */
|
||||
.section {
|
||||
padding-block: var(--space-xl);
|
||||
padding-inline: var(--space-md);
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: var(--space-md);
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.stack > * + * {
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
```
|
||||
|
||||
### Container Widths
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* Fluid max-widths */
|
||||
--container-xs: min(100% - 2rem, 20rem);
|
||||
--container-sm: min(100% - 2rem, 30rem);
|
||||
--container-md: min(100% - 2rem, 45rem);
|
||||
--container-lg: min(100% - 2rem, 65rem);
|
||||
--container-xl: min(100% - 3rem, 80rem);
|
||||
--container-2xl: min(100% - 4rem, 96rem);
|
||||
}
|
||||
|
||||
.container {
|
||||
width: var(--container-lg);
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.prose {
|
||||
max-width: var(--container-md);
|
||||
}
|
||||
|
||||
.full-bleed {
|
||||
width: 100vw;
|
||||
margin-inline: calc(-50vw + 50%);
|
||||
}
|
||||
```
|
||||
|
||||
## CSS Grid Fluid Layouts
|
||||
|
||||
### Auto-fit Grid
|
||||
|
||||
```css
|
||||
/* Grid that fills available space */
|
||||
.auto-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(
|
||||
auto-fit,
|
||||
minmax(min(100%, 250px), 1fr)
|
||||
);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
/* With maximum columns */
|
||||
.auto-grid-max-4 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(
|
||||
auto-fit,
|
||||
minmax(min(100%, max(200px, calc((100% - 3 * var(--space-md)) / 4))), 1fr)
|
||||
);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
```
|
||||
|
||||
### Responsive Grid Areas
|
||||
|
||||
```css
|
||||
.page-grid {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
1fr
|
||||
min(var(--container-lg), 100%)
|
||||
1fr;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
|
||||
.page-grid > * {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* Content with sidebar */
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr min(300px, 30%);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Fluid Aspect Ratios
|
||||
|
||||
```css
|
||||
/* Maintain aspect ratio fluidly */
|
||||
.aspect-video {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.aspect-square {
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
/* Fluid aspect ratio that changes */
|
||||
.hero-image {
|
||||
aspect-ratio: 1; /* Mobile: square */
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.hero-image {
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.hero-image {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Flexbox Fluid Patterns
|
||||
|
||||
### Flexible Sidebar
|
||||
|
||||
```css
|
||||
/* Sidebar that collapses when too narrow */
|
||||
.with-sidebar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.with-sidebar > :first-child {
|
||||
flex-basis: 300px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.with-sidebar > :last-child {
|
||||
flex-basis: 0;
|
||||
flex-grow: 999;
|
||||
min-width: 60%;
|
||||
}
|
||||
```
|
||||
|
||||
### Cluster Layout
|
||||
|
||||
```css
|
||||
/* Items cluster and wrap naturally */
|
||||
.cluster {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Center-aligned cluster */
|
||||
.cluster-center {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Space-between cluster */
|
||||
.cluster-spread {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
```
|
||||
|
||||
### Switcher Layout
|
||||
|
||||
```css
|
||||
/* Switches from horizontal to vertical based on container */
|
||||
.switcher {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.switcher > * {
|
||||
/* Items go vertical when container is narrower than threshold */
|
||||
flex-grow: 1;
|
||||
flex-basis: calc((30rem - 100%) * 999);
|
||||
}
|
||||
|
||||
/* Limit columns */
|
||||
.switcher > :nth-last-child(n+4),
|
||||
.switcher > :nth-last-child(n+4) ~ * {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
```
|
||||
|
||||
## Intrinsic Sizing
|
||||
|
||||
### Content-Based Widths
|
||||
|
||||
```css
|
||||
/* Size based on content */
|
||||
.fit-content {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Minimum content size */
|
||||
.min-content {
|
||||
width: min-content;
|
||||
}
|
||||
|
||||
/* Maximum content size */
|
||||
.max-content {
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
/* Practical examples */
|
||||
.button {
|
||||
width: fit-content;
|
||||
min-width: 8rem; /* Prevent too-narrow buttons */
|
||||
padding-inline: var(--space-md);
|
||||
}
|
||||
|
||||
.tag {
|
||||
width: fit-content;
|
||||
padding: var(--space-2xs) var(--space-xs);
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: min(90vw, 600px);
|
||||
max-height: min(90vh, 800px);
|
||||
}
|
||||
```
|
||||
|
||||
### min() and max() Functions
|
||||
|
||||
```css
|
||||
/* Responsive sizing without media queries */
|
||||
.container {
|
||||
/* 90% of viewport or 1200px, whichever is smaller */
|
||||
width: min(90%, 1200px);
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.hero-text {
|
||||
/* At least 2rem, at most 4rem */
|
||||
font-size: max(2rem, min(5vw, 4rem));
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
/* At least 200px, at most 25% of parent */
|
||||
width: max(200px, min(300px, 25%));
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
/* Each card at least 200px, fill available space */
|
||||
grid-template-columns: repeat(
|
||||
auto-fit,
|
||||
minmax(max(200px, 100%/4), 1fr)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Viewport Units
|
||||
|
||||
### Modern Viewport Units
|
||||
|
||||
```css
|
||||
/* Dynamic viewport height - accounts for mobile browser UI */
|
||||
.full-height {
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
/* Small viewport - minimum size when UI is visible */
|
||||
.hero {
|
||||
min-height: 100svh;
|
||||
}
|
||||
|
||||
/* Large viewport - maximum size when UI is hidden */
|
||||
.backdrop {
|
||||
height: 100lvh;
|
||||
}
|
||||
|
||||
/* Viewport-relative positioning */
|
||||
.fixed-nav {
|
||||
position: fixed;
|
||||
inset-inline: 0;
|
||||
top: 0;
|
||||
height: max(60px, 8vh);
|
||||
}
|
||||
|
||||
/* Safe area insets for notched devices */
|
||||
.safe-area {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
padding-left: env(safe-area-inset-left);
|
||||
}
|
||||
```
|
||||
|
||||
### Combining Viewport and Container Units
|
||||
|
||||
```css
|
||||
/* Responsive based on both viewport and container */
|
||||
.component {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.component-text {
|
||||
/* Uses viewport when small, container when in container */
|
||||
font-size: clamp(1rem, 2vw + 0.5rem, 1.5rem);
|
||||
}
|
||||
|
||||
@container (min-width: 400px) {
|
||||
.component-text {
|
||||
font-size: clamp(1rem, 4cqi, 1.5rem);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Utility Classes
|
||||
|
||||
```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); }
|
||||
|
||||
.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); }
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Utopia Fluid Type Calculator](https://utopia.fyi/)
|
||||
- [Modern Fluid Typography](https://www.smashingmagazine.com/2022/01/modern-fluid-typography-css-clamp/)
|
||||
- [Every Layout](https://every-layout.dev/)
|
||||
- [CSS min(), max(), and clamp()](https://web.dev/min-max-clamp/)
|
||||
322
plugins/ui-design/skills/visual-design-foundations/SKILL.md
Normal file
322
plugins/ui-design/skills/visual-design-foundations/SKILL.md
Normal file
@@ -0,0 +1,322 @@
|
||||
---
|
||||
name: visual-design-foundations
|
||||
description: Apply typography, color theory, spacing systems, and iconography principles to create cohesive visual designs. Use when establishing design tokens, building style guides, or improving visual hierarchy and consistency.
|
||||
---
|
||||
|
||||
# Visual Design Foundations
|
||||
|
||||
Build cohesive, accessible visual systems using typography, color, spacing, and iconography fundamentals.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
- Establishing design tokens for a new project
|
||||
- Creating or refining a spacing and sizing system
|
||||
- Selecting and pairing typefaces
|
||||
- Building accessible color palettes
|
||||
- Designing icon systems and visual assets
|
||||
- Improving visual hierarchy and readability
|
||||
- Auditing designs for visual consistency
|
||||
- Implementing dark mode or theming
|
||||
|
||||
## Core Systems
|
||||
|
||||
### 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-3xl: 1.875rem; /* 30px */
|
||||
--font-size-4xl: 2.25rem; /* 36px */
|
||||
--font-size-5xl: 3rem; /* 48px */
|
||||
}
|
||||
```
|
||||
|
||||
**Line Height Guidelines**:
|
||||
| Text Type | Line Height |
|
||||
|-----------|-------------|
|
||||
| Headings | 1.1 - 1.3 |
|
||||
| Body text | 1.5 - 1.7 |
|
||||
| UI labels | 1.2 - 1.4 |
|
||||
|
||||
### 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 */
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Color System
|
||||
|
||||
**Semantic color tokens**:
|
||||
```css
|
||||
:root {
|
||||
/* Brand */
|
||||
--color-primary: #2563eb;
|
||||
--color-primary-hover: #1d4ed8;
|
||||
--color-primary-active: #1e40af;
|
||||
|
||||
/* Semantic */
|
||||
--color-success: #16a34a;
|
||||
--color-warning: #ca8a04;
|
||||
--color-error: #dc2626;
|
||||
--color-info: #0891b2;
|
||||
|
||||
/* Neutral */
|
||||
--color-gray-50: #f9fafb;
|
||||
--color-gray-100: #f3f4f6;
|
||||
--color-gray-200: #e5e7eb;
|
||||
--color-gray-300: #d1d5db;
|
||||
--color-gray-400: #9ca3af;
|
||||
--color-gray-500: #6b7280;
|
||||
--color-gray-600: #4b5563;
|
||||
--color-gray-700: #374151;
|
||||
--color-gray-800: #1f2937;
|
||||
--color-gray-900: #111827;
|
||||
}
|
||||
```
|
||||
|
||||
## Quick Start: Design Tokens in Tailwind
|
||||
|
||||
```js
|
||||
// tailwind.config.js
|
||||
module.exports = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
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' }],
|
||||
},
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#eff6ff',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
},
|
||||
},
|
||||
spacing: {
|
||||
// Extends default with custom values
|
||||
'18': '4.5rem',
|
||||
'88': '22rem',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Typography Best Practices
|
||||
|
||||
### 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)
|
||||
|
||||
### Responsive Typography
|
||||
|
||||
```css
|
||||
/* Fluid typography using clamp() */
|
||||
h1 {
|
||||
font-size: clamp(2rem, 5vw + 1rem, 3.5rem);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: clamp(1rem, 2vw + 0.5rem, 1.125rem);
|
||||
line-height: 1.6;
|
||||
max-width: 65ch; /* Optimal reading width */
|
||||
}
|
||||
```
|
||||
|
||||
### Font Loading
|
||||
|
||||
```css
|
||||
/* Prevent layout shift */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src: url('/fonts/Inter.woff2') format('woff2');
|
||||
font-display: swap;
|
||||
font-weight: 400 700;
|
||||
}
|
||||
```
|
||||
|
||||
## Color Theory
|
||||
|
||||
### 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) |
|
||||
|
||||
### Dark Mode Strategy
|
||||
|
||||
```css
|
||||
:root {
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f9fafb;
|
||||
--text-primary: #111827;
|
||||
--text-secondary: #6b7280;
|
||||
--border: #e5e7eb;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg-primary: #111827;
|
||||
--bg-secondary: #1f2937;
|
||||
--text-primary: #f9fafb;
|
||||
--text-secondary: #9ca3af;
|
||||
--border: #374151;
|
||||
}
|
||||
```
|
||||
|
||||
### Color Accessibility
|
||||
|
||||
```tsx
|
||||
// Check contrast programmatically
|
||||
function getContrastRatio(foreground: string, background: string): number {
|
||||
const getLuminance = (hex: string) => {
|
||||
const rgb = hexToRgb(hex);
|
||||
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);
|
||||
});
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
};
|
||||
|
||||
const l1 = getLuminance(foreground);
|
||||
const l2 = getLuminance(background);
|
||||
const lighter = Math.max(l1, l2);
|
||||
const darker = Math.min(l1, l2);
|
||||
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
```
|
||||
|
||||
## Spacing Guidelines
|
||||
|
||||
### Component Spacing
|
||||
|
||||
```
|
||||
Card padding: 16-24px (--space-4 to --space-6)
|
||||
Section gap: 32-64px (--space-8 to --space-16)
|
||||
Form field gap: 16-24px (--space-4 to --space-6)
|
||||
Button padding: 8-16px vertical, 16-24px horizontal
|
||||
Icon-text gap: 8px (--space-2)
|
||||
```
|
||||
|
||||
### Visual Rhythm
|
||||
|
||||
```css
|
||||
/* Consistent vertical rhythm */
|
||||
.prose > * + * {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.prose > h2 + * {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.prose > * + h2 {
|
||||
margin-top: var(--space-8);
|
||||
}
|
||||
```
|
||||
|
||||
## Iconography
|
||||
|
||||
### Icon Sizing System
|
||||
|
||||
```css
|
||||
:root {
|
||||
--icon-xs: 12px;
|
||||
--icon-sm: 16px;
|
||||
--icon-md: 20px;
|
||||
--icon-lg: 24px;
|
||||
--icon-xl: 32px;
|
||||
}
|
||||
```
|
||||
|
||||
### Icon Component
|
||||
|
||||
```tsx
|
||||
interface IconProps {
|
||||
name: string;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeMap = {
|
||||
xs: 12,
|
||||
sm: 16,
|
||||
md: 20,
|
||||
lg: 24,
|
||||
xl: 32,
|
||||
};
|
||||
|
||||
export function Icon({ name, size = 'md', className }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={sizeMap[size]}
|
||||
height={sizeMap[size]}
|
||||
className={cn('inline-block flex-shrink-0', className)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href={`/icons.svg#${name}`} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Establish Constraints**: Limit choices to maintain consistency
|
||||
2. **Document Decisions**: Create a living style guide
|
||||
3. **Test Accessibility**: Verify contrast, sizing, touch targets
|
||||
4. **Use Semantic Tokens**: Name by purpose, not appearance
|
||||
5. **Design Mobile-First**: Start with constraints, add complexity
|
||||
6. **Maintain Vertical Rhythm**: Consistent spacing creates harmony
|
||||
7. **Limit Font Weights**: 2-3 weights per family is sufficient
|
||||
|
||||
## Common Issues
|
||||
|
||||
- **Inconsistent Spacing**: Not using a defined scale
|
||||
- **Poor Contrast**: Failing WCAG requirements
|
||||
- **Font Overload**: Too many families or weights
|
||||
- **Magic Numbers**: Arbitrary values instead of tokens
|
||||
- **Missing States**: Forgetting hover, focus, disabled
|
||||
- **No Dark Mode Plan**: Retrofitting is harder than planning
|
||||
|
||||
## Resources
|
||||
|
||||
- [Tailwind CSS Documentation](https://tailwindcss.com/docs)
|
||||
- [Type Scale Calculator](https://typescale.com/)
|
||||
- [Contrast Checker](https://webaim.org/resources/contrastchecker/)
|
||||
- [Material Design Color System](https://m3.material.io/styles/color/overview)
|
||||
- [Radix Colors](https://www.radix-ui.com/colors)
|
||||
@@ -0,0 +1,417 @@
|
||||
# Color Systems Reference
|
||||
|
||||
## Color Palette Generation
|
||||
|
||||
### Perceptually Uniform Scales
|
||||
|
||||
Using OKLCH for perceptually uniform color scales:
|
||||
|
||||
```css
|
||||
/* OKLCH: Lightness, Chroma, Hue */
|
||||
:root {
|
||||
/* Generate a blue scale with consistent perceived lightness steps */
|
||||
--blue-50: oklch(97% 0.02 250);
|
||||
--blue-100: oklch(93% 0.04 250);
|
||||
--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-600: oklch(48% 0.18 250);
|
||||
--blue-700: oklch(40% 0.16 250);
|
||||
--blue-800: oklch(32% 0.12 250);
|
||||
--blue-900: oklch(25% 0.08 250);
|
||||
--blue-950: oklch(18% 0.05 250);
|
||||
}
|
||||
```
|
||||
|
||||
### Programmatic Scale Generation
|
||||
|
||||
```tsx
|
||||
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 },
|
||||
];
|
||||
|
||||
return Object.fromEntries(
|
||||
lightnessStops.map(({ name, l }) => [
|
||||
name,
|
||||
`hsl(${hue}, ${saturation}%, ${l}%)`,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
// Generate semantic colors
|
||||
const brand = generateColorScale(220); // Blue
|
||||
const success = generateColorScale(142); // Green
|
||||
const warning = generateColorScale(38); // Amber
|
||||
const error = generateColorScale(0); // Red
|
||||
```
|
||||
|
||||
## Semantic Color Tokens
|
||||
|
||||
### Two-Tier Token System
|
||||
|
||||
```css
|
||||
/* Tier 1: Primitive colors (raw values) */
|
||||
:root {
|
||||
--primitive-blue-500: #3b82f6;
|
||||
--primitive-blue-600: #2563eb;
|
||||
--primitive-green-500: #22c55e;
|
||||
--primitive-red-500: #ef4444;
|
||||
--primitive-gray-50: #f9fafb;
|
||||
--primitive-gray-900: #111827;
|
||||
}
|
||||
|
||||
/* Tier 2: Semantic tokens (purpose-based) */
|
||||
:root {
|
||||
/* Background */
|
||||
--color-bg-primary: var(--primitive-gray-50);
|
||||
--color-bg-secondary: white;
|
||||
--color-bg-tertiary: var(--primitive-gray-100);
|
||||
--color-bg-inverse: var(--primitive-gray-900);
|
||||
|
||||
/* Text */
|
||||
--color-text-primary: var(--primitive-gray-900);
|
||||
--color-text-secondary: var(--primitive-gray-600);
|
||||
--color-text-tertiary: var(--primitive-gray-400);
|
||||
--color-text-inverse: white;
|
||||
--color-text-link: var(--primitive-blue-600);
|
||||
|
||||
/* Border */
|
||||
--color-border-default: var(--primitive-gray-200);
|
||||
--color-border-strong: var(--primitive-gray-300);
|
||||
--color-border-focus: var(--primitive-blue-500);
|
||||
|
||||
/* Interactive */
|
||||
--color-interactive-primary: var(--primitive-blue-600);
|
||||
--color-interactive-primary-hover: var(--primitive-blue-700);
|
||||
--color-interactive-primary-active: var(--primitive-blue-800);
|
||||
|
||||
/* Status */
|
||||
--color-status-success: var(--primitive-green-500);
|
||||
--color-status-warning: var(--primitive-amber-500);
|
||||
--color-status-error: var(--primitive-red-500);
|
||||
--color-status-info: var(--primitive-blue-500);
|
||||
}
|
||||
```
|
||||
|
||||
### Component Tokens
|
||||
|
||||
```css
|
||||
/* Tier 3: Component-specific tokens */
|
||||
:root {
|
||||
/* Button */
|
||||
--button-bg: var(--color-interactive-primary);
|
||||
--button-bg-hover: var(--color-interactive-primary-hover);
|
||||
--button-text: white;
|
||||
--button-border-radius: 0.375rem;
|
||||
|
||||
/* Input */
|
||||
--input-bg: var(--color-bg-secondary);
|
||||
--input-border: var(--color-border-default);
|
||||
--input-border-focus: var(--color-border-focus);
|
||||
--input-text: var(--color-text-primary);
|
||||
--input-placeholder: var(--color-text-tertiary);
|
||||
|
||||
/* Card */
|
||||
--card-bg: var(--color-bg-secondary);
|
||||
--card-border: var(--color-border-default);
|
||||
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
```
|
||||
|
||||
## Dark Mode Implementation
|
||||
|
||||
### CSS Custom Properties Approach
|
||||
|
||||
```css
|
||||
/* Light theme (default) */
|
||||
:root {
|
||||
--color-bg-primary: #ffffff;
|
||||
--color-bg-secondary: #f9fafb;
|
||||
--color-bg-tertiary: #f3f4f6;
|
||||
--color-text-primary: #111827;
|
||||
--color-text-secondary: #4b5563;
|
||||
--color-border-default: #e5e7eb;
|
||||
}
|
||||
|
||||
/* Dark theme */
|
||||
[data-theme="dark"] {
|
||||
--color-bg-primary: #111827;
|
||||
--color-bg-secondary: #1f2937;
|
||||
--color-bg-tertiary: #374151;
|
||||
--color-text-primary: #f9fafb;
|
||||
--color-text-secondary: #9ca3af;
|
||||
--color-border-default: #374151;
|
||||
}
|
||||
|
||||
/* System preference */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--color-bg-primary: #111827;
|
||||
/* ... dark theme values */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### React Theme Context
|
||||
|
||||
```tsx
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
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');
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
|
||||
if (theme === 'system') {
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
setResolvedTheme(systemTheme);
|
||||
root.setAttribute('data-theme', systemTheme);
|
||||
} else {
|
||||
setResolvedTheme(theme);
|
||||
root.setAttribute('data-theme', theme);
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme !== 'system') return;
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
const newTheme = e.matches ? 'dark' : 'light';
|
||||
setResolvedTheme(newTheme);
|
||||
document.documentElement.setAttribute('data-theme', newTheme);
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handler);
|
||||
return () => mediaQuery.removeEventListener('change', handler);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) throw new Error('useTheme must be within ThemeProvider');
|
||||
return context;
|
||||
}
|
||||
```
|
||||
|
||||
## Contrast and Accessibility
|
||||
|
||||
### WCAG Contrast Checker
|
||||
|
||||
```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');
|
||||
return [
|
||||
parseInt(result[1], 16),
|
||||
parseInt(result[2], 16),
|
||||
parseInt(result[3], 16),
|
||||
];
|
||||
}
|
||||
|
||||
function getLuminance(r: number, g: number, b: number): number {
|
||||
const [rs, gs, bs] = [r, g, b].map((c) => {
|
||||
c = c / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
|
||||
}
|
||||
|
||||
function getContrastRatio(hex1: string, hex2: string): number {
|
||||
const [r1, g1, b1] = hexToRgb(hex1);
|
||||
const [r2, g2, b2] = hexToRgb(hex2);
|
||||
|
||||
const l1 = getLuminance(r1, g1, b1);
|
||||
const l2 = getLuminance(r2, g2, b2);
|
||||
|
||||
const lighter = Math.max(l1, l2);
|
||||
const darker = Math.min(l1, l2);
|
||||
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
function meetsWCAG(
|
||||
foreground: string,
|
||||
background: string,
|
||||
size: 'normal' | 'large' = 'normal',
|
||||
level: 'AA' | 'AAA' = 'AA'
|
||||
): boolean {
|
||||
const ratio = getContrastRatio(foreground, background);
|
||||
|
||||
const requirements = {
|
||||
normal: { AA: 4.5, AAA: 7 },
|
||||
large: { AA: 3, AAA: 4.5 },
|
||||
};
|
||||
|
||||
return ratio >= requirements[size][level];
|
||||
}
|
||||
|
||||
// Usage
|
||||
meetsWCAG('#ffffff', '#3b82f6'); // true (4.5:1 for AA normal)
|
||||
meetsWCAG('#ffffff', '#60a5fa'); // false (below 4.5:1)
|
||||
```
|
||||
|
||||
### Accessible Color Pairs
|
||||
|
||||
```tsx
|
||||
// Generate accessible text color for any background
|
||||
function getAccessibleTextColor(backgroundColor: string): string {
|
||||
const [r, g, b] = hexToRgb(backgroundColor);
|
||||
const luminance = getLuminance(r, g, b);
|
||||
|
||||
// Use white text on dark backgrounds, black on light
|
||||
return luminance > 0.179 ? '#111827' : '#ffffff';
|
||||
}
|
||||
|
||||
// Find the nearest accessible shade
|
||||
function findAccessibleShade(
|
||||
textColor: string,
|
||||
backgroundScale: string[],
|
||||
minContrast: number = 4.5
|
||||
): string | null {
|
||||
for (const shade of backgroundScale) {
|
||||
if (getContrastRatio(textColor, shade) >= minContrast) {
|
||||
return shade;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
## Color Harmony
|
||||
|
||||
### Harmony Functions
|
||||
|
||||
```tsx
|
||||
type HarmonyType = 'complementary' | 'triadic' | 'analogous' | 'split-complementary';
|
||||
|
||||
function generateHarmony(baseHue: number, type: HarmonyType): number[] {
|
||||
switch (type) {
|
||||
case 'complementary':
|
||||
return [baseHue, (baseHue + 180) % 360];
|
||||
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,
|
||||
];
|
||||
default:
|
||||
return [baseHue];
|
||||
}
|
||||
}
|
||||
|
||||
// Generate palette from harmony
|
||||
function generateHarmoniousPalette(
|
||||
baseHue: number,
|
||||
type: HarmonyType
|
||||
): Record<string, string> {
|
||||
const hues = generateHarmony(baseHue, type);
|
||||
const names = ['primary', 'secondary', 'tertiary'];
|
||||
|
||||
return Object.fromEntries(
|
||||
hues.map((hue, i) => [names[i] || `color-${i}`, `hsl(${hue}, 70%, 50%)`])
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Color Blindness Considerations
|
||||
|
||||
```tsx
|
||||
// Simulate color blindness
|
||||
type ColorBlindnessType = 'protanopia' | 'deuteranopia' | 'tritanopia';
|
||||
|
||||
// Matrix transforms for common types
|
||||
const colorBlindnessMatrices: Record<ColorBlindnessType, number[][]> = {
|
||||
protanopia: [
|
||||
[0.567, 0.433, 0],
|
||||
[0.558, 0.442, 0],
|
||||
[0, 0.242, 0.758],
|
||||
],
|
||||
deuteranopia: [
|
||||
[0.625, 0.375, 0],
|
||||
[0.7, 0.3, 0],
|
||||
[0, 0.3, 0.7],
|
||||
],
|
||||
tritanopia: [
|
||||
[0.95, 0.05, 0],
|
||||
[0, 0.433, 0.567],
|
||||
[0, 0.475, 0.525],
|
||||
],
|
||||
};
|
||||
|
||||
// Best practices for color-blind accessibility:
|
||||
// 1. Do not rely solely on color to convey information
|
||||
// 2. Use patterns or icons alongside color
|
||||
// 3. Ensure sufficient contrast between colors
|
||||
// 4. Test with color blindness simulators
|
||||
// 5. Use blue-orange instead of red-green for contrast
|
||||
```
|
||||
|
||||
## CSS Color Functions
|
||||
|
||||
```css
|
||||
/* Modern CSS color functions */
|
||||
.modern-colors {
|
||||
/* Relative color syntax */
|
||||
--lighter: hsl(from var(--base-color) h s calc(l + 20%));
|
||||
--darker: hsl(from var(--base-color) h s calc(l - 20%));
|
||||
|
||||
/* Color mixing */
|
||||
--mixed: color-mix(in srgb, var(--color-1), var(--color-2) 30%);
|
||||
|
||||
/* Transparency */
|
||||
--semi-transparent: rgb(from var(--base-color) r g b / 50%);
|
||||
|
||||
/* OKLCH for perceptual uniformity */
|
||||
--vibrant-blue: oklch(60% 0.2 250);
|
||||
}
|
||||
|
||||
/* Alpha variations */
|
||||
.alpha-scale {
|
||||
--color-10: rgb(59 130 246 / 0.1);
|
||||
--color-20: rgb(59 130 246 / 0.2);
|
||||
--color-30: rgb(59 130 246 / 0.3);
|
||||
--color-40: rgb(59 130 246 / 0.4);
|
||||
--color-50: rgb(59 130 246 / 0.5);
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,426 @@
|
||||
# Spacing and Iconography Reference
|
||||
|
||||
## Spacing Systems
|
||||
|
||||
### 8-Point Grid System
|
||||
|
||||
The 8-point grid is the industry standard for consistent spacing.
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* Base spacing unit */
|
||||
--space-unit: 0.25rem; /* 4px */
|
||||
|
||||
/* 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 */
|
||||
}
|
||||
```
|
||||
|
||||
### Semantic Spacing Tokens
|
||||
|
||||
```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 */
|
||||
|
||||
/* 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 Utility Functions
|
||||
|
||||
```tsx
|
||||
// Tailwind-like spacing scale generator
|
||||
function createSpacingScale(baseUnit: number = 4): Record<string, string> {
|
||||
const scale: Record<string, string> = {
|
||||
'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,
|
||||
];
|
||||
|
||||
for (const m of multipliers) {
|
||||
const key = m % 1 === 0 ? String(m) : String(m).replace('.', '-');
|
||||
scale[key] = `${baseUnit * m}px`;
|
||||
}
|
||||
|
||||
return scale;
|
||||
}
|
||||
```
|
||||
|
||||
## Layout Spacing Patterns
|
||||
|
||||
### Container Queries for Spacing
|
||||
|
||||
```css
|
||||
/* Responsive spacing based on container size */
|
||||
.card {
|
||||
container-type: inline-size;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
@container (min-width: 400px) {
|
||||
.card {
|
||||
padding: var(--space-6);
|
||||
}
|
||||
}
|
||||
|
||||
@container (min-width: 600px) {
|
||||
.card {
|
||||
padding: var(--space-8);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Negative Space Patterns
|
||||
|
||||
```css
|
||||
/* Asymmetric spacing for visual hierarchy */
|
||||
.hero-section {
|
||||
padding-top: var(--space-24);
|
||||
padding-bottom: var(--space-16);
|
||||
}
|
||||
|
||||
/* Content breathing room */
|
||||
.prose > * + * {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.prose > h2 + * {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.prose > * + h2 {
|
||||
margin-top: var(--space-8);
|
||||
}
|
||||
```
|
||||
|
||||
## Icon Systems
|
||||
|
||||
### Icon Size Scale
|
||||
|
||||
```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 */
|
||||
|
||||
/* Touch target sizes */
|
||||
--touch-target-min: 44px; /* WCAG minimum */
|
||||
--touch-target-comfortable: 48px;
|
||||
}
|
||||
```
|
||||
|
||||
### SVG Icon Component
|
||||
|
||||
```tsx
|
||||
import { forwardRef, type SVGProps } from 'react';
|
||||
|
||||
interface IconProps extends SVGProps<SVGSVGElement> {
|
||||
name: string;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const sizeMap = {
|
||||
xs: 12,
|
||||
sm: 16,
|
||||
md: 20,
|
||||
lg: 24,
|
||||
xl: 32,
|
||||
'2xl': 48,
|
||||
};
|
||||
|
||||
export const Icon = forwardRef<SVGSVGElement, IconProps>(
|
||||
({ name, size = 'md', label, className, ...props }, ref) => {
|
||||
const pixelSize = sizeMap[size];
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={ref}
|
||||
width={pixelSize}
|
||||
height={pixelSize}
|
||||
className={`inline-block flex-shrink-0 ${className}`}
|
||||
aria-hidden={!label}
|
||||
aria-label={label}
|
||||
role={label ? 'img' : undefined}
|
||||
{...props}
|
||||
>
|
||||
<use href={`/icons.svg#${name}`} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Icon.displayName = 'Icon';
|
||||
```
|
||||
|
||||
### Icon Button Patterns
|
||||
|
||||
```tsx
|
||||
interface IconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
icon: string;
|
||||
label: string;
|
||||
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 */
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
sm: 'sm' as const,
|
||||
md: 'md' as const,
|
||||
lg: 'lg' as const,
|
||||
};
|
||||
|
||||
export function IconButton({
|
||||
icon,
|
||||
label,
|
||||
size = 'md',
|
||||
variant = 'ghost',
|
||||
className,
|
||||
...props
|
||||
}: IconButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={`
|
||||
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'}
|
||||
${className}
|
||||
`}
|
||||
aria-label={label}
|
||||
{...props}
|
||||
>
|
||||
<Icon name={icon} size={iconSizes[size]} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Icon Sprite Generation
|
||||
|
||||
```tsx
|
||||
// Build script for SVG sprite
|
||||
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 symbols = await Promise.all(
|
||||
svgFiles.map(async (file) => {
|
||||
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',
|
||||
],
|
||||
});
|
||||
|
||||
// Extract viewBox and content
|
||||
const viewBoxMatch = result.data.match(/viewBox="([^"]+)"/);
|
||||
const viewBox = viewBoxMatch ? viewBoxMatch[1] : '0 0 24 24';
|
||||
const innerContent = result.data
|
||||
.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>`;
|
||||
|
||||
await writeFile(outputPath, sprite);
|
||||
console.log(`Generated sprite with ${symbols.length} icons`);
|
||||
}
|
||||
```
|
||||
|
||||
### Icon Libraries Integration
|
||||
|
||||
```tsx
|
||||
// Lucide React
|
||||
import { Home, Settings, User, Search } from 'lucide-react';
|
||||
|
||||
function Navigation() {
|
||||
return (
|
||||
<nav className="flex gap-4">
|
||||
<NavItem icon={Home} label="Home" />
|
||||
<NavItem icon={Search} label="Search" />
|
||||
<NavItem icon={Settings} label="Settings" />
|
||||
<NavItem icon={User} label="Profile" />
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
// Heroicons
|
||||
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;
|
||||
return <Icon className="w-6 h-6" />;
|
||||
}
|
||||
|
||||
// Radix Icons
|
||||
import { HomeIcon, GearIcon } from '@radix-ui/react-icons';
|
||||
```
|
||||
|
||||
## Sizing Systems
|
||||
|
||||
### Element Sizing Scale
|
||||
|
||||
```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 */
|
||||
|
||||
/* Component heights */
|
||||
--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-2xl: var(--size-24); /* 96px */
|
||||
}
|
||||
```
|
||||
|
||||
### Aspect Ratios
|
||||
|
||||
```css
|
||||
.aspect-ratios {
|
||||
/* Standard ratios */
|
||||
--aspect-square: 1 / 1;
|
||||
--aspect-video: 16 / 9;
|
||||
--aspect-photo: 4 / 3;
|
||||
--aspect-portrait: 3 / 4;
|
||||
--aspect-cinema: 21 / 9;
|
||||
--aspect-golden: 1.618 / 1;
|
||||
}
|
||||
|
||||
/* Usage */
|
||||
.thumbnail {
|
||||
aspect-ratio: var(--aspect-video);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
aspect-ratio: var(--aspect-square);
|
||||
border-radius: 50%;
|
||||
}
|
||||
```
|
||||
|
||||
### Border Radius Scale
|
||||
|
||||
```css
|
||||
:root {
|
||||
--radius-none: 0;
|
||||
--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-full: 9999px;
|
||||
|
||||
/* Component-specific */
|
||||
--radius-button: var(--radius-md);
|
||||
--radius-input: var(--radius-md);
|
||||
--radius-card: var(--radius-lg);
|
||||
--radius-modal: var(--radius-xl);
|
||||
--radius-badge: var(--radius-full);
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,425 @@
|
||||
# Typography Systems Reference
|
||||
|
||||
## Type Scale Construction
|
||||
|
||||
### Modular Scale
|
||||
|
||||
A modular scale creates harmonious relationships between font sizes using a mathematical ratio.
|
||||
|
||||
```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
|
||||
augmentedFourth: 1.414, // √2
|
||||
perfectFifth: 1.5, // 3:2
|
||||
goldenRatio: 1.618, // φ
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
return scale;
|
||||
}
|
||||
|
||||
// Generate a scale with 16px base and perfect fourth ratio
|
||||
const typeScale = generateScale(16, RATIOS.perfectFourth, 6);
|
||||
// Result: [9, 12, 16, 21.33, 28.43, 37.9, 50.52, 67.34, 89.76]
|
||||
```
|
||||
|
||||
### CSS Custom Properties
|
||||
|
||||
```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 weights */
|
||||
--font-weight-normal: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
|
||||
/* Line heights */
|
||||
--line-height-tight: 1.1;
|
||||
--line-height-snug: 1.25;
|
||||
--line-height-normal: 1.5;
|
||||
--line-height-relaxed: 1.625;
|
||||
--line-height-loose: 2;
|
||||
|
||||
/* Letter spacing */
|
||||
--letter-spacing-tighter: -0.05em;
|
||||
--letter-spacing-tight: -0.025em;
|
||||
--letter-spacing-normal: 0;
|
||||
--letter-spacing-wide: 0.025em;
|
||||
--letter-spacing-wider: 0.05em;
|
||||
--letter-spacing-widest: 0.1em;
|
||||
}
|
||||
```
|
||||
|
||||
## Font Loading Strategies
|
||||
|
||||
### FOUT Prevention
|
||||
|
||||
```css
|
||||
/* Use font-display to control loading behavior */
|
||||
@font-face {
|
||||
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 */
|
||||
}
|
||||
|
||||
/* Optional: size-adjust for better fallback matching */
|
||||
@font-face {
|
||||
font-family: 'Inter Fallback';
|
||||
src: local('Arial');
|
||||
size-adjust: 107%; /* Adjust to match Inter metrics */
|
||||
ascent-override: 90%;
|
||||
descent-override: 22%;
|
||||
line-gap-override: 0%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', 'Inter Fallback', system-ui, sans-serif;
|
||||
}
|
||||
```
|
||||
|
||||
### Preloading Critical Fonts
|
||||
|
||||
```html
|
||||
<head>
|
||||
<!-- Preload critical fonts -->
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/Inter-Variable.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
</head>
|
||||
```
|
||||
|
||||
### Variable Fonts
|
||||
|
||||
```css
|
||||
/* Variable font with weight and width axes */
|
||||
@font-face {
|
||||
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;
|
||||
}
|
||||
|
||||
/* Or use standard properties */
|
||||
.semi-expanded {
|
||||
font-weight: 550;
|
||||
font-stretch: 110%;
|
||||
}
|
||||
```
|
||||
|
||||
## Responsive Typography
|
||||
|
||||
### Fluid Type Scale
|
||||
|
||||
```css
|
||||
/* Using clamp() for responsive sizing */
|
||||
h1 {
|
||||
/* min: 32px, preferred: 5vw + 16px, max: 64px */
|
||||
font-size: clamp(2rem, 5vw + 1rem, 4rem);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: clamp(1.5rem, 3vw + 0.5rem, 2.5rem);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: clamp(1rem, 1vw + 0.75rem, 1.25rem);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Fluid line height */
|
||||
.fluid-text {
|
||||
--min-line-height: 1.3;
|
||||
--max-line-height: 1.6;
|
||||
--min-vw: 320;
|
||||
--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)))
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Viewport-Based Scaling
|
||||
|
||||
```tsx
|
||||
// Tailwind config for responsive type
|
||||
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' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Component with responsive classes
|
||||
function Heading({ children }) {
|
||||
return (
|
||||
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold leading-tight">
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Readability Guidelines
|
||||
|
||||
### Optimal Line Length
|
||||
|
||||
```css
|
||||
/* Optimal reading width: 45-75 characters */
|
||||
.prose {
|
||||
max-width: 65ch; /* ~65 characters */
|
||||
}
|
||||
|
||||
/* Narrower for callouts */
|
||||
.callout {
|
||||
max-width: 50ch;
|
||||
}
|
||||
|
||||
/* Wider for code blocks */
|
||||
pre {
|
||||
max-width: 80ch;
|
||||
}
|
||||
```
|
||||
|
||||
### Vertical Rhythm
|
||||
|
||||
```css
|
||||
/* Establish baseline grid */
|
||||
:root {
|
||||
--baseline: 1.5rem; /* 24px at 16px base */
|
||||
}
|
||||
|
||||
/* All margins should be multiples of baseline */
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
line-height: calc(var(--baseline) * 2);
|
||||
margin-top: calc(var(--baseline) * 2);
|
||||
margin-bottom: var(--baseline);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 2rem;
|
||||
line-height: calc(var(--baseline) * 1.5);
|
||||
margin-top: calc(var(--baseline) * 1.5);
|
||||
margin-bottom: calc(var(--baseline) * 0.5);
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1rem;
|
||||
line-height: var(--baseline);
|
||||
margin-bottom: var(--baseline);
|
||||
}
|
||||
```
|
||||
|
||||
### Text Wrapping
|
||||
|
||||
```css
|
||||
/* Prevent orphans and widows */
|
||||
p {
|
||||
text-wrap: pretty; /* Experimental: improves line breaks */
|
||||
widows: 3;
|
||||
orphans: 3;
|
||||
}
|
||||
|
||||
/* Balance headings */
|
||||
h1, h2, h3 {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* Prevent breaking in specific elements */
|
||||
.no-wrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Hyphenation for justified text */
|
||||
.justified {
|
||||
text-align: justify;
|
||||
hyphens: auto;
|
||||
-webkit-hyphens: auto;
|
||||
}
|
||||
```
|
||||
|
||||
## Font Pairing Guidelines
|
||||
|
||||
### Contrast Pairings
|
||||
|
||||
```css
|
||||
/* Serif heading + Sans body */
|
||||
:root {
|
||||
--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;
|
||||
}
|
||||
|
||||
/* Modern sans heading + Classic serif body */
|
||||
:root {
|
||||
--font-heading: 'Inter', system-ui, sans-serif;
|
||||
--font-body: 'Georgia', Times, serif;
|
||||
}
|
||||
```
|
||||
|
||||
### Superfamily Approach
|
||||
|
||||
```css
|
||||
/* Single variable font family for all uses */
|
||||
:root {
|
||||
--font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: var(--font-family);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
p {
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.caption {
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
```
|
||||
|
||||
## Semantic Typography Classes
|
||||
|
||||
```css
|
||||
/* Text styles by purpose, not appearance */
|
||||
.text-display {
|
||||
font-size: var(--font-size-5xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
line-height: var(--line-height-tight);
|
||||
letter-spacing: var(--letter-spacing-tight);
|
||||
}
|
||||
|
||||
.text-headline {
|
||||
font-size: var(--font-size-3xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: var(--line-height-snug);
|
||||
}
|
||||
|
||||
.text-title {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: var(--line-height-snug);
|
||||
}
|
||||
|
||||
.text-body {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-normal);
|
||||
}
|
||||
|
||||
.text-body-sm {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-normal);
|
||||
}
|
||||
|
||||
.text-caption {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-normal);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--letter-spacing-wide);
|
||||
}
|
||||
|
||||
.text-overline {
|
||||
font-size: var(--font-size-2xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: var(--line-height-normal);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--letter-spacing-widest);
|
||||
}
|
||||
```
|
||||
|
||||
## OpenType Features
|
||||
|
||||
```css
|
||||
/* Enable advanced typography features */
|
||||
.fancy-text {
|
||||
/* Small caps */
|
||||
font-variant-caps: small-caps;
|
||||
|
||||
/* Ligatures */
|
||||
font-variant-ligatures: common-ligatures;
|
||||
|
||||
/* Numeric features */
|
||||
font-variant-numeric: tabular-nums lining-nums;
|
||||
|
||||
/* Fractions */
|
||||
font-feature-settings: 'frac' 1;
|
||||
}
|
||||
|
||||
/* Tabular numbers for aligned columns */
|
||||
.data-table td {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Old-style figures for body text */
|
||||
.prose {
|
||||
font-variant-numeric: oldstyle-nums;
|
||||
}
|
||||
|
||||
/* Discretionary ligatures for headings */
|
||||
.fancy-heading {
|
||||
font-variant-ligatures: discretionary-ligatures;
|
||||
}
|
||||
```
|
||||
268
plugins/ui-design/skills/web-component-design/SKILL.md
Normal file
268
plugins/ui-design/skills/web-component-design/SKILL.md
Normal file
@@ -0,0 +1,268 @@
|
||||
---
|
||||
name: web-component-design
|
||||
description: Master React, Vue, and Svelte component patterns including CSS-in-JS, composition strategies, and reusable component architecture. Use when building UI component libraries, designing component APIs, or implementing frontend design systems.
|
||||
---
|
||||
|
||||
# Web Component Design
|
||||
|
||||
Build reusable, maintainable UI components using modern frameworks with clean composition patterns and styling approaches.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
- Designing reusable component libraries or design systems
|
||||
- Implementing complex component composition patterns
|
||||
- Choosing and applying CSS-in-JS solutions
|
||||
- Building accessible, responsive UI components
|
||||
- Creating consistent component APIs across a codebase
|
||||
- Refactoring legacy components into modern patterns
|
||||
- Implementing compound components or render props
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### 1. Component Composition Patterns
|
||||
|
||||
**Compound Components**: Related components that work together
|
||||
```tsx
|
||||
// Usage
|
||||
<Select value={value} onChange={setValue}>
|
||||
<Select.Trigger>Choose option</Select.Trigger>
|
||||
<Select.Options>
|
||||
<Select.Option value="a">Option A</Select.Option>
|
||||
<Select.Option value="b">Option B</Select.Option>
|
||||
</Select.Options>
|
||||
</Select>
|
||||
```
|
||||
|
||||
**Render Props**: Delegate rendering to parent
|
||||
```tsx
|
||||
<DataFetcher url="/api/users">
|
||||
{({ data, loading, error }) => (
|
||||
loading ? <Spinner /> : <UserList users={data} />
|
||||
)}
|
||||
</DataFetcher>
|
||||
```
|
||||
|
||||
**Slots (Vue/Svelte)**: Named content injection points
|
||||
```vue
|
||||
<template>
|
||||
<Card>
|
||||
<template #header>Title</template>
|
||||
<template #content>Body text</template>
|
||||
<template #footer><Button>Action</Button></template>
|
||||
</Card>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 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 |
|
||||
|
||||
### 3. Component API Design
|
||||
|
||||
```tsx
|
||||
interface ButtonProps {
|
||||
variant?: 'primary' | 'secondary' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
isLoading?: boolean;
|
||||
isDisabled?: boolean;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
**Principles**:
|
||||
- Use semantic prop names (`isLoading` vs `loading`)
|
||||
- Provide sensible defaults
|
||||
- Support composition via `children`
|
||||
- Allow style overrides via `className` or `style`
|
||||
|
||||
## 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';
|
||||
|
||||
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',
|
||||
{
|
||||
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',
|
||||
},
|
||||
size: {
|
||||
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',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
interface ButtonProps
|
||||
extends ComponentPropsWithoutRef<'button'>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, isLoading, children, ...props }, ref) => (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant, size }), className)}
|
||||
disabled={isLoading || props.disabled}
|
||||
{...props}
|
||||
>
|
||||
{isLoading && <Spinner className="mr-2 h-4 w-4" />}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
```
|
||||
|
||||
## Framework Patterns
|
||||
|
||||
### React: Compound Components
|
||||
|
||||
```tsx
|
||||
import { createContext, useContext, useState, type ReactNode } from 'react';
|
||||
|
||||
interface AccordionContextValue {
|
||||
openItems: Set<string>;
|
||||
toggle: (id: string) => void;
|
||||
}
|
||||
|
||||
const AccordionContext = createContext<AccordionContextValue | null>(null);
|
||||
|
||||
function useAccordion() {
|
||||
const context = useContext(AccordionContext);
|
||||
if (!context) throw new Error('Must be used within Accordion');
|
||||
return context;
|
||||
}
|
||||
|
||||
export function Accordion({ children }: { children: ReactNode }) {
|
||||
const [openItems, setOpenItems] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setOpenItems(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AccordionContext.Provider value={{ openItems, toggle }}>
|
||||
<div className="divide-y">{children}</div>
|
||||
</AccordionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
Accordion.Item = function AccordionItem({
|
||||
id,
|
||||
title,
|
||||
children
|
||||
}: { id: string; title: string; children: ReactNode }) {
|
||||
const { openItems, toggle } = useAccordion();
|
||||
const isOpen = openItems.has(id);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => toggle(id)} className="w-full text-left py-3">
|
||||
{title}
|
||||
</button>
|
||||
{isOpen && <div className="pb-3">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Vue 3: Composables
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, provide, inject, type InjectionKey } from 'vue';
|
||||
|
||||
interface TabsContext {
|
||||
activeTab: Ref<string>;
|
||||
setActive: (id: string) => void;
|
||||
}
|
||||
|
||||
const TabsKey: InjectionKey<TabsContext> = Symbol('tabs');
|
||||
|
||||
// Parent component
|
||||
const activeTab = ref('tab-1');
|
||||
provide(TabsKey, {
|
||||
activeTab,
|
||||
setActive: (id: string) => { activeTab.value = id; }
|
||||
});
|
||||
|
||||
// Child component usage
|
||||
const tabs = inject(TabsKey);
|
||||
const isActive = computed(() => tabs?.activeTab.value === props.id);
|
||||
</script>
|
||||
```
|
||||
|
||||
### Svelte 5: Runes
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
variant?: 'primary' | 'secondary';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
onclick?: () => void;
|
||||
children: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let { variant = 'primary', size = 'md', onclick, children }: Props = $props();
|
||||
|
||||
const classes = $derived(
|
||||
`btn btn-${variant} btn-${size}`
|
||||
);
|
||||
</script>
|
||||
|
||||
<button class={classes} {onclick}>
|
||||
{@render children()}
|
||||
</button>
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Single Responsibility**: Each component does one thing well
|
||||
2. **Prop Drilling Prevention**: Use context for deeply nested data
|
||||
3. **Accessible by Default**: Include ARIA attributes, keyboard support
|
||||
4. **Controlled vs Uncontrolled**: Support both patterns when appropriate
|
||||
5. **Forward Refs**: Allow parent access to DOM nodes
|
||||
6. **Memoization**: Use `React.memo`, `useMemo` for expensive renders
|
||||
7. **Error Boundaries**: Wrap components that may fail
|
||||
|
||||
## Common Issues
|
||||
|
||||
- **Prop Explosion**: Too many props - consider composition instead
|
||||
- **Style Conflicts**: Use scoped styles or CSS Modules
|
||||
- **Re-render Cascades**: Profile with React DevTools, memo appropriately
|
||||
- **Accessibility Gaps**: Test with screen readers and keyboard navigation
|
||||
- **Bundle Size**: Tree-shake unused component variants
|
||||
|
||||
## Resources
|
||||
|
||||
- [React Component Patterns](https://reactpatterns.com/)
|
||||
- [Vue Composition API Guide](https://vuejs.org/guide/reusability/composables.html)
|
||||
- [Svelte Component Documentation](https://svelte.dev/docs/svelte-components)
|
||||
- [Radix UI Primitives](https://www.radix-ui.com/primitives)
|
||||
- [shadcn/ui Components](https://ui.shadcn.com/)
|
||||
@@ -0,0 +1,602 @@
|
||||
# Accessibility Patterns Reference
|
||||
|
||||
## ARIA Patterns for Common Components
|
||||
|
||||
### Modal Dialog
|
||||
|
||||
```tsx
|
||||
import { useEffect, useRef, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Modal({ isOpen, onClose, title, children }: ModalProps) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const previousActiveElement = useRef<Element | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
previousActiveElement.current = document.activeElement;
|
||||
dialogRef.current?.focus();
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
(previousActiveElement.current as HTMLElement)?.focus();
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'Tab') trapFocus(e, dialogRef.current);
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
aria-hidden={!isOpen}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
tabIndex={-1}
|
||||
className="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl"
|
||||
>
|
||||
<h2 id="modal-title" className="text-lg font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close dialog"
|
||||
className="absolute right-4 top-4 p-1"
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</button>
|
||||
<div className="mt-4">{children}</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
function trapFocus(e: KeyboardEvent, container: HTMLElement | null) {
|
||||
if (!container) return;
|
||||
|
||||
const focusableElements = container.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements[focusableElements.length - 1];
|
||||
|
||||
if (e.shiftKey && document.activeElement === firstElement) {
|
||||
e.preventDefault();
|
||||
lastElement.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === lastElement) {
|
||||
e.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dropdown Menu
|
||||
|
||||
```tsx
|
||||
import { useState, useRef, useEffect, type ReactNode } from 'react';
|
||||
|
||||
interface DropdownProps {
|
||||
trigger: ReactNode;
|
||||
children: ReactNode;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function Dropdown({ trigger, children, label }: DropdownProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
setIsOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
} else {
|
||||
focusNextItem(menuRef.current, 1);
|
||||
}
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
if (isOpen) {
|
||||
focusNextItem(menuRef.current, -1);
|
||||
}
|
||||
break;
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
focusFirstItem(menuRef.current);
|
||||
break;
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
focusLastItem(menuRef.current);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative" onKeyDown={handleKeyDown}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isOpen}
|
||||
aria-label={label}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-2 px-3 py-2"
|
||||
>
|
||||
{trigger}
|
||||
<ChevronDownIcon
|
||||
aria-hidden="true"
|
||||
className={`transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
className="absolute left-0 mt-1 min-w-48 rounded-md bg-white py-1 shadow-lg ring-1 ring-black/5"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MenuItemProps {
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function MenuItem({ children, onClick, disabled }: MenuItemProps) {
|
||||
return (
|
||||
<button
|
||||
role="menuitem"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-100 disabled:opacity-50"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function focusNextItem(menu: HTMLElement | null, direction: 1 | -1) {
|
||||
if (!menu) return;
|
||||
const items = menu.querySelectorAll<HTMLElement>('[role="menuitem"]:not([disabled])');
|
||||
const currentIndex = Array.from(items).indexOf(document.activeElement as HTMLElement);
|
||||
const nextIndex = (currentIndex + direction + items.length) % items.length;
|
||||
items[nextIndex]?.focus();
|
||||
}
|
||||
|
||||
function focusFirstItem(menu: HTMLElement | null) {
|
||||
menu?.querySelector<HTMLElement>('[role="menuitem"]:not([disabled])')?.focus();
|
||||
}
|
||||
|
||||
function focusLastItem(menu: HTMLElement | null) {
|
||||
const items = menu?.querySelectorAll<HTMLElement>('[role="menuitem"]:not([disabled])');
|
||||
items?.[items.length - 1]?.focus();
|
||||
}
|
||||
```
|
||||
|
||||
### Combobox / Autocomplete
|
||||
|
||||
```tsx
|
||||
import { useState, useRef, useId, type ChangeEvent, type KeyboardEvent } from 'react';
|
||||
|
||||
interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ComboboxProps {
|
||||
options: Option[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function Combobox({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
placeholder,
|
||||
}: ComboboxProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listboxRef = useRef<HTMLUListElement>(null);
|
||||
const inputId = useId();
|
||||
const listboxId = useId();
|
||||
|
||||
const filteredOptions = options.filter((option) =>
|
||||
option.label.toLowerCase().includes(inputValue.toLowerCase())
|
||||
);
|
||||
|
||||
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value);
|
||||
setIsOpen(true);
|
||||
setActiveIndex(-1);
|
||||
};
|
||||
|
||||
const handleSelect = (option: Option) => {
|
||||
onChange(option.value);
|
||||
setInputValue(option.label);
|
||||
setIsOpen(false);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
} else {
|
||||
setActiveIndex((prev) =>
|
||||
prev < filteredOptions.length - 1 ? prev + 1 : prev
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => (prev > 0 ? prev - 1 : prev));
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (activeIndex >= 0 && filteredOptions[activeIndex]) {
|
||||
handleSelect(filteredOptions[activeIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
setIsOpen(false);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<label htmlFor={inputId} className="block text-sm font-medium mb-1">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={inputId}
|
||||
type="text"
|
||||
role="combobox"
|
||||
aria-expanded={isOpen}
|
||||
aria-autocomplete="list"
|
||||
aria-controls={listboxId}
|
||||
aria-activedescendant={
|
||||
activeIndex >= 0 ? `option-${activeIndex}` : undefined
|
||||
}
|
||||
value={inputValue}
|
||||
placeholder={placeholder}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onBlur={() => setTimeout(() => setIsOpen(false), 200)}
|
||||
className="w-full rounded-md border px-3 py-2"
|
||||
/>
|
||||
|
||||
{isOpen && filteredOptions.length > 0 && (
|
||||
<ul
|
||||
ref={listboxRef}
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
aria-label={label}
|
||||
className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 shadow-lg ring-1 ring-black/5"
|
||||
>
|
||||
{filteredOptions.map((option, index) => (
|
||||
<li
|
||||
key={option.value}
|
||||
id={`option-${index}`}
|
||||
role="option"
|
||||
aria-selected={activeIndex === index}
|
||||
onClick={() => handleSelect(option)}
|
||||
className={`cursor-pointer px-3 py-2 ${
|
||||
activeIndex === index ? 'bg-blue-100' : 'hover:bg-gray-100'
|
||||
} ${value === option.value ? 'font-medium' : ''}`}
|
||||
>
|
||||
{option.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{isOpen && filteredOptions.length === 0 && (
|
||||
<div className="absolute z-10 mt-1 w-full rounded-md bg-white px-3 py-2 shadow-lg">
|
||||
No results found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Form Validation
|
||||
|
||||
```tsx
|
||||
import { useId, type FormEvent } from 'react';
|
||||
|
||||
interface FormFieldProps {
|
||||
label: string;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
children: (props: {
|
||||
id: string;
|
||||
'aria-describedby': string | undefined;
|
||||
'aria-invalid': boolean;
|
||||
}) => ReactNode;
|
||||
}
|
||||
|
||||
export function FormField({ label, error, required, children }: FormFieldProps) {
|
||||
const id = useId();
|
||||
const errorId = `${id}-error`;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label htmlFor={id} className="block text-sm font-medium">
|
||||
{label}
|
||||
{required && (
|
||||
<span aria-hidden="true" className="ml-1 text-red-500">
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{children({
|
||||
id,
|
||||
'aria-describedby': error ? errorId : undefined,
|
||||
'aria-invalid': !!error,
|
||||
})}
|
||||
|
||||
{error && (
|
||||
<p id={errorId} role="alert" className="text-sm text-red-600">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage
|
||||
function ContactForm() {
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
// Validation logic...
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<FormField label="Email" error={errors.email} required>
|
||||
{(props) => (
|
||||
<input
|
||||
{...props}
|
||||
type="email"
|
||||
required
|
||||
className={`w-full rounded border px-3 py-2 ${
|
||||
props['aria-invalid'] ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<button type="submit" className="mt-4 px-4 py-2 bg-blue-600 text-white rounded">
|
||||
Submit
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Skip Links
|
||||
|
||||
```tsx
|
||||
export function SkipLinks() {
|
||||
return (
|
||||
<div className="sr-only focus-within:not-sr-only">
|
||||
<a
|
||||
href="#main-content"
|
||||
className="absolute left-4 top-4 z-50 rounded bg-blue-600 px-4 py-2 text-white focus:outline-none focus:ring-2"
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
<a
|
||||
href="#main-navigation"
|
||||
className="absolute left-4 top-16 z-50 rounded bg-blue-600 px-4 py-2 text-white focus:outline-none focus:ring-2"
|
||||
>
|
||||
Skip to navigation
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Live Regions
|
||||
|
||||
```tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface LiveAnnouncerProps {
|
||||
message: string;
|
||||
politeness?: 'polite' | 'assertive';
|
||||
}
|
||||
|
||||
export function LiveAnnouncer({ message, politeness = 'polite' }: LiveAnnouncerProps) {
|
||||
const [announcement, setAnnouncement] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
// Clear first, then set - ensures screen readers pick up the change
|
||||
setAnnouncement('');
|
||||
const timer = setTimeout(() => setAnnouncement(message), 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, [message]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live={politeness}
|
||||
aria-atomic="true"
|
||||
className="sr-only"
|
||||
>
|
||||
{announcement}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage in a search component
|
||||
function SearchResults({ results, loading }: { results: Item[]; loading: boolean }) {
|
||||
const message = loading
|
||||
? 'Loading results...'
|
||||
: `${results.length} results found`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<LiveAnnouncer message={message} />
|
||||
<ul>{/* results */}</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Focus Management Utilities
|
||||
|
||||
```tsx
|
||||
// useFocusReturn - restore focus after closing
|
||||
function useFocusReturn() {
|
||||
const previousElement = useRef<Element | null>(null);
|
||||
|
||||
const saveFocus = () => {
|
||||
previousElement.current = document.activeElement;
|
||||
};
|
||||
|
||||
const restoreFocus = () => {
|
||||
(previousElement.current as HTMLElement)?.focus();
|
||||
};
|
||||
|
||||
return { saveFocus, restoreFocus };
|
||||
}
|
||||
|
||||
// useFocusTrap - keep focus within container
|
||||
function useFocusTrap(containerRef: RefObject<HTMLElement>, isActive: boolean) {
|
||||
useEffect(() => {
|
||||
if (!isActive || !containerRef.current) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
const focusableSelector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') return;
|
||||
|
||||
const focusableElements = container.querySelectorAll<HTMLElement>(focusableSelector);
|
||||
const first = focusableElements[0];
|
||||
const last = focusableElements[focusableElements.length - 1];
|
||||
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
container.addEventListener('keydown', handleKeyDown);
|
||||
return () => container.removeEventListener('keydown', handleKeyDown);
|
||||
}, [containerRef, isActive]);
|
||||
}
|
||||
```
|
||||
|
||||
## Color Contrast Utilities
|
||||
|
||||
```tsx
|
||||
// Check if colors meet WCAG requirements
|
||||
function getContrastRatio(fg: string, bg: string): number {
|
||||
const getLuminance = (hex: string): number => {
|
||||
const rgb = parseInt(hex.slice(1), 16);
|
||||
const r = (rgb >> 16) & 0xff;
|
||||
const g = (rgb >> 8) & 0xff;
|
||||
const b = rgb & 0xff;
|
||||
|
||||
const [rs, gs, bs] = [r, g, b].map((c) => {
|
||||
c = c / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
|
||||
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
|
||||
};
|
||||
|
||||
const l1 = getLuminance(fg);
|
||||
const l2 = getLuminance(bg);
|
||||
const lighter = Math.max(l1, l2);
|
||||
const darker = Math.min(l1, l2);
|
||||
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
function meetsWCAG(fg: string, bg: string, level: 'AA' | 'AAA' = 'AA'): boolean {
|
||||
const ratio = getContrastRatio(fg, bg);
|
||||
return level === 'AAA' ? ratio >= 7 : ratio >= 4.5;
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,441 @@
|
||||
# Component Patterns Reference
|
||||
|
||||
## Compound Components Deep Dive
|
||||
|
||||
Compound components share implicit state while allowing flexible composition.
|
||||
|
||||
### Implementation with Context
|
||||
|
||||
```tsx
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
} from 'react';
|
||||
|
||||
// Types
|
||||
interface TabsContextValue {
|
||||
activeTab: string;
|
||||
setActiveTab: Dispatch<SetStateAction<string>>;
|
||||
}
|
||||
|
||||
interface TabsProps {
|
||||
defaultValue: string;
|
||||
children: ReactNode;
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
interface TabListProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface TabProps {
|
||||
value: string;
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface TabPanelProps {
|
||||
value: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// Context
|
||||
const TabsContext = createContext<TabsContextValue | null>(null);
|
||||
|
||||
function useTabs() {
|
||||
const context = useContext(TabsContext);
|
||||
if (!context) {
|
||||
throw new Error('Tabs components must be used within <Tabs>');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
// Root Component
|
||||
export function Tabs({ defaultValue, children, onChange }: TabsProps) {
|
||||
const [activeTab, setActiveTab] = useState(defaultValue);
|
||||
|
||||
const handleChange: Dispatch<SetStateAction<string>> = useCallback(
|
||||
(value) => {
|
||||
const newValue = typeof value === 'function' ? value(activeTab) : value;
|
||||
setActiveTab(newValue);
|
||||
onChange?.(newValue);
|
||||
},
|
||||
[activeTab, onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<TabsContext.Provider value={{ activeTab, setActiveTab: handleChange }}>
|
||||
<div className="tabs">{children}</div>
|
||||
</TabsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// Tab List (container for tab triggers)
|
||||
Tabs.List = function TabList({ children, className }: TabListProps) {
|
||||
return (
|
||||
<div role="tablist" className={`flex border-b ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Individual Tab (trigger)
|
||||
Tabs.Tab = function Tab({ value, children, disabled }: TabProps) {
|
||||
const { activeTab, setActiveTab } = useTabs();
|
||||
const isActive = activeTab === value;
|
||||
|
||||
return (
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls={`panel-${value}`}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
disabled={disabled}
|
||||
onClick={() => setActiveTab(value)}
|
||||
className={`
|
||||
px-4 py-2 font-medium transition-colors
|
||||
${isActive
|
||||
? 'border-b-2 border-blue-600 text-blue-600'
|
||||
: 'text-gray-600 hover:text-gray-900'}
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// Tab Panel (content)
|
||||
Tabs.Panel = function TabPanel({ value, children }: TabPanelProps) {
|
||||
const { activeTab } = useTabs();
|
||||
|
||||
if (activeTab !== value) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
id={`panel-${value}`}
|
||||
aria-labelledby={`tab-${value}`}
|
||||
tabIndex={0}
|
||||
className="py-4"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```tsx
|
||||
<Tabs defaultValue="overview" onChange={console.log}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="overview">Overview</Tabs.Tab>
|
||||
<Tabs.Tab value="features">Features</Tabs.Tab>
|
||||
<Tabs.Tab value="pricing" disabled>Pricing</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="overview">
|
||||
<h2>Product Overview</h2>
|
||||
<p>Description here...</p>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="features">
|
||||
<h2>Key Features</h2>
|
||||
<ul>...</ul>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
```
|
||||
|
||||
## Render Props Pattern
|
||||
|
||||
Delegate rendering control to the consumer while providing state and helpers.
|
||||
|
||||
```tsx
|
||||
interface DataLoaderRenderProps<T> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
interface DataLoaderProps<T> {
|
||||
url: string;
|
||||
children: (props: DataLoaderRenderProps<T>) => ReactNode;
|
||||
}
|
||||
|
||||
function DataLoader<T>({ url, children }: DataLoaderProps<T>) {
|
||||
const [state, setState] = useState<{
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
}>({
|
||||
data: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setState(prev => ({ ...prev, loading: true, error: null }));
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Fetch failed');
|
||||
const data = await response.json();
|
||||
setState({ data, loading: false, error: null });
|
||||
} catch (error) {
|
||||
setState(prev => ({ ...prev, loading: false, error: error as Error }));
|
||||
}
|
||||
}, [url]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
return <>{children({ ...state, refetch: fetchData })}</>;
|
||||
}
|
||||
|
||||
// Usage
|
||||
<DataLoader<User[]> url="/api/users">
|
||||
{({ data, loading, error, refetch }) => {
|
||||
if (loading) return <Spinner />;
|
||||
if (error) return <ErrorMessage error={error} onRetry={refetch} />;
|
||||
return <UserList users={data!} />;
|
||||
}}
|
||||
</DataLoader>
|
||||
```
|
||||
|
||||
## Polymorphic Components
|
||||
|
||||
Components that can render as different HTML elements.
|
||||
|
||||
```tsx
|
||||
type AsProp<C extends React.ElementType> = {
|
||||
as?: C;
|
||||
};
|
||||
|
||||
type PropsToOmit<C extends React.ElementType, P> = keyof (AsProp<C> & P);
|
||||
|
||||
type PolymorphicComponentProp<
|
||||
C extends React.ElementType,
|
||||
Props = {}
|
||||
> = React.PropsWithChildren<Props & AsProp<C>> &
|
||||
Omit<React.ComponentPropsWithoutRef<C>, PropsToOmit<C, Props>>;
|
||||
|
||||
interface TextOwnProps {
|
||||
variant?: 'body' | 'heading' | 'label';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
type TextProps<C extends React.ElementType> = PolymorphicComponentProp<C, TextOwnProps>;
|
||||
|
||||
function Text<C extends React.ElementType = 'span'>({
|
||||
as,
|
||||
variant = 'body',
|
||||
size = 'md',
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TextProps<C>) {
|
||||
const Component = as || 'span';
|
||||
|
||||
const variantClasses = {
|
||||
body: 'font-normal',
|
||||
heading: 'font-bold',
|
||||
label: 'font-medium uppercase tracking-wide',
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'text-sm',
|
||||
md: 'text-base',
|
||||
lg: 'text-lg',
|
||||
};
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={`${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage
|
||||
<Text>Default span</Text>
|
||||
<Text as="p" variant="body" size="lg">Paragraph</Text>
|
||||
<Text as="h1" variant="heading" size="lg">Heading</Text>
|
||||
<Text as="label" variant="label" htmlFor="input">Label</Text>
|
||||
```
|
||||
|
||||
## Controlled vs Uncontrolled Pattern
|
||||
|
||||
Support both modes for maximum flexibility.
|
||||
|
||||
```tsx
|
||||
interface InputProps {
|
||||
// Controlled
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
// Uncontrolled
|
||||
defaultValue?: string;
|
||||
// Common
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function Input({
|
||||
value: controlledValue,
|
||||
onChange,
|
||||
defaultValue = '',
|
||||
...props
|
||||
}: InputProps) {
|
||||
const [internalValue, setInternalValue] = useState(defaultValue);
|
||||
|
||||
// Determine if controlled
|
||||
const isControlled = controlledValue !== undefined;
|
||||
const value = isControlled ? controlledValue : internalValue;
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
|
||||
if (!isControlled) {
|
||||
setInternalValue(newValue);
|
||||
}
|
||||
|
||||
onChange?.(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Controlled usage
|
||||
const [search, setSearch] = useState('');
|
||||
<Input value={search} onChange={setSearch} />
|
||||
|
||||
// Uncontrolled usage
|
||||
<Input defaultValue="initial" onChange={console.log} />
|
||||
```
|
||||
|
||||
## Slot Pattern
|
||||
|
||||
Allow consumers to replace internal parts.
|
||||
|
||||
```tsx
|
||||
interface CardProps {
|
||||
children: ReactNode;
|
||||
header?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
media?: ReactNode;
|
||||
}
|
||||
|
||||
function Card({ children, header, footer, media }: CardProps) {
|
||||
return (
|
||||
<article className="rounded-lg border bg-white shadow-sm">
|
||||
{media && (
|
||||
<div className="aspect-video overflow-hidden rounded-t-lg">
|
||||
{media}
|
||||
</div>
|
||||
)}
|
||||
{header && (
|
||||
<header className="border-b px-4 py-3">
|
||||
{header}
|
||||
</header>
|
||||
)}
|
||||
<div className="px-4 py-4">
|
||||
{children}
|
||||
</div>
|
||||
{footer && (
|
||||
<footer className="border-t px-4 py-3 bg-gray-50 rounded-b-lg">
|
||||
{footer}
|
||||
</footer>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage with slots
|
||||
<Card
|
||||
media={<img src="/image.jpg" alt="" />}
|
||||
header={<h2 className="font-semibold">Card Title</h2>}
|
||||
footer={<Button>Action</Button>}
|
||||
>
|
||||
<p>Card content goes here.</p>
|
||||
</Card>
|
||||
```
|
||||
|
||||
## Forward Ref Pattern
|
||||
|
||||
Allow parent components to access the underlying DOM node.
|
||||
|
||||
```tsx
|
||||
import { forwardRef, useRef, useImperativeHandle } from 'react';
|
||||
|
||||
interface InputHandle {
|
||||
focus: () => void;
|
||||
clear: () => void;
|
||||
getValue: () => string;
|
||||
}
|
||||
|
||||
interface FancyInputProps {
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const FancyInput = forwardRef<InputHandle, FancyInputProps>(
|
||||
({ label, placeholder }, ref) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => inputRef.current?.focus(),
|
||||
clear: () => {
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
},
|
||||
getValue: () => inputRef.current?.value ?? '',
|
||||
}));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{label}</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
FancyInput.displayName = 'FancyInput';
|
||||
|
||||
// Usage
|
||||
function Form() {
|
||||
const inputRef = useRef<InputHandle>(null);
|
||||
|
||||
const handleSubmit = () => {
|
||||
console.log(inputRef.current?.getValue());
|
||||
inputRef.current?.clear();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<FancyInput ref={inputRef} label="Name" />
|
||||
<button type="button" onClick={() => inputRef.current?.focus()}>
|
||||
Focus Input
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,585 @@
|
||||
# CSS Styling Approaches Reference
|
||||
|
||||
## Comparison Matrix
|
||||
|
||||
| Approach | Runtime | Bundle Size | Learning Curve | Dynamic Styles | SSR |
|
||||
|----------|---------|-------------|----------------|----------------|-----|
|
||||
| CSS Modules | None | Minimal | Low | Limited | Yes |
|
||||
| Tailwind | None | Small (purged) | Medium | Via classes | Yes |
|
||||
| styled-components | Yes | Medium | Medium | Full | Yes* |
|
||||
| Emotion | Yes | Medium | Medium | Full | Yes |
|
||||
| Vanilla Extract | None | Minimal | High | Limited | Yes |
|
||||
|
||||
## CSS Modules
|
||||
|
||||
Scoped CSS with zero runtime overhead.
|
||||
|
||||
### Setup
|
||||
|
||||
```tsx
|
||||
// Button.module.css
|
||||
.button {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background-color: #2563eb;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.primary:hover {
|
||||
background-color: #1d4ed8;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background-color: #f3f4f6;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.secondary:hover {
|
||||
background-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.small {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.large {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Button.tsx
|
||||
import styles from './Button.module.css';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface ButtonProps {
|
||||
variant?: 'primary' | 'secondary';
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
size = 'medium',
|
||||
children,
|
||||
onClick,
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
styles.button,
|
||||
styles[variant],
|
||||
size !== 'medium' && styles[size]
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Composition
|
||||
|
||||
```css
|
||||
/* base.module.css */
|
||||
.visuallyHidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Button.module.css */
|
||||
.srOnly {
|
||||
composes: visuallyHidden from './base.module.css';
|
||||
}
|
||||
```
|
||||
|
||||
## Tailwind CSS
|
||||
|
||||
Utility-first CSS with design system constraints.
|
||||
|
||||
### Class Variance Authority (CVA)
|
||||
|
||||
```tsx
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
// Base styles
|
||||
'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Tailwind Merge Utility
|
||||
|
||||
```tsx
|
||||
// lib/utils.ts
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
// Usage - handles conflicting classes
|
||||
cn('px-4 py-2', 'px-6'); // => 'py-2 px-6'
|
||||
cn('text-red-500', condition && 'text-blue-500'); // => 'text-blue-500' if condition
|
||||
```
|
||||
|
||||
### Custom Plugin
|
||||
|
||||
```js
|
||||
// tailwind.config.js
|
||||
const plugin = require('tailwindcss/plugin');
|
||||
|
||||
module.exports = {
|
||||
plugins: [
|
||||
plugin(function({ addUtilities, addComponents, theme }) {
|
||||
// Add utilities
|
||||
addUtilities({
|
||||
'.text-balance': {
|
||||
'text-wrap': 'balance',
|
||||
},
|
||||
'.scrollbar-hide': {
|
||||
'-ms-overflow-style': 'none',
|
||||
'scrollbar-width': 'none',
|
||||
'&::-webkit-scrollbar': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Add components
|
||||
addComponents({
|
||||
'.card': {
|
||||
backgroundColor: theme('colors.white'),
|
||||
borderRadius: theme('borderRadius.lg'),
|
||||
padding: theme('spacing.6'),
|
||||
boxShadow: theme('boxShadow.md'),
|
||||
},
|
||||
});
|
||||
}),
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
## styled-components
|
||||
|
||||
CSS-in-JS with template literals.
|
||||
|
||||
```tsx
|
||||
import styled, { css, keyframes } from 'styled-components';
|
||||
|
||||
// Keyframes
|
||||
const fadeIn = keyframes`
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
`;
|
||||
|
||||
// Base button with variants
|
||||
interface ButtonProps {
|
||||
$variant?: 'primary' | 'secondary' | 'ghost';
|
||||
$size?: 'sm' | 'md' | 'lg';
|
||||
$isLoading?: boolean;
|
||||
}
|
||||
|
||||
const sizeStyles = {
|
||||
sm: css`
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
`,
|
||||
md: css`
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 1rem;
|
||||
`,
|
||||
lg: css`
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1.125rem;
|
||||
`,
|
||||
};
|
||||
|
||||
const variantStyles = {
|
||||
primary: css`
|
||||
background-color: ${({ theme }) => theme.colors.primary};
|
||||
color: white;
|
||||
&:hover:not(:disabled) {
|
||||
background-color: ${({ theme }) => theme.colors.primaryHover};
|
||||
}
|
||||
`,
|
||||
secondary: css`
|
||||
background-color: ${({ theme }) => theme.colors.secondary};
|
||||
color: ${({ theme }) => theme.colors.text};
|
||||
&:hover:not(:disabled) {
|
||||
background-color: ${({ theme }) => theme.colors.secondaryHover};
|
||||
}
|
||||
`,
|
||||
ghost: css`
|
||||
background-color: transparent;
|
||||
color: ${({ theme }) => theme.colors.text};
|
||||
&:hover:not(:disabled) {
|
||||
background-color: ${({ theme }) => theme.colors.ghost};
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
const Button = styled.button<ButtonProps>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
animation: ${fadeIn} 0.3s ease;
|
||||
|
||||
${({ $size = 'md' }) => sizeStyles[$size]}
|
||||
${({ $variant = 'primary' }) => variantStyles[$variant]}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
${({ $isLoading }) =>
|
||||
$isLoading &&
|
||||
css`
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
`}
|
||||
`;
|
||||
|
||||
// Extending components
|
||||
const IconButton = styled(Button)`
|
||||
padding: 0.5rem;
|
||||
aspect-ratio: 1;
|
||||
`;
|
||||
|
||||
// Theme provider
|
||||
const theme = {
|
||||
colors: {
|
||||
primary: '#2563eb',
|
||||
primaryHover: '#1d4ed8',
|
||||
secondary: '#f3f4f6',
|
||||
secondaryHover: '#e5e7eb',
|
||||
ghost: 'rgba(0, 0, 0, 0.05)',
|
||||
text: '#1f2937',
|
||||
},
|
||||
};
|
||||
|
||||
// Usage
|
||||
<ThemeProvider theme={theme}>
|
||||
<Button $variant="primary" $size="lg">
|
||||
Click me
|
||||
</Button>
|
||||
</ThemeProvider>
|
||||
```
|
||||
|
||||
## Emotion
|
||||
|
||||
Flexible CSS-in-JS with object and template syntax.
|
||||
|
||||
```tsx
|
||||
/** @jsxImportSource @emotion/react */
|
||||
import { css, Theme, ThemeProvider, useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
// Theme typing
|
||||
declare module '@emotion/react' {
|
||||
export interface Theme {
|
||||
colors: {
|
||||
primary: string;
|
||||
background: string;
|
||||
text: string;
|
||||
};
|
||||
spacing: (factor: number) => string;
|
||||
}
|
||||
}
|
||||
|
||||
const theme: Theme = {
|
||||
colors: {
|
||||
primary: '#2563eb',
|
||||
background: '#ffffff',
|
||||
text: '#1f2937',
|
||||
},
|
||||
spacing: (factor: number) => `${factor * 0.25}rem`,
|
||||
};
|
||||
|
||||
// Object syntax
|
||||
const cardStyles = (theme: Theme) => css({
|
||||
backgroundColor: theme.colors.background,
|
||||
padding: theme.spacing(4),
|
||||
borderRadius: '0.5rem',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
});
|
||||
|
||||
// Template literal syntax
|
||||
const buttonStyles = css`
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
`;
|
||||
|
||||
// Styled component with theme
|
||||
const Card = styled.div`
|
||||
background-color: ${({ theme }) => theme.colors.background};
|
||||
padding: ${({ theme }) => theme.spacing(4)};
|
||||
border-radius: 0.5rem;
|
||||
`;
|
||||
|
||||
// Component with css prop
|
||||
function Alert({ children }: { children: React.ReactNode }) {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<div
|
||||
css={css`
|
||||
padding: ${theme.spacing(3)};
|
||||
background-color: ${theme.colors.primary}10;
|
||||
border-left: 4px solid ${theme.colors.primary};
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage
|
||||
<ThemeProvider theme={theme}>
|
||||
<Card>
|
||||
<Alert>Important message</Alert>
|
||||
</Card>
|
||||
</ThemeProvider>
|
||||
```
|
||||
|
||||
## Vanilla Extract
|
||||
|
||||
Zero-runtime CSS-in-JS with full type safety.
|
||||
|
||||
```tsx
|
||||
// styles.css.ts
|
||||
import { style, styleVariants, createTheme } from '@vanilla-extract/css';
|
||||
import { recipe, type RecipeVariants } from '@vanilla-extract/recipes';
|
||||
|
||||
// Theme contract
|
||||
export const [themeClass, vars] = createTheme({
|
||||
color: {
|
||||
primary: '#2563eb',
|
||||
secondary: '#64748b',
|
||||
background: '#ffffff',
|
||||
text: '#1f2937',
|
||||
},
|
||||
space: {
|
||||
small: '0.5rem',
|
||||
medium: '1rem',
|
||||
large: '1.5rem',
|
||||
},
|
||||
radius: {
|
||||
small: '0.25rem',
|
||||
medium: '0.375rem',
|
||||
large: '0.5rem',
|
||||
},
|
||||
});
|
||||
|
||||
// Simple style
|
||||
export const container = style({
|
||||
padding: vars.space.medium,
|
||||
backgroundColor: vars.color.background,
|
||||
});
|
||||
|
||||
// Style variants
|
||||
export const text = styleVariants({
|
||||
primary: { color: vars.color.text },
|
||||
secondary: { color: vars.color.secondary },
|
||||
accent: { color: vars.color.primary },
|
||||
});
|
||||
|
||||
// Recipe (like CVA)
|
||||
export const button = recipe({
|
||||
base: {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 500,
|
||||
borderRadius: vars.radius.medium,
|
||||
transition: 'background-color 0.2s',
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
':disabled': {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
variant: {
|
||||
primary: {
|
||||
backgroundColor: vars.color.primary,
|
||||
color: 'white',
|
||||
':hover': {
|
||||
backgroundColor: '#1d4ed8',
|
||||
},
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: '#f3f4f6',
|
||||
color: vars.color.text,
|
||||
':hover': {
|
||||
backgroundColor: '#e5e7eb',
|
||||
},
|
||||
},
|
||||
},
|
||||
size: {
|
||||
small: {
|
||||
padding: '0.25rem 0.75rem',
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
medium: {
|
||||
padding: '0.5rem 1rem',
|
||||
fontSize: '1rem',
|
||||
},
|
||||
large: {
|
||||
padding: '0.75rem 1.5rem',
|
||||
fontSize: '1.125rem',
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'primary',
|
||||
size: 'medium',
|
||||
},
|
||||
});
|
||||
|
||||
export type ButtonVariants = RecipeVariants<typeof button>;
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Button.tsx
|
||||
import { button, type ButtonVariants, themeClass } from './styles.css';
|
||||
|
||||
interface ButtonProps extends ButtonVariants {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function Button({ variant, size, children, onClick }: ButtonProps) {
|
||||
return (
|
||||
<button className={button({ variant, size })} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// App.tsx - wrap with theme
|
||||
function App() {
|
||||
return (
|
||||
<div className={themeClass}>
|
||||
<Button variant="primary" size="large">
|
||||
Click me
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Critical CSS Extraction
|
||||
|
||||
```tsx
|
||||
// Next.js with styled-components
|
||||
// pages/_document.tsx
|
||||
import Document, { DocumentContext } from 'next/document';
|
||||
import { ServerStyleSheet } from 'styled-components';
|
||||
|
||||
export default class MyDocument extends Document {
|
||||
static async getInitialProps(ctx: DocumentContext) {
|
||||
const sheet = new ServerStyleSheet();
|
||||
const originalRenderPage = ctx.renderPage;
|
||||
|
||||
try {
|
||||
ctx.renderPage = () =>
|
||||
originalRenderPage({
|
||||
enhanceApp: (App) => (props) =>
|
||||
sheet.collectStyles(<App {...props} />),
|
||||
});
|
||||
|
||||
const initialProps = await Document.getInitialProps(ctx);
|
||||
return {
|
||||
...initialProps,
|
||||
styles: [initialProps.styles, sheet.getStyleElement()],
|
||||
};
|
||||
} finally {
|
||||
sheet.seal();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Code Splitting Styles
|
||||
|
||||
```tsx
|
||||
// Dynamically import heavy styled components
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const HeavyChart = dynamic(() => import('./HeavyChart'), {
|
||||
loading: () => <Skeleton height={400} />,
|
||||
ssr: false,
|
||||
});
|
||||
```
|
||||
Reference in New Issue
Block a user