Styling Internals

Sub-elements and States

Style internal parts of modules explicitly — repeatable elements, states, and selector resolution.

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 — repeaterKey mismatch. If repeaterKey does 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 clones items[0]._styles into 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:

  1. Add the new key alongside the old one in the ModuleDefinition.
  2. Write a database migration that renames the key in every affected tgx_pages.data JSON blob.
  3. Remove the old key from the ModuleDefinition in 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