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.
How components work
Section titled “How components work”All components extend the Wage\Component base class. The base class handles:
- Output buffering —
render()is called insideob_start(), captured as$this->html - CSS collection —
$this->css()loads a paired CSS file into a static pool - JS collection —
$this->js()loads a paired JS file into a static pool (output in footer) - String casting —
__toString()returns the HTML, soecho new \Wage\Button(...)works - Deduplication — CSS and JS are deduped by hash so the same component used twice only outputs once
- Utility classes —
$this->classesarray merged onto root element via$this->root_classes()
The base class
Section titled “The base class”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', ... ] */ } }}Writing a component
Section titled “Writing a component”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 } } }}Passing utility classes
Section titled “Passing utility classes”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
Section titled “Component JS”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 theWagenamespaceclass_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 (triggersrender())$this->css()— loads a paired.cssfile from the same directorydata-wage-componentis 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.
Using components
Section titled “Using components”// 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.
Paired CSS files
Section titled “Paired CSS files”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 stylesThe 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.
Core components
Section titled “Core components”| Class | Purpose |
|---|---|
Wage\Button | Styled button or link |
Wage\Badge | Small inline label (on cards, in lists, anywhere) |
Wage\Eyebrow | Label placed above a heading. Default uses badge styling; variant: 'plain' renders unstyled uppercase text. as prop controls element tag (default span). |
Wage\Svg | SVG wrapper — renders from the SVG registry by name. boxed: true adds background + padding. size: 'sm'|'md'|'lg'. |
Wage\HeroIntro | Hero heading block — eyebrow (h1) + display title (p) + lede + optional content slot. layout prop: center, center-left, left, split. |
Wage\SectionIntro | Section heading with optional eyebrow and subtitle. align layouts: left (default), center, center-left. Left-aligned intros auto-centre on tablet. |
Wage\Rating | Star rating display |
Wage\Accordion | Collapsible FAQ/content panels |
Wage\ButtonGroup | Row of buttons with consistent spacing |
Wage\Carousel | Horizontal scroll with nav buttons |
Wage\Tabs | Tabbed content panels |
Wage\Gallery | Image gallery with lightbox |
Wage\Modal | Dialog overlay |
Wage\ImagePlaceholder | Placeholder image (wireframe or Unsplash) |
Wage\VideoPlaceholder | Placeholder video thumbnail with play button |
Wage\SectionBackground | Background image layer for sections (lazy loaded) |
Wage\TrustStrip | Scrolling trust/credibility strip |
Wage\FormField | Form input with label, icon, validation |
Wage\Form | Form wrapper with spam prevention and status messages |
Wage\Lightbox | Image lightbox dialog with navigation |
SectionIntro layouts
Section titled “SectionIntro layouts”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 centeredecho new \Wage\SectionIntro( title: '...', align: 'center' );
// Centered → left on mobileecho new \Wage\SectionIntro( title: '...', align: 'center-left' );| Variant | Desktop | Tablet | Mobile |
|---|---|---|---|
left | Left | Left | Left |
left-center-left | Left | Center | Left |
center | Center | Center | Center |
center-left | Center | Center | Left |
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 contextscenter-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.
HeroIntro
Section titled “HeroIntro”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 desktopecho 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' ); // inlineecho new \Wage\Svg( name: 'map-pin', boxed: true ); // with backgroundecho new \Wage\Svg( name: 'map-pin', boxed: true, size: 'lg' ); // largerecho new \Wage\Svg( svg: '<svg>...</svg>' ); // raw SVG stringAll 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.
Button variants
Section titled “Button variants”| Variant | Description |
|---|---|
primary | Main CTA — accent background |
secondary | Secondary action — primary color background |
outline | Transparent with border |
ghost | Transparent, no border — underline on hover |
ghost-link | Same as ghost but no padding — sits flush with text |
white | White background (for dark surfaces) |
outline-light | Light border (for dark surfaces) |
ghost-link is useful for subtle CTAs in split hero layouts or inline with content.
ButtonGroup pattern
Section titled “ButtonGroup pattern”In a ButtonGroup with a primary and secondary action:
- Primary button — no icon
- Secondary button —
variant: 'ghost-link'withicon: '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.
IconCard
Section titled “IconCard”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 iconecho 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 cardecho 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).
Eyebrow variants
Section titled “Eyebrow variants”- 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'.
Eyebrow vs Badge
Section titled “Eyebrow vs Badge”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 insideWage\SectionIntroand standalone in heroes
The SectionIntro component uses Wage\Eyebrow internally for its eyebrow slot.
FormField
Section titled “FormField”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' ); },);Carousel snap mode
Section titled “Carousel snap mode”The Carousel defaults to free scrolling. Pass snap: true to enable snap-to-item behaviour:
echo new \Wage\Carousel( snap: true, content: function() { ... } );Component composition rules
Section titled “Component composition rules”One component, one job
Section titled “One component, one job”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.
JS ownership
Section titled “JS ownership”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
Overriding a component
Section titled “Overriding a component”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:
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 } } }}Components vs Sections
Section titled “Components vs Sections”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.
Nesting components
Section titled “Nesting components”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.
Adding new components
Section titled “Adding new components”- Create a folder:
inc/components/my-thing/(orinc/sections/my-thing/for page sections) - Create the PHP file with the class extending
Wage\Component - Create a paired
.cssfile 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.