Component Authoring
How to create, style, and test Astro components in this project.
File Structure and Ownership
Components use PascalCase.astro and live in src/core/components/ organized by purpose (primitives, sections, ui, nav, forms, search, layouts, base). See Directory Structure for the full layout.
Every file starts with an ownership comment (// CORE-OWNED or // SITE-OWNED) and a brief description citing the relevant charter section(s) using (§N) notation. See File Ownership for the ownership model and zone boundaries.
---
// CORE-OWNED
// src/core/components/primitives/Button.astro — Polymorphic button/link (REQ-00052)
---
Any new shipped component must also be added to the component inventory in ARCHITECTURE.md. File the entry under the matching section (Primitives, UI Components, Sections, Layouts, and so on) and give it a concise one-line description so the architecture registry stays aligned with the implemented system.
Component Sourcing
Before building a new component, check the tier-1 curated sources defined in the charter: Tailwind Plus Blocks, Flowbite Pro, Starwind, and Font Awesome Pro. Build custom only when curated sources don’t meet requirements. Convert sourced patterns to Astro components and adapt them to use project tokens and primitives.
Props and Types
Interface pattern
Define a Props interface (or type) with typed fields and defaults:
---
interface Props {
variant?: "primary" | "secondary";
size?: "sm" | "md" | "lg";
href?: string;
disabled?: boolean;
class?: string;
[key: string]: unknown; // rest props sink
}
const {
variant = "primary",
size = "md",
href,
disabled = false,
class: className, // rename reserved word
...rest // catch-all for data-* attributes
} = Astro.props;
---
Key conventions:
class?: string— always accept aclassprop for consumer overrides. Destructure asclass: className.[key: string]: unknown— include a rest props sink so consumers can passdata-*attributes or other HTML attributes without modifying the component source....restspread — always spread on the outermost rendered element.- Defaults at destructure — set defaults in the destructuring assignment, not in the interface.
Shared type contracts
Sections that share common layout props extend SectionProps from src/lib/section-types.ts:
import type {
SectionProps,
SectionAction,
SectionMedia,
} from "@/lib/section-types";
interface Props extends SectionProps {
heading: string;
primaryAction?: SectionAction;
image?: SectionMedia;
class?: string;
[key: string]: unknown;
}
This gives every section consistent background, contentWidth, verticalPadding, align, and gutter props.
Configurable styling props
Components should expose props for any visual setting that a site owner would reasonably need to change when restyling. Hardcoding values that vary by context forces consumers to edit core components instead of configuring them.
Heading level and size are the canonical example. A component’s heading level depends on where it appears in the page hierarchy, and the visual size may need to differ across sites or layouts. All components that render a Heading should expose headingLevel? and headingSize? props with sensible defaults:
---
import type {
SectionHeadingLevel,
SectionHeadingSize,
} from "@/lib/section-types";
interface Props {
headingLevel?: SectionHeadingLevel;
headingSize?: SectionHeadingSize;
// ...
}
const { headingLevel = 2, headingSize = "2xl", ...rest } = Astro.props;
---
<Heading level={headingLevel} size={headingSize}>...</Heading>
The same principle applies to other visual decisions that vary by context — icon sizes, card orientations, spacing variants, etc. When in doubt, expose the prop with the current value as the default.
Use project primitives over raw HTML
When a project component exists for a purpose (e.g., Heading, Button, Icon, Link), use it instead of the raw HTML element. This ensures consistent styling, token usage, and behavior. Only use raw elements when the component’s built-in styles conflict with the design intent and cannot be overridden via props or class.
Slots
Default slot
Most components use a single default <slot />:
<button class:list={["btn", className]} {...rest}>
<slot />
</button>
Named slots
Components with multiple content regions use named slots. Provide fallback content where appropriate:
<slot name="trigger">
<button>Default trigger</button>
</slot>
<div class="dropdown__panel">
<slot />
</div>
PageGridLayout slot conventions
Hero sections (including compact page-title bands) go in slot="subheader", never slot="main-header". The main-header area is reserved for content metadata, filters, or other elements that sit directly above <main> within the content column. The subheader spans full width below the site header.
Styling Convention
Tailwind utilities are the primary styling mechanism. BEM classes are retained as CSS API hooks for site-level overrides. Scoped <style> blocks are reserved for patterns that utilities cannot express cleanly.
Primary rule
Use utilities in markup for layout, spacing, typography, colors, borders, radius, shadows, pseudo-elements, hover/focus states, and responsive breakpoints — everything expressible as a utility.
Text sizing defaults
Unlike a standard Tailwind project where Preflight strips all element styles, this project re-adds sensible typographic defaults for body text, headings, lists, and other elements. Most elements already have an appropriate font size without any utility class. Only add text-size utilities (e.g., text-sm, text-(length:--pt-text-lg)) when you need to override the default — not to restate it.
Token references
Components consume semantic tokens (--st-*), never raw color values. See Design Tokens for the full three-layer architecture (primitives → semantic → Tailwind bridge).
Use Tailwind v4 parenthesis shorthand to reference CSS custom properties:
bg-(--st-color-surface-soft) v4 shorthand (preferred)
bg-[var(--st-color-surface-soft)] v4 full syntax (also works)
bg-[#3b82f6] raw values — NEVER (violates token contract)
For ambiguous utilities (e.g., text-* can mean font-size or color), add a type hint:
text-(color:--st-color-text-muted) color
text-(length:--pt-text-lg) font-size
Use arbitrary values for tokens not in the bridge. Promote to a bridge entry when used in 3+ components.
BEM classes as CSS API hooks
Elements retain their BEM class names, but style declarations live in utility classes, not <style>. BEM classes serve as stable selectors for site-level CSS overrides in src/site/styles/ or src/site/components/.
<!-- BEM name present for site-level overrides; styling is all utilities -->
<ol
class="breadcrumbs__list flex flex-wrap items-center list-none m-0 p-0 text-sm"
>
</ol>
Class handling with class:list
Use class:list={[...]} for conditional and variant classes. Always place className last so consumer overrides take precedence:
class:list={
[
"btn",
`btn--${variant}`,
`btn--${size}`,
"inline-flex items-center justify-center gap-[0.5em]",
disabled && "btn--disabled opacity-50 cursor-not-allowed",
fullWidth && "btn--full w-full",
className,
]
}
When to use scoped <style> blocks
Scoped styles are reserved for cases where Tailwind arbitrary variants would materially hurt readability:
- Compound/complex selectors — adjacent sibling combinators, descendant selectors targeting slotted content,
:global()patterns for Astro scoping - Component-scoped variant variable binding — the
--_*pattern (see Multi-variant components below) - Transition class definitions —
.drawer-enter,.search-modal__fade-leave, etc. - Multi-property transitions —
transition: background-color var(--pt-duration-fast) var(--pt-ease-standard)
The bar is maintainability, not technical capability. Tailwind arbitrary variants can express most selectors, but some are cleaner as CSS rules.
What stays out of <style>
These always use utilities, never scoped styles:
- Colors —
bg-surface-softorbg-(--st-color-action-primary-bg) - Hover/focus/active —
hover:bg-surface-strong,focus-visible:outline-focus-ring - Responsive layout —
md:grid-cols-2,lg:grid-cols-3 - Pseudo-elements —
before:content-['/'],after:block - Spacing, typography, radius, shadows
Multi-variant components
For components with variant matrices (e.g., Button with 11 variants x 3 sizes), use component-scoped CSS variables to bridge variants into utilities. Each variant sets --_* variables in <style>, and the template uses a single set of utilities:
<Tag
class:list={[
"btn",
`btn--${variant}`,
"inline-flex items-center justify-center gap-[0.5em]",
"bg-(--_btn-bg) text-(--_btn-fg) border-(--_btn-border)",
"hover:bg-(--_btn-bg-hover)",
]}
>
<style>
.btn--primary {
--_btn-bg: var(--st-color-action-primary-bg);
--_btn-fg: var(--st-color-action-primary-fg);
--_btn-border: var(--st-color-action-primary-border);
--_btn-bg-hover: var(--st-color-action-primary-bg-active);
}
.btn--outline {
--_btn-bg: transparent;
--_btn-fg: var(--st-color-action-primary-bg);
--_btn-border: var(--st-color-action-primary-border);
--_btn-bg-hover: var(--st-color-action-primary-bg);
}
.btn--secondary {
--_btn-bg: var(--st-color-action-secondary-bg);
--_btn-fg: var(--st-color-action-secondary-fg);
--_btn-border: var(--st-color-action-secondary-border);
--_btn-bg-hover: var(--st-color-action-secondary-bg-active);
}
/* … tertiary, neutral, inverted, on-primary, on-secondary, on-tertiary */
</style></Tag
>
Scoped <style> handles only variant variable binding — actual styling stays in utilities. Adding a new variant is just a new block of variable assignments. Use the --_ prefix convention (double-dash, underscore) for component-scoped variables. Outline variants reuse their parent action token family with a transparent background rather than requiring separate outline-specific token families.
Canonical focus ring
All interactive elements use the same focus-ring utility bundle:
focus-visible:outline-width-focus focus-visible:outline-focus-ring focus-visible:outline-offset-focus focus-visible:rounded-sm
Omit focus-visible:rounded-sm on elements that already have a border-radius (e.g., buttons with rounded-md) to prevent the focus outline from mismatching the element shape.
No @apply
Use utilities in markup or var() in the rare scoped style. Never mix via @apply.
Bridge expansion
New @theme bridge entries in src/core/styles/bridge.css must pass two gates:
- Semantic role test (required): The token represents a reusable UI meaning (e.g., focus ring width, interactive border, quiet surface) — not a one-off component-internal value.
- Reuse signal (supporting): The token appears in 3+ components or is clearly expected to.
Keep arbitrary values for one-offs even if repeated locally within a single component.
Before/after example: Breadcrumbs
Before (BEM + scoped styles):
<ol class="breadcrumbs__list">
<li class="breadcrumbs__item">
<a href={item.href}>{item.label}</a>
</li>
</ol>
<style>
.breadcrumbs__list {
display: flex;
flex-wrap: wrap;
align-items: center;
list-style: none;
margin: 0;
padding: 0;
font-size: var(--pt-text-sm);
}
.breadcrumbs__item a {
color: var(--st-color-link);
text-decoration: none;
}
.breadcrumbs__item a:hover {
color: var(--st-color-link-hover);
text-decoration: underline;
}
.breadcrumbs__item a:focus-visible {
outline: var(--pt-focus-ring-width) solid var(--st-color-focus-ring);
outline-offset: var(--pt-focus-ring-offset);
border-radius: var(--pt-radius-sm);
}
.breadcrumbs__item + .breadcrumbs__item::before {
content: "/";
margin-inline: var(--pt-space-2xs);
color: var(--st-color-text-muted);
}
</style>
After (hybrid: utilities + BEM hooks):
<ol
class="breadcrumbs__list flex flex-wrap items-center list-none m-0 p-0 text-sm"
>
<li class="breadcrumbs__item">
<a
class="text-link no-underline hover:text-link-hover hover:underline focus-visible:outline-width-focus focus-visible:outline-focus-ring focus-visible:outline-offset-focus focus-visible:rounded-sm"
href={item.href}
>
{item.label}
</a>
</li>
</ol>
<!-- Compound selector stays in <style> -->
<style>
.breadcrumbs__item + .breadcrumbs__item::before {
content: "/";
margin-inline: var(--pt-space-2xs);
color: var(--st-color-text-muted);
}
</style>
What changed:
- Layout, reset, and typography moved to utilities
- Link colors and states use utility variants
- Focus ring uses the canonical utility bundle
- BEM class names retained on every element as CSS API hooks
- Only the compound selector (adjacent sibling
::before) stays in<style>
Accessibility
WCAG 2.2 AA is the baseline. Every component must meet these requirements without additional effort from the consumer. See Accessibility Standards for the full policy and Keyboard Checklist for per-component keyboard testing procedures.
Key authoring rules:
- Semantic HTML — use
<button>for actions,<a>for navigation, landmark elements for page regions,<label>for every form input. - ARIA attributes — apply
aria-label(icon-only buttons),aria-expanded(disclosures),aria-current="page"(active nav links),aria-live="polite"(dynamic regions),aria-hidden="true"(decorative elements) as appropriate. - Focus states — every interactive element must be keyboard-operable with visible focus. Use the canonical focus ring (see Canonical focus ring).
- Conditional attributes — set props to
undefinedto omit attributes from rendered HTML rather than rendering empty or misleading values:
aria-disabled={disabled ? "true" : undefined}
aria-required={required ? "true" : undefined}
Vanilla JS Integration
The project uses vanilla TypeScript for interactive islands. Default to zero-JS; hydrate only with explicit justification.
Data attributes for state
Interactive components use data-slot attributes to identify DOM elements and data-state attributes to communicate component state:
<div data-slot="disclosure">
<button data-slot="disclosure-trigger" aria-expanded="false">Toggle</button>
<div data-slot="disclosure-content" data-state="closed">Content</div>
</div>
TypeScript controller classes query these attributes to wire up behavior.
Prettier constraints
- Do not wrap
<script>tags inside Astro template expressions ({condition && <script>...</script>}). Use an unconditional<script>with a runtime DOM guard instead. - Do not add custom
data-*attributes to Astro<script>tags. Attributes forceis:inlinemode, which disables TypeScript processing.
State management patterns
Self-contained state — a TypeScript controller class manages its own state:
<div data-slot="disclosure">
<button data-slot="disclosure-trigger" aria-expanded="false">Toggle</button>
<div data-slot="disclosure-content" data-state="closed">Content</div>
</div>
The controller discovers elements via data-slot selectors and manages open/closed state internally.
Progressive enhancement
Forms should work without JavaScript via native action/method attributes. Layer vanilla JS behavior on top:
<form method="POST" action="/api/newsletter/" data-slot="newsletter-form">
<!-- Vanilla JS handles AJAX when JS is available -->
</form>
<noscript>
<p>JavaScript is required to submit this form inline.</p>
</noscript>
Layered UI Contexts
Components that participate in stacking (dropdowns, overlays, modals, sticky headers, tooltips) must consume the shared semantic layer contract rather than setting ad-hoc z-index values.
How to choose a layer: Match the component’s UI role to the semantic layer map in src/core/styles/semantic.css (the authoritative layer inventory). See Design Tokens — Z-Index Layer Tokens for the naming convention and consumption rules. Use the bridge utility in markup (e.g., z-dropdown, z-modal-surface) and fall back to var(--st-layer-*) only in scoped <style> blocks.
Backdrop/surface pairs: If the component renders both a backdrop and a foreground surface, use the explicit pair for its category — z-overlay-backdrop + z-overlay-surface for non-modal overlays, z-modal-backdrop + z-modal-surface for modal-class surfaces. Do not use calc() offsets to stack a surface above its backdrop.
What to avoid:
- Raw numeric
z-indexvalues in component code - Direct primitive
--pt-z-*token references - Local arithmetic like
calc(var(--pt-z-overlay) + 1) - Arbitrary-value z-index utilities for shared layers (e.g.,
z-[999])
Tailwind v4 Pitfalls
These naming pitfalls apply to any work with the @theme bridge in src/core/styles/bridge.css:
-
containeris reserved. Tailwind v4 generates a built-in.containerutility with responsivemax-widthbreakpoints. Any@themeentry usingcontainerin its name will collide or be silently ignored. We use--max-width-siteinstead. -
@themenamespace determines the utility prefix.--width-*generatesw-*(width) utilities, notmax-w-*. To getmax-w-*utilities, use--max-width-*. Similarly,--border-width-*generatesborder-*(notborder-border-width-*). -
Shorthand utilities affect all sides.
border-thin(from--border-width-thin) setsborder-styleandborder-widthon all four sides. For single-side borders, use directional variants:border-t-thin,border-b-thin. Never combineborder-thinwithborder-t/border-b. -
CSS cascade layers matter. Any CSS imported without a
@layerdirective is “unlayered” and beats all@layerstyles, including@layer utilities. Base reset styles must be imported withlayer(base)so utilities can override them. -
Underscore-prefixed custom properties break in
text-(length:...). Tailwind v4 converts underscores to spaces in arbitrary values, sotext-(length:--_my-font-size)generatesfont-size: var(-- my-font-size)(with a space) — which silently resolves to nothing. Forfont-size, use direct declarations in scoped<style>instead of the--_*+ utility indirection pattern:/* ✅ Direct — works reliably */ .heading--md { font-size: var(--pt-text-md); } /* ❌ Indirect — broken by underscore conversion */ .heading--md { --_heading-font-size: var(--pt-text-md); } /* + text-(length:--_heading-font-size) in template */The
--_*custom property pattern remains valid for other properties (background,color,padding) consumed viabg-(...),text-(color:...),py-(...)— those don’t hit the underscore issue because the property names don’t start with underscores after the--prefix in those utilities’ arbitrary value syntax. The issue is specific totext-(length:...)referencing a--_prefixed custom property.