Sub-elements and States
Sub-elements and states let a single module expose multiple independently-styleable parts and multiple visual variants — all without writing a line of custom CSS by hand. Everything is declared in PHP and the builder generates scoped CSS automatically.
Two kinds of sub-element
Structural sub-elements
A structural sub-element maps to a fixed DOM node that is always present in the rendered HTML — one icon wrapper, one title, one divider line, etc. There is exactly one instance per module instance.
Declared with ->withSubElement(string $key, array $args = []) on a ModuleDefinition.
Accepted $args keys
| Key | Type | Description |
|---|---|---|
label |
string |
Label shown in the builder drawer. |
designProps |
array |
Prop type keys from the Design tab to expose on this sub-element (e.g. ['typography', 'color']). |
advancedProps |
array |
Prop type keys from the Advanced tab to expose (e.g. ['spacing', 'border']). |
contentFieldKeys |
array |
Content field keys that belong to this sub-element in the Content tab. |
defaults |
array |
Default prop values applied when the module is first inserted. |
states |
array |
State definitions — see the States section below. |
Structural sub-elements store their styles under props.elements.<key> in the page JSON.
Repeatable sub-elements
A repeatable sub-element maps to one DOM node per repeater item — the icon inside each icon-list card, the label on each pricing-table row, etc. There are N instances, one for each entry in the parent repeater.
Declared with ->withRepeatableSubElement(string $key, array $args = []).
Accepted $args keys
| Key | Type | Description |
|---|---|---|
label |
string |
Label shown in the builder drawer. |
repeaterKey |
string |
Required. The key of the parent repeater prop. Must match exactly. |
itemStyles |
bool |
When true, styles are written per-item rather than shared. Default true. |
designProps |
array |
Same as structural. |
advancedProps |
array |
Same as structural. |
defaults |
array |
Default prop values for new items. |
states |
array |
State definitions. |
Warning —
repeaterKeymismatch. IfrepeaterKeydoes not exactly match the key you passed to->withRepeater(), the sub-element panel will render but editing will silently do nothing. Always copy-paste the key.
Sub-element storage in JSON
Before any edit (bare items)
When a module is first dropped onto the canvas, the repeater items carry no _styles key:
{
"props": {
"items": [
{ "icon": "star", "label": "Fast" },
{ "icon": "shield", "label": "Secure" }
]
}
}
After "apply to all" fan-out
When the user edits a repeatable sub-element and clicks "Apply to all items", the builder copies the current style set into every item's _styles object:
{
"props": {
"items": [
{ "icon": "star", "label": "Fast", "_styles": { "icon": { "color": "#0ea5e9" } } },
{ "icon": "shield", "label": "Secure", "_styles": { "icon": { "color": "#0ea5e9" } } }
]
}
}
After a per-item override
If the user later edits item 1 independently, only that item's _styles differ:
{
"props": {
"items": [
{ "icon": "star", "label": "Fast", "_styles": { "icon": { "color": "#0ea5e9" } } },
{ "icon": "shield", "label": "Secure", "_styles": { "icon": { "color": "#f97316" } } }
]
}
}
Structural sub-element storage
Structural sub-elements are stored under props.elements:
{
"props": {
"elements": {
"title": { "typography": { "fontSize": "1.5rem", "fontWeight": "700" } },
"divider": { "color": { "background": "#e5e7eb" } }
}
}
}
Copy-down model
Repeatables intentionally write styles into each items[N]._styles rather than into a shared slot. This means:
- The base style set (shared across all items) is determined by the first item with
_styles, or by the module defaults when no item has been edited yet. - The builder's "Apply to all" button fans the current item's style out to every other item's
_styles, overwriting individual overrides. - When a new item is added via the repeater UI, the builder calls
inheritSlotStyles()which clonesitems[0]._stylesinto the new item so new rows start consistent with the rest.
If you are migrating a module that previously stored repeatable styles in a top-level props.elements slot, move the values into each item's _styles in a database migration — the PHP CSS generator only reads from _styles for repeatables.
CSS generation and selector scoping
Every module instance on the page is isolated by a UUID written into a data-component-id attribute on its root DOM node. The CSS generator wraps every generated rule in that UUID selector so styles from one instance can never bleed into another.
/* Structural sub-element */
[data-component-id="a1b2c3d4"] .tgx-icon-list__title {
font-size: 1.5rem;
font-weight: 700;
}
/* Repeatable sub-element — shared rule (no item index) */
[data-component-id="a1b2c3d4"] .tgx-icon-list__icon {
color: #0ea5e9;
}
/* Repeatable sub-element — per-item override */
[data-component-id="a1b2c3d4"] [data-item-index="1"] .tgx-icon-list__icon {
color: #f97316;
}
The [data-item-index="N"] selector is added automatically by the CSS generator when it detects that a specific item's _styles diverges from the shared base. It relies on the data-item-index attribute being present in your Blade template — see Common mistakes.
States
States let the module expose alternative visual configurations (hover, active, focus, disabled, etc.) that the user can style independently in the builder. Each state maps to a CSS pseudo-class or a custom class added at runtime.
Field reference
| Field | Type | Description |
|---|---|---|
key |
string |
Internal key used in JSON storage. |
label |
string |
Label shown in the builder state switcher. |
selector |
string |
CSS selector suffix appended to the component root selector (e.g. :hover, .is-active). |
designProps |
array |
Which design prop types the user can edit in this state. |
advancedProps |
array |
Which advanced prop types are editable in this state. |
defaults |
array |
Default values applied when the state is first activated. |
PHP declaration
->withSubElement('link', [
'label' => 'Link',
'designProps' => ['typography', 'color'],
'states' => [
[
'key' => 'hover',
'label' => 'Hover',
'selector' => ':hover',
'designProps' => ['color'],
'defaults' => ['color' => ['text' => '#0ea5e9']],
],
[
'key' => 'active',
'label' => 'Active',
'selector' => '.is-active',
'designProps' => ['color', 'typography'],
],
],
])
Generated CSS output
/* Base state */
[data-component-id="a1b2c3d4"] .tgx-menu__link {
color: #1e293b;
font-size: 1rem;
}
/* Hover state */
[data-component-id="a1b2c3d4"] .tgx-menu__link:hover {
color: #0ea5e9;
}
/* Active state */
[data-component-id="a1b2c3d4"] .tgx-menu__link.is-active {
color: #0ea5e9;
font-weight: 700;
}
State JSON storage
State styles are stored as siblings of the base styles under the sub-element key, namespaced by state key:
{
"props": {
"elements": {
"link": {
"color": { "text": "#1e293b" },
"typography": { "fontSize": "1rem" },
"_states": {
"hover": { "color": { "text": "#0ea5e9" } },
"active": { "color": { "text": "#0ea5e9" }, "typography": { "fontWeight": "700" } }
}
}
}
}
}
Fully worked example — IconList module
Blade template
<div {{ $attributes }} data-component-id="{{ $componentId }}">
<h3 class="tgx-icon-list__title">{{ $title }}</h3>
<ul class="tgx-icon-list__list">
@foreach ($items as $index => $item)
<li class="tgx-icon-list__item" data-item-index="{{ $index }}">
<span class="tgx-icon-list__icon">
<x-dynamic-component :component="'heroicon-o-' . $item['icon']" />
</span>
<span class="tgx-icon-list__label">{{ $item['label'] }}</span>
</li>
@endforeach
</ul>
</div>
Module definition
use Ccast\Tagixo\Builder\ModuleDefinition;
ModuleDefinition::make('icon-list')
->label('Icon List')
->withRepeater('items', [
'label' => 'Items',
'minItems' => 1,
'fields' => [
'icon' => ['type' => 'icon', 'label' => 'Icon'],
'label' => ['type' => 'text', 'label' => 'Label'],
],
])
// Structural sub-element: the heading above the list
->withSubElement('title', [
'label' => 'Title',
'designProps' => ['typography', 'color'],
'advancedProps' => ['spacing'],
])
// Structural sub-element: each list item wrapper
->withSubElement('item', [
'label' => 'Item',
'advancedProps' => ['spacing', 'border'],
])
// Repeatable sub-element: the icon inside each item
->withRepeatableSubElement('icon', [
'label' => 'Icon',
'repeaterKey' => 'items',
'designProps' => ['color', 'sizing'],
'states' => [
[
'key' => 'hover',
'label' => 'Hover',
'selector' => ':hover',
'designProps' => ['color'],
],
],
])
// Repeatable sub-element: the label text inside each item
->withRepeatableSubElement('label', [
'label' => 'Label',
'repeaterKey' => 'items',
'designProps' => ['typography', 'color'],
]);
Generated CSS output (two items, icon override on item 1, hover state)
/* Structural: title */
[data-component-id="a1b2c3d4"] .tgx-icon-list__title {
font-size: 1.25rem;
font-weight: 600;
color: #0f172a;
}
/* Structural: item spacing */
[data-component-id="a1b2c3d4"] .tgx-icon-list__item {
padding: 0.75rem 1rem;
}
/* Repeatable: icon — shared base */
[data-component-id="a1b2c3d4"] .tgx-icon-list__icon {
color: #0ea5e9;
width: 1.5rem;
height: 1.5rem;
}
/* Repeatable: icon — per-item override on item index 1 */
[data-component-id="a1b2c3d4"] [data-item-index="1"] .tgx-icon-list__icon {
color: #f97316;
}
/* Repeatable: icon — hover state (shared) */
[data-component-id="a1b2c3d4"] .tgx-icon-list__icon:hover {
color: #0284c7;
}
/* Repeatable: label */
[data-component-id="a1b2c3d4"] .tgx-icon-list__label {
font-size: 1rem;
color: #334155;
}
Common mistakes
Missing data-item-index
Per-item overrides are silently lost if data-item-index is absent from the item wrapper. The generator writes the rule, but no DOM element matches it.
Wrong — index on the inner element only:
<li class="tgx-icon-list__item">
<span class="tgx-icon-list__icon" data-item-index="{{ $index }}">...</span>
</li>
Correct — index on the wrapper that contains all sub-elements for this item:
<li class="tgx-icon-list__item" data-item-index="{{ $index }}">
<span class="tgx-icon-list__icon">...</span>
</li>
Mismatched repeaterKey
If you rename the repeater from items to entries but forget to update repeaterKey in the sub-element declaration, the builder will show the sub-element panel but it will read from (and write to) a key that does not match any repeater data. The result is styles that appear to save but never render.
Always update both ->withRepeater('entries', ...) and repeaterKey: 'entries' together.
Renaming element keys in production
The element key ('icon', 'title', etc.) is stored verbatim in the page JSON. Renaming a key in PHP without a corresponding JSON migration causes existing pages to lose their styles for that sub-element — the generator simply finds no stored values under the new key.
Migration path:
- Add the new key alongside the old one in the
ModuleDefinition. - Write a database migration that renames the key in every affected
tgx_pages.dataJSON blob. - Remove the old key from the
ModuleDefinitionin a follow-up release.
Over-engineering simple modules
Not every module needs sub-elements. If a module has a single visual part and no repeating rows, add prop types directly to the module root via ->withDesignProps() and ->withAdvancedProps(). Sub-elements add builder UI complexity — use them when there are genuinely distinct parts the user needs to style independently.
Generic or duplicate state selectors
Two sub-elements on the same module that both declare a state with 'selector' => ':hover' will produce rules that may conflict depending on the DOM nesting. Make state selectors as specific as possible, or scope them to the sub-element's class:
// Prefer explicit scoping when nesting is tight:
'selector' => '.tgx-menu__link:hover' // scoped to the element class
// rather than
'selector' => ':hover' // resolved relative to component root
See also
- Module system and catalog — how modules are registered and discovered
- PropTypes system — all available design and advanced prop types
- Storage model and data shape — full page JSON schema
- Custom form modules — modules with interactive form fields