ui-component-patterns
npx skills add https://github.com/supercent-io/skills-template --skill ui-component-patterns
Agent 安装分布
Skill 文档
UI Component Patterns
When to use this skill
- ì»´í¬ëí¸ ë¼ì´ë¸ë¬ë¦¬ 구ì¶: ì¬ì¬ì© ê°ë¥í UI ì»´í¬ëí¸ ì ì
- ëìì¸ ìì¤í 구í: ì¼ê´ë UI í¨í´ ì ì©
- ë³µì¡í UI: ì¬ë¬ ë³íì´ íìí ì»´í¬ëí¸ (Button, Modal, Dropdown)
- 리í©í ë§: ì¤ë³µ ì½ë를 ì»´í¬ëí¸ë¡ ì¶ì¶
Instructions
Step 1: Props API ì¤ê³
ì¬ì©í기 ì½ê³ íì¥ ê°ë¥í Props를 ì¤ê³í©ëë¤.
ìì¹:
- ëª íí ì´ë¦
- í©ë¦¬ì ì¸ ê¸°ë³¸ê°
- TypeScriptë¡ íì ì ì
- ì íì Propsë optional (?)
ìì (Button):
interface ButtonProps {
// íì
children: React.ReactNode;
// ì íì (ê¸°ë³¸ê° ìì)
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
isLoading?: boolean;
// ì´ë²¤í¸ í¸ë¤ë¬
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
// HTML ìì± ìì
type?: 'button' | 'submit' | 'reset';
className?: string;
}
function Button({
children,
variant = 'primary',
size = 'md',
disabled = false,
isLoading = false,
onClick,
type = 'button',
className = '',
...rest
}: ButtonProps) {
const baseClasses = 'btn';
const variantClasses = `btn-${variant}`;
const sizeClasses = `btn-${size}`;
const classes = `${baseClasses} ${variantClasses} ${sizeClasses} ${className}`;
return (
<button
type={type}
className={classes}
disabled={disabled || isLoading}
onClick={onClick}
{...rest}
>
{isLoading ? <Spinner /> : children}
</button>
);
}
// ì¬ì© ìì
<Button variant="primary" size="lg" onClick={() => alert('Clicked!')}>
Click Me
</Button>
Step 2: Composition Pattern (í©ì± í¨í´)
ìì ì»´í¬ëí¸ë¥¼ ì¡°í©íì¬ ë³µì¡í UI를 ë§ëëë¤.
ìì (Card):
// Card ì»´í¬ëí¸ (Container)
interface CardProps {
children: React.ReactNode;
className?: string;
}
function Card({ children, className = '' }: CardProps) {
return <div className={`card ${className}`}>{children}</div>;
}
// Card.Header
function CardHeader({ children }: { children: React.ReactNode }) {
return <div className="card-header">{children}</div>;
}
// Card.Body
function CardBody({ children }: { children: React.ReactNode }) {
return <div className="card-body">{children}</div>;
}
// Card.Footer
function CardFooter({ children }: { children: React.ReactNode }) {
return <div className="card-footer">{children}</div>;
}
// Compound Component í¨í´
Card.Header = CardHeader;
Card.Body = CardBody;
Card.Footer = CardFooter;
export default Card;
// ì¬ì©
import Card from './Card';
function ProductCard() {
return (
<Card>
<Card.Header>
<h3>Product Name</h3>
</Card.Header>
<Card.Body>
<img src="..." alt="Product" />
<p>Product description here...</p>
</Card.Body>
<Card.Footer>
<button>Add to Cart</button>
</Card.Footer>
</Card>
);
}
Step 3: Render Props / Children as Function
ì ì°í 커ì¤í°ë§ì´ì§ì ìí í¨í´ì ëë¤.
ìì (Dropdown):
interface DropdownProps<T> {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
onSelect: (item: T) => void;
placeholder?: string;
}
function Dropdown<T>({ items, renderItem, onSelect, placeholder }: DropdownProps<T>) {
const [isOpen, setIsOpen] = useState(false);
const [selected, setSelected] = useState<T | null>(null);
const handleSelect = (item: T) => {
setSelected(item);
onSelect(item);
setIsOpen(false);
};
return (
<div className="dropdown">
<button onClick={() => setIsOpen(!isOpen)}>
{selected ? renderItem(selected, -1) : placeholder || 'Select...'}
</button>
{isOpen && (
<ul className="dropdown-menu">
{items.map((item, index) => (
<li key={index} onClick={() => handleSelect(item)}>
{renderItem(item, index)}
</li>
))}
</ul>
)}
</div>
);
}
// ì¬ì©
interface User {
id: string;
name: string;
avatar: string;
}
function UserDropdown() {
const users: User[] = [...];
return (
<Dropdown
items={users}
placeholder="Select a user"
renderItem={(user) => (
<div className="user-item">
<img src={user.avatar} alt={user.name} />
<span>{user.name}</span>
</div>
)}
onSelect={(user) => console.log('Selected:', user)}
/>
);
}
Step 4: Custom Hooksë¡ ë¡ì§ ë¶ë¦¬
UIì ë¹ì¦ëì¤ ë¡ì§ì ë¶ë¦¬í©ëë¤.
ìì (Modal):
// hooks/useModal.ts
function useModal(initialOpen = false) {
const [isOpen, setIsOpen] = useState(initialOpen);
const open = useCallback(() => setIsOpen(true), []);
const close = useCallback(() => setIsOpen(false), []);
const toggle = useCallback(() => setIsOpen(prev => !prev), []);
return { isOpen, open, close, toggle };
}
// components/Modal.tsx
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
function Modal({ isOpen, onClose, title, children }: ModalProps) {
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>{title}</h2>
<button onClick={onClose} aria-label="Close">Ã</button>
</div>
<div className="modal-body">{children}</div>
</div>
</div>
);
}
// ì¬ì©
function App() {
const { isOpen, open, close } = useModal();
return (
<>
<button onClick={open}>Open Modal</button>
<Modal isOpen={isOpen} onClose={close} title="My Modal">
<p>Modal content here...</p>
</Modal>
</>
);
}
Step 5: ì±ë¥ ìµì í
ë¶íìí 리ë ëë§ì ë°©ì§í©ëë¤.
React.memo:
// â ëì ì: ë¶ëª¨ê° 리ë ëë§ë ëë§ë¤ ììë 리ë ëë§
function ExpensiveComponent({ data }) {
console.log('Rendering...');
return <div>{/* ë³µì¡í UI */}</div>;
}
// â
ì¢ì ì: propsê° ë³ê²½ë ëë§ ë¦¬ë ëë§
const ExpensiveComponent = React.memo(({ data }) => {
console.log('Rendering...');
return <div>{/* ë³µì¡í UI */}</div>;
});
useMemo & useCallback:
function ProductList({ products, category }: { products: Product[]; category: string }) {
// â
íí°ë§ ê²°ê³¼ ë©ëª¨ì´ì ì´ì
const filteredProducts = useMemo(() => {
return products.filter(p => p.category === category);
}, [products, category]);
// â
ì½ë°± ë©ëª¨ì´ì ì´ì
const handleAddToCart = useCallback((productId: string) => {
// ì¥ë°êµ¬ëì ì¶ê°
console.log('Adding:', productId);
}, []);
return (
<div>
{filteredProducts.map(product => (
<ProductCard
key={product.id}
product={product}
onAddToCart={handleAddToCart}
/>
))}
</div>
);
}
const ProductCard = React.memo(({ product, onAddToCart }) => {
return (
<div>
<h3>{product.name}</h3>
<button onClick={() => onAddToCart(product.id)}>Add to Cart</button>
</div>
);
});
Output format
ì»´í¬ëí¸ íì¼ êµ¬ì¡°
components/
âââ Button/
â âââ Button.tsx # ë©ì¸ ì»´í¬ëí¸
â âââ Button.test.tsx # í
ì¤í¸
â âââ Button.stories.tsx # Storybook
â âââ Button.module.css # ì¤íì¼
â âââ index.ts # Export
âââ Card/
â âââ Card.tsx
â âââ CardHeader.tsx
â âââ CardBody.tsx
â âââ CardFooter.tsx
â âââ index.ts
âââ Modal/
âââ Modal.tsx
âââ useModal.ts # Custom hook
âââ index.ts
ì»´í¬ëí¸ í í릿
import React from 'react';
export interface ComponentProps {
// Props ì ì
children: React.ReactNode;
className?: string;
}
/**
* Component description
*
* @example
* ```tsx
* <Component>Hello</Component>
* ```
*/
export const Component = React.forwardRef<HTMLDivElement, ComponentProps>(
({ children, className = '', ...rest }, ref) => {
return (
<div ref={ref} className={`component ${className}`} {...rest}>
{children}
</div>
);
}
);
Component.displayName = 'Component';
export default Component;
Constraints
íì ê·ì¹ (MUST)
-
ë¨ì¼ ì± ì ìì¹: í ì»´í¬ëí¸ë íëì ìí ë§
- Buttonì ë²í¼ë§, Formì í¼ë§
-
Props íì ì ì: TypeScript interface íì
- ìëìì± ì§ì
- íì ìì ì±
-
ì ê·¼ì± ê³ ë ¤: aria-*, role, tabindex ë±
ê¸ì§ ì¬í (MUST NOT)
-
ê³¼ëí props drilling: 5ë¨ê³ ì´ì ê¸ì§
- Context ëë Composition ì¬ì©
-
ë¹ì¦ëì¤ ë¡ì§ í¬í¨: UI ì»´í¬ëí¸ì API í¸ì¶, ë³µì¡í ê³ì° ê¸ì§
- Custom hooksë¡ ë¶ë¦¬
-
inline ê°ì²´/í¨ì: ì±ë¥ ì í
// â ëì ì <Component style={{ color: 'red' }} onClick={() => handleClick()} /> // â ì¢ì ì const style = { color: 'red' }; const handleClick = useCallback(() => {...}, []); <Component style={style} onClick={handleClick} />
Examples
ìì 1: Accordion (Compound Component)
import React, { createContext, useContext, useState } from 'react';
// Contextë¡ ìí ê³µì
const AccordionContext = createContext<{
activeIndex: number | null;
setActiveIndex: (index: number | null) => void;
} | null>(null);
function Accordion({ children }: { children: React.ReactNode }) {
const [activeIndex, setActiveIndex] = useState<number | null>(null);
return (
<AccordionContext.Provider value={{ activeIndex, setActiveIndex }}>
<div className="accordion">{children}</div>
</AccordionContext.Provider>
);
}
function AccordionItem({ index, title, children }: {
index: number;
title: string;
children: React.ReactNode;
}) {
const context = useContext(AccordionContext);
if (!context) throw new Error('AccordionItem must be used within Accordion');
const { activeIndex, setActiveIndex } = context;
const isActive = activeIndex === index;
return (
<div className="accordion-item">
<button
className="accordion-header"
onClick={() => setActiveIndex(isActive ? null : index)}
aria-expanded={isActive}
>
{title}
</button>
{isActive && <div className="accordion-body">{children}</div>}
</div>
);
}
Accordion.Item = AccordionItem;
export default Accordion;
// ì¬ì©
<Accordion>
<Accordion.Item index={0} title="Section 1">
Content for section 1
</Accordion.Item>
<Accordion.Item index={1} title="Section 2">
Content for section 2
</Accordion.Item>
</Accordion>
ìì 2: Polymorphic Component (as prop)
type PolymorphicComponentProps<C extends React.ElementType> = {
as?: C;
children: React.ReactNode;
} & React.ComponentPropsWithoutRef<C>;
function Text<C extends React.ElementType = 'span'>({
as,
children,
...rest
}: PolymorphicComponentProps<C>) {
const Component = as || 'span';
return <Component {...rest}>{children}</Component>;
}
// ì¬ì©
<Text>Default span</Text>
<Text as="h1">Heading 1</Text>
<Text as="p" style={{ color: 'blue' }}>Paragraph</Text>
<Text as={Link} href="/about">Link</Text>
Best practices
- Composition over Props: ë§ì props ëì children íì©
- Controlled vs Uncontrolled: ìí©ì ë§ê² ì í
- Default Props: í©ë¦¬ì ì¸ ê¸°ë³¸ê° ì ê³µ
- Storybook: ì»´í¬ëí¸ ë¬¸ìí ë° ê°ë°
References
- React Patterns
- Compound Components
- Radix UI – Accessible components
- Chakra UI – Component library
- shadcn/ui – Copy-paste components
Metadata
ë²ì
- íì¬ ë²ì : 1.0.0
- ìµì¢ ì ë°ì´í¸: 2025-01-01
- í¸í íë«í¼: Claude, ChatGPT, Gemini
ê´ë ¨ ì¤í¬
- web-accessibility: ì ê·¼ ê°ë¥í ì»´í¬ëí¸
- state-management: ì»´í¬ëí¸ ìí ê´ë¦¬
íê·¸
#UI-components #React #design-patterns #composition #TypeScript #frontend