Component Suites & Cross-Component Communication
Design families of components that work together
Some problems are too big for one component. A modal needs a close button the user can style and place freely. A filterable grid needs a search input and a row of tag buttons, and neither belongs inside the grid's markup. The answer is a suite: several small components that ship together, find each other at runtime, and cooperate. This guide covers when to split a component into a suite, the three communication channels the Core Pack uses to connect suite members — window events, a shared Alpine store, and page-level engines wired through shared templates — plus the quieter fourth option: children that discover their parent through the DOM.
This guide dissects the Filter (com.realmacsoftware.filter), Filter Tags (com.realmacsoftware.filterTags), Modal / Modal Close (com.realmacsoftware.modal, com.realmacsoftware.modalClose), and Reveal (com.realmacsoftware.reveal) components from the open-source Core Pack. Open their source alongside this page.
When One Component Should Become Several
Split when you notice any of these:
Inspector overload. You keep adding properties to configure a sub-part — "Show close button", "Close button position", "Close button icon", "Close button color". Every switch is a decision you made for the user. The Core Pack's answer is Modal Close: instead of a stack of inspector controls, the close button is a component the user drops in, styles like anything else, and places anywhere inside the modal.
Reusable, repeatable children. A form has an unpredictable number of fields; a filter UI might need the search input in a sidebar and the tag buttons above the grid. When you can't predict how many children there are or where they go, each child should be its own component.
A dropzone isn't enough.
@dropzonegives users a hole to fill, but the filling is inert — it has no inspector of its own, no hooks, no behaviour. The moment the thing inside the hole needs logic — a button that knows how to close its modal, a field that validates itself — promote it to a component.
Stay single when the repeating parts are pure markup driven by data: tabs, table rows, and slides are better served by a collection and an @each loop — see Collection-Driven Dropzones.
Once you split, the parts need a way to talk. There are three channels, plus DOM ancestry:
Window events
An agreed event name and payload
Modal trigger → Modal panel
One-shot signals: open, close, notify
Alpine store
A shared singleton, keyed by group id
Filter + Filter Tags
Several components read/write ongoing state
Shared page engine
data-* attributes read by one script
Reveal + its GSAP engine
Many instances, one behaviour, zero per-instance JS
DOM ancestry
Child must render inside parent
Modal Close, form fields
The relationship is containment
Channel 1: Window Events
The lightest channel. Sender and receiver share nothing but an event name and a payload shape — they don't reference each other, don't share state, and can be authored in different packs. The Modal pair is the canonical example. The trigger dispatches an event that bubbles to window:
<!-- templates/index.html (Modal) — the sender -->
<span
@click="$dispatch('open-modal', { id: '{{id}}' })"
class="{{classes.trigger}}"
>
@dropzone("trigger", title: "Trigger")
</span>And the teleported panel — no longer a DOM descendant of the trigger once @portal has moved it — listens at the window level and compares the payload against its own instance id:
Every panel on the page hears every open-modal event; the id comparison means only the matching panel opens — and, because the comparison result is assigned rather than used as a guard, every other modal sets open = false at the same time. One convention, two attributes, and the suite coordinates itself.
That is deliberately all this guide says about events, because Modals, Overlays & Portals walks the whole pattern in depth — the portal split, the id handshake, and the edit-mode handling. The same shape powers the Accordion's group behaviour, dissected in Interactive Components with Alpine.js. Use events when the message is a moment ("open now"). When components need to share state that persists — a query string, a set of active tags — you want the next channel.
Channel 2: A Shared Alpine Store
Filter and Filter Tags are two inspector-light components that drive the same outcome: showing and hiding items in a grid. They never reference each other. Instead, both talk to a page-level singleton — an Alpine store named filters — and the items being filtered are ordinary layout components that advertise themselves with data-* attributes. Three parties, one shared blackboard.
The Store: One Singleton, Many Groups
The store lives in the Core Pack's shared assets and is loaded once per page by a shared template (shared/templates/headEnd/alpine-directives.html — the same mechanism as Channel 3). Everything inside it is keyed by a listId, which is what lets any number of independent filter groups coexist on one page:
Two design decisions here carry the whole suite. First, registerList is idempotent: if the list already exists, it merges the new options and returns. That means Filter and Filter Tags can both register the same group in any order — whichever initialises first creates the list, the second just contributes its options. Suite members never need to know who arrived first. Second, the store finds its items by querying the DOM for [data-filter-group="listId"] — so anything that renders that attribute is filterable, with a MutationObserver catching items added after load.
Writing: The Filter Input
The Filter component is a search input whose entire job is writing one value into the store. Its template registers a tiny factory (note that a portal's includeOnce id only has to be unique — the Filter's carries a legacy name) and binds the input:
x-model binds the input to local state q; the $watch forwards every keystroke into the store via updateQuery, which re-runs the group's filterList. The instance is wired to its group in hooks.js:
Reading: Filter Tags
Filter Tags never sees the search input. It renders one button per tag the store discovered, and every interaction is a store call:
The get tags() getter makes the button list reactive: because it reads from the store, Alpine re-renders the x-for whenever the store's tag set changes — including when the MutationObserver indexes items that arrived after load. The @if(edit) block is a nice suite touch: on the canvas, where no real items exist yet, the user still sees placeholder tags instead of an empty gap (see Designing the Edit-Mode Experience). Note also :data-active="isActive(tag)" — active styling is driven by data-[active=true]: Tailwind variants computed in hooks.js, so the store never touches classes.
Namespacing: How Groups Get Their Ids
Both components resolve their group id through the shared globalFilter build helper (documented under Build Tools). Reformatted from the compiled hooks bundle:
The line that matters is group == "parent" ? parent.id : groupId. By default the group id is the parent component's instance id (rw.node.parent.id) — so a Filter, a Filter Tags, and a grid of items dropped into the same container automatically share a group with zero configuration, and a second container elsewhere on the page forms a second, fully independent group. When the controls can't live beside their items, the inspector's Group → Custom option substitutes a user-typed id, letting a search field in a sidebar drive a grid in the main column.
The third party — the items — emits the same attributes from the other side. Layout components like Container run the same helper and spread its args, plus the user's tags from a collection:
The Flow End to End
The user drops Containers into a grid and enables Filter on each, tagging them via a collection. Each renders
data-filter-group="<parentId>" data-filter-tags="design,2024".A Filter and a Filter Tags dropped into the same parent resolve the same
<parentId>at build time — no runtime discovery needed.On
alpine:init, whichever control initialises first callsregisterList('<parentId>'); the store queries[data-filter-group="<parentId>"], indexes each item's text and tags, and starts observing for late arrivals. The second control'sregisterListmerges its options into the existing list.Typing calls
updateQuery; clicking a tag callstoggleTag. Both funnel intofilterList, which runs every item throughmatches(text and tags must match) and transitions visibility.Filter Tags' reactive
tagsgetter keeps the button row in sync with whatever the store has indexed.
The store pattern beats events whenever state outlives the moment: a new suite member (a "clear filters" button, a results counter) just reads the same store keyed by the same group id — no changes to Filter, Filter Tags, or the items.
Channel 3: Page-Level Engines via Shared Templates
The third channel removes per-instance JavaScript entirely. Reveal — the Core Pack's scroll-animation component — ships no script of its own. Its template is a single line (@dropzone("content", title: "Content")); its hooks.js compiles the inspector settings into data-* attributes on the root element:
The shared globalReveal helper returns nothing but attributes (reformatted from the compiled bundle):
The behaviour lives in one engine, registered page-wide through the pack's shared templates — GSAP and ScrollTrigger load from shared/templates/headStart/setup.html, and the engine itself is injected at bodyEnd, where it sweeps the page for anything carrying the contract attribute:
Note the defaults in the destructuring: every attribute is optional, so the engine tolerates emitters that only set what they care about. That's what makes this a channel rather than a private implementation detail — the contract is "carry data-reveal plus any overrides", and it doesn't matter which component (or which pack helper) emitted the attributes.
Shared Template or includeOnce Portal?
Both give you exactly one copy of a script per page, so choose by scope:
@portal(bodyEnd, includeOnce: true, id: …)belongs to one component and ships only when that component is used. It's the right home for a factory that only its own component instantiates — the Accordion, Tabs, and Filter factories all live this way.Shared templates are emitted once per page whenever any component from your pack is present (Shared Files). Choose them when the code is genuinely pack-level: several components depend on it (the Alpine runtime, the
filtersstore), the contract is attribute-based so any emitter can participate (the Reveal engine), or the pieces must land at different injection points in the right order (libraries inheadStart, engine inbodyEnd— a single portal can't span locations).
The trade-off is bytes: a shared template rides along on every page that uses your pack, even pages where no instance needs it. Keep engines lean, and keep per-component factories in portals.
Parent/Child Contracts Without Events
Sometimes the relationship between suite members is containment — a field only means something inside its form. In that case you don't need events or stores at all: the parent marks its rendered root, and children find it at runtime with DOM traversal. You've already seen the Core Pack version — Modal Close calls $dialog.close(), which resolves the nearest enclosing dialog by ancestry. Here is the generic skeleton, as a form suite:
The parent's whole contribution to the contract is one attribute: data-form-id, carrying its instance id (id and action are forwarded by a tiny hooks.js — rw.setProps({ id: rw.node.id, action }) — exactly like every other example in these guides). Child fields render ordinary inputs, with label and fieldName as plain text properties:
And the factory discovers the parent the moment it initialises:
closest() makes the child position-independent: the user can nest the field inside grids, flex wrappers, or anything else dropped into the fields dropzone, and it still finds its form. The contract stays one attribute deep, so the parent can evolve freely. The full architecture — field types, validation, submission handling — is covered in Building Form Components, coming in this section.
Packaging a Suite
A suite is also a product-design exercise: the pieces must be discoverable together and hard to misuse.
Group suite members in the palette. The
groupkey in each component'sinfo.jsonmust be one of the pre-defined categories, and components with the same category are grouped together in the UI — which is why Modal, Modal Close, Filter, and Filter Tags all ship as"group": "Interactive"and sit side by side. You can't invent a category per suite, so use thetagsarray to make members co-searchable (both filter components should surface for "filter").requiresPhpis effectively inherited. Per theinfo.jsonreference, the flag is "not needed for child Components that only ever appear nested inside another Component that already requires PHP". Ifcom.example.formgains server-side handling and setsrequiresPhp: true, field components that only ever render inside it can omit the flag.Keep child inspectors minimal. A child that needs its parent for meaning should hold only the properties that describe itself — a field's label and name, a close button's cursor. Anything about the relationship (grouping, ids, wiring) belongs to the parent or is derived automatically, the way
globalFilterderives the group fromrw.node.parent.id. Modal Close's inspector is a fraction of Modal's; that asymmetry is a sign the split is right.Warn when a child is orphaned. A Form Field dropped outside any Form silently does nothing — tell the user on the canvas instead. You might expect to compute
isOrphanedinhooks.js, butrw.node.parentexposes only the parent instance'sidand itscontainername — not which component the parent is — so hooks can't distinguish your Form from any other component with a dropzone. The rendered DOM, however, carries the real contract, and Alpine runs on the edit canvas, so do the check in the browser and gate the banner with@if(edit):
The banner ships zero bytes to published pages — the whole block is compiled away outside edit mode. If your child can partially work standalone, prefer a quieter hint over a warning; reserve loud banners for children that genuinely do nothing alone.
Related Documentation
Modals, Overlays & Portals — the window-event handshake in full depth
Interactive Components with Alpine.js — factories,
alpine:init, and instance wiringShared Files — pack-level assets and templates
Shared Templates — injection points and once-per-page behaviour
rw.node— what hooks can (and can't) know about a component's parentBuilding Form Components — the form suite architecture, end to end
Last updated
Was this helpful?

