Skip to content

Component Patterns

🎨 Purpose: Reusable UI patterns for consistent, accessible components

<!-- ❌ Avoid: Too many props -->
<Card
title="..."
subtitle="..."
image="..."
imageAlt="..."
showButton={true}
buttonText="..."
buttonHref="..."
/>
<!-- ✅ Prefer: Composable slots -->
<Card>
<h3>Title</h3>
<p>Subtitle</p>
<Image src="..." alt="..." />
<Button href="...">Action</Button>
</Card>
***
// Always define props interface
export interface Props {
variant?: 'default' | 'outline';
size?: 'sm' | 'md' | 'lg';
}
// Destructure with defaults
const {
variant = 'default',
size = 'md'
} = Astro.props;
***
***
// Components handle their own a11y
const uniqueId = `component-${Math.random().toString(36).substr(2, 9)}`;
***
<div
role="region"
aria-labelledby={uniqueId}
>
<h2 id={uniqueId}>
<slot name="title" />
</h2>
<slot />
</div>

Allow components to render as different HTML elements:

PolymorphicBox.astro
***
export interface Props {
as?: keyof HTMLElementTagNameMap;
}
const { as: Tag = 'div', ...props } = Astro.props;
***
<Tag {...props}>
<slot />
</Tag>

Usage:

<PolymorphicBox as="section" class="hero">
<h1>Hero Content</h1>
</PolymorphicBox>

Components that work together as a group:

Tabs/TabGroup.astro
***
***
<div class="tab-group" role="tablist">
<slot />
</div>
***
// Tabs/Tab.astro
export interface Props {
id: string;
active?: boolean;
}
***
<button
role="tab"
aria-selected={active}
aria-controls={`panel-${id}`}
>
<slot />
</button>
***
// Tabs/TabPanel.astro
export interface Props {
id: string;
active?: boolean;
}
***
<div
role="tabpanel"
id={`panel-${id}`}
hidden={!active}
>
<slot />
</div>

For dynamic content rendering:

List.astro
***
export interface Props {
items: any[];
renderItem: (item: any, index: number) => any;
}
const { items, renderItem } = Astro.props;
***
<ul>
{items.map((item, index) => (
<li>{renderItem(item, index)}</li>
))}
</ul>

Encapsulate data fetching logic:

FeaturedPosts.astro
***
import { getCollection } from 'astro:content';
import PostCard from './PostCard.astro';
const posts = await getCollection('blog', ({ data }) =>
data.featured && !data.draft
);
***
<section>
<h2>Featured Posts</h2>
<div class="grid">
{posts.map(post => (
<PostCard post={post} />
))}
</div>
</section>

Graceful error handling:

SafeComponent.astro
***
let content;
let error;
try {
// Risky operation
content = await riskyOperation();
} catch (e) {
error = e;
console.error('Component error:', e);
}
***
{error ? (
<div class="error-fallback">
<p>Unable to load content</p>
</div>
) : (
<div>{content}</div>
)}

View Transitions vs. Island Hydration: A Decision Guide

Section titled “View Transitions vs. Island Hydration: A Decision Guide”

Astro offers powerful tools for creating dynamic user experiences: View Transitions for seamless page navigation and Astro Islands for client-side interactivity. Choosing the right tool for the job is key to building performant and maintainable applications. This guide provides a decision matrix to help you select the best approach.

Scenario / GoalPrefer View Transitions (VT)Prefer Astro Island HydrationReasoning / Key Considerations
Full page navigation with smooth visual transitionsVTs excel at animating between different page states, persisting shared elements, and providing an app-like feel.
Animating specific elements across different pagesVTs can identify and morph elements (e.g., images, headers) between old and new pages.
Client-side interactivity within a componentFor components like counters, dropdowns, interactive forms, or data tables that need client-side JavaScript to function.
Updating a small part of a page without full reloadIslands can re-render or fetch data on the client, ideal for dynamic sections within a mostly static page.
Maintaining complex client-side state within a pageIf a component needs to manage its own state or interact with other client-side components without page navigation.
Progressive enhancement for a static componentStart with static HTML, then hydrate an island to add richer JS-driven interactions (client:idle, client:visible).
Morphing elements between states on the same page✅ (sometimes)Often best handled by CSS transitions/animations. Use an Island if complex JS logic is required to manage these states.
Global, app-like navigationVTs are designed for SPA-like navigation experiences across your entire Astro site or specific sections.
Reducing JavaScript shipped for page transitionsVTs can often achieve sophisticated transitions with minimal or no custom JavaScript.
Component needs to run JS immediately on page load✅ (client:load)Use with caution due to performance impact. Ensure it’s justified by the Preact Island Usage Policy ADR.

Concluding Note: This matrix provides general guidelines. Always consider the specific requirements of your feature, the desired user experience, performance implications (especially JavaScript bundle sizes and Core Web Vitals), and accessibility. Sometimes, a combination of both might be appropriate, but strive to avoid mixing metaphors in a way that complicates the codebase or degrades performance. Refer to the Astro 5.11.0 View Transitions documentation and Island architecture concepts for more details.

Modern responsive design:

ResponsiveCard.astro
***
***
<div class="card-container">
<article class="card">
<slot />
</article>
</div>
<style>
.card-container {
container-type: inline-size;
}
.card {
display: grid;
gap: 1rem;
}
@container (min-width: 400px) {
.card {
grid-template-columns: 1fr 2fr;
}
}
</style>

Optimal image loading:

ResponsiveImage.astro
***
import { Image } from 'astro:assets';
export interface Props {
src: ImageMetadata;
alt: string;
sizes?: string;
}
const {
src,
alt,
sizes = "(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
} = Astro.props;
***
<Image
src={src}
alt={alt}
sizes={sizes}
widths={[320, 640, 768, 1024, 1280]}
formats={['avif', 'webp']}
loading="lazy"
/>

Using URL for component state:

FilterableList.astro
***
const currentFilter = Astro.url.searchParams.get('filter') || 'all';
const items = await getFilteredItems(currentFilter);
***
<div>
<nav>
<a href="?filter=all" aria-current={currentFilter === 'all'}>All</a>
<a href="?filter=active" aria-current={currentFilter === 'active'}>Active</a>
<a href="?filter=archived" aria-current={currentFilter === 'archived'}>Archived</a>
</nav>
<ul>
{items.map(item => <li>{item.name}</li>)}
</ul>
</div>

Progressive form enhancement:

EnhancedForm.astro
***
***
<form
method="POST"
action="/api/submit"
class="enhanced-form"
>
<slot />
<noscript>
<button type="submit">Submit</button>
</noscript>
</form>
<script>
// Only enhance if JS is available
const forms = document.querySelectorAll('.enhanced-form');
forms.forEach(form => {
form.addEventListener('submit', async (e) => {
e.preventDefault();
// Show loading state
form.classList.add('submitting');
try {
const formData = new FormData(form);
const response = await fetch(form.action, {
method: form.method,
body: formData
});
if (response.ok) {
// Handle success
}
} catch (error) {
// Fall back to regular submission
form.submit();
}
});
});
</script>

Prefer CSS over JavaScript:

AnimatedCard.astro
***
***
<article class="animated-card">
<slot />
</article>
<style>
.animated-card {
opacity: 0;
transform: translateY(20px);
animation: fadeInUp 0.6s ease forwards;
}
@keyframes fadeInUp {
to {
opacity: 1;
transform: translateY(0);
}
}
/* Respect motion preferences */
@media (prefers-reduced-motion: reduce) {
.animated-card {
animation: none;
opacity: 1;
transform: none;
}
}
</style>

For lists and grids:

StaggeredList.astro
***
const items = Astro.props.items;
***
<ul class="staggered-list">
{items.map((item, i) => (
<li style={`--index: ${i}`}>
{item}
</li>
))}
</ul>
<style>
.staggered-list li {
opacity: 0;
transform: translateX(-20px);
animation: slideIn 0.3s ease forwards;
animation-delay: calc(var(--index) * 0.05s);
}
@keyframes slideIn {
to {
opacity: 1;
transform: translateX(0);
}
}
</style>
components/Button/Button.test.astro
***
import Button from './Button.astro';
***
<div class="test-grid">
<h2>Button Variants</h2>
<!-- Default States -->
<section>
<h3>Default State</h3>
<Button>Default</Button>
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="ghost">Ghost</Button>
</section>
<!-- Disabled States -->
<section>
<h3>Disabled State</h3>
<Button disabled>Default</Button>
<Button variant="primary" disabled>Primary</Button>
</section>
<!-- Sizes -->
<section>
<h3>Sizes</h3>
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
</section>
</div>
<style>
.test-grid {
display: grid;
gap: 2rem;
padding: 2rem;
}
section {
display: flex;
gap: 1rem;
align-items: center;
}
</style>
TestAccessibility.astro
***
// Component to verify a11y compliance
***
<div class="a11y-test">
<h2>Accessibility Checklist</h2>
<!-- Keyboard Navigation -->
<section>
<h3>Keyboard Navigation</h3>
<p>Tab through all interactive elements:</p>
<slot name="interactive" />
</section>
<!-- Screen Reader -->
<section>
<h3>Screen Reader Content</h3>
<div class="sr-only">
<slot name="screen-reader" />
</div>
<p>✓ Hidden content for screen readers included</p>
</section>
<!-- Color Contrast -->
<section>
<h3>Color Contrast</h3>
<div class="contrast-grid">
<slot name="contrast" />
</div>
</section>
</div>
LazySection.astro
***
export interface Props {
threshold?: number;
rootMargin?: string;
}
const {
threshold = 0.1,
rootMargin = '50px'
} = Astro.props;
const id = `lazy-${Math.random().toString(36).slice(2)}`;
***
<div
id={id}
class="lazy-section"
data-threshold={threshold}
data-margin={rootMargin}
>
<slot />
</div>
<script>
// Use Intersection Observer for lazy loading
const lazyElements = document.querySelectorAll('.lazy-section');
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('loaded');
observer.unobserve(entry.target);
}
});
}, {
threshold: 0.1,
rootMargin: '50px'
});
lazyElements.forEach(el => observer.observe(el));
</script>
<style>
.lazy-section {
opacity: 0;
transition: opacity 0.3s ease;
}
.lazy-section.loaded {
opacity: 1;
}
</style>
ResourceHints.astro
***
export interface Props {
preconnect?: string[];
prefetch?: string[];
preload?: Array<{
href: string;
as: string;
type?: string;
}>;
}
const { preconnect = [], prefetch = [], preload = [] } = Astro.props;
***
<!-- Preconnect to external domains -->
{preconnect.map(domain => (
<link rel="preconnect" href={domain} crossorigin />
))}
<!-- Prefetch navigation targets -->
{prefetch.map(url => (
<link rel="prefetch" href={url} />
))}
<!-- Preload critical resources -->
{preload.map(resource => (
<link
rel="preload"
href={resource.href}
as={resource.as}
type={resource.type}
crossorigin={resource.as === 'font' ? 'anonymous' : undefined}
/>
))}
<!-- Don't create generic components too early -->
<SuperFlexibleComponent
config={{
type: 'card',
layout: 'vertical',
showImage: true,
// 20 more options...
}}
/>
<!-- Avoid passing props through multiple levels -->
<Parent userTheme={theme}>
<Child userTheme={theme}>
<GrandChild userTheme={theme} />
</Child>
</Parent>
<!-- Always handle loading/error states -->
{isLoading && <Skeleton />}
{error && <ErrorMessage />}
{data && <Content />}
<!-- Handle empty states, errors, and boundaries -->
{items.length === 0 ? (
<EmptyState />
) : (
<ItemList items={items} />
)}

When documenting components, include:

Component.astro
***
/**
* @component ComponentName
* @description Brief description of what this component does
*
* @example
* <ComponentName variant="primary" size="lg">
* Content goes here
* </ComponentName>
*
* @props
* - variant: Visual style variant
* - size: Component size
*
* @slots
* - default: Main content
* - icon: Optional icon slot
*
* @emits None
*
* @a11y
* - Keyboard navigable
* - Screen reader friendly
* - ARIA labels included
*/
export interface Props {
variant?: 'primary' | 'secondary';
size?: 'sm' | 'md' | 'lg';
}
***

When adopting these patterns in existing projects:

  1. Start with new components - Don’t refactor everything at once
  2. Document as you go - Add JSDoc comments to new components
  3. Test accessibility - Use automated tools and manual testing
  4. Measure performance - Ensure patterns don’t degrade metrics
  5. Share learnings - Document new patterns discovered