Contact Us

Component Authoring

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 a class prop for consumer overrides. Destructure as class: className.
  • [key: string]: unknown — include a rest props sink so consumers can pass data-* attributes or other HTML attributes without modifying the component source.
  • ...rest spread — 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 transitionstransition: 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-soft or bg-(--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:

  1. 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.
  2. 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 undefined to 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 force is:inline mode, 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-index values 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:

  1. container is reserved. Tailwind v4 generates a built-in .container utility with responsive max-width breakpoints. Any @theme entry using container in its name will collide or be silently ignored. We use --max-width-site instead.

  2. @theme namespace determines the utility prefix. --width-* generates w-* (width) utilities, not max-w-*. To get max-w-* utilities, use --max-width-*. Similarly, --border-width-* generates border-* (not border-border-width-*).

  3. Shorthand utilities affect all sides. border-thin (from --border-width-thin) sets border-style and border-width on all four sides. For single-side borders, use directional variants: border-t-thin, border-b-thin. Never combine border-thin with border-t/border-b.

  4. CSS cascade layers matter. Any CSS imported without a @layer directive is “unlayered” and beats all @layer styles, including @layer utilities. Base reset styles must be imported with layer(base) so utilities can override them.

  5. Underscore-prefixed custom properties break in text-(length:...). Tailwind v4 converts underscores to spaces in arbitrary values, so text-(length:--_my-font-size) generates font-size: var(-- my-font-size) (with a space) — which silently resolves to nothing. For font-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 via bg-(...), 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 to text-(length:...) referencing a --_ prefixed custom property.

REQ-00153 implemented Tailwind utility token mappings shall include documented semantic aliases for link, status, form, and icon roles so component/layout code does not rely on raw palette literals.
REQ-00251 normative Components sourced from external pattern libraries shall follow the tier-1 curated source hierarchy defined in §13 and shall document their source origin and any modifications applied, enabling traceability from component to source pattern.
REQ-00260 implemented Cross-component communication shall use named custom DOM events dispatched on the window object (e.g., theme-changed, search:open, listbox-change) rather than direct component coupling. Event names and payload shapes shall be treated as public API contracts.
REQ-00262 implemented External link indicators (icon and screen-reader announcement) shall be automatically applied only to links rendered through the markdown/MDX content pipeline. Component-rendered and navigation links shall use the ExternalLink component for explicit opt-in.

Search

Search across pages and articles. Use arrow keys to navigate results.

Search across pages and articles.

Loading search...

Search is unavailable. Please try again later.

    No results for ""

    Try different keywords or fewer words.