From workflow-skills
Scaffold Astro components, layouts, and pages with TypeScript props, Tailwind CSS styling, proper hydration patterns, and best practice defaults. Use when creating new Astro files, building component libraries, or setting up project structure.
npx claudepluginhub arosenkranz/claude-code-config --plugin workflow-skillsThis skill is limited to using the following tools:
Generate production-ready Astro components, layouts, and pages following framework best practices.
Compares coding agents like Claude Code and Aider on custom YAML-defined codebase tasks using git worktrees, measuring pass rate, cost, time, and consistency.
Designs and optimizes AI agent action spaces, tool definitions, observation formats, error recovery, and context for higher task completion rates.
Designs, implements, and audits WCAG 2.2 AA accessible UIs for Web (ARIA/HTML5), iOS (SwiftUI traits), and Android (Compose semantics). Audits code for compliance gaps.
Generate production-ready Astro components, layouts, and pages following framework best practices.
Use for: Non-interactive UI components, presentational elements
---
// src/components/Card.astro
interface Props {
title: string;
description: string;
imageUrl?: string;
variant?: 'default' | 'featured' | 'compact';
class?: string;
}
const {
title,
description,
imageUrl,
variant = 'default',
class: className,
} = Astro.props;
import { Image } from 'astro:assets';
import { twMerge } from 'tailwind-merge';
const cardStyles = {
default: 'bg-white dark:bg-gray-800 rounded-lg shadow-md p-6',
featured: 'bg-gradient-to-br from-blue-500 to-purple-600 text-white rounded-lg shadow-lg p-8',
compact: 'bg-white dark:bg-gray-800 rounded-md shadow-sm p-4',
};
---
<article class={twMerge(cardStyles[variant], className)}>
{imageUrl && (
<div class="mb-4">
<Image
src={imageUrl}
alt=""
width={400}
height={250}
class="rounded-md w-full object-cover"
loading="lazy"
/>
</div>
)}
<h3 class="text-2xl font-bold mb-2">{title}</h3>
<p class="text-gray-600 dark:text-gray-300">{description}</p>
{Astro.slots.has('default') && (
<div class="mt-4">
<slot />
</div>
)}
</article>
Key features:
<article>)Use for: Page layouts, wrapping content with common structure
---
// src/layouts/BaseLayout.astro
interface Props {
title: string;
description: string;
ogImage?: string;
class?: string;
}
const { title, description, ogImage, class: className } = Astro.props;
const canonicalUrl = new URL(Astro.url.pathname, Astro.site);
const socialImage = ogImage || `${Astro.site}og-default.jpg`;
---
<!doctype html>
<html lang="en" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonicalUrl} />
<!-- Open Graph -->
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={socialImage} />
<meta property="og:url" content={canonicalUrl} />
<meta property="og:type" content="website" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={socialImage} />
<slot name="head" />
</head>
<body class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 min-h-screen flex flex-col">
<!-- Skip link for accessibility -->
<a
href="#main-content"
class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 bg-blue-500 text-white px-4 py-2 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Skip to main content
</a>
<slot name="header" />
<main id="main-content" class={className}>
<slot />
</main>
<slot name="footer" />
<script is:inline>
// Dark mode initialization (runs before hydration)
const theme = localStorage.getItem('theme') ||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.classList.toggle('dark', theme === 'dark');
</script>
</body>
</html>
Key features:
Use for: Wrapping React/Vue/Svelte components with proper hydration
---
// src/components/SearchBar.astro
import SearchBarReact from './SearchBarReact.tsx';
interface Props {
placeholder?: string;
onSearch?: string; // Callback function name (client-side)
variant?: 'default' | 'header' | 'fullwidth';
class?: string;
}
const {
placeholder = 'Search...',
onSearch,
variant = 'default',
class: className,
} = Astro.props;
// Determine hydration strategy based on variant
const hydrationDirective = {
default: 'client:visible',
header: 'client:load', // Critical for navigation
fullwidth: 'client:idle',
}[variant];
---
<!-- ✅ Astro wrapper provides styling context -->
<div class={className}>
{hydrationDirective === 'client:load' && (
<SearchBarReact
client:load
placeholder={placeholder}
onSearch={onSearch}
className="w-full"
/>
)}
{hydrationDirective === 'client:idle' && (
<SearchBarReact
client:idle
placeholder={placeholder}
onSearch={onSearch}
className="w-full"
/>
)}
{hydrationDirective === 'client:visible' && (
<SearchBarReact
client:visible
placeholder={placeholder}
onSearch={onSearch}
className="w-full"
/>
)}
</div>
Companion React component:
// src/components/SearchBarReact.tsx
import { useState } from 'react';
import { twMerge } from 'tailwind-merge';
interface SearchBarProps {
placeholder?: string;
onSearch?: (query: string) => void;
className?: string;
}
export default function SearchBar({
placeholder = 'Search...',
onSearch,
className,
}: SearchBarProps) {
const [query, setQuery] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSearch?.(query);
};
return (
<form onSubmit={handleSubmit} className={twMerge('flex gap-2', className)}>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={placeholder}
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
aria-label="Search"
/>
<button
type="submit"
className="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Search
</button>
</form>
);
}
Key features:
Use for: Dynamic pages generated from content collections
---
// src/pages/blog/[...slug].astro
import { type CollectionEntry, getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { Image } from 'astro:assets';
export async function getStaticPaths() {
const posts = await getCollection('blog', ({ data }) => {
// Filter out drafts in production
return import.meta.env.PROD ? data.draft !== true : true;
});
// Build-time performance check
if (posts.length > 1000) {
console.warn(`⚠️ Large collection: ${posts.length} posts. Consider pagination.`);
}
return posts.map((post) => ({
params: { slug: post.slug },
props: { post },
}));
}
interface Props {
post: CollectionEntry<'blog'>;
}
const { post } = Astro.props;
const { Content } = await post.render();
// Format date
const formattedDate = post.data.pubDate.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
---
<BaseLayout
title={post.data.title}
description={post.data.description}
ogImage={post.data.coverImage?.src}
>
<article class="container mx-auto px-4 py-8 max-w-4xl">
<header class="mb-8">
{post.data.coverImage && (
<Image
src={post.data.coverImage.src}
alt={post.data.coverImage.alt}
width={1200}
height={630}
class="rounded-lg shadow-lg mb-6 w-full"
/>
)}
<h1 class="text-4xl md:text-5xl font-bold mb-4 text-gray-900 dark:text-white">
{post.data.title}
</h1>
<div class="flex items-center gap-4 text-gray-600 dark:text-gray-400">
<time datetime={post.data.pubDate.toISOString()} class="text-sm">
{formattedDate}
</time>
{post.data.author && (
<>
<span aria-hidden="true">•</span>
<span class="text-sm">By {post.data.author}</span>
</>
)}
</div>
{post.data.tags && post.data.tags.length > 0 && (
<div class="flex flex-wrap gap-2 mt-4">
{post.data.tags.map((tag) => (
<a
href={`/blog/tags/${tag}`}
class="px-3 py-1 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-full text-sm hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
>
{tag}
</a>
))}
</div>
)}
</header>
<div class="prose prose-lg dark:prose-invert max-w-none
prose-headings:font-bold prose-headings:text-gray-900 dark:prose-headings:text-white
prose-p:text-gray-700 dark:prose-p:text-gray-300
prose-a:text-blue-600 dark:prose-a:text-blue-400 prose-a:no-underline hover:prose-a:underline
prose-code:text-pink-600 dark:prose-code:text-pink-400
prose-pre:bg-gray-900 prose-pre:text-gray-100">
<Content />
</div>
</article>
</BaseLayout>
Key features:
src/
├── components/
│ ├── ui/ # Reusable UI components
│ │ ├── Button.astro
│ │ ├── Card.astro
│ │ ├── Badge.astro
│ │ └── Alert.astro
│ ├── layout/ # Layout components
│ │ ├── Header.astro
│ │ ├── Footer.astro
│ │ └── Navigation.astro
│ ├── sections/ # Page sections
│ │ ├── Hero.astro
│ │ ├── Features.astro
│ │ └── CTA.astro
│ └── react/ # Framework components (if needed)
│ ├── SearchBar.tsx
│ └── CommentForm.tsx
├── layouts/
│ ├── BaseLayout.astro # Base HTML structure
│ ├── BlogLayout.astro # Blog-specific layout
│ └── DocsLayout.astro # Documentation layout
└── pages/
├── index.astro
├── blog/
│ ├── index.astro # Blog listing
│ └── [...slug].astro # Blog post pages
└── [...404].astro
---
// src/pages/products/[category]/[...product].astro
import { getCollection } from 'astro:content';
export async function getStaticPaths() {
const products = await getCollection('products');
// Group by category
const paths = products.map((product) => ({
params: {
category: product.data.category,
product: product.slug,
},
props: { product },
}));
// Check for pagination needs
const categoryCounts = products.reduce((acc, p) => {
acc[p.data.category] = (acc[p.data.category] || 0) + 1;
return acc;
}, {} as Record<string, number>);
Object.entries(categoryCounts).forEach(([cat, count]) => {
if (count > 50) {
console.warn(`⚠️ Category "${cat}" has ${count} products. Consider pagination.`);
}
});
return paths;
}
const { product } = Astro.props;
---
<!-- Product page template -->
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string().max(160),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
author: z.string().default('Anonymous'),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
featured: z.boolean().default(false),
coverImage: z.object({
src: z.string(),
alt: z.string(), // Accessibility: required alt text
}).optional(),
}),
});
export const collections = { blog };
// src/types/component-props.ts
import type { HTMLAttributes } from 'astro/types';
/** Base props for all components */
export interface BaseProps {
class?: string;
id?: string;
}
/** Props for components with variants */
export interface VariantProps<T extends string> extends BaseProps {
variant?: T;
}
/** Button variant types */
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive';
/** Card variant types */
export type CardVariant = 'default' | 'featured' | 'compact';
/** Props with HTML attributes */
export interface ComponentProps extends BaseProps, Omit<HTMLAttributes<'div'>, 'class'> {}
// src/types/content.ts
import type { CollectionEntry } from 'astro:content';
/** Type-safe blog post */
export type BlogPost = CollectionEntry<'blog'>;
/** Published blog posts only */
export type PublishedBlogPost = BlogPost & {
data: BlogPost['data'] & { draft: false };
};
/** Helper to get post data */
export function getPostData(post: BlogPost) {
return {
...post.data,
url: `/blog/${post.slug}`,
readingTime: Math.ceil(post.body.split(' ').length / 200), // ~200 WPM
};
}
---
// src/components/ContactForm.astro
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
---
<!-- ✅ Wrap Shadcn React components for Astro -->
<form class="space-y-4 max-w-md">
<div>
<Label htmlFor="email" client:load>Email</Label>
<Input
client:load
type="email"
id="email"
name="email"
required
className="mt-1"
/>
</div>
<div>
<Label htmlFor="message" client:load>Message</Label>
<textarea
id="message"
name="message"
rows={4}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<Button type="submit" client:load>
Send Message
</Button>
</form>
---
// src/pages/newsletter.astro
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="Newsletter" description="Subscribe to our newsletter">
<div class="container mx-auto px-4 py-8">
<h1 class="text-4xl font-bold mb-8">Subscribe</h1>
<form id="newsletter-form" class="max-w-md">
<div class="mb-4">
<label for="email" class="block text-sm font-medium mb-2">
Email address
</label>
<input
type="email"
id="email"
name="email"
required
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<button
type="submit"
class="w-full px-6 py-3 bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Subscribe
</button>
</form>
<div id="message" role="status" aria-live="polite" class="mt-4"></div>
</div>
<script>
const form = document.getElementById('newsletter-form') as HTMLFormElement;
const message = document.getElementById('message') as HTMLDivElement;
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
try {
const response = await fetch('/api/newsletter', {
method: 'POST',
body: formData,
});
const result = await response.json();
if (response.ok) {
message.textContent = 'Successfully subscribed!';
message.className = 'mt-4 text-green-600';
form.reset();
} else {
message.textContent = result.error || 'Subscription failed';
message.className = 'mt-4 text-red-600';
}
} catch (error) {
message.textContent = 'Network error. Please try again.';
message.className = 'mt-4 text-red-600';
}
});
</script>
</BaseLayout>
// src/pages/api/newsletter.ts
import type { APIRoute } from 'astro';
export const POST: APIRoute = async ({ request }) => {
try {
const formData = await request.formData();
const email = formData.get('email')?.toString();
if (!email) {
return new Response(
JSON.stringify({ error: 'Email is required' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
// Add to newsletter (external API call)
await fetch('https://api.newsletter-service.com/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
return new Response(
JSON.stringify({ success: true }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
return new Response(
JSON.stringify({ error: 'Internal server error' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
};
When scaffolding components, consider these options:
All scaffolded components follow these standards:
Ask Claude to scaffold components using this skill:
Claude will generate production-ready code following all best practices.