Skip to content

Creating Components

This guide walks you through creating new Astro components for the FiNAN website, following the established patterns and conventions.

Create a new component when:

  • Content repeats across multiple pages (navigation, footers, cards)
  • Data structure is reusable (committee members, FAQs, partners)
  • Functionality is self-contained (galleries, accordions, forms)
  • Styling should be consistent (buttons, sections, layouts)

Don’t create a component if:

  • Content appears only once and won’t be reused
  • Logic is page-specific and tightly coupled to one route
  • A simpler solution exists (plain HTML in page file)

Use this template as a starting point for new components:

src/components/ExampleComponent.astro
---
// 1. Import types and utilities
import type { ImageMetadata } from 'astro';
// 2. Define Props interface
interface Props {
title: string;
description?: string;
image?: ImageMetadata;
variant?: 'primary' | 'secondary';
}
// 3. Destructure props with defaults
const {
title,
description,
image,
variant = 'primary'
} = Astro.props;
---
<!-- 4. Component markup -->
<section class={`example-component ${variant}`}>
<div class="container">
<h2>{title}</h2>
{description && <p>{description}</p>}
{image && <img src={image.src} alt={title} />}
<slot />
</div>
</section>
<!-- 5. Scoped styles -->
<style>
.example-component {
padding: 4rem 0;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}
h2 {
font-size: 2rem;
margin-bottom: 1rem;
}
/* Variant styles */
.primary {
background-color: var(--color-primary);
}
.secondary {
background-color: var(--color-secondary);
}
</style>
  1. Create the component file

    Navigate to src/components/ and create a new .astro file with a descriptive PascalCase name:

    Terminal window
    # Example: Creating a testimonials component
    touch src/components/Testimonials.astro
  2. Define TypeScript props interface

    Start by defining the Props interface based on what data the component needs:

    interface Props {
    title: string; // Required prop
    subtitle?: string; // Optional prop
    items: TestimonialItem[]; // Array of typed objects
    }
  3. Import required dependencies

    Add imports at the top of the component:

    // Type imports
    import type { ImageMetadata } from 'astro';
    import type { Testimonial } from '@/data/testimonialsData';
    // Component imports (if needed)
    import Icon from './Icon.astro';
    // Utility imports (if needed)
    import { formatDate } from '@/lib/utils';
  4. Destructure props with default values

    Extract props from Astro.props and provide defaults for optional props:

    const {
    title,
    subtitle = '', // Default empty string
    items = [], // Default empty array
    variant = 'primary'
    } = Astro.props;
  5. Add component logic (if needed)

    Process data or calculate values before rendering:

    // Example: Filter and sort items
    const activeItems = items
    .filter((item) => item.isActive)
    .sort((a, b) => a.order - b.order);
    // Example: Derive computed values
    const itemCount = activeItems.length;
    const hasItems = itemCount > 0;
  6. Write the component markup

    Create the HTML structure using Astro’s template syntax:

    <section class="testimonials">
    <div class="container">
    <h2>{title}</h2>
    {subtitle && <p class="subtitle">{subtitle}</p>}
    {hasItems ? (
    <div class="grid">
    {activeItems.map((item) => (
    <div class="testimonial-card">
    <p class="quote">{item.quote}</p>
    <p class="author">{item.author}</p>
    </div>
    ))}
    </div>
    ) : (
    <p class="empty-state">No testimonials available.</p>
    )}
    </div>
    </section>
  7. Add scoped styles

    Style the component using Astro’s scoped <style> block:

    <style>
    .testimonials {
    padding: 4rem 0;
    background-color: var(--color-background);
    }
    .container {
    max-width: 1200px;
    margin: 0 auto;
    padding: 0 1rem;
    }
    .grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
    gap: 2rem;
    }
    .testimonial-card {
    padding: 2rem;
    border-radius: 0.5rem;
    background-color: white;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
    }
    </style>
  8. Test the component

    Import and use the component in a page:

    src/pages/testimonials.astro
    ---
    import Testimonials from '@/components/Testimonials.astro';
    import { testimonials } from '@/data/testimonialsData';
    ---
    <Testimonials
    title="What People Say"
    subtitle="Testimonials from our community"
    items={testimonials}
    />
  9. Run build validation

    Ensure the component builds without errors:

    Terminal window
    pnpm build

    Fix any TypeScript or build errors before committing.

Use required props for essential data and optional props for customization:

interface Props {
// Required - Component won't work without these
title: string;
items: Item[];
// Optional - Has sensible defaults
variant?: 'primary' | 'secondary';
limit?: number;
showEmpty?: boolean;
}

Use TypeScript union types to constrain prop values:

interface Props {
// ✅ Good - Limited to specific values
size?: 'small' | 'medium' | 'large';
theme?: 'light' | 'dark';
// ❌ Bad - Any string allowed
size?: string;
}

Always type array props using imported interfaces:

import type { Partner } from '@/data/partnersData';
interface Props {
// ✅ Good - Type-safe array
partners: Partner[];
// ❌ Bad - No type safety
partners: any[];
}

Use Astro’s ImageMetadata type for image props:

import type { ImageMetadata } from 'astro';
interface Props {
// ✅ Good - Type-safe image imports
logo: ImageMetadata;
heroImage?: ImageMetadata;
// ❌ Bad - String paths aren't type-safe
logo: string;
}

Always use scoped styles to prevent CSS conflicts:

<style>
/* Scoped to this component */
.component {
padding: 2rem;
}
/* Child elements */
.component h2 {
font-size: 2rem;
}
</style>

Use CSS custom properties for consistent theming:

<style>
.component {
/* Use existing CSS variables */
background-color: var(--color-primary);
color: var(--color-text);
padding: var(--spacing-large);
}
</style>

Common variables in the FiNAN website:

  • --color-primary - Primary brand color
  • --color-secondary - Secondary brand color
  • --color-text - Default text color
  • --color-background - Background color
  • --spacing-small, --spacing-medium, --spacing-large - Consistent spacing

Use mobile-first responsive design:

<style>
/* Mobile styles (default) */
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
/* Tablet and up */
@media (min-width: 768px) {
.grid {
grid-template-columns: repeat(2, 1fr);
gap: 2rem;
}
}
/* Desktop and up */
@media (min-width: 1024px) {
.grid {
grid-template-columns: repeat(3, 1fr);
}
}
</style>

Only use :global() when absolutely necessary:

<style>
/* ✅ Preferred - Scoped */
.button {
padding: 1rem;
}
/* ❌ Avoid - Global scope */
:global(.button) {
padding: 1rem;
}
</style>

Component that accepts configuration data:

src/components/Partners.astro
---
import type { Partner } from '@/data/partnersData';
interface Props {
partners: Partner[];
title?: string;
}
const { partners, title = 'Our Partners' } = Astro.props;
---
<section class="partners">
<h2>{title}</h2>
<div class="grid">
{partners.map((partner) => (
<a href={partner.url} target="_blank" rel="noopener noreferrer">
<img src={partner.logo.src} alt={partner.name} />
</a>
))}
</div>
</section>

Component that wraps other content:

src/components/Section.astro
---
interface Props {
title: string;
background?: 'light' | 'dark';
}
const { title, background = 'light' } = Astro.props;
---
<section class={`section ${background}`}>
<div class="container">
<h2>{title}</h2>
<slot />
</div>
</section>
<style>
.section {
padding: 4rem 0;
}
.light {
background-color: white;
}
.dark {
background-color: #1a1a1a;
color: white;
}
</style>

Component with conditional logic:

---
interface Props {
items: Item[];
emptyMessage?: string;
limit?: number;
}
const {
items,
emptyMessage = 'No items found',
limit
} = Astro.props;
const displayItems = limit ? items.slice(0, limit) : items;
const hasItems = displayItems.length > 0;
---
{hasItems ? (
<ul class="list">
{displayItems.map((item) => (
<li>{item.name}</li>
))}
</ul>
) : (
<p class="empty-state">{emptyMessage}</p>
)}
// ❌ Bad - No type safety
const { title, description } = Astro.props;
// ✅ Good - Type-safe props
interface Props {
title: string;
description: string;
}
const { title, description } = Astro.props;
// ❌ Bad - Hardcoded data
<div>
<p>Partner 1</p>
<p>Partner 2</p>
</div>
// ✅ Good - Data from props
{partners.map((partner) => (
<p>{partner.name}</p>
))}
<!-- ❌ Bad - Affects all .button elements -->
<style>
.button {
padding: 1rem;
}
</style>
<!-- ✅ Good - Scoped to component -->
<style>
.my-component-button {
padding: 1rem;
}
</style>
<!-- ❌ Bad - No alt text -->
<img src={image.src} />
<!-- ✅ Good - Descriptive alt text -->
<img src={image.src} alt={`${member.name} - ${member.role}`} />
// ❌ Bad - Will error if items is undefined
const count = items.length;
// ✅ Good - Safe access
const count = items?.length ?? 0;

Before committing a new component, verify:

  • Props interface defined with TypeScript
  • Required props are clearly marked (no ?)
  • Optional props have sensible defaults
  • Styles are scoped (not :global())
  • Images have alt text
  • Responsive design implemented (mobile-first)
  • Conditional rendering handles edge cases
  • Component builds without errors (pnpm build)
  • Component tested in at least one page
  • Code follows existing patterns from similar components

Study these well-implemented components from the main repository:

  • Committee.astro - Data-driven component with image handling
  • FAQAccordion.astro - Interactive component with client-side logic
  • Partners.astro - Simple grid layout with responsive design
  • Navbar.astro - Layout component with navigation logic
  • Footer.astro - Layout component with structured content

Location: https://github.com/poncardasm/finan-website/tree/main/src/components