Skip to content

Molecules

Molecules are simple groups of atoms functioning together as a unit. They take on their own properties and serve as the backbone of design systems by creating reusable, purpose-driven components.

  • Composed - Made of 2-5 atoms working together
  • Single Purpose - Serve one specific function
  • Reusable - Used across multiple organisms
  • Self-Contained - Include all atoms needed for their purpose
  • Stateless - Don’t manage complex state (usually)
components/molecules/SearchBar.astro
---
import Input from '@/components/atoms/Input.astro';
import Button from '@/components/atoms/Button.astro';
import Icon from '@/components/atoms/Icon.astro';
interface Props {
placeholder?: string;
buttonText?: string;
}
const {
placeholder = 'Search...',
buttonText = 'Search'
} = Astro.props;
---
<form class="search-bar" role="search">
<Input
type="text"
name="query"
placeholder={placeholder}
/>
<Button type="submit" variant="primary">
<Icon name="search" size="sm" />
<span class="button-text">{buttonText}</span>
</Button>
</form>
<style>
.search-bar {
display: flex;
gap: 0.5rem;
max-width: 600px;
}
.button-text {
margin-left: 0.5rem;
}
@media (max-width: 640px) {
.button-text {
display: none;
}
}
</style>
components/molecules/FormField.astro
---
import Label from '@/components/atoms/Label.astro';
import Input from '@/components/atoms/Input.astro';
interface Props {
label: string;
name: string;
type?: 'text' | 'email' | 'password' | 'number';
placeholder?: string;
required?: boolean;
error?: string;
helpText?: string;
}
const {
label,
name,
type = 'text',
placeholder,
required = false,
error,
helpText
} = Astro.props;
---
<div class="form-field">
<Label for={name} required={required}>
{label}
</Label>
<Input
type={type}
name={name}
id={name}
placeholder={placeholder}
required={required}
error={!!error}
/>
{helpText && !error && (
<p class="help-text">{helpText}</p>
)}
{error && (
<p class="error-text">{error}</p>
)}
</div>
<style>
.form-field {
margin-bottom: 1rem;
}
.help-text {
margin-top: 0.25rem;
font-size: 0.875rem;
color: var(--color-text-muted);
}
.error-text {
margin-top: 0.25rem;
font-size: 0.875rem;
color: var(--color-danger);
}
</style>
components/molecules/CardHeader.astro
---
import Heading from '@/components/atoms/Heading.astro';
import Icon from '@/components/atoms/Icon.astro';
import Badge from '@/components/atoms/Badge.astro';
interface Props {
title: string;
icon?: string;
badge?: string;
badgeVariant?: 'success' | 'warning' | 'danger';
}
const { title, icon, badge, badgeVariant } = Astro.props;
---
<div class="card-header">
<div class="header-content">
{icon && <Icon name={icon} />}
<Heading level={3}>{title}</Heading>
</div>
{badge && (
<Badge variant={badgeVariant}>
{badge}
</Badge>
)}
</div>
<style>
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
border-bottom: 1px solid var(--color-border);
}
.header-content {
display: flex;
align-items: center;
gap: 0.75rem;
}
</style>
components/molecules/NavItem.astro
---
import Link from '@/components/atoms/Link.astro';
import Icon from '@/components/atoms/Icon.astro';
interface Props {
href: string;
label: string;
icon?: string;
active?: boolean;
external?: boolean;
}
const {
href,
label,
icon,
active = false,
external = false
} = Astro.props;
---
<li class="nav-item">
<Link
href={href}
external={external}
class:list={['nav-link', { 'nav-link-active': active }]}
>
{icon && <Icon name={icon} size="sm" />}
<span>{label}</span>
</Link>
</li>
<style>
.nav-item {
list-style: none;
}
.nav-link {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
transition: all 0.2s;
}
.nav-link:hover {
background: var(--color-background-hover);
}
.nav-link-active {
background: var(--color-primary-alpha);
color: var(--color-primary);
}
</style>
components/molecules/SocialLink.astro
---
import Link from '@/components/atoms/Link.astro';
import Icon from '@/components/atoms/Icon.astro';
interface Props {
platform: 'github' | 'twitter' | 'linkedin' | 'youtube';
href: string;
label?: string;
}
const { platform, href, label } = Astro.props;
---
<Link href={href} external class="social-link" aria-label={label || platform}>
<Icon name={platform} />
{label && <span class="social-label">{label}</span>}
</Link>
<style>
.social-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
border-radius: 0.375rem;
transition: all 0.2s;
}
.social-link:hover {
background: var(--color-background-hover);
transform: translateY(-2px);
}
.social-label {
font-size: 0.875rem;
}
</style>
components/molecules/PriceTag.astro
---
import Badge from '@/components/atoms/Badge.astro';
interface Props {
price: number;
originalPrice?: number;
currency?: string;
}
const {
price,
originalPrice,
currency = '$'
} = Astro.props;
const discount = originalPrice
? Math.round(((originalPrice - price) / originalPrice) * 100)
: 0;
---
<div class="price-tag">
<div class="price-content">
<span class="current-price">
{currency}{price.toFixed(2)}
</span>
{originalPrice && (
<span class="original-price">
{currency}{originalPrice.toFixed(2)}
</span>
)}
</div>
{discount > 0 && (
<Badge variant="success" size="sm">
{discount}% OFF
</Badge>
)}
</div>
<style>
.price-tag {
display: flex;
align-items: center;
gap: 0.75rem;
}
.price-content {
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.current-price {
font-size: 1.5rem;
font-weight: 700;
color: var(--color-primary);
}
.original-price {
font-size: 1rem;
color: var(--color-text-muted);
text-decoration: line-through;
}
</style>
components/molecules/StatDisplay.astro
---
import Heading from '@/components/atoms/Heading.astro';
import Icon from '@/components/atoms/Icon.astro';
interface Props {
label: string;
value: string | number;
icon?: string;
trend?: 'up' | 'down';
trendValue?: string;
}
const { label, value, icon, trend, trendValue } = Astro.props;
---
<div class="stat-display">
{icon && (
<div class="stat-icon">
<Icon name={icon} size="lg" />
</div>
)}
<div class="stat-content">
<p class="stat-label">{label}</p>
<Heading level={2}>{value}</Heading>
{trend && trendValue && (
<div class:list={['stat-trend', `stat-trend-${trend}`]}>
<Icon name={trend === 'up' ? 'arrow-up' : 'arrow-down'} size="sm" />
<span>{trendValue}</span>
</div>
)}
</div>
</div>
<style>
.stat-display {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: var(--color-background-secondary);
border-radius: 0.5rem;
}
.stat-icon {
color: var(--color-primary);
}
.stat-content {
flex: 1;
}
.stat-label {
font-size: 0.875rem;
color: var(--color-text-muted);
margin-bottom: 0.25rem;
}
.stat-trend {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
margin-top: 0.25rem;
}
.stat-trend-up {
color: var(--color-success);
}
.stat-trend-down {
color: var(--color-danger);
}
</style>

✅ Combine 2-5 atoms maximum ✅ Give molecules clear names ✅ Make molecules reusable ✅ Keep logic simple ✅ Handle their own layout ✅ Accept configuration props ✅ Document molecule usage ✅ Test molecule interactions

❌ Make molecules too complex ❌ Include business logic ❌ Depend on page context ❌ Create one-off molecules ❌ Nest molecules too deeply ❌ Mix too many concerns ❌ Forget accessibility

FormField.test.ts
import { render, screen } from '@testing-library/svelte';
import FormField from './FormField.svelte';
describe('FormField Molecule', () => {
test('renders label and input', () => {
render(FormField, {
props: {
label: 'Email',
name: 'email'
}
});
expect(screen.getByLabelText('Email')).toBeInTheDocument();
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
test('shows error message when error prop provided', () => {
render(FormField, {
props: {
label: 'Email',
name: 'email',
error: 'Invalid email'
}
});
expect(screen.getByText('Invalid email')).toBeInTheDocument();
});
test('shows required indicator', () => {
render(FormField, {
props: {
label: 'Email',
name: 'email',
required: true
}
});
expect(screen.getByText('*')).toBeInTheDocument();
});
});

Create molecules that can adapt to different contexts:

<FormField
label="Email"
name="email"
type="email"
required
helpText="We'll never share your email"
/>
<FormField
label="Password"
name="password"
type="password"
required
error="Password must be at least 8 characters"
/>

Allow customization through slots:

<CardHeader title="User Profile">
<slot name="actions">
<Button size="sm">Edit</Button>
</slot>
</CardHeader>