Atomic Design: Molecules
Master the creation of molecule components - functional groups of atoms that work together as a unit. Molecules combine multiple atoms to create more complex, purposeful UI elements.
What Are Molecules?
Molecules are the first level of composition in Atomic Design. They are:
Composed of atoms only: Never include other molecules Single purpose: Do one thing well Functional units: Atoms working together for a specific task Reusable: Used across different organisms and contexts Minimally stateful: May have limited internal state for UI concerns Common Molecule Types Form Molecules Form fields (label + input + error) Search forms (input + button) Toggle groups (label + toggle) Date pickers (input + calendar trigger) File uploaders (dropzone + button) Navigation Molecules Nav items (icon + text + indicator) Breadcrumb items (link + separator) Pagination controls (buttons + page indicator) Tab items (icon + label) Display Molecules Media objects (avatar + text) Card headers (title + subtitle + action) List items (checkbox + content + actions) Stat displays (label + value + trend) Action Molecules Button groups (multiple buttons) Dropdown triggers (button + icon) Icon buttons (icon + tooltip) Action menus (button + menu items) FormField Molecule Example Complete Implementation // molecules/FormField/FormField.tsx import React from 'react'; import { Label } from '@/components/atoms/Label'; import { Input, type InputProps } from '@/components/atoms/Input'; import { Text } from '@/components/atoms/Typography'; import styles from './FormField.module.css';
export interface FormFieldProps extends InputProps { / Field label */ label: string; / Unique field identifier / name: string; / Help text below input / helpText?: string; / Error message */ error?: string; / Required field indicator */ required?: boolean; }
export const FormField = React.forwardReffield-${name};
const helpTextId = helpText ? ${fieldId}-help : undefined;
const errorId = error ? ${fieldId}-error : undefined;
const describedBy = [helpTextId, errorId].filter(Boolean).join(' ') || undefined;
return (
<div className={`${styles.field} ${className || ''}`}>
<Label htmlFor={fieldId} required={required} disabled={inputProps.disabled}>
{label}
</Label>
<Input
ref={ref}
id={fieldId}
name={name}
hasError={!!error}
aria-describedby={describedBy}
aria-required={required}
{...inputProps}
/>
{helpText && !error && (
<Text id={helpTextId} size="sm" color="muted" className={styles.helpText}>
{helpText}
</Text>
)}
{error && (
<Text id={errorId} size="sm" color="danger" className={styles.error} role="alert">
{error}
</Text>
)}
</div>
);
} );
FormField.displayName = 'FormField';
/ molecules/FormField/FormField.module.css / .field { display: flex; flex-direction: column; gap: 6px; }
.helpText { margin-top: 2px; }
.error { margin-top: 2px; display: flex; align-items: center; gap: 4px; }
SearchForm Molecule Example // molecules/SearchForm/SearchForm.tsx import React, { useState, useCallback } from 'react'; import { Input } from '@/components/atoms/Input'; import { Button } from '@/components/atoms/Button'; import { Icon } from '@/components/atoms/Icon'; import styles from './SearchForm.module.css';
export interface SearchFormProps { / Placeholder text */ placeholder?: string; / Initial search value / defaultValue?: string; / Submit handler / onSubmit: (query: string) => void; / Change handler for live search */ onChange?: (query: string) => void; / Loading state / isLoading?: boolean; / Size variant / size?: 'sm' | 'md' | 'lg'; /* Show clear button / clearable?: boolean; }
export const SearchForm: React.FC
const handleChange = useCallback(
(e: React.ChangeEvent
const handleSubmit = useCallback( (e: React.FormEvent) => { e.preventDefault(); onSubmit(query.trim()); }, [onSubmit, query] );
const handleClear = useCallback(() => { setQuery(''); onChange?.(''); }, [onChange]);
return (
); };SearchForm.displayName = 'SearchForm';
/ molecules/SearchForm/SearchForm.module.css / .form { display: flex; gap: 8px; align-items: stretch; }
.clearButton { display: flex; align-items: center; justify-content: center; background: transparent; border: none; cursor: pointer; padding: 4px; color: var(--color-neutral-500); transition: color 150ms; }
.clearButton:hover { color: var(--color-neutral-700); }
MediaObject Molecule Example // molecules/MediaObject/MediaObject.tsx import React from 'react'; import { Avatar, type AvatarProps } from '@/components/atoms/Avatar'; import { Text, Heading } from '@/components/atoms/Typography'; import styles from './MediaObject.module.css';
export interface MediaObjectProps { / Avatar image source */ avatarSrc?: string; / Avatar alt text / avatarAlt: string; / Avatar initials fallback / avatarInitials?: string; / Avatar size */ avatarSize?: AvatarProps['size']; / Primary text/title / title: React.ReactNode; / Secondary text/subtitle / subtitle?: React.ReactNode; / Additional metadata */ meta?: React.ReactNode; / Right-aligned action element / action?: React.ReactNode; / Alignment of content / align?: 'top' | 'center' | 'bottom'; /* Additional class name / className?: string; }
export const MediaObject: React.FCalign-${align}], className]
.filter(Boolean)
.join(' ');
return (
<div className={styles.content}>
<div className={styles.title}>{title}</div>
{subtitle && (
<Text size="sm" color="muted" className={styles.subtitle}>
{subtitle}
</Text>
)}
{meta && (
<Text size="xs" color="muted" className={styles.meta}>
{meta}
</Text>
)}
</div>
{action && <div className={styles.action}>{action}</div>}
</div>
); };
MediaObject.displayName = 'MediaObject';
NavItem Molecule Example // molecules/NavItem/NavItem.tsx import React from 'react'; import { Icon } from '@/components/atoms/Icon'; import { Badge } from '@/components/atoms/Badge'; import styles from './NavItem.module.css';
export interface NavItemProps { / Navigation icon */ icon?: string; / Item label / label: string; / Link destination / href: string; / Active state */ isActive?: boolean; / Badge count / badge?: number; / Disabled state / disabled?: boolean; /* Click handler / onClick?: (e: React.MouseEvent) => void; }
export const NavItem: React.FC
const handleClick = (e: React.MouseEvent) => { if (disabled) { e.preventDefault(); return; } onClick?.(e); };
return (
{icon &&
NavItem.displayName = 'NavItem';
CardHeader Molecule Example // molecules/CardHeader/CardHeader.tsx import React from 'react'; import { Heading, Text } from '@/components/atoms/Typography'; import { Icon } from '@/components/atoms/Icon'; import { Button } from '@/components/atoms/Button'; import styles from './CardHeader.module.css';
export interface CardHeaderProps { / Card title */ title: string; / Optional subtitle / subtitle?: string; / Title icon / icon?: string; / Action button label */ actionLabel?: string; / Action button click handler / onAction?: () => void; / Additional class name / className?: string; }
export const CardHeader: React.FC );
}; CardHeader.displayName = 'CardHeader'; ListItem Molecule Example
// molecules/ListItem/ListItem.tsx
import React from 'react';
import { Checkbox } from '@/components/atoms/Checkbox';
import { Text } from '@/components/atoms/Typography';
import { Icon } from '@/components/atoms/Icon';
import styles from './ListItem.module.css'; export interface ListItemProps {
/ Item ID for selection */
id: string;
/ Primary content /
primary: React.ReactNode;
/ Secondary content /
secondary?: React.ReactNode;
/ Left icon */
icon?: string;
/ Whether item is selectable /
selectable?: boolean;
/ Selection state /
selected?: boolean;
/ Selection change handler */
onSelect?: (id: string, selected: boolean) => void;
/ Right-aligned action buttons /
actions?: React.ReactNode;
/ Click handler /
onClick?: () => void;
} export const ListItem: React.FC const handleCheckboxChange = (e: React.ChangeEvent return (
);
}; ListItem.displayName = 'ListItem'; ButtonGroup Molecule Example
// molecules/ButtonGroup/ButtonGroup.tsx
import React from 'react';
import { Button, type ButtonProps } from '@/components/atoms/Button';
import styles from './ButtonGroup.module.css'; export interface ButtonGroupItem {
id: string;
label: string;
icon?: string;
disabled?: boolean;
} export interface ButtonGroupProps {
/ Button items */
items: ButtonGroupItem[];
/ Selected item ID(s) /
value?: string | string[];
/ Selection change handler /
onChange?: (value: string | string[]) => void;
/ Allow multiple selection */
multiple?: boolean;
/ Size variant /
size?: ButtonProps['size'];
/ Disabled state /
disabled?: boolean;
} export const ButtonGroup: React.FC const handleClick = (itemId: string) => {
if (!onChange) return; }; return (
);
}; ButtonGroup.displayName = 'ButtonGroup'; Stat Molecule Example
// molecules/Stat/Stat.tsx
import React from 'react';
import { Text, Heading } from '@/components/atoms/Typography';
import { Icon } from '@/components/atoms/Icon';
import styles from './Stat.module.css'; export type TrendDirection = 'up' | 'down' | 'neutral'; export interface StatProps {
/ Stat label */
label: string;
/ Stat value /
value: string | number;
/ Previous value for comparison /
previousValue?: string | number;
/ Trend direction */
trend?: TrendDirection;
/ Trend percentage /
trendValue?: string;
/ Stat icon /
icon?: string;
/* Help text /
helpText?: string;
} export const Stat: React.FC const getTrendIcon = (direction?: TrendDirection) => {
switch (direction) {
case 'up':
return 'trending-up';
case 'down':
return 'trending-down';
default:
return 'minus';
}
}; return (
);
}; Stat.displayName = 'Stat'; Best Practices
1. Keep Molecules Focused
// GOOD: Single, clear purpose
const SearchForm = () => (
); // BAD: Doing too much
const SearchWithFiltersAndResults = () => (
); // BAD: Importing from other molecules
import { FormField } from '@/components/molecules/FormField'; // Wrong level!
import { Button } from '@/components/atoms/Button'; // BAD: Confusing prop naming
interface SearchFormProps {
inputPlaceholder?: string;
inputDisabled?: boolean;
inputSize?: string;
buttonVariant?: string;
buttonDisabled?: boolean;
// ... endless prop forwarding
} return (
// BAD: Too much internal state
const SearchForm = () => {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]); // Should be in parent
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null); useEffect(() => {
fetchResults(query).then(setResults); // Business logic in molecule!
}, [query]);
}; Anti-Patterns to Avoid
1. Molecules Containing Molecules
// BAD: Molecule importing another molecule
// molecules/ComplexForm/ComplexForm.tsx
import { FormField } from '../FormField'; // Wrong!
import { SearchForm } from '../SearchForm'; // Wrong! // GOOD: Keep at atom level, or promote to organism
// organisms/ComplexForm/ComplexForm.tsx
import { FormField } from '@/components/molecules/FormField';
import { SearchForm } from '@/components/molecules/SearchForm'; // GOOD: Delegate to parent
const SearchForm = ({ onSubmit }) => {
const handleSubmit = (query) => {
onSubmit(query); // Parent handles API logic
};
}; // GOOD: Just use the atom directly
When to Use This Skill
Combining atoms for specific functionality
Creating reusable form components
Building navigation elements
Creating card and list components
Establishing patterns for common UI combinations
Related Skills
atomic-design-fundamentals - Core methodology overview
atomic-design-atoms - Creating atomic components
atomic-design-organisms - Building complex organisms {actionLabel && onAction && (
<Button variant="tertiary" size="sm" onClick={onAction}>
{actionLabel}
</Button>
)}
</div>
{icon && <Icon name={icon} size="md" className={styles.icon} />}
<div className={styles.content}>
<div className={styles.primary}>{primary}</div>
{secondary && (
<Text size="sm" color="muted" className={styles.secondary}>
{secondary}
</Text>
)}
</div>
{actions && (
<div className={styles.actions} onClick={(e) => e.stopPropagation()}>
{actions}
</div>
)}
</div>
if (multiple) {
const newValue = selectedIds.includes(itemId)
? selectedIds.filter((id) => id !== itemId)
: [...selectedIds, itemId];
onChange(newValue);
} else {
onChange(itemId);
}
return (
<Button
key={item.id}
variant={isSelected ? 'primary' : 'secondary'}
size={size}
disabled={disabled || item.disabled}
onClick={() => handleClick(item.id)}
aria-pressed={isSelected}
className={styles.button}
>
{item.label}
</Button>
);
})}
</div>
<div className={styles.value}>
<Heading level={2}>{value}</Heading>
</div>
{(trend || trendValue) && (
<div className={styles.trend}>
{trend && (
<Icon
name={getTrendIcon(trend)}
size="xs"
color={`var(--color-${getTrendColor(trend)}-500)`}
/>
)}
{trendValue && (
<Text size="sm" color={getTrendColor(trend)}>
{trendValue}
</Text>
)}
</div>
)}
{helpText && (
<Text size="xs" color="muted" className={styles.helpText}>
{helpText}
</Text>
)}
</div>
${apiEndpoint}?q=${query});
// Processing results here...
};
};