A Component is a reusable piece rendered inside a Module (or another Component) via
View::component(). Unlike a Module, an author never
places it directly — it only ever appears because a host chose to render it. Buttons,
images, content stacks, navigation, borders: these are Components.
Reach for a Component when the same markup shows up in more than one Module, or when a
piece of a Module carries its own behaviour and deserves its own file. Promoting markup
to a Component is how you stop copying a <figure> between six views.
Two kinds
A Component is one of two things, and the difference is does it have a JS file.
View-only
Most Components are just a PHP partial. text and button render markup from field
data and stop there — there is no asset, no selector, no lifecycle.
// resources/public/views/components/text.php
$data = Settings::parseArgs($args['data'], Text::defaults());
$text_class = TextAppearance::cssClass($data);
?>
<<?= $data['tag']; ?>
class="font-style <?= $text_class; ?>"
style="--text-color: <?= ColorSelect::getCssColor($data['color']); ?>;">
<?php echo Text::doText($data['content']); ?>
</<?= $data['tag']; ?>>
A view-only Component still uses the full Field machinery — it emits utility classes and CSS custom properties the SCSS reads. It just has no runtime behaviour, so it needs no JavaScript.
A Component without JS is the common case. The taxonomy defines a Component by authorial placeability (none), not by whether it ships behaviour. Keep the partial pure: parse args, emit markup, defer all styling to CSS variables.
View + behaviour
Some Components need runtime behaviour. Image lazy-loads its real source when it
scrolls into view and flags the wrapper loaded once decoded. It has both a view and an
asset:
// resources/public/assets/components/Image/Image.js
class Image extends Instantiable {
static selector = 'div.image'
get isLazy() {
return this.element.classList.contains('image--lazyload')
}
#bindEvents() {
if ( ! this.isLazy ) return
;(new GlobalIntersectionObserver(this.element, () => {
manipulateDOM(() => this.load())
})).observe()
}
async init() {
if ( this.isInitialized ) return
this.#bindEvents()
await super.init()
}
}
const Final = new Traits(Image, [])
export { Final as Image }
A behavioural Component is an Instantiable like any
Module — a static selector, a lifecycle, colocated SCSS. Two things set it apart:
- It's
isInitialized-guarded and it does not mixAutoInitializes. A Component never self-inits. It waits for its host to init it, so the host keeps ordering control. This is the sub-component contract. - It's discovered by a host's
HasXtrait, not a global loader entry. You won't findImageinDeferred.js. It has no processor entry of its own.
The isInitialized guard is not optional for a behavioural Component. Its host may
init it during hand-ordered work and again through autoInitializeComponents(); the
guard makes the second call a no-op. Without it, the Component double-binds.
How a host discovers a behavioural Component
A behavioural Component is loaded by the HasX trait its host mixes in. The host
declares HasImage; the trait gives it an imageComponents getter (which
querySelectorAlls the host's subtree) and a bootImageComponents() that queues the
Image asset — but only if a div.image is actually present.
// the host declares the capability
const Final = new Traits(Intro, [ /* … */ HasImage, HasButtons ])
So the rule is a two-sided contract: the view renders View::component('image', …), and
the host's asset must mix the matching HasImage. Miss the trait and the image
renders as static markup that never wires up its lazy-loading.
Why HasX and not a global entry. A global loader entry (like a Module's) boots a
component the moment its selector appears anywhere on the page — independently. A
Component must instead boot under the control of its host, so its host can order init
relative to everything else in its subtree. HasX is what hands that control to the
host. See Sub-components & HasX.
When to promote markup to a Component
- The same markup appears in two or more Module views → extract it.
- A piece of a Module needs its own behaviour (lazy-load, a toggle, a carousel) → give it a selector and an asset.
- The piece has editor-configurable style → back it with a Field so its options travel as CSS custom properties.
If none of those hold, leave it as markup in the Module view. Not every <div> needs to
be a Component.
Novaa-specific: the border Component. The notched-border overlay (.notched-border,
loaded by HasBorder) is a Novaa design detail, not framework canon. Treat it as an
example of the pattern, not a piece every theme ships.
Related
- Modules — the hosts that render Components.
- Sub-components &
HasX— the discovery-and-load contract in full. - Writing a new
HasXtrait — teach a host a new Component type. - Fields → CSS custom properties — how a Component's style options reach SCSS.