Skip to content

Components

Every WAGE component is a namespaced PHP class under Wage\ that extends Wage\Component. Components use typed constructors with named arguments and output HTML via echo.

All components extend the Wage\Component base class. The base class handles:

  1. Output bufferingrender() is called inside ob_start(), captured as $this->html
  2. CSS collection$this->css() loads a paired CSS file into a static pool
  3. JS collection$this->js() loads a paired JS file into a static pool (output in footer)
  4. String casting__toString() returns the HTML, so echo new \Wage\Button(...) works
  5. Deduplication — CSS and JS are deduped by hash so the same component used twice only outputs once
  6. Utility classes$this->classes array merged onto root element via $this->root_classes()
namespace Wage {
class Component {
public string $css = '';
public string $js = '';
public string $html = '';
public array $classes = [];
public function __construct() {
ob_start();
$this->render();
$this->html = ob_get_clean();
// CSS + JS collected in static pools, deduped by hash
}
protected function render(): void {}
protected function css( string $file ): void { /* loads CSS, checks child override */ }
protected function js( string $file ): void { /* loads JS, checks child override */ }
// Merges component base class with passed utility classes
protected function root_classes( string $base ): string {
$all = array_merge( [ $base ], $this->classes );
return esc_attr( implode( ' ', array_filter( $all ) ) );
}
public function __toString(): string { return $this->html; }
public static function flush_css(): string { /* ... */ }
public static function flush_js(): array { /* returns [ 'name' => 'js', ... ] */ }
}
}

Every component follows this pattern:

namespace Wage {
if ( ! class_exists( 'Wage\\MyComponent' ) ) {
class MyComponent extends Component {
public function __construct(
public string $title = '',
array $classes = [],
) {
$this->classes = $classes;
parent::__construct();
}
protected function render(): void {
$this->css( __DIR__ . '/my-component.css' );
$this->js( __DIR__ . '/my-component.js' ); // optional, only if component has JS
?>
<div class="<?php echo $this->root_classes( 'my-component' ); ?>">
<h2><?php echo \esc_html( $this->title ); ?></h2>
</div>
<?php
}
}
}
}

Any component that accepts classes can receive utility classes like surface--dark:

echo new \Wage\MyComponent( title: 'Hello', classes: ['surface--dark'] );
// Output: <div class="my-component surface--dark">

The classes parameter is NOT a promoted property (no public keyword). It’s assigned to the parent’s $this->classes before calling parent::__construct(), so root_classes() can read it during render().

Component JS is loaded via $this->js() inside render(). Each component’s JS is output as its own <script id="wage-js-{name}"> tag in the footer (name derived from the JS filename). Deduped by content hash — multiple instances of the same component only load the JS once.

This per-component output allows performance plugins (e.g. PerfMatters) to delay individual scripts without breaking critical ones like the mobile nav.

Each JS file should be a self-executing IIFE:

(function() {
// Component JS here — runs after DOM is ready (script is in footer)
})();

Key patterns:

  • namespace Wage — all components live in the Wage namespace
  • class_exists() guard — wraps the class so child themes can override it
  • PHP 8 promoted properties — typed constructor parameters become public properties
  • parent::__construct() — must be called last in the constructor (triggers render())
  • $this->css() — loads a paired .css file from the same directory
  • data-wage-component is auto-injected — the base class automatically adds this attribute to the first HTML element for X-Ray mode. Components in /components/ directories get it; sections in /sections/ do not. No manual call needed.
// In a page template or inside another component's render()
echo new \Wage\Button( label: 'Find Events', variant: 'primary', href: '/roadshows' );
echo new \Wage\SectionIntro( title: 'How It Works', lede: 'Three simple steps.' );

Named arguments make the API self-documenting. IDE autocomplete shows all available parameters.

Component CSS lives in a real .css file alongside the component PHP file:

inc/components/info-card/
├── info-card.php # Wage\InfoCard class
└── info-card.css # Component styles

The CSS is loaded via $this->css( __DIR__ . '/info-card.css' ) inside the render() method. Because it is inside render(), the CSS only loads when the component is actually used on a page.

All component CSS is collected in a static pool during rendering, then flushed by Wage\Page::render() as a single <style id="wage-components"> block in <head>. No FOUC, no extra HTTP requests.

What goes in component CSS: structural layout (flex, grid, positioning, gaps) and component-specific visual styling (colours, borders, transitions). Component CSS is self-contained — it includes everything the component needs (structural + visual). There is no reliance on core.css for component-specific rules.

What stays in site.css: project-specific styles that are not tied to a component (page layouts, one-off treatments).

When overriding component CSS in a child theme: the child’s CSS file must be fully self-contained. It replaces the core CSS entirely, so it must include all structural and visual rules — not just the differences.

ClassPurpose
Wage\ButtonStyled button or link
Wage\BadgeSmall inline label (on cards, in lists, anywhere)
Wage\EyebrowLabel placed above a heading. Default uses badge styling; variant: 'plain' renders unstyled uppercase text. as prop controls element tag (default span).
Wage\SvgSVG wrapper — renders from the SVG registry by name. boxed: true adds background + padding. size: 'sm'|'md'|'lg'.
Wage\HeroIntroHero heading block — eyebrow (h1) + display title (p) + lede + optional content slot. layout prop: center, center-left, left, split.
Wage\SectionIntroSection heading with optional eyebrow and subtitle. align layouts: left (default), center, center-left. Left-aligned intros auto-centre on tablet.
Wage\RatingStar rating display
Wage\AccordionCollapsible FAQ/content panels
Wage\ButtonGroupRow of buttons with consistent spacing
Wage\CarouselHorizontal scroll with nav buttons
Wage\TabsTabbed content panels
Wage\GalleryImage gallery with lightbox
Wage\ModalDialog overlay
Wage\ImagePlaceholderPlaceholder image (wireframe or Unsplash)
Wage\VideoPlaceholderPlaceholder video thumbnail with play button
Wage\SectionBackgroundBackground image layer for sections (lazy loaded)
Wage\TrustStripScrolling trust/credibility strip
Wage\FormFieldForm input with label, icon, validation
Wage\FormForm wrapper with spam prevention and status messages
Wage\LightboxImage lightbox dialog with navigation

The align prop controls how the intro content is arranged. Default is left-center-left.

// Always left — for split layouts (FAQ left column, prepare section)
echo new \Wage\SectionIntro( title: '...', align: 'left' );
// Left → center on tablet → left on mobile (default)
echo new \Wage\SectionIntro( title: '...' ); // or align: 'left-center-left'
// Always centered
echo new \Wage\SectionIntro( title: '...', align: 'center' );
// Centered → left on mobile
echo new \Wage\SectionIntro( title: '...', align: 'center-left' );
VariantDesktopTabletMobile
leftLeftLeftLeft
left-center-leftLeftCenterLeft
centerCenterCenterCenter
center-leftCenterCenterLeft

When to use each:

  • left — inside split layouts where the intro is in a column (FAQ, prepare sections)
  • left-center-left — standalone sections on full-width pages (default, most common)
  • center — hero-adjacent sections, always-centered contexts
  • center-left — sections that center on desktop but need left-alignment on mobile for readability

Width behaviour: base max-width is 48ch. Centred tablet states widen to 78ch. The lede has max-width: 50ch in CSS — no inline prop needed.

lede_max has been removed. Do not pass it to SectionIntro. Lede width is CSS-driven.

Hero heading block used inside hero section components. Renders an eyebrow as <h1> (plain variant), a display title as <p>, a lede, and an optional content slot.

// Centered (default)
echo new \Wage\HeroIntro( eyebrow: 'The Process', title: 'From Valuation to <em>Instant Payment</em>', lede: '...', layout: 'center' );
// Split — title left, lede+content right on desktop
echo new \Wage\HeroIntro( eyebrow: 'The Process', title: '...', lede: '...', layout: 'split', content: function() {
echo new \Wage\Button( label: 'Find a Roadshow', variant: 'ghost-link', icon: 'arrow', href: '...' );
} );

Layouts: center, center-left, left, split. The em in titles gets --accent-light automatically.

Renders an SVG from the registry, optionally boxed with a background.

echo new \Wage\Svg( name: 'map-pin' ); // inline
echo new \Wage\Svg( name: 'map-pin', boxed: true ); // with background
echo new \Wage\Svg( name: 'map-pin', boxed: true, size: 'lg' ); // larger
echo new \Wage\Svg( svg: '<svg>...</svg>' ); // raw SVG string

All SVGs should be registered in inc/core/svgs.php (core) or inc/svgs.php (child) and rendered via this component. Never inline raw <svg> markup in templates.

VariantDescription
primaryMain CTA — accent background
secondarySecondary action — primary color background
outlineTransparent with border
ghostTransparent, no border — underline on hover
ghost-linkSame as ghost but no padding — sits flush with text
whiteWhite background (for dark surfaces)
outline-lightLight border (for dark surfaces)

ghost-link is useful for subtle CTAs in split hero layouts or inline with content.

In a ButtonGroup with a primary and secondary action:

  • Primary button — no icon
  • Secondary buttonvariant: 'ghost-link' with icon: 'arrow'
echo new \Wage\ButtonGroup( align: 'center', content: function() {
echo new \Wage\Button( label: 'Find a Roadshow', href: '...', variant: 'primary' );
echo new \Wage\Button( label: 'Request a Postal Pack', href: '...', variant: 'ghost-link', icon: 'arrow' );
} );

The arrow icon goes on the ghost-link, not the primary. This creates a clear visual hierarchy: solid CTA + subtle text link with directional affordance.

Flexible card component with icon, title, and description. Supports multiple layouts and boxing options.

// Inline with boxed icon (default for feature grids)
echo new \Wage\IconCard( icon: 'shield-check', title: 'No Obligation', description: '...', layout: 'inline', icon_size: 'lg' );
// Stacked with boxed icon
echo new \Wage\IconCard( icon: 'clock', title: 'Take Your Time', description: '...', layout: 'stacked', icon_size: 'lg' );
// Boxed card (with background, border, padding)
echo new \Wage\IconCard( icon: 'star', title: 'Featured', description: '...', layout: 'stacked', boxed: true );
// Centered layout in boxed card
echo new \Wage\IconCard( icon: 'star', title: 'Centered', description: '...', layout: 'centered', boxed: true );

Props: icon (SVG name), title, description, layout (stacked/inline/centered), boxed (card background), boxed_icon (icon background, default true), icon_size (sm/md/lg), media (raw HTML), footer (closure).

  • Default (variant: 'default') — renders as a badge (badge badge--default badge--sm)
  • Outline (variant: 'outline') — badge with outline styling
  • Plain (variant: 'plain') — unstyled uppercase text, no badge chrome. Used in HeroIntro for the <h1> eyebrow.

The as prop controls the HTML element (default span). HeroIntro uses as: 'h1'.

Both render visually similar elements, but they serve different purposes:

  • Wage\Badge — inline label used anywhere (on cards, in lists, next to prices)
  • Wage\Eyebrow — always positioned above a heading to establish context. Used inside Wage\SectionIntro and standalone in heroes

The SectionIntro component uses Wage\Eyebrow internally for its eyebrow slot.

Handles all form input types with consistent styling, labels, required indicators, and optional icons. All inputs must use this component — never use raw <input> elements. FormField provides consistent placeholder colour (--placeholder-color), icon support, sizing variants, and accessibility attributes.

Props: label, name, type (text/email/tel/textarea/select/checkbox), placeholder, icon, required, options, rows, value, help, size (md/lg), pattern, title

echo new \Wage\FormField( label: 'Name', name: 'name', required: true );
echo new \Wage\FormField( name: 'postcode', icon: 'map-pin', placeholder: 'Enter postcode' );
echo new \Wage\FormField( label: 'Subject', name: 'subject', type: 'select', options: [...] );
echo new \Wage\FormField( label: 'Message', name: 'message', type: 'textarea' );
echo new \Wage\FormField( type: 'checkbox', name: 'consent', placeholder: 'I agree to...' );

Wraps a <form> element with automatic action URL (admin-post.php), spam prevention fields (nonce, honeypot, Turnstile), and status messages.

Props: name, method, action, class, content (closure)

echo new \Wage\Form(
name: 'contact',
content: function() {
echo new \Wage\FormField( label: 'Name', name: 'name', required: true );
echo new \Wage\FormField( label: 'Email', name: 'email', type: 'email', required: true );
echo new \Wage\Button( label: 'Send', type: 'submit', variant: 'primary' );
},
);

The Carousel defaults to free scrolling. Pass snap: true to enable snap-to-item behaviour:

echo new \Wage\Carousel( snap: true, content: function() { ... } );

Every component should do exactly one thing. When you find a component doing two distinct things — rendering a trigger AND rendering what it triggers, or containing markup that belongs to two different concerns — split it into separate components.

The test: if you’re tempted to put a modal, lightbox, or any shared DOM inside a button/card/trigger component, that’s the signal to split. The trigger is one component, the thing it opens is another.

When a component needs companion DOM (a modal, lightbox, toast, etc.), split them into separate components. The section composes them together:

// Section template — composing trigger + target
<?php echo new \Wage\RsvpButton( venue: $event['venue'], ... ); ?>
<?php // ... more buttons ... ?>
<?php echo new \Wage\RsvpModal(); ?>

This keeps components single-purpose, avoids hidden side effects, and makes templates readable — you can see exactly what’s on the page.

Component JS should live with the component whose behaviour it drives:

  • Trigger JS (populating modal fields on click) → lives with the trigger component
  • Target JS (open/close/backdrop behaviour) → lives with the target component
  • Self-contained JS (scroll, animate, toggle) → lives with the component itself

In the child theme, create the same class in the Wage namespace. The child’s inc/includes.php loads before core, and the class_exists() guard in core prevents the default from loading:

inc/components/trust-strip/trust-strip.php
namespace Wage {
if ( ! class_exists( 'Wage\\TrustStrip' ) ) {
class TrustStrip extends Component {
public function __construct(
public string $headline = '',
public array $phrases = [],
) {
parent::__construct();
}
protected function render(): void {
$this->css( __DIR__ . '/trust-strip.css' );
// Custom implementation with client-specific content
}
}
}
}

The child theme separates building blocks from page sections. Both follow the same class pattern:

  • inc/components/ — reusable building blocks (step indicator, info card, product card, etc.). These appear in the Wage Dashboard styleguide and can be previewed in isolation.
  • inc/sections/ — full page sections (hero, how-it-works, testimonials, etc.). Each section is a class in its own folder. Use X-Ray mode on the actual page to inspect them.

Components can be nested by echoing one component inside another’s render():

protected function render(): void {
$this->css( __DIR__ . '/hero.css' );
?>
<section class="hero">
<div class="hero__container container">
<h1>Welcome</h1>
<?php echo new \Wage\ButtonGroup( buttons: [
new \Wage\Button( label: 'Find Events', variant: 'primary', href: '/roadshows' ),
new \Wage\Button( label: 'Learn More', variant: 'ghost', href: '/how-it-works' ),
] ); ?>
</div>
</section>
<?php
}

Each nested component’s CSS is automatically collected into the same pool.

  1. Create a folder: inc/components/my-thing/ (or inc/sections/my-thing/ for page sections)
  2. Create the PHP file with the class extending Wage\Component
  3. Create a paired .css file in the same folder

No manual require_once or includes.php needed — the framework auto-scans inc/components/, inc/sections/, and inc/core/components/ folders in the child theme and loads all PHP files automatically.

If the component is reusable across all sites, add it to the parent theme’s inc/core/components/ with a class_exists() guard.