Skip to content

Phase 5 - Code Examples

These examples are the template’s shipped components, organised by atomic design (ADR-003): src/components/{atoms,molecules,structural}/.

src/components/atoms/Button.astro
---
interface Props {
variant?: "primary" | "secondary" | "ghost";
size?: "sm" | "md" | "lg";
class?: string;
href?: string;
disabled?: boolean;
[key: string]: any;
}
const {
variant = "primary",
size = "md",
class: className,
href,
disabled = false,
...rest
} = Astro.props as Props;
const baseStyles =
"inline-flex items-center justify-center rounded-lg font-semibold no-underline transition-colors duration-200 motion-reduce:transition-none focus:outline-hidden focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 forced-colors:border forced-colors:text-[ButtonText]";
function getVariantStyles(v: Props["variant"]) {
switch (v) {
case "secondary":
// border-emphasis (gray-600 light, gray-300 dark) maintains ≥3:1 against
// both the page bg and the button bg, satisfying WCAG AA non-text
// contrast.
return "bg-surface text-foreground border border-border-emphasis hover:bg-background active:bg-background focus:ring-primary-500 shadow-sm hover:shadow";
case "ghost":
// Subtle default border for high-contrast support
return "text-muted-foreground border border-border/40 hover:border-border hover:bg-surface hover:text-foreground active:bg-surface focus:ring-primary-500";
default:
return "bg-primary-600 text-primary-foreground hover:bg-primary-700 active:bg-primary-800 focus:ring-primary-500 shadow-sm hover:shadow-md";
}
}
function getSizeStyles(s: Props["size"]) {
// Remove fixed heights to avoid clipping; rely on padding for accessible hit areas
switch (s) {
case "sm":
return "text-xs px-3 py-2 min-h-[2rem]";
case "lg":
return "text-base px-6 py-3 min-h-[2.75rem]";
default:
return "text-sm px-4 py-2.5 min-h-[2.75rem]";
}
}
// Render appropriate element without a dynamic uppercase variable to satisfy lint rules
const classList = [baseStyles, getVariantStyles(variant), getSizeStyles(size), className];
---
{href ? (
<a
class:list={classList}
href={href}
aria-disabled={disabled}
tabindex={disabled ? -1 : undefined}
{...rest}
>
<slot />
</a>
) : (
<button
class:list={classList}
type="button"
aria-disabled={disabled}
disabled={disabled}
{...rest}
>
<slot />
</button>
)}
src/components/molecules/Card.astro
---
interface Props {
class?: string;
animated?: boolean;
}
const { class: className, animated = false } = Astro.props;
---
<div class:list={[
"card overflow-hidden rounded-lg border border-border bg-surface shadow-sm",
animated && "card--animated",
className,
]}>
<slot />
</div>
<style>
/*
* @starting-style enables entry animations when the card first renders —
* particularly visible during View Transitions page navigation.
* Previously required JavaScript (requestAnimationFrame or Intersection Observer).
*/
@media (prefers-reduced-motion: no-preference) {
.card--animated {
transition: opacity 0.3s ease-out, transform 0.3s ease-out;
}
@starting-style {
.card--animated {
opacity: 0;
transform: translateY(0.5rem);
}
}
}
</style>
src/components/structural/Section.astro
---
interface Props {
class?: string;
id?: string;
fullHeight?: boolean; // Optional: allow disabling full viewport height when needed
ariaLabel?: string; // Optional: ARIA label for screen readers (recommended for snap sections)
ariaLabelledBy?: string; // Optional: reference to heading ID that labels this section
}
const {
class: className,
id,
fullHeight = false,
ariaLabel,
ariaLabelledBy,
} = Astro.props as Props;
---
<section
id={id}
class:list={[
"py-16 sm:py-24 lg:py-32",
fullHeight && "min-h-screen flex flex-col justify-center snap-start",
className,
]}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
>
<slot />
</section>
src/components/structural/Container.astro
---
interface Props {
class?: string;
}
const { class: className } = Astro.props;
---
<div class:list={["mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8", className]}>
<slot />
</div>
src/components/structural/Grid.astro
---
interface Props {
class?: string;
}
const { class: className } = Astro.props;
---
<div class:list={["@container grid grid-cols-1 gap-8 @md:grid-cols-2 @lg:grid-cols-3", className]}>
<slot />
</div>

The template’s Image atom wraps astro:assets with responsive-srcset defaults, AVIF-first format resolution, and an SVG pass-through. Interface excerpt — see src/components/atoms/Image.astro for the full implementation:

---
// src/components/atoms/Image.astro (excerpt)
import { Image as AstroImage } from "astro:assets";
import type { ImageMetadata } from "astro";
import { resolveImageFormat } from "@/utils/resolveImageFormat";
interface Props {
src: ImageMetadata | string | Promise<{ default: ImageMetadata }>;
alt: string;
class?: string;
format?: "avif" | "webp" | "png" | "jpeg" | "jpg" | "svg" | "gif";
quality?: number | "low" | "mid" | "high" | "max";
width?: number;
height?: number;
sizes?: string;
widths?: number[];
densities?: number[];
loading?: "lazy" | "eager";
decoding?: "async" | "sync" | "auto";
/** Optional shadow styles for performance modularity */
hasShadow?: boolean;
}
---

Key behaviors:

  • Defaults to AVIF output (via resolveImageFormat) with loading="lazy" and decoding="async"
  • Generates a widths-based srcset ([320, 640, 1024]) unless fixed dimensions are passed, in which case it switches to a densities-based srcset ([1.5, 2])
  • SVG sources pass through unprocessed — a vector stays a vector
  • String src paths fall back to a native <img> element
src/components/atoms/Badge.astro
---
import type { HTMLAttributes } from "astro/types";
interface Props extends Omit<HTMLAttributes<"span">, "class"> {
class?: string;
variant?: "primary" | "secondary" | "neutral";
size?: "xs" | "sm" | "md";
}
const { class: className, role, variant = "primary", size = "sm", ...rest } = Astro.props;
const variantClasses: Record<"primary" | "secondary" | "neutral", string> = {
primary: "bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200",
secondary: "bg-secondary-100 text-secondary-800 dark:bg-secondary-900 dark:text-secondary-200",
neutral: "bg-surface text-muted-foreground border border-border",
};
const sizeClasses: Record<"xs" | "sm" | "md", string> = {
xs: "text-xs px-[0.5rem] py-0.5",
sm: "text-sm px-2.5 py-0.5",
md: "text-base px-[0.75rem] py-[0.25rem]",
};
---
<span
role={role}
class:list={[
'badge inline-flex items-center rounded-full font-medium',
variantClasses[variant],
sizeClasses[size],
className,
]}
{...rest}
>
<slot />
</span>
<style>
/*
* prefers-contrast: more — strengthens badge visibility for users who
* request higher contrast.
*/
@media (prefers-contrast: more) {
.badge {
border: 2px solid currentColor;
font-weight: 700;
}
}
</style>

The Link component has been moved into a dedicated tutorial to empower you to build it yourself and understand the template’s architecture. This approach avoids providing an overly opinionated component out-of-the-box.

This guide will walk you through creating a flexible Link component that can handle internal and external links, apply consistent styling, and manage accessibility attributes.

The template ships its modal as src/components/molecules/Dialog.astro, built on the native <dialog> element — the browser provides focus trapping, backdrop rendering, Escape-to-close, and stacking context, so no modal library is needed. Interface excerpt:

---
// src/components/molecules/Dialog.astro (excerpt)
interface Props {
id: string;
title: string;
size?: "sm" | "md" | "lg";
class?: string;
/**
* Heading level for the dialog title only. Defaults to "h2" for top-level
* dialogs. Pass a deeper level when the dialog is nested inside a section
* that already uses h2/h3 — heading hierarchy should not skip levels.
*/
headingLevel?: "h2" | "h3" | "h4" | "h5" | "h6";
}
const { id, title, size = "md", class: className, headingLevel = "h2" } = Astro.props;
const sizeClasses = {
sm: "max-w-sm",
md: "max-w-lg",
lg: "max-w-2xl",
} as const;
---

Usage — open it with the native showModal() API:

<Dialog id="my-dialog" title="Dialog Title" size="md">
<p>Dialog content here</p>
</Dialog>
<button onclick="document.getElementById('my-dialog').showModal()">
Open Dialog
</button>

The template’s Tabs live at src/components/molecules/Tabs.astro. Panel switching is CSS-only (hidden radio inputs plus :has(...:checked) selectors); a ~30-line inline script adds arrow-key navigation and ARIA wiring per the WAI-ARIA tabs pattern. Interface excerpt:

---
// src/components/molecules/Tabs.astro (excerpt)
interface TabItem {
id: string;
label: string;
}
interface Props {
tabs: TabItem[];
defaultTab?: string;
class?: string;
}
const { tabs, defaultTab, class: className } = Astro.props;
---

Usage — panels are slotted content matched by data-tab-panel:

<Tabs tabs={[
{ id: "overview", label: "Overview" },
{ id: "usage", label: "Usage" },
]} defaultTab="overview">
<div data-tab-panel="overview" role="tabpanel">First panel</div>
<div data-tab-panel="usage" role="tabpanel">Second panel</div>
</Tabs>

The starter documents components on a real page instead of a separate tool: src/pages/showcase.astro renders each component’s variants directly (ADR-049). To add a component to the style guide, import it and render its variants:

---
// src/pages/showcase.astro (excerpt)
import Button from "@/components/atoms/Button.astro";
import Card from "@/components/molecules/Card.astro";
---
<section>
<h2>Buttons</h2>
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="ghost">Ghost</Button>
</section>

Because the showcase is a normal Astro page, it ships through the same performance and accessibility gates as the rest of the site — no extra tooling to configure.