AgentSkillsCN

modular-design

指导开发者以恰当的架构设计,打造模块化且可复用的UI组件。适用于新建UI组件、梳理组件库、践行设计系统模式,或构建可复用的UI基础组件等场景。此技能尤其适用于涉及组件架构、原子设计、复合组件,以及样式抽象等任务。

SKILL.md
--- frontmatter
name: modular-design
description: Guide for creating modular, reusable UI components with proper architecture. Use when creating new UI components, organizing a component library, implementing design system patterns, or building reusable UI building blocks. Applies to tasks involving component architecture, atomic design, compound components, or styling abstractions.

Modular Design Patterns

This skill provides guidance for building beautiful, maintainable UI components using atomic design principles and Tamagui's composition patterns. It covers component architecture, file organization, and reusable patterns.

Core Philosophy

  1. Composition over inheritance - Build complex UIs from simple, composable pieces
  2. Single responsibility - Each component does one thing well
  3. Props down, events up - Data flows predictably
  4. Style at the edges - Keep logic and styling separate
  5. Progressive disclosure - Simple defaults, advanced options available

Atomic Design Structure

Organize components into layers based on complexity:

code
components/
├── atoms/           # Basic building blocks (Button, Input, Text)
├── molecules/       # Simple combinations (FormField, SearchBar)
├── organisms/       # Complex sections (Header, Card, Form)
├── templates/       # Page layouts (MainLayout, AuthLayout)
└── index.ts         # Public exports

Atoms

Smallest building blocks that can't be broken down further:

typescript
// components/atoms/Label.tsx
import { Text, styled } from 'tamagui'

export const Label = styled(Text, {
  name: 'Label',
  fontSize: '$2',
  fontWeight: '500',
  color: '$color',

  variants: {
    required: {
      true: {
        // Add asterisk via ::after on web, or handle in component
      },
    },
    error: {
      true: {
        color: '$red10',
      },
    },
  } as const,
})

Molecules

Combinations of atoms that form functional units:

typescript
// components/molecules/FormField.tsx
import { YStack } from 'tamagui'
import { Label } from '../atoms/Label'
import { Input } from '../atoms/Input'
import { ErrorMessage } from '../atoms/ErrorMessage'

interface FormFieldProps {
  label: string
  error?: string
  required?: boolean
  children: React.ReactNode
}

export function FormField({ label, error, required, children }: FormFieldProps) {
  return (
    <YStack gap="$2">
      <Label required={required}>{label}</Label>
      {children}
      {error && <ErrorMessage>{error}</ErrorMessage>}
    </YStack>
  )
}

Organisms

Complete, self-contained UI sections:

typescript
// components/organisms/LoginForm.tsx
import { YStack, Button } from 'tamagui'
import { FormField } from '../molecules/FormField'
import { Input } from '../atoms/Input'

export function LoginForm({ onSubmit, isLoading }) {
  return (
    <YStack gap="$4" padding="$4">
      <FormField label="Email" required>
        <Input placeholder="email@example.com" />
      </FormField>
      <FormField label="Password" required>
        <Input secureTextEntry placeholder="Password" />
      </FormField>
      <Button onPress={onSubmit} disabled={isLoading}>
        {isLoading ? 'Signing in...' : 'Sign In'}
      </Button>
    </YStack>
  )
}

Compound Components

Build components with sub-components that work together via context:

typescript
// components/organisms/Card/index.tsx
import { createStyledContext, styled, withStaticProperties, YStack, Text } from 'tamagui'
import type { SizeTokens } from 'tamagui'

// 1. Create shared context
const CardContext = createStyledContext({
  size: '$4' as SizeTokens,
})

// 2. Create styled frame
const CardFrame = styled(YStack, {
  name: 'Card',
  context: CardContext,
  backgroundColor: '$background',
  borderRadius: '$4',
  borderWidth: 1,
  borderColor: '$borderColor',
  overflow: 'hidden',

  variants: {
    size: {
      '...size': (size, { tokens }) => ({
        padding: tokens.space[size],
      }),
    },
    elevated: {
      true: {
        shadowColor: '$shadowColor',
        shadowOffset: { width: 0, height: 2 },
        shadowOpacity: 0.1,
        shadowRadius: 8,
        elevation: 3,
      },
    },
  } as const,

  defaultVariants: {
    size: '$4',
  },
})

// 3. Create sub-components
const CardHeader = styled(YStack, {
  name: 'CardHeader',
  context: CardContext,
  paddingBottom: '$2',
  borderBottomWidth: 1,
  borderBottomColor: '$borderColor',
})

const CardTitle = styled(Text, {
  name: 'CardTitle',
  context: CardContext,
  fontSize: '$5',
  fontWeight: '600',
  color: '$color',
})

const CardContent = styled(YStack, {
  name: 'CardContent',
  context: CardContext,
  paddingTop: '$2',
})

const CardFooter = styled(YStack, {
  name: 'CardFooter',
  context: CardContext,
  paddingTop: '$4',
  borderTopWidth: 1,
  borderTopColor: '$borderColor',
  flexDirection: 'row',
  justifyContent: 'flex-end',
  gap: '$2',
})

// 4. Export with static properties
export const Card = withStaticProperties(CardFrame, {
  Props: CardContext.Provider,
  Header: CardHeader,
  Title: CardTitle,
  Content: CardContent,
  Footer: CardFooter,
})

// Usage:
// <Card size="$4" elevated>
//   <Card.Header>
//     <Card.Title>Title</Card.Title>
//   </Card.Header>
//   <Card.Content>Content here</Card.Content>
//   <Card.Footer>
//     <Button>Action</Button>
//   </Card.Footer>
// </Card>

The asChild Pattern

Pass styles and behavior to a child component:

typescript
import { Button } from 'tamagui'
import { Link } from 'expo-router'

// Button styles apply to Link
<Button asChild>
  <Link href="/settings">Settings</Link>
</Button>

// Common uses:
// - Wrapping navigation links with styled buttons
// - Applying trigger styles to custom elements
// - Dialog/Popover triggers

How it works:

  • asChild clones the child element
  • Merges parent's styles and props onto child
  • Child must accept onPress/onClick and style props
typescript
// Custom component supporting asChild
import { Pressable } from 'react-native'
import { styled, GetProps } from 'tamagui'

const StyledPressable = styled(Pressable, {
  // styles...
})

type Props = GetProps<typeof StyledPressable> & {
  asChild?: boolean
  children: React.ReactNode
}

export function CustomButton({ asChild, children, ...props }: Props) {
  if (asChild && React.isValidElement(children)) {
    return React.cloneElement(children, props)
  }
  return <StyledPressable {...props}>{children}</StyledPressable>
}

Size Scaling System

Tamagui's size system scales multiple properties from a single prop:

typescript
const ScaledButton = styled(View, {
  name: 'ScaledButton',

  variants: {
    size: {
      '...size': (size, { tokens }) => {
        const sizeValue = tokens.size[size] ?? size
        return {
          height: sizeValue,
          paddingHorizontal: tokens.space[size],
          borderRadius: tokens.radius[size] ?? tokens.radius.$4,
        }
      },
    },
  } as const,

  defaultVariants: {
    size: '$4',
  },
})

// size="$2" → small height, small padding, small radius
// size="$6" → large height, large padding, large radius

Size token convention:

TokenTypical Use
$2Compact/dense UI
$3Small elements
$4Default/medium (use as default)
$5Large elements
$6Extra large/hero

Variant Patterns

Boolean Variants

typescript
variants: {
  disabled: {
    true: {
      opacity: 0.5,
      pointerEvents: 'none',
    },
  },
  active: {
    true: {
      backgroundColor: '$primary',
      color: '$background',
    },
  },
} as const

Enum Variants

typescript
variants: {
  variant: {
    solid: {
      backgroundColor: '$primary',
      color: '$background',
    },
    outline: {
      backgroundColor: 'transparent',
      borderWidth: 1,
      borderColor: '$primary',
      color: '$primary',
    },
    ghost: {
      backgroundColor: 'transparent',
      color: '$primary',
    },
  },
} as const

Functional Variants

typescript
variants: {
  // Spread tokens with custom logic
  size: {
    '...size': (size, { tokens }) => ({
      padding: tokens.space[size],
      fontSize: tokens.fontSize[size],
    }),
  },
  // Direct value mapping
  width: (val) => ({ width: val }),
  // Conditional logic
  truncate: (lines: number) => ({
    numberOfLines: lines,
    overflow: 'hidden',
  }),
} as const

Component File Structure

Single Component

code
components/atoms/Button/
├── index.tsx        # Main component + exports
├── Button.tsx       # Component implementation (if complex)
├── Button.test.tsx  # Tests
└── types.ts         # TypeScript types (if many)

Compound Component

code
components/organisms/Dialog/
├── index.tsx           # Main export with withStaticProperties
├── DialogContext.tsx   # Shared context
├── DialogFrame.tsx     # Main container
├── DialogTitle.tsx     # Title sub-component
├── DialogContent.tsx   # Content sub-component
├── DialogActions.tsx   # Actions sub-component
└── types.ts            # Shared types

Export Patterns

Barrel Exports

typescript
// components/atoms/index.ts
export { Button } from './Button'
export { Input } from './Input'
export { Label } from './Label'
export type { ButtonProps, InputProps, LabelProps } from './types'

// components/index.ts
export * from './atoms'
export * from './molecules'
export * from './organisms'

Named vs Default Exports

typescript
// Prefer named exports for components
export function Button() {}  // Good
export default Button        // Avoid

// Exception: page components in Expo Router
export default function HomeScreen() {}  // Required by router

Styling Best Practices

Use Tokens Consistently

typescript
// Wrong - hardcoded values
<View padding={16} backgroundColor="#fff" />

// Correct - token references
<View padding="$4" backgroundColor="$background" />

Responsive Design

typescript
<YStack
  padding="$4"
  $sm={{ padding: '$2' }}
  flexDirection="column"
  $md={{ flexDirection: 'row' }}
/>

Pseudo-States

typescript
<Button
  backgroundColor="$primary"
  hoverStyle={{ backgroundColor: '$primaryHover' }}
  pressStyle={{ backgroundColor: '$primaryPress', scale: 0.98 }}
  focusVisibleStyle={{ outlineWidth: 2, outlineColor: '$primaryFocus' }}
/>

Accessibility Patterns

Required Elements

typescript
// Dialog must have title and description
<Dialog>
  <Dialog.Title>Confirm Action</Dialog.Title>
  <Dialog.Description>Are you sure?</Dialog.Description>
</Dialog>

// Form inputs need labels
<Label htmlFor="email">Email</Label>
<Input id="email" />

// Icon-only buttons need accessible label
<Button icon={<MenuIcon />} aria-label="Open menu" />

Focus Management

typescript
// Use FocusScope for dialogs (web)
import { FocusScope } from 'tamagui'

<FocusScope trapped restoreFocus>
  <Dialog.Content>...</Dialog.Content>
</FocusScope>

References

For detailed patterns and checklists, see: