
Frontend Design
- 21 installs
- 7 repo stars
- Updated August 2, 2026
- practicalswan/agent-skills
frontend-design is a Claude Code skill for frontend development.
About
frontend-design is a Claude Code skill for frontend development. It helps solo builders move faster with AI-assisted development.
- frontend-design
- Frontend Development
- AI-coding skill
Frontend Design by the numbers
- 21 all-time installs (skills.sh)
- Ranked #1,549 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/practicalswan/agent-skills --skill frontend-designAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 21 |
|---|---|
| repo stars | ★ 7 |
| Last updated | August 2, 2026 |
| Repository | practicalswan/agent-skills ↗ |
How do I helps with frontend development tasks.?
Helps with frontend development tasks.
Who is it for?
Best when you're working on frontend development and need structured help with frontend design.
Skip if: Teams with no frontend development needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to helps with frontend development tasks., or when frontend-design is a claude code skill for frontend development.
What you get
Structured output aligned to frontend-design: frontend-design, Frontend Development.
Files
Frontend Design
Expert guidance for creating beautiful, accessible, and responsive frontend designs using modern UI principles, color theory, and React+Tailwind CSS patterns.
- Leverage native parallel subagent dispatch and 200k+ context windows where available.
Activation Conditions
Use symptom -> action triggers: when one matches, apply this skill and verify with the protocol below.
Color & Design:
- Choosing color palettes for applications
- Applying the 60-30-10 design rule
- Creating accessible color combinations
- Designing backgrounds, text, and accent colors
- Triggered on color selection, UI color palette design, gradient creation
- Ensuring WCAG contrast compliance
UI Components & Layouts:
- Creating React UI components with Tailwind CSS
- Building forms, modals, cards, badges, buttons, inputs, tabs, tables
- Implementing responsive design patterns
- Designing accessible UI components
- Working with states, variants, and animations
Design Review & Correction:
- Reviewing website design (local or remote)
- Checking UI for consistency issues
- Finding and fixing layout breakage
- Detecting responsive design problems
- Fixing accessibility violations
- "Review website design", "check UI", "fix layout", "find design problems"
Part 1: Color Theory & Palettes
Color Categories
- Hot Colors: Oranges, reds, and yellows - energizing, attention-grabbing
- Cool Colors: Blues, greens, and purples - calming, professional
- Neutral Colors: Grays and grayscale variations - balancing, sophisticated
- Binary Colors: Black and white - high contrast, stark
The 60-30-10 Rule
Golden Ratio for Color Balance:
| Proportion | Role | Recommended Colors |
|---|---|---|
| 60% | Primary/Dominant | Cool or light colors, neutrals |
| 30% | Secondary | Complementary or analogous colors |
| 10% | Accent | Complementary hot color for emphasis |
Application in Code
/* Tailwind CSS CSS Variables Approach */
:root {
/* 60% - Primary (backgrounds, large areas) */
--color-primary-bg: #f5f7fa;
--color-primary-text: #374151;
/* 30% - Secondary (cards, sections) */
--color-secondary-bg: #ffffff;
--color-secondary-border: #e5e7eb;
--color-secondary-text: #1f2937;
/* 10% - Accent (buttons, highlights) */
--color-accent-primary: #3b82f6;
--color-accent-hover: #2563eb;
--color-accent-text: #ffffff;
}
/* Implementing in Tailwind config */
module.exports = {
theme: {
extend: {
colors: {
// 60% - Primary
primary: {
bg: 'var(--color-primary-bg)',
text: 'var(--color-primary-text)',
},
// 30% - Secondary
secondary: {
bg: 'var(--color-secondary-bg)',
border: 'var(--color-secondary-border)',
text: 'var(--color-secondary-text)',
},
// 10% - Accent
accent: {
primary: 'var(--color-accent-primary)',
hover: 'var(--color-accent-hover)',
text: 'var(--color-accent-text)',
},
},
},
},
};Part 2: Accessibility (WCAG Compliance)
Web Content Accessibility Guidelines (WCAG 2.1 Level AA
Contrast Requirements
| Element | Minimum Contrast Ratio | Recommended |
|---|---|---|
| Normal text (< 18pt) | 4.5:1 | 7:1 |
| Large text (18pt+) or bold | 3:1 | 4.5:1 |
| Graphical objects and UI components | 3:1 | Higher is better |
Contrast Validation
// Calculate contrast ratio
function getContrastRatio(foreground: string, background: string): number {
const lum1 = getLuminance(foreground);
const lum2 = getLuminance(background);
function getLuminance(hex: string): number {
const rgb = hexToRgb(hex);
const [r, g, b] = rgb.map(c => {
c /= 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * r[0] + 0.7152 * r[1] + 0.0722 * r[2];
}
const lighter = Math.max(lum1, lum2);
const darker = Math.min(lum1, lum2);
return (lighter + 0.05) / (darker + 0.05);
}
// Validate
const ratio = getContrastRatio('#3b82f6', '#ffffff');
console.log(ratio >= 4.5 ? '✅ WCAG AA compliant' : '❌ Not compliant');Accessibility Best Practices
Color Independence
/* ❌ BAD - Color-only indication */
.success {
color: green;
}
.error {
color: red;
}
/* ✅ GOOD - Color + other visual indicator */
.success {
color: #22c55e;
border-left: 4px solid #22c55e;
padding-left: 8px;
}
.error {
color: #ef4444;
border-left: 4px solid #ef4444;
padding-left: 8px;
}Focus States
/* Keyboard navigation needs visible focus */
button:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
/* Tailwind */
<button className="focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">ARIA Labels
// Icon-only buttons need labels
<button aria-label="Close dialog">
<CloseIcon />
</button>
// Toggle buttons need pressed state
<button aria-pressed={isSelected}>
{isSelected ? 'Selected' : 'Not selected'}
</button>
// Screen reader only text
<span className="sr-only">Required field</span>Responsive Typography
/* Use relative units for scalability */
html {
font-size: 100%; /* Browser default usually 16px */
}
body {
font-size: 1rem; /* 16px */
line-height: 1.5; /* Readable line height */
}
/* Responsive scaling */
@media (min-width: 768px) {
body {
font-size: 1.125rem; /* 18px on tablets+ */
}
}---
Part 3: Responsive Design
Mobile-First Approach
/* Base styles - mobile by default */
.container {
width: 100%;
padding: 1rem;
display: block; /* Column layout on mobile */
}
/* Tablet - 768px+ */
@media (min-width: 768px) {
.container {
max-width: 720px;
display: grid; /* Grid on tablet */
grid-template-columns: 1fr 1fr;
}
}
/* Desktop - 1024px+ */
@media (min-width: 1024px) {
.container {
max-width: 1200px;
grid-template-columns: 1fr 1fr 1fr;
}
}
/* Large desktop - 1280px+ */
@media (min-width: 1280px) {
.container {
max-width: 1400px;
}
}Tailwind Responsive Classes
// Mobile-first approach
<div className="
container
mx-auto
px-4 /* 16px padding on all sizes */
py-8
">
<div className="
grid
grid-cols-1 /* 1 column on mobile */
md:grid-cols-2 /* 2 columns on tablet */
lg:grid-cols-3 /* 3 columns on desktop */
gap-4
">
{items.map(item => (
<Card key={item.id}>{item.content}</Card>
))}
</div>
</div>Responsive Breakpoints (Tailwind Default)
| Breakpoint | Width | Device |
|---|---|---|
| sm | 640px | Small phones, portrait |
| md | 768px | Tablets, small laptops |
| lg | 1024px | Laptops, desktops |
| xl | 1280px | Large desktops |
| 2xl | 1536px | Extra large displays |
---
Part 4: UI Component Patterns
Button Component
const buttonVariants = {
primary: 'bg-blue-600 hover:bg-blue-700 text-white',
secondary: 'bg-gray-200 hover:bg-gray-300 text-gray-900',
danger: 'bg-red-600 hover:bg-red-700 text-white',
ghost: 'hover:bg-gray-100 text-gray-700',
outline: 'border-2 border-blue-600 text-blue-600 hover:bg-blue-50',
};
const buttonSizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-5 py-2.5 text-lg',
xl: 'px-6 py-3 text-xl',
};
export function Button({
variant = 'primary',
size = 'md',
disabled = false,
isLoading = false,
className = '',
children,
...props
}) {
return (
<button
disabled={disabled || isLoading}
className={cn(
// Base styles
'rounded-lg font-medium transition-all',
'focus:outline-none focus:ring-2 focus:ring-offset-2',
'disabled:opacity-50 disabled:cursor-not-allowed',
// Variant styles
buttonVariants[variant],
// Size styles
buttonSizes[size],
// Additional classes
className
)}
aria-busy={isLoading}
{...props}
>
{isLoading ? (
<LoadingSpinner size="sm" className="mr-2" />
) : null}
{children}
</button>
);
}Card Component
export function Card({
children,
variant = 'default',
hoverable = false,
className = '',
...props
}) {
const variants = {
default: 'bg-white border border-gray-200',
elevated: 'bg-white shadow-lg border border-gray-100',
outlined: 'bg-transparent border-2 border-gray-300',
// Interactive variants
hover: 'hover:shadow-xl transition-shadow duration-200',
};
return (
<div
className={cn(
'rounded-lg overflow-hidden',
variants.default,
variants[variant],
hoverable && variants.hover,
className
)}
{...props}
>
{children}
</div>
);
}
// Usage examples
<Card>
<CardHeader>
<CardTitle>Card Title</CardTitle>
</CardHeader>
<CardContent>
<p>Card content goes here.</p>
</CardContent>
</Card>Modal Component
export function Modal({
isOpen,
onClose,
title,
children,
size = 'md',
}) {
// Prevent body scroll when modal is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'unset';
}
return () => {
document.body.style.overflow = 'unset';
};
}, [isOpen]);
// Close on escape key
useEffect(() => {
const handleEscape = (e) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, onClose]);
if (!isOpen) return null;
const sizes = {
sm: 'max-w-md',
md: 'max-w-2xl',
lg: 'max-w-4xl',
xl: 'max-w-6xl',
};
return createPortal(
<div
className="fixed inset-0 z-50"
role="dialog"
aria-modal="true"
aria-labelledby={title}
>
<!-- Backdrop -->
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
<!-- Modal Container -->
<div
className="
relative
bg-white
rounded-lg
shadow-2xl
mx-4
my-8
max-h-[calc(100vh-4rem)]
overflow-y-auto
{sizes[size]}
"
>
<div className="flex items-center justify-between p-6 border-b">
<h2 id="modal-title" className="text-2xl font-bold">
{title}
</h2>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-full"
aria-label="Close modal"
>
<CloseIcon />
</button>
</div>
<div className="p-6">
{children}
</div>
</div>
</div>,
document.body
);
}Form Component
export function FormField({
label,
error,
hint,
required = false,
children,
}) {
return (
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
{children}
{hint && (
<p className="mt-1 text-sm text-gray-500">{hint}</p>
)}
{error && (
<p className="mt-1 text-sm text-red-600 flex items-center">
<ExclamationIcon className="w-4 h-4 mr-1" />
{error}
</p>
)}
</div>
);
}
// Usage
<FormField
label="Email Address"
error={errors.email}
hint="We'll never share your email."
required
>
<input
type="email"
className="w-full px-3 py-2 border rounded-lg"
{...register('email')}
/>
</FormField>---
Component Review Rubric
Use this shared rubric for React, Next.js, Vite, and premium UI work.
1. Contract: props, state, events, errors, and data ownership are explicit. 2. States: loading, empty, error, success, disabled, and responsive states are represented. 3. Accessibility: semantics, keyboard path, focus order, contrast, and reduced-motion behavior pass inspection. 4. Craft: spacing, hierarchy, typography, motion, and visual density feel intentional rather than default. 5. Performance: rendering, bundle impact, image strategy, and hydration boundaries match the framework.
Anti-Patterns
- Starting from a generic template without adapting it: The output may look polished but still miss the real audience or medium.
- Ignoring final render or export review: Layout bugs often appear only after the asset is opened in its destination tool.
- Fixing content and presentation in one pass: It becomes hard to tell whether a problem is structural or visual.
Verification Protocol
Before claiming "skill applied successfully":
1. Pass/fail: The Frontend Design guidance is tied to a concrete route, component, screen, or design artifact. 2. Pass/fail: Component states cover loading, empty, error, success, and responsive breakpoints where applicable. 3. Pass/fail: Accessibility, visual hierarchy, and interaction behavior are reviewed against the shared component rubric. 4. Pressure-test scenario: Review the component on a narrow mobile viewport, keyboard-only path, and slow-loading state. 5. Success metric: Zero generic UI approval; every approval cites rendered behavior or source evidence.
Part 5: Design Review Checklist
Visual Inspection Process
## Design Review Workflow
### Step 1: Information Gathering
- [ ] Understand target audience and use cases
- [ ] Identify design constraints (brand, accessibility)
- [ ] Review existing design system/component library
- [ ] Gather user feedback or pain points
### Step 2: Visual Inspection
- [ ] Capture screenshots of current implementation
- [ ] Review layout at multiple viewport sizes
- [ ] Test color contrast with accessibility tools
- [ ] Check spacing and alignment
- [ ] Verify hierarchy and readability
### Step 3: Issue Identification
- [ ] Document发现的问题s with severity ratings
- [ ] Group related issues together
- [ ] Prioritize by impact on UX
### Step 4: Issue Fixing
- [ ] Fix issues at source code level
- [ ] Test fixes across browsers and devices
- [ ] Verify accessibility improvements
- [ ] Get user/stakeholder validation
### Step 5: Re-verification
- [ ] Compare before/after results
- [ ] Ensure no regressions introduced
- [ ] Document changes madeCommon Design Issues
Layout & Spacing Issues
/* Issue: Inconsistent spacing */
.bad {
padding: 10px; /* Magic number */
margin: 5px; /* Different from padding */
}
/* Fix: Consistent spacing scale */
.good {
padding: 1rem; /* 16px - using scale */
gap: 0.5rem; /* 8px - consistent with scale */
}Color Contrast Issues
// Issue: Poor contrast
<button className="bg-blue-300 text-blue-100">
Can't read this
</button>
// Fix: WCAG compliant
<button className="bg-blue-600 text-white">
Readable
</button>Typography Issues
/* Issue: Line height too tight */
.bad {
line-height: 1; /* Can be hard to read */
}
/* Fix: Comfortable reading */
.good {
line-height: 1.6; /* Recommended for body text */
}Responsive Testing Checklist
## Responsive Testing
### Viewport Testing
- [ ] Mobile: 375px (iPhone SE)
- [ ] Mobile: 414px (iPhone Max)
- [ ] Tablet: 768px (iPad)
- [ ] Desktop: 1024px (Small laptop)
- [ ] Desktop: 1440px (Large desktop)
- [ ] Desktop: 1920px (Fullscreen)
### Design Elements to Check
- [ ] Navigation menu accessible on all sizes
- [ ] Text readable at all breakpoints
- [ ] Images scale correctly
- [ ] Forms usable without horizontal scroll
- [ ] Touch targets ≥ 44x44px on mobile
- [ ] No horizontal scrollbar
### Content Flow
- [ ] Content stacks vertically on mobile
- [ ] Content uses grid/multi-column on larger screens
- [ ] Important content above the fold on all sizes
- [ ] No content cut off or hidden---
Part 6: Design System Integration
Design Tokens
// tokens.js - Centralized design tokens
export const tokens = {
colors: {
brand: {
primary: '#3b82f6',
secondary: '#6366f1',
accent: '#f97316',
},
neutral: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
950: '#020617',
},
},
spacing: {
0: '0',
1: '0.25rem', /* 4px */
2: '0.5rem', /* 8px */
3: '0.75rem', /* 12px */
4: '1rem', /* 16px */
5: '1.25rem', /* 20px */
6: '1.5rem', /* 24px */
8: '2rem', /* 32px */
10: '2.5rem', /* 40px */
12: '3rem', /* 48px */
},
typography: {
fontSizes: {
xs: '0.75rem', /* 12px */
sm: '0.875rem', /* 14px */
base: '1rem', /* 16px */
lg: '1.125rem', /* 18px */
xl: '1.25rem', /* 20px */
'2xl': '1.5rem', /* 24px */
'3xl': '1.875rem', /* 30px */
'4xl': '2.25rem', /* 36px */
},
fontWeights: {
normal: '400',
medium: '500',
semibold: '600',
bold: '700',
},
lineHeights: {
tight: '1.25',
normal: '1.5',
relaxed: '1.75',
},
},
borderRadius: {
none: '0',
sm: '0.25rem', /* 4px */
DEFAULT: '0.375rem', /* 6px */
md: '0.5rem', /* 8px */
lg: '0.75rem', /* 12px */
xl: '1rem', /* 16px */
full: '9999px',
},
shadows: {
sm: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
DEFAULT: '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
md: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
lg: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
},
};
// Tailwind configuration with tokens
module.exports = {
theme: {
extend: {
colors: tokens.colors,
spacing: tokens.spacing,
fontSize: tokens.typography.fontSizes,
fontWeight: tokens.typography.fontWeights,
lineHeight: tokens.typography.lineHeights,
borderRadius: tokens.borderRadius,
boxShadow: tokens.shadows,
},
},
};---
Frontend Design Best Practices
Color & Visual Design
- [ ] Follow 60-30-10 rule for color balance
- [ ] Maintain contrast ratio ≥ 4.5:1 for normal text
- [ ] Use color for decoration, not sole indicator of meaning
- [ ] Document color palette and usage guidelines
- [ ] Test colorblind accessibility
Typography
- [ ] Use max-width for optimal reading length (~65-75 characters)
- [ ] Maintain consistent line height (1.5-1.75 for body text)
- [ ] Use relative font sizes (rem, em) for scalability
- [ ] Establish type scale and use consistently
- [ ] Ensure heading hierarchy is clear
Layout & Spacing
- [ ] Use consistent spacing scale (4px or 6px base)
- [ ] Ensure adequate white space for breathing room
- [ ] Align elements to grid for visual harmony
- [ ] Test responsive behavior at all breakpoints
- [ ] Maintain touch targets ≥ 44x44px on mobile
Accessibility
- [ ] Keyboard navigation works without mouse
- [ ] Focus states are clearly visible
- [ ] ARIA labels on icon-only buttons
- [ ] Form fields have associated labels
- [ ] Images have alt text (except decorative)
- [ ] Screen reader only text for visual-only info
- [ ] Skip navigation for reaching main content
Performance
- [ ] Optimize images (WebP, AVIF when supported)
- [ ] Use responsive images with srcset
- [ ] Implement code splitting for large bundles
- [ ] Lazy load offscreen images and components
- [ ] Minimize layout shifts (CLS) and paint issues (LCP)
---
References & Resources
Documentation
- Tailwind Component Patterns — Cards, forms, navigation, modals, tables, and skeleton loading patterns
- Accessibility Checklist — WCAG 2.2 checklist with React/HTML/ARIA code examples
Scripts
- Contrast Checker — Python WCAG contrast ratio checker with batch mode for CSS files
Examples
- Responsive Recipe Card — Complete React+Tailwind recipe card with responsive variants and states
---
<!-- PORTABILITY:START -->
Cross-Client Portability
This skill is written to stay usable across GitHub Copilot, Claude Code, Codex, and Gemini CLI.
- GitHub Copilot: keep the folder in a Copilot-visible skill or plugin path, or wrap the workflow as project instructions if the host does not support portable skill folders directly.
- Claude Code: keep the folder in a local skills directory or a compatible plugin or marketplace source.
- Codex: install or sync the folder into
$CODEX_HOME/skills/<skill-name>and restart Codex after major changes. - Gemini CLI: this repository generates a project command named
/skills:frontend-designfrom this skill. Rebuild commands withpython scripts/export-gemini-skill.py frontend-designand then run/commands reloadinside Gemini CLI.
<!-- PORTABILITY:END -->
<!-- MCP:START -->
MCP Availability And Fallback
Preferred MCP Server: None required
- Fallback prompt: "Use the Frontend Design skill without MCP. Rely on the local
SKILL.md, bundled references or scripts, and manual verification. Show the exact commands, evidence, and final checks you used before concluding." - If the current host does not expose a matching server, use the bundled references, scripts, native toolchain, and manual workflow already described in this skill.
- Treat direct local verification, rendered output, logs, tests, or screenshots as the fallback evidence path before completion.
<!-- MCP:END -->
Related Skills
- premium-frontend-ui: Use it when the workflow also needs high-fidelity UI polish and interaction detail.
- web-design-reviewer: Use it when the workflow also needs browser-based UI review and responsive QA.
- stitch-design: Use it when the workflow also needs turning interface designs into implementation-ready assets.
- canvas-design: Use it when the workflow also needs visual composition and presentation-ready diagram work.
Changelog
[2026-04-25] - Version 1.2 Verification Protocol Refresh
Added
- Added a
Verification Protocolsection with skill-specific pass/fail checks, one pressure-test scenario, and a measurable success metric. - Added guidance to leverage native parallel subagent dispatch and 200k+ context windows where available.
- Added or referenced the shared Component Review Rubric for frontend component review.
Changed
- Updated
SKILL.mdfrontmatter toversion: "1.2"andlast_updated: 2026-04-25. - Reframed activation guidance toward symptom -> action triggers and standardized two-stage review wording where applicable.
[2026-04-24] - Version 1.1 Refresh
Changed
- Updated the SKILL frontmatter version to
1.1for the 2026-04-24 catalog refresh.
[2026-04-24] - Skill Refresh
Changed
- Standardized the SKILL frontmatter with version metadata, last-updated date, tags, and a concise catalog description.
- Reformatted the portability and MCP guidance with a preferred server line, a copy-paste fallback prompt, and consistent bullet lists.
- Added a catalog-standard Anti-Patterns section and refreshed the Related Skills links at the end of the skill.
[2026-04-24] - Catalog Audit Cleanup
Fixed
- Removed obsolete standalone Skill Paths guidance that duplicated the generated portability section.
All notable changes to this skill will be documented in this file.
[2026-04-04] - Cross-Client Portability Refresh
Changed
- Added a standard portability note covering GitHub Copilot, Claude Code, Codex, and Gemini CLI.
- Clarified that the core workflow does not require a dedicated MCP server and can run with local tools alone.
Tested
- Validated
SKILL.mdfrontmatter, portability sections, and Gemini export readiness withpython scripts/validate-skills.py.
[2026-03-09] - Workspace Modernization
Added
- Added a 2026-03-09 maintenance entry after reviewing the skill; earlier activation fixes remained the only content changes needed.
[2026-03-01] — Activation Fix
Fixed
- Added generic "CSS" keyword alongside Tailwind — previously only matched "Tailwind CSS" prompts
- Added "wireframes" keyword for wireframe-related prompts
- Added "writing CSS" to use-case triggers
[2026-02-28] — Description Rewrite & Cross-References
Changed
- Rewrote skill description to ~200 characters with clear, specific activation keywords
- Improved keyword specificity to reduce overlap with related skills
Added
## Related Skillscross-reference table with 2-4 related skills and "Use When" guidance
Responsive Recipe Card — React + Tailwind CSS
A complete recipe card component for Kitchen Odyssey with responsive layouts, loading/error/empty states, and hover effects.
---
Overview
| Breakpoint | Layout | Columns |
|---|---|---|
| Mobile (< 640px) | Stacked vertical card | 1 column |
| Tablet (640px–1023px) | Horizontal card | 1 column (side-by-side image + content) |
| Desktop (≥ 1024px) | Vertical cards in grid | 3 columns |
---
Tailwind Config Customizations
Add these to tailwind.config.js for the recipe card theme:
export default {
theme: {
extend: {
colors: {
recipe: {
50: '#fef7f0',
100: '#fdebd5',
200: '#fad4aa',
300: '#f6b574',
400: '#f18d3c',
500: '#ee7316',
600: '#df590c',
700: '#b9420c',
800: '#933512',
900: '#772d12',
},
},
animation: {
'shimmer': 'shimmer 2s infinite linear',
},
keyframes: {
shimmer: {
'0%': { backgroundPosition: '-200% 0' },
'100%': { backgroundPosition: '200% 0' },
},
},
},
},
};---
RecipeCard Component
import { useState } from 'react';
const DIFFICULTY_CONFIG = {
easy: { label: 'Easy', className: 'bg-green-100 text-green-700' },
medium: { label: 'Medium', className: 'bg-amber-100 text-amber-700' },
hard: { label: 'Hard', className: 'bg-red-100 text-red-700' },
};
function DifficultyBadge({ difficulty }) {
const config = DIFFICULTY_CONFIG[difficulty] || DIFFICULTY_CONFIG.easy;
return (
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${config.className}`}>
{config.label}
</span>
);
}
function StarRating({ rating, max = 5 }) {
return (
<div className="flex items-center gap-0.5" aria-label={`${rating} out of ${max} stars`}>
{Array.from({ length: max }, (_, i) => (
<svg
key={i}
className={`h-4 w-4 ${i < Math.round(rating) ? 'text-amber-400' : 'text-gray-300'}`}
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
))}
<span className="ml-1 text-xs text-gray-500">({rating})</span>
</div>
);
}
export default function RecipeCard({ recipe, onClick }) {
const [imgError, setImgError] = useState(false);
return (
<article
onClick={onClick}
className="
group cursor-pointer overflow-hidden rounded-xl bg-white shadow-md
transition-all duration-200 hover:shadow-xl hover:-translate-y-0.5
/* Mobile: stacked vertical */
flex flex-col
/* Tablet: horizontal */
sm:flex-row sm:h-48
/* Desktop: back to vertical (grid handles columns) */
lg:flex-col lg:h-auto
"
>
{/* Image */}
<div className="
relative overflow-hidden bg-gray-100
h-48 w-full flex-shrink-0
sm:h-full sm:w-44
lg:h-48 lg:w-full
">
{imgError ? (
<div className="flex h-full w-full items-center justify-center bg-gray-100 text-gray-400">
<svg className="h-12 w-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
) : (
<img
src={recipe.image}
alt={recipe.title}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
onError={() => setImgError(true)}
loading="lazy"
/>
)}
{/* Overlay badges */}
{recipe.featured && (
<span className="absolute left-2 top-2 rounded-full bg-recipe-500 px-2.5 py-0.5 text-xs font-semibold text-white shadow-sm">
Featured
</span>
)}
<span className="absolute right-2 top-2 rounded-full bg-black/60 px-2 py-0.5 text-xs text-white">
{recipe.prepTime} min
</span>
</div>
{/* Content */}
<div className="flex flex-1 flex-col justify-between p-4">
<div>
<div className="flex items-start justify-between gap-2">
<h3 className="line-clamp-1 text-base font-semibold text-gray-900 group-hover:text-recipe-600 transition-colors">
{recipe.title}
</h3>
<DifficultyBadge difficulty={recipe.difficulty} />
</div>
<p className="mt-1 line-clamp-2 text-sm text-gray-500">
{recipe.description}
</p>
</div>
<div className="mt-3 flex items-center justify-between">
<StarRating rating={recipe.rating} />
<span className="text-xs text-gray-400">{recipe.servings} servings</span>
</div>
</div>
</article>
);
}---
RecipeCardSkeleton — Loading State
export function RecipeCardSkeleton() {
return (
<div className="
animate-pulse overflow-hidden rounded-xl bg-white shadow-md
flex flex-col
sm:flex-row sm:h-48
lg:flex-col lg:h-auto
">
{/* Image skeleton */}
<div className="
h-48 w-full flex-shrink-0 bg-gray-200
sm:h-full sm:w-44
lg:h-48 lg:w-full
" />
{/* Content skeleton */}
<div className="flex flex-1 flex-col justify-between p-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="h-5 w-3/5 rounded bg-gray-200" />
<div className="h-5 w-14 rounded-full bg-gray-200" />
</div>
<div className="h-4 w-full rounded bg-gray-200" />
<div className="h-4 w-2/3 rounded bg-gray-200" />
</div>
<div className="mt-3 flex items-center justify-between">
<div className="flex gap-0.5">
{Array.from({ length: 5 }, (_, i) => (
<div key={i} className="h-4 w-4 rounded bg-gray-200" />
))}
</div>
<div className="h-4 w-16 rounded bg-gray-200" />
</div>
</div>
</div>
);
}---
RecipeCardError — Error State
export function RecipeCardError({ message = 'Failed to load recipe', onRetry }) {
return (
<div className="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-red-200 bg-red-50 p-8 text-center">
<svg className="h-10 w-10 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<p className="mt-2 text-sm font-medium text-red-700">{message}</p>
{onRetry && (
<button
onClick={onRetry}
className="mt-3 rounded-lg bg-red-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-red-700 transition-colors"
>
Retry
</button>
)}
</div>
);
}---
RecipeCardEmpty — Empty State
export function RecipeCardEmpty({ message = 'No recipes found' }) {
return (
<div className="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-gray-200 bg-gray-50 p-12 text-center">
<svg className="h-12 w-12 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
<p className="mt-3 text-sm font-medium text-gray-500">{message}</p>
<p className="mt-1 text-xs text-gray-400">Try adjusting your search or filters.</p>
</div>
);
}---
RecipeCardGrid — Responsive Grid Container
import RecipeCard, { RecipeCardSkeleton } from './RecipeCard';
import { RecipeCardError, RecipeCardEmpty } from './RecipeCardStates';
export default function RecipeCardGrid({ recipes, loading, error, onRetry, onCardClick }) {
if (error) {
return <RecipeCardError message={error} onRetry={onRetry} />;
}
if (loading) {
return (
<div className="grid gap-6 sm:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }, (_, i) => (
<RecipeCardSkeleton key={i} />
))}
</div>
);
}
if (!recipes || recipes.length === 0) {
return <RecipeCardEmpty />;
}
return (
<div className="grid gap-6 sm:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
{recipes.map((recipe) => (
<RecipeCard
key={recipe.id}
recipe={recipe}
onClick={() => onCardClick?.(recipe.id)}
/>
))}
</div>
);
}---
Showcase: All Variants
Below is a demonstration page rendering every state. Use this as a local preview or Storybook entry.
import { useState } from 'react';
import RecipeCard, { RecipeCardSkeleton } from './RecipeCard';
import { RecipeCardError, RecipeCardEmpty } from './RecipeCardStates';
import RecipeCardGrid from './RecipeCardGrid';
const MOCK_RECIPES = [
{
id: '1',
title: 'Thai Green Curry',
description: 'Creamy coconut curry with fresh vegetables and aromatic herbs.',
image: '/images/thai-curry.jpg',
prepTime: 35,
difficulty: 'medium',
rating: 4.8,
servings: 4,
featured: true,
},
{
id: '2',
title: 'Classic Margherita Pizza',
description: 'Simple, fresh pizza with San Marzano tomatoes and buffalo mozzarella.',
image: '/images/pizza.jpg',
prepTime: 45,
difficulty: 'easy',
rating: 4.6,
servings: 2,
featured: false,
},
{
id: '3',
title: 'Beef Wellington',
description: 'Tenderloin wrapped in mushroom duxelles and golden puff pastry.',
image: '/images/wellington.jpg',
prepTime: 120,
difficulty: 'hard',
rating: 4.9,
servings: 6,
featured: false,
},
{
id: '4',
title: 'Overnight Oats',
description: 'Healthy make-ahead breakfast with oats, yogurt, and fresh berries.',
image: '/images/oats.jpg',
prepTime: 10,
difficulty: 'easy',
rating: 4.2,
servings: 1,
featured: false,
},
{
id: '5',
title: 'Pad Thai',
description: 'Stir-fried rice noodles with shrimp, peanuts, and tamarind sauce.',
image: '/images/pad-thai.jpg',
prepTime: 30,
difficulty: 'medium',
rating: 4.7,
servings: 3,
featured: true,
},
{
id: '6',
title: 'French Onion Soup',
description: 'Caramelized onion soup with crusty bread and melted Gruyere cheese.',
image: '/images/onion-soup.jpg',
prepTime: 60,
difficulty: 'medium',
rating: 4.5,
servings: 4,
featured: false,
},
];
export default function RecipeCardShowcase() {
const [activeView, setActiveView] = useState('grid');
const views = [
{ id: 'grid', label: 'Grid (Normal)' },
{ id: 'loading', label: 'Loading' },
{ id: 'error', label: 'Error' },
{ id: 'empty', label: 'Empty' },
{ id: 'single', label: 'Single Card' },
];
return (
<div className="mx-auto max-w-7xl px-4 py-8">
<h1 className="text-2xl font-bold text-gray-900">Recipe Card — All Variants</h1>
{/* View selector */}
<div className="mt-4 flex flex-wrap gap-2">
{views.map((view) => (
<button
key={view.id}
onClick={() => setActiveView(view.id)}
className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
activeView === view.id
? 'bg-recipe-500 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{view.label}
</button>
))}
</div>
{/* Variant display */}
<div className="mt-8">
{activeView === 'grid' && (
<RecipeCardGrid
recipes={MOCK_RECIPES}
loading={false}
error={null}
onCardClick={(id) => console.log('Clicked recipe:', id)}
/>
)}
{activeView === 'loading' && (
<RecipeCardGrid recipes={[]} loading={true} error={null} />
)}
{activeView === 'error' && (
<RecipeCardGrid
recipes={[]}
loading={false}
error="Failed to load recipes. Server returned 500."
onRetry={() => console.log('Retry clicked')}
/>
)}
{activeView === 'empty' && (
<RecipeCardGrid recipes={[]} loading={false} error={null} />
)}
{activeView === 'single' && (
<div className="max-w-sm">
<RecipeCard
recipe={MOCK_RECIPES[0]}
onClick={() => console.log('Card clicked')}
/>
</div>
)}
</div>
</div>
);
}---
Accessibility Notes
- Card uses
<article>for semantic grouping - Image has meaningful
alttext (recipe title) - Star rating uses
aria-labelfor screen reader context - Fallback image state avoids broken image icons
- Touch targets exceed 44x44px minimum
- Colors meet WCAG AA contrast requirements
loading="lazy"on images for performanceline-clampprevents layout shifts from long titles
MIT License
Copyright (c) 2026 Sithu Win San
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.WCAG 2.2 Accessibility Checklist
Practical accessibility checklist for web developers. Organized by WCAG principle with code examples, testing methods, and common fixes.
---
1. Perceivable
Content must be presentable to users in ways they can perceive.
1.1 Text Alternatives (Level A)
What to check: Every non-text element (images, icons, charts) has a text alternative.
How to check:
- Inspect all
<img>tags foraltattributes - Check icon buttons for
aria-labelor screen-reader-only text - Verify decorative images use
alt=""
Compliant examples:
<!-- Informative image -->
<img src="/recipe.jpg" alt="Thai green curry with jasmine rice in a white bowl" />
<!-- Decorative image (no alt needed) -->
<img src="/divider.svg" alt="" role="presentation" />
<!-- Icon button -->
<button aria-label="Delete recipe">
<svg aria-hidden="true">...</svg>
</button>
<!-- Icon with visible label (no aria-label needed) -->
<button>
<svg aria-hidden="true">...</svg>
<span>Delete</span>
</button>Common violations:
- Missing
alton<img>tags - Icon-only buttons without
aria-label alt="image"oralt="photo"— describe the content, not the format
1.2 Time-Based Media (Level A/AA)
What to check: Audio and video content has captions and/or transcripts.
How to check:
- Verify
<video>elements include<track>for captions - Check for transcript links near media elements
<video controls>
<source src="/demo.mp4" type="video/mp4" />
<track kind="captions" src="/demo-captions.vtt" srclang="en" label="English" default />
</video>1.3 Adaptable — Info and Relationships (Level A)
What to check: Semantic HTML conveys structure and relationships.
How to check:
- Headings use
<h1>–<h6>in logical order - Lists use
<ul>,<ol>,<li> - Tables use
<th>withscopeattributes - Form inputs have associated
<label>elements
<!-- Form with proper labels -->
<div>
<label for="recipe-name">Recipe Name</label>
<input id="recipe-name" type="text" />
</div>
<!-- Table with proper headers -->
<table>
<thead>
<tr>
<th scope="col">Ingredient</th>
<th scope="col">Amount</th>
</tr>
</thead>
<tbody>
<tr>
<td>Coconut milk</td>
<td>400ml</td>
</tr>
</tbody>
</table>Common violations:
- Using
<div>or<span>where semantic elements exist - Inputs without associated labels (placeholder is NOT a label)
- Heading hierarchy skips (H2 → H4)
1.4 Distinguishable — Color Contrast (Level AA)
What to check:
- Normal text (< 18pt / < 14pt bold): contrast ratio >= 4.5:1
- Large text (>= 18pt / >= 14pt bold): contrast ratio >= 3:1
- UI components (borders, icons, focus indicators): contrast ratio >= 3:1
How to check:
- Browser DevTools → Accessibility panel → Contrast ratio
- Use the
contrast-checker.pyscript from this skill - Chrome Lighthouse accessibility audit
Common violations:
- Light gray text on white:
#999on#fff= 2.85:1 (FAIL) - Placeholder text too light: ensure >= 4.5:1
- Disabled states still need 3:1 against background
1.4 Distinguishable — Non-Text Contrast (Level AA)
What to check: UI components and graphical objects have >= 3:1 contrast against adjacent colors.
<!-- Input border must contrast with background -->
<input class="border border-gray-400 bg-white ..." />
<!-- gray-400 (#9ca3af) on white = 3.04:1 — passes for UI components -->
<!-- Focus ring must be visible -->
<input class="... focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2" />1.4 Distinguishable — Resize Text (Level AA)
What to check: Text can scale to 200% without loss of content.
How to check:
- Browser zoom to 200% — verify no content is clipped or overlapping
- Verify layout adapts (use
rem/emunits, not fixedpxfor text)
---
2. Operable
User interface components must be operable by all users.
2.1 Keyboard Accessible (Level A)
What to check: All interactive elements are reachable and operable with keyboard alone.
How to check:
- Tab through the entire page — every interactive element should receive focus
- Enter/Space activates buttons and links
- Arrow keys navigate within composite widgets (tabs, menus, radio groups)
- Escape closes modals and popups
- No keyboard traps (user can always Tab away)
Keyboard navigation patterns:
// React: Tab panel with arrow key navigation
function TabList({ tabs, activeTab, onSelect }) {
const handleKeyDown = (e, index) => {
let newIndex;
if (e.key === 'ArrowRight') {
newIndex = (index + 1) % tabs.length;
} else if (e.key === 'ArrowLeft') {
newIndex = (index - 1 + tabs.length) % tabs.length;
} else if (e.key === 'Home') {
newIndex = 0;
} else if (e.key === 'End') {
newIndex = tabs.length - 1;
} else {
return;
}
e.preventDefault();
onSelect(newIndex);
};
return (
<div role="tablist">
{tabs.map((tab, i) => (
<button
key={tab.id}
role="tab"
aria-selected={i === activeTab}
tabIndex={i === activeTab ? 0 : -1}
onKeyDown={(e) => handleKeyDown(e, i)}
onClick={() => onSelect(i)}
>
{tab.label}
</button>
))}
</div>
);
}Common violations:
- Custom buttons using
<div onClick>withouttabIndex,role, oronKeyDown - Modals that don't trap focus
- Dropdown menus that can't be navigated with arrow keys
2.1 No Keyboard Trap (Level A)
What to check: Focus can always move away from any component.
Modal focus trap (correct implementation):
import { useEffect, useRef } from 'react';
function Modal({ isOpen, onClose, children }) {
const modalRef = useRef(null);
useEffect(() => {
if (!isOpen) return;
const modal = modalRef.current;
const focusableEls = modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstEl = focusableEls[0];
const lastEl = focusableEls[focusableEls.length - 1];
firstEl?.focus();
function trapFocus(e) {
if (e.key === 'Tab') {
if (e.shiftKey && document.activeElement === firstEl) {
e.preventDefault();
lastEl.focus();
} else if (!e.shiftKey && document.activeElement === lastEl) {
e.preventDefault();
firstEl.focus();
}
}
if (e.key === 'Escape') onClose();
}
modal.addEventListener('keydown', trapFocus);
return () => modal.removeEventListener('keydown', trapFocus);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div role="dialog" aria-modal="true" ref={modalRef}>
{children}
</div>
);
}2.4 Focus Visible (Level AA)
What to check: All interactive elements show a visible focus indicator.
/* Never do this globally */
/* *:focus { outline: none; } */
/* Do this instead — custom focus style */
*:focus-visible {
outline: 2px solid #4f46e5;
outline-offset: 2px;
}
/* Tailwind equivalent */
/* focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 */2.4 Page Titled (Level A)
What to check: Every page has a descriptive <title>.
// React with document title
useEffect(() => {
document.title = 'Thai Green Curry — Kitchen Odyssey';
}, []);2.4 Focus Order (Level A)
What to check: Focus order follows a logical reading sequence.
How to check: Tab through the page — focus should move top-to-bottom, left-to-right (for LTR languages), matching visual layout.
Common violations:
- CSS
orderorflex-direction: row-reversechanges visual order but not DOM/focus order - Dynamically-inserted content placed at the wrong DOM position
2.5 Target Size (Level AA — New in WCAG 2.2)
What to check: Interactive targets are at least 24x24 CSS pixels, with exceptions for inline text links.
<!-- Minimum touch target -->
<button class="min-h-[44px] min-w-[44px] p-2">
<svg class="h-5 w-5">...</svg>
</button>---
3. Understandable
Information and UI operation must be understandable.
3.1 Language of Page (Level A)
What to check: The <html> element declares the page language.
<html lang="en">3.2 Labels or Instructions (Level A)
What to check: Form fields have visible labels and required fields are identified.
<label for="servings">
Number of Servings <span class="text-red-500" aria-hidden="true">*</span>
<span class="sr-only">(required)</span>
</label>
<input id="servings" type="number" required aria-required="true" min="1" />3.3 Error Identification (Level A)
What to check: Errors are clearly identified and described in text (not just color).
function FormField({ label, error, id, ...props }) {
const errorId = `${id}-error`;
return (
<div>
<label htmlFor={id}>{label}</label>
<input
id={id}
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
{...props}
/>
{error && (
<p id={errorId} role="alert" className="mt-1 text-sm text-red-600">
{error}
</p>
)}
</div>
);
}3.3 Error Suggestion (Level AA)
What to check: Suggestions for correction are provided when input errors are detected.
<p id="email-error" role="alert" class="text-sm text-red-600">
Please enter a valid email address (e.g., user@example.com).
</p>3.2 Consistent Navigation (Level AA)
What to check: Navigation appears in the same relative order across pages.
3.2 On Input (Level A)
What to check: Changing a form control does not cause an unexpected context change (page navigation, modal opening, focus shift) unless the user is warned.
---
4. Robust
Content must be robust enough to work with current and future technologies.
4.1 Parsing / Valid HTML (Level A)
What to check:
- No duplicate
idattributes - All elements are properly nested and closed
- ARIA attributes use valid values
How to check:
- W3C Validator: https://validator.w3.org/
axe-corebrowser extension- ESLint
eslint-plugin-jsx-a11y
4.1 Name, Role, Value (Level A)
What to check: Custom components expose correct ARIA semantics.
// Custom toggle/switch
function Toggle({ checked, onChange, label }) {
return (
<button
role="switch"
aria-checked={checked}
aria-label={label}
onClick={() => onChange(!checked)}
className={`relative h-6 w-11 rounded-full transition-colors ${
checked ? 'bg-indigo-600' : 'bg-gray-300'
}`}
>
<span
className={`block h-5 w-5 rounded-full bg-white shadow transition-transform ${
checked ? 'translate-x-5' : 'translate-x-0.5'
}`}
/>
</button>
);
}4.1 Status Messages (Level AA)
What to check: Status messages (search results count, form submission confirmation, loading states) are announced to screen readers without receiving focus.
<!-- Live region for dynamic updates -->
<div aria-live="polite" aria-atomic="true" class="sr-only">
12 recipes found matching "chicken"
</div>
<!-- Toast/notification -->
<div role="status" aria-live="polite">
Recipe saved successfully.
</div>
<!-- Error alert -->
<div role="alert">
Failed to save recipe. Please try again.
</div>---
Screen Reader Patterns
Visually Hidden (Screen Reader Only) Text
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}Tailwind: class="sr-only"
Skip Navigation Link
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:rounded focus:bg-indigo-600 focus:px-4 focus:py-2 focus:text-white">
Skip to main content
</a>
<!-- ... header/nav ... -->
<main id="main-content" tabindex="-1">
<!-- Main content -->
</main>Announcing Dynamic Content
function SearchResults({ results, query }) {
return (
<>
{/* Announced to screen readers when results change */}
<div role="status" aria-live="polite" className="sr-only">
{results.length} recipes found for "{query}"
</div>
<h2>{results.length} Results</h2>
<ul>
{results.map((r) => (
<li key={r.id}>{r.title}</li>
))}
</ul>
</>
);
}---
Focus Management Patterns
Return Focus After Modal Close
function useModal() {
const [isOpen, setIsOpen] = useState(false);
const triggerRef = useRef(null);
const open = () => setIsOpen(true);
const close = () => {
setIsOpen(false);
triggerRef.current?.focus();
};
return { isOpen, open, close, triggerRef };
}
function App() {
const { isOpen, open, close, triggerRef } = useModal();
return (
<>
<button ref={triggerRef} onClick={open}>
Open Settings
</button>
{isOpen && <Modal onClose={close}>...</Modal>}
</>
);
}Focus on Route Change (SPA)
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
function FocusOnRouteChange() {
const location = useLocation();
const mainRef = useRef(null);
useEffect(() => {
mainRef.current?.focus();
}, [location.pathname]);
return <main ref={mainRef} tabIndex={-1}>{/* page content */}</main>;
}---
Testing Tools
| Tool | Type | What It Catches |
|---|---|---|
| axe DevTools | Browser extension | Automated WCAG violations |
| Lighthouse | Built into Chrome | Accessibility scoring |
| eslint-plugin-jsx-a11y | Linter | React-specific a11y issues |
| NVDA / VoiceOver | Screen reader | Real-world screen reader behavior |
| Keyboard only | Manual testing | Focus order, keyboard traps, visibility |
| Color contrast analyzers | Manual/automated | Contrast ratio compliance |
| WAVE | Browser extension | Visual overlay of a11y issues |
Quick Manual Test (5-minute check)
1. Tab through page — Can you reach and operate every interactive element? 2. Screen reader — Turn on VoiceOver (Mac) or NVDA (Windows), navigate the page. Does it make sense? 3. Zoom to 200% — Is content still readable without horizontal scrolling? 4. Check forms — Do all inputs have labels? Are errors described in text? 5. Check images — Do informative images have meaningful alt text?
---
Checklist Summary
Level A (Minimum)
- [ ] All images have appropriate
alttext - [ ] Videos have captions
- [ ] Semantic HTML used (headings, lists, tables, landmarks)
- [ ] Page has
<html lang="..."> - [ ] All form inputs have labels
- [ ] No keyboard traps
- [ ] All functionality keyboard accessible
- [ ] Focus order is logical
- [ ] Page has descriptive
<title> - [ ] Errors identified in text (not just color)
- [ ] No auto-playing media
- [ ] Custom controls have name, role, value
Level AA (Standard Compliance)
- [ ] Color contrast >= 4.5:1 (normal text) / >= 3:1 (large text)
- [ ] Non-text contrast >= 3:1 (UI components)
- [ ] Text resizable to 200% without loss
- [ ] Focus indicator visible on all interactive elements
- [ ] Consistent navigation across pages
- [ ] Error suggestions provided
- [ ] Status messages announced via
aria-liveorrole - [ ] Target size >= 24x24 CSS pixels
- [ ] Skip navigation link present
- [ ] Heading hierarchy logical (no skipped levels)
Tailwind CSS Component Patterns
Production-ready component patterns with complete HTML + Tailwind CSS code. Copy, adapt, and compose.
---
Table of Contents
1. Responsive Containers 2. Card Layouts 3. Form Patterns 4. Navigation Patterns 5. Modal / Dialog 6. Toast / Notification 7. Table Patterns 8. Skeleton Loading
---
Responsive Containers
Centered Container with Max Width
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<!-- Content -->
</div>Responsive Padding Container
<div class="px-4 py-6 sm:px-6 sm:py-8 lg:px-8 lg:py-12">
<!-- Content adapts padding at each breakpoint -->
</div>Full-Bleed with Constrained Content
<div class="bg-gray-50">
<div class="mx-auto max-w-5xl px-4 py-12 sm:px-6 lg:px-8">
<!-- Full-width background, centered content -->
</div>
</div>---
Card Layouts
Vertical Card
<div class="overflow-hidden rounded-xl bg-white shadow-md transition-shadow hover:shadow-lg">
<img
src="/recipe.jpg"
alt="Recipe thumbnail"
class="h-48 w-full object-cover"
/>
<div class="p-5">
<h3 class="text-lg font-semibold text-gray-900">Card Title</h3>
<p class="mt-1 text-sm text-gray-500">Short description goes here.</p>
<div class="mt-4 flex items-center justify-between">
<span class="text-sm font-medium text-indigo-600">$12.99</span>
<button class="rounded-lg bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-700">
View
</button>
</div>
</div>
</div>Horizontal Card
<div class="flex overflow-hidden rounded-xl bg-white shadow-md transition-shadow hover:shadow-lg">
<img
src="/recipe.jpg"
alt="Recipe thumbnail"
class="h-auto w-40 flex-shrink-0 object-cover sm:w-48"
/>
<div class="flex flex-1 flex-col justify-between p-5">
<div>
<h3 class="text-lg font-semibold text-gray-900">Card Title</h3>
<p class="mt-1 text-sm text-gray-500">
Description that wraps to multiple lines on smaller widths.
</p>
</div>
<div class="mt-3 flex items-center gap-2">
<span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-700">
Easy
</span>
<span class="text-xs text-gray-400">25 min</span>
</div>
</div>
</div>Responsive Card Grid
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<!-- Card items -->
<div class="overflow-hidden rounded-xl bg-white shadow-md">...</div>
<div class="overflow-hidden rounded-xl bg-white shadow-md">...</div>
<div class="overflow-hidden rounded-xl bg-white shadow-md">...</div>
<div class="overflow-hidden rounded-xl bg-white shadow-md">...</div>
</div>Card with Overlay Badge
<div class="group relative overflow-hidden rounded-xl bg-white shadow-md">
<img src="/recipe.jpg" alt="Recipe" class="h-48 w-full object-cover transition-transform group-hover:scale-105" />
<span class="absolute left-3 top-3 rounded-full bg-black/60 px-2.5 py-1 text-xs font-medium text-white">
Featured
</span>
<div class="p-5">
<h3 class="font-semibold text-gray-900">Recipe Name</h3>
<p class="mt-1 text-sm text-gray-500">Quick weeknight dinner.</p>
</div>
</div>---
Form Patterns
Floating Label Input
<div class="relative">
<input
id="email"
type="email"
placeholder=" "
class="peer w-full rounded-lg border border-gray-300 px-3 pb-2 pt-5 text-sm text-gray-900 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
<label
for="email"
class="absolute left-3 top-1 text-xs text-gray-500 transition-all peer-placeholder-shown:top-3.5 peer-placeholder-shown:text-sm peer-focus:top-1 peer-focus:text-xs peer-focus:text-indigo-600"
>
Email Address
</label>
</div>Inline Validation Input
<!-- Success state -->
<div>
<label for="username" class="block text-sm font-medium text-gray-700">Username</label>
<div class="relative mt-1">
<input
id="username"
type="text"
value="johndoe"
class="w-full rounded-lg border border-green-500 px-3 py-2 pr-10 text-sm text-gray-900 focus:outline-none focus:ring-1 focus:ring-green-500"
/>
<span class="absolute inset-y-0 right-3 flex items-center text-green-500">
<!-- Checkmark icon -->
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
</span>
</div>
<p class="mt-1 text-xs text-green-600">Username is available.</p>
</div>
<!-- Error state -->
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
<div class="relative mt-1">
<input
id="email"
type="email"
value="invalid-email"
class="w-full rounded-lg border border-red-500 px-3 py-2 pr-10 text-sm text-gray-900 focus:outline-none focus:ring-1 focus:ring-red-500"
/>
<span class="absolute inset-y-0 right-3 flex items-center text-red-500">
<!-- Exclamation icon -->
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
</span>
</div>
<p class="mt-1 text-xs text-red-600">Please enter a valid email address.</p>
</div>Multi-Step Form
<!-- Step indicator -->
<nav class="mb-8">
<ol class="flex items-center">
<!-- Completed step -->
<li class="flex items-center">
<span class="flex h-8 w-8 items-center justify-center rounded-full bg-indigo-600 text-sm font-medium text-white">
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
</span>
<span class="ml-2 text-sm font-medium text-indigo-600">Account</span>
</li>
<!-- Connector -->
<li class="mx-4 h-0.5 w-12 bg-indigo-600 sm:w-20"></li>
<!-- Current step -->
<li class="flex items-center">
<span class="flex h-8 w-8 items-center justify-center rounded-full border-2 border-indigo-600 text-sm font-medium text-indigo-600">
2
</span>
<span class="ml-2 text-sm font-medium text-indigo-600">Details</span>
</li>
<!-- Connector -->
<li class="mx-4 h-0.5 w-12 bg-gray-300 sm:w-20"></li>
<!-- Upcoming step -->
<li class="flex items-center">
<span class="flex h-8 w-8 items-center justify-center rounded-full border-2 border-gray-300 text-sm font-medium text-gray-400">
3
</span>
<span class="ml-2 text-sm font-medium text-gray-400">Confirm</span>
</li>
</ol>
</nav>
<!-- Form content area -->
<div class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
<h2 class="text-lg font-semibold text-gray-900">Step 2: Your Details</h2>
<div class="mt-4 space-y-4">
<!-- Form fields for this step -->
</div>
<div class="mt-6 flex justify-between">
<button class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">
Back
</button>
<button class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700">
Continue
</button>
</div>
</div>---
Navigation Patterns
Top Navbar with Mobile Hamburger
<header class="border-b border-gray-200 bg-white">
<div class="mx-auto flex max-w-7xl items-center justify-between px-4 py-3 sm:px-6 lg:px-8">
<!-- Logo -->
<a href="/" class="text-xl font-bold text-indigo-600">Kitchen Odyssey</a>
<!-- Desktop nav -->
<nav class="hidden items-center gap-6 md:flex">
<a href="/" class="text-sm font-medium text-gray-700 hover:text-indigo-600">Home</a>
<a href="/search" class="text-sm font-medium text-gray-700 hover:text-indigo-600">Search</a>
<a href="/create" class="text-sm font-medium text-gray-700 hover:text-indigo-600">Create</a>
<a href="/profile" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700">
Profile
</a>
</nav>
<!-- Mobile hamburger button -->
<button class="rounded-lg p-2 text-gray-500 hover:bg-gray-100 md:hidden" aria-label="Open menu">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
</button>
</div>
<!-- Mobile menu (toggle visibility with JS) -->
<nav class="border-t border-gray-200 bg-white px-4 py-3 md:hidden">
<div class="flex flex-col gap-2">
<a href="/" class="rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">Home</a>
<a href="/search" class="rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">Search</a>
<a href="/create" class="rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">Create</a>
<a href="/profile" class="rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">Profile</a>
</div>
</nav>
</header>Sidebar Navigation
<aside class="flex h-screen w-64 flex-col border-r border-gray-200 bg-white">
<!-- Logo -->
<div class="flex h-16 items-center border-b border-gray-200 px-6">
<span class="text-lg font-bold text-indigo-600">Admin</span>
</div>
<!-- Nav links -->
<nav class="flex-1 overflow-y-auto px-3 py-4">
<ul class="space-y-1">
<!-- Active item -->
<li>
<a href="/admin/stats" class="flex items-center gap-3 rounded-lg bg-indigo-50 px-3 py-2 text-sm font-medium text-indigo-700">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0h4"/></svg>
Dashboard
</a>
</li>
<!-- Inactive item -->
<li>
<a href="/admin/recipes" class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
Recipes
</a>
</li>
<li>
<a href="/admin/users" class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
Users
</a>
</li>
</ul>
</nav>
<!-- Footer -->
<div class="border-t border-gray-200 px-3 py-3">
<a href="/logout" class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
Logout
</a>
</div>
</aside>Breadcrumbs
<nav class="flex" aria-label="Breadcrumb">
<ol class="flex items-center gap-1.5 text-sm">
<li>
<a href="/" class="text-gray-500 hover:text-gray-700">Home</a>
</li>
<li class="text-gray-400">/</li>
<li>
<a href="/recipes" class="text-gray-500 hover:text-gray-700">Recipes</a>
</li>
<li class="text-gray-400">/</li>
<li>
<span class="font-medium text-gray-900" aria-current="page">Thai Green Curry</span>
</li>
</ol>
</nav>---
Modal / Dialog
Centered Modal with Backdrop
<!-- Backdrop -->
<div class="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm" aria-hidden="true"></div>
<!-- Modal -->
<div class="fixed inset-0 z-50 flex items-center justify-center p-4" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
<!-- Header -->
<div class="flex items-start justify-between">
<h2 id="modal-title" class="text-lg font-semibold text-gray-900">Delete Recipe?</h2>
<button class="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-500" aria-label="Close">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Body -->
<p class="mt-3 text-sm text-gray-500">
This action cannot be undone. The recipe and all associated data will be permanently removed.
</p>
<!-- Footer -->
<div class="mt-6 flex justify-end gap-3">
<button class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">
Cancel
</button>
<button class="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700">
Delete
</button>
</div>
</div>
</div>Mobile Bottom Sheet
<!-- Backdrop -->
<div class="fixed inset-0 z-40 bg-black/50"></div>
<!-- Bottom Sheet -->
<div class="fixed inset-x-0 bottom-0 z-50 rounded-t-2xl bg-white pb-safe">
<!-- Drag handle -->
<div class="flex justify-center pt-3">
<div class="h-1.5 w-10 rounded-full bg-gray-300"></div>
</div>
<!-- Content -->
<div class="max-h-[70vh] overflow-y-auto px-4 pb-6 pt-4">
<h3 class="text-lg font-semibold text-gray-900">Filters</h3>
<div class="mt-4 space-y-4">
<!-- Filter content here -->
</div>
<button class="mt-6 w-full rounded-lg bg-indigo-600 py-3 text-sm font-medium text-white hover:bg-indigo-700">
Apply Filters
</button>
</div>
</div>---
Toast / Notification
Toast Stack (Bottom Right)
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-3">
<!-- Success toast -->
<div class="flex w-80 items-start gap-3 rounded-lg border border-green-200 bg-green-50 p-4 shadow-lg" role="alert">
<svg class="h-5 w-5 flex-shrink-0 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
<div class="flex-1">
<p class="text-sm font-medium text-green-800">Recipe saved!</p>
<p class="mt-0.5 text-xs text-green-600">Your recipe has been published.</p>
</div>
<button class="text-green-400 hover:text-green-600" aria-label="Dismiss">
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
</button>
</div>
<!-- Error toast -->
<div class="flex w-80 items-start gap-3 rounded-lg border border-red-200 bg-red-50 p-4 shadow-lg" role="alert">
<svg class="h-5 w-5 flex-shrink-0 text-red-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-13a1 1 0 10-2 0v4a1 1 0 102 0V5zm-1 8a1 1 0 100 2 1 1 0 000-2z" clip-rule="evenodd"/>
</svg>
<div class="flex-1">
<p class="text-sm font-medium text-red-800">Failed to save</p>
<p class="mt-0.5 text-xs text-red-600">Please check your connection and try again.</p>
</div>
<button class="text-red-400 hover:text-red-600" aria-label="Dismiss">
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
</button>
</div>
</div>---
Table Patterns
Sortable, Filterable Table
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white">
<!-- Table toolbar -->
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-3">
<div class="relative">
<input
type="text"
placeholder="Search recipes..."
class="w-64 rounded-lg border border-gray-300 py-1.5 pl-9 pr-3 text-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
<svg class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</div>
<select class="rounded-lg border border-gray-300 px-3 py-1.5 text-sm text-gray-700 focus:border-indigo-500 focus:outline-none">
<option>All Categories</option>
<option>Italian</option>
<option>Thai</option>
<option>Mexican</option>
</select>
</div>
<!-- Table -->
<div class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead class="bg-gray-50 text-xs uppercase text-gray-500">
<tr>
<th class="px-4 py-3">
<button class="group inline-flex items-center gap-1 font-medium">
Recipe
<svg class="h-3 w-3 text-gray-400 group-hover:text-gray-600" fill="currentColor" viewBox="0 0 20 20"><path d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"/></svg>
</button>
</th>
<th class="px-4 py-3">Category</th>
<th class="px-4 py-3">Time</th>
<th class="px-4 py-3">Rating</th>
<th class="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 font-medium text-gray-900">Thai Green Curry</td>
<td class="px-4 py-3">
<span class="inline-flex rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700">Thai</span>
</td>
<td class="px-4 py-3 text-gray-500">35 min</td>
<td class="px-4 py-3 text-gray-500">4.8</td>
<td class="px-4 py-3 text-right">
<button class="text-indigo-600 hover:text-indigo-800">Edit</button>
</td>
</tr>
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 font-medium text-gray-900">Margherita Pizza</td>
<td class="px-4 py-3">
<span class="inline-flex rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">Italian</span>
</td>
<td class="px-4 py-3 text-gray-500">45 min</td>
<td class="px-4 py-3 text-gray-500">4.6</td>
<td class="px-4 py-3 text-right">
<button class="text-indigo-600 hover:text-indigo-800">Edit</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="flex items-center justify-between border-t border-gray-200 px-4 py-3">
<p class="text-sm text-gray-500">Showing <span class="font-medium">1</span> to <span class="font-medium">10</span> of <span class="font-medium">42</span></p>
<div class="flex gap-1">
<button class="rounded-lg border border-gray-300 px-3 py-1 text-sm text-gray-500 hover:bg-gray-50">Previous</button>
<button class="rounded-lg bg-indigo-600 px-3 py-1 text-sm text-white">1</button>
<button class="rounded-lg border border-gray-300 px-3 py-1 text-sm text-gray-500 hover:bg-gray-50">2</button>
<button class="rounded-lg border border-gray-300 px-3 py-1 text-sm text-gray-500 hover:bg-gray-50">3</button>
<button class="rounded-lg border border-gray-300 px-3 py-1 text-sm text-gray-500 hover:bg-gray-50">Next</button>
</div>
</div>
</div>---
Skeleton Loading
Card Skeleton
<div class="animate-pulse overflow-hidden rounded-xl bg-white shadow-md">
<div class="h-48 bg-gray-200"></div>
<div class="p-5 space-y-3">
<div class="h-5 w-3/4 rounded bg-gray-200"></div>
<div class="h-4 w-full rounded bg-gray-200"></div>
<div class="h-4 w-1/2 rounded bg-gray-200"></div>
<div class="flex items-center justify-between pt-2">
<div class="h-4 w-16 rounded bg-gray-200"></div>
<div class="h-8 w-16 rounded-lg bg-gray-200"></div>
</div>
</div>
</div>Table Row Skeleton
<tr class="animate-pulse">
<td class="px-4 py-3"><div class="h-4 w-32 rounded bg-gray-200"></div></td>
<td class="px-4 py-3"><div class="h-5 w-16 rounded-full bg-gray-200"></div></td>
<td class="px-4 py-3"><div class="h-4 w-14 rounded bg-gray-200"></div></td>
<td class="px-4 py-3"><div class="h-4 w-8 rounded bg-gray-200"></div></td>
<td class="px-4 py-3 text-right"><div class="ml-auto h-4 w-10 rounded bg-gray-200"></div></td>
</tr>Text Block Skeleton
<div class="animate-pulse space-y-3">
<div class="h-6 w-1/3 rounded bg-gray-200"></div>
<div class="space-y-2">
<div class="h-4 w-full rounded bg-gray-200"></div>
<div class="h-4 w-full rounded bg-gray-200"></div>
<div class="h-4 w-5/6 rounded bg-gray-200"></div>
<div class="h-4 w-2/3 rounded bg-gray-200"></div>
</div>
</div>Form Skeleton
<div class="animate-pulse space-y-5">
<div>
<div class="h-4 w-20 rounded bg-gray-200"></div>
<div class="mt-1.5 h-10 w-full rounded-lg bg-gray-200"></div>
</div>
<div>
<div class="h-4 w-28 rounded bg-gray-200"></div>
<div class="mt-1.5 h-10 w-full rounded-lg bg-gray-200"></div>
</div>
<div>
<div class="h-4 w-24 rounded bg-gray-200"></div>
<div class="mt-1.5 h-24 w-full rounded-lg bg-gray-200"></div>
</div>
<div class="h-10 w-28 rounded-lg bg-gray-200"></div>
</div>#!/usr/bin/env python3
"""
WCAG Color Contrast Ratio Checker
Check color contrast ratios between two hex colors and report AA/AAA
compliance for normal text, large text, and UI components.
Usage:
python contrast-checker.py #1a1a2e #e0e0e0
python contrast-checker.py "#333" "#fff" --verbose
python contrast-checker.py --batch palette.css
python contrast-checker.py --batch tailwind.config.js
Stdlib only (uses colorsys from stdlib).
"""
import argparse
import json
import os
import re
import sys
from typing import Optional
def hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
"""Convert a hex color string to an (R, G, B) tuple (0–255 each)."""
h = hex_color.lstrip("#")
if len(h) == 3:
h = h[0] * 2 + h[1] * 2 + h[2] * 2
if len(h) != 6:
raise ValueError(f"Invalid hex color: '{hex_color}'")
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
def relative_luminance(r: int, g: int, b: int) -> float:
"""
Calculate relative luminance per WCAG 2.x specification.
https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
"""
def linearize(channel: int) -> float:
s = channel / 255.0
return s / 12.92 if s <= 0.04045 else ((s + 0.055) / 1.055) ** 2.4
return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
def contrast_ratio(color1: str, color2: str) -> float:
"""
Calculate the WCAG contrast ratio between two hex colors.
Returns a value between 1.0 and 21.0.
"""
r1, g1, b1 = hex_to_rgb(color1)
r2, g2, b2 = hex_to_rgb(color2)
l1 = relative_luminance(r1, g1, b1)
l2 = relative_luminance(r2, g2, b2)
lighter = max(l1, l2)
darker = min(l1, l2)
return (lighter + 0.05) / (darker + 0.05)
def check_wcag(ratio: float) -> dict:
"""Evaluate a contrast ratio against WCAG 2.x thresholds."""
return {
"ratio": round(ratio, 2),
"normal_text_aa": ratio >= 4.5, # Level AA, normal text
"normal_text_aaa": ratio >= 7.0, # Level AAA, normal text
"large_text_aa": ratio >= 3.0, # Level AA, large text (>=18pt or >=14pt bold)
"large_text_aaa": ratio >= 4.5, # Level AAA, large text
"ui_components_aa": ratio >= 3.0, # Level AA, UI components and graphical objects
}
def format_result(fg: str, bg: str, result: dict, verbose: bool = False) -> str:
"""Format a single contrast check result as human-readable text."""
lines = []
def status(passed: bool) -> str:
return "PASS" if passed else "FAIL"
lines.append(f" Foreground: {fg} | Background: {bg}")
lines.append(f" Contrast Ratio: {result['ratio']}:1")
lines.append("")
lines.append(f" {'Check':<28} {'Result':<6} {'Required'}")
lines.append(f" {'─' * 28} {'─' * 6} {'─' * 10}")
lines.append(f" {'Normal Text (AA)':<28} {status(result['normal_text_aa']):<6} >= 4.5:1")
lines.append(f" {'Normal Text (AAA)':<28} {status(result['normal_text_aaa']):<6} >= 7.0:1")
lines.append(f" {'Large Text (AA)':<28} {status(result['large_text_aa']):<6} >= 3.0:1")
lines.append(f" {'Large Text (AAA)':<28} {status(result['large_text_aaa']):<6} >= 4.5:1")
lines.append(f" {'UI Components (AA)':<28} {status(result['ui_components_aa']):<6} >= 3.0:1")
if verbose:
r1, g1, b1 = hex_to_rgb(fg)
r2, g2, b2 = hex_to_rgb(bg)
l1 = relative_luminance(r1, g1, b1)
l2 = relative_luminance(r2, g2, b2)
lines.append("")
lines.append(f" Foreground RGB: ({r1}, {g1}, {b1}) Luminance: {l1:.4f}")
lines.append(f" Background RGB: ({r2}, {g2}, {b2}) Luminance: {l2:.4f}")
return "\n".join(lines)
HEX_COLOR_PATTERN = re.compile(r"#(?:[0-9a-fA-F]{3}){1,2}\b")
def extract_colors_from_file(file_path: str) -> list[str]:
"""Extract all hex color values from a CSS, JS, or JSON file."""
try:
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
except UnicodeDecodeError:
with open(file_path, "r", encoding="latin-1") as f:
content = f.read()
return list(set(HEX_COLOR_PATTERN.findall(content)))
def normalize_hex(color: str) -> str:
"""Normalize a hex color to 6-digit lowercase."""
h = color.lstrip("#")
if len(h) == 3:
h = h[0] * 2 + h[1] * 2 + h[2] * 2
return f"#{h.lower()}"
def batch_check(file_path: str, verbose: bool = False) -> list[dict]:
"""
Extract colors from a file and check all pair combinations.
Returns a list of result dicts sorted by contrast ratio (ascending).
"""
colors = extract_colors_from_file(file_path)
if len(colors) < 2:
print(f" Found {len(colors)} color(s) in '{file_path}'. Need at least 2 for comparison.",
file=sys.stderr)
return []
colors = sorted(set(normalize_hex(c) for c in colors))
print(f" Found {len(colors)} unique colors in '{file_path}'.")
print(f" Checking {len(colors) * (len(colors) - 1) // 2} color pairs...\n")
results = []
for i, c1 in enumerate(colors):
for c2 in colors[i + 1:]:
ratio = contrast_ratio(c1, c2)
wcag = check_wcag(ratio)
results.append({
"fg": c1,
"bg": c2,
"ratio": wcag["ratio"],
"normal_aa": wcag["normal_text_aa"],
"normal_aaa": wcag["normal_text_aaa"],
"large_aa": wcag["large_text_aa"],
"ui_aa": wcag["ui_components_aa"],
})
results.sort(key=lambda r: r["ratio"])
return results
def format_batch_results(results: list[dict]) -> str:
"""Format batch results as a table."""
lines = []
lines.append(f" {'Foreground':<12} {'Background':<12} {'Ratio':<8} "
f"{'Norm AA':<9} {'Norm AAA':<10} {'Large AA':<10} {'UI AA'}")
lines.append(f" {'─' * 12} {'─' * 12} {'─' * 8} {'─' * 9} {'─' * 10} {'─' * 10} {'─' * 6}")
for r in results:
def s(v: bool) -> str:
return "PASS" if v else "FAIL"
lines.append(
f" {r['fg']:<12} {r['bg']:<12} {r['ratio']:<8} "
f"{s(r['normal_aa']):<9} {s(r['normal_aaa']):<10} "
f"{s(r['large_aa']):<10} {s(r['ui_aa'])}"
)
failing = [r for r in results if not r["normal_aa"]]
passing = [r for r in results if r["normal_aa"]]
lines.append("")
lines.append(f" Summary: {len(passing)} pairs pass Normal AA, "
f"{len(failing)} pairs fail Normal AA.")
if failing:
lines.append("")
lines.append(" Failing pairs (Normal Text AA):")
for r in failing:
lines.append(f" {r['fg']} / {r['bg']} — {r['ratio']}:1 (need 4.5:1)")
return "\n".join(lines)
def main() -> None:
parser = argparse.ArgumentParser(
description="WCAG color contrast ratio checker.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s "#1a1a2e" "#e0e0e0"
%(prog)s "#333" "#fff" --verbose
%(prog)s --batch styles.css
%(prog)s --batch tailwind.config.js --json
""",
)
parser.add_argument("colors", nargs="*",
help="Two hex colors to compare (e.g., '#333' '#fff').")
parser.add_argument("--batch", metavar="FILE",
help="Extract colors from a file and check all pairs.")
parser.add_argument("--verbose", "-v", action="store_true",
help="Show additional details (RGB values, luminance).")
parser.add_argument("--json", action="store_true",
help="Output results as JSON.")
args = parser.parse_args()
if args.batch:
if not os.path.isfile(args.batch):
print(f"Error: File not found: '{args.batch}'", file=sys.stderr)
sys.exit(1)
results = batch_check(args.batch, args.verbose)
if args.json:
print(json.dumps(results, indent=2))
elif results:
print(format_batch_results(results))
sys.exit(0)
if len(args.colors) != 2:
parser.error("Provide exactly two hex colors (e.g., '#333' '#fff'), or use --batch.")
fg, bg = args.colors
try:
ratio = contrast_ratio(fg, bg)
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
result = check_wcag(ratio)
if args.json:
output = {
"foreground": normalize_hex(fg),
"background": normalize_hex(bg),
**result,
}
print(json.dumps(output, indent=2))
else:
print(f"\n{'=' * 50}")
print(f" WCAG Contrast Check")
print(f"{'=' * 50}\n")
print(format_result(fg, bg, result, args.verbose))
print(f"\n{'=' * 50}\n")
if not result["normal_text_aa"]:
sys.exit(1)
if __name__ == "__main__":
main()
Related skills
FAQ
What does frontend-design do?
frontend-design is a Claude Code skill for frontend development.
When should I use frontend-design?
When you need to helps with frontend development tasks., or when frontend-design is a claude code skill for frontend development.
What are the main capabilities?
frontend-design; Frontend Development; AI-coding skill.