Sub-elements and States
Sub-elements let a module expose stylable internal parts without relying on undocumented CSS selectors.
This is one of the most important features for high-quality custom modules.
Core concept
A module can declare internal elements such as:
- title
- description
- button
- badge
- wrapper
- repeater items
These elements are declared through subElements(...) and serialized into the builder metadata.
Value object
Core class:
Ccast\Tagixo\Core\SubElement
Main factories:
SubElement::structural(...)SubElement::repeatable(...)
What sub-elements can describe
A sub-element definition can include:
keylabelselector- design prop groups
- advanced prop groups
- linked content field keys
- repeater key
- per-item styles
- state definitions
- defaults
Why use them
Without sub-elements, a module has only one styling surface: the root node.
That is not enough for modules such as:
- tabs
- accordions
- pricing tables
- cards with badges and actions
- counters with multiple text layers
- form fields with label, input, helper, and error parts
Structural sub-element example
use Ccast\Tagixo\Core\SubElement;
ModuleDefinition::make()->subElements(
SubElement::structural(
key: 'title',
label: __('Title'),
selector: '.alert-box__title',
styles: ['typography', 'spacing']
),
SubElement::structural(
key: 'body',
label: __('Body'),
selector: '.alert-box__body',
styles: ['typography', 'spacing']
),
);
Repeatable sub-element example
Repeatable sub-elements are for structures backed by repeaters:
SubElement::repeatable(
key: 'tab',
label: __('Tab'),
selector: '.tabs__trigger',
styles: ['typography', 'spacing', 'border'],
repeaterKey: 'tabs',
itemStyles: ['typography', 'spacing', 'background', 'border']
)
The repeaterKey must match the real content schema. If it does not, the drawer will expose configuration that cannot map back to the saved payload correctly.
States
Sub-elements can also expose state-specific styling, such as:
- active
- hover
- selected
- current
A state is declared inside states[] on the SubElement and contains four keys:
| Key | Meaning |
|---|---|
label |
Human-readable name shown in the drawer (e.g. "Active state") |
elementKey |
Internal key under which the state's PropType values are persisted in the saved JSON. Use a stable kebab/snake identifier — once content exists in production, do not rename it without a migration. |
selector |
CSS selector applied to the base selector when this state is active (e.g. .is-active, [aria-current="page"], .vb-tab-button--active). The renderer concatenates it onto the parent selector at CSS generation time. |
designProps |
The list of PropType keys allowed when styling this state. Usually a SUBSET of the base sub-element's styles — you rarely want full design control on a hover/active state. |
Example with explicit fields:
SubElement::repeatable(
key: 'tab',
label: __('Tab navigation'),
selector: '.vb-tab-button',
styles: ['typography', 'spacing', 'border'],
repeaterKey: 'tabs',
itemStyles: ['typography', 'spacing', 'background', 'border'],
states: [
'active' => [
'label' => __('Active state'),
'elementKey' => 'tab_active',
'selector' => '.vb-tab-button--active',
'designProps' => ['typography', 'background', 'border'],
],
'hover' => [
'label' => __('Hover state'),
'elementKey' => 'tab_hover',
'selector' => ':hover',
'designProps' => ['typography', 'background'],
],
],
)
Linked elements
If two sub-elements should share their style buffer (i.e., one edits affects both, no duplicate state on the saved JSON), declare them via linked. This is useful for, e.g., a "title in card" sub-element that should remain consistent with a "title in detail view" sub-element.
SubElement::structural(
key: 'card_title',
label: __('Card title'),
selector: '.vb-card .vb-title',
styles: ['typography'],
linked: 'detail_title', // shares props with the 'detail_title' sub-element
)
The drawer renders the secondary entry as a read-only mirror with a "linked to..." label, so editors understand they're sharing styles.
Per-item style overrides on repeatables
For SubElement::repeatable(...), the standard itemStyles array describes which PropTypes apply to each repeater item uniformly. Sometimes a single item should override (one specific tab styled differently). Tagixo exposes perItemStyles for this:
SubElement::repeatable(
key: 'plan',
label: __('Plan card'),
selector: '.vb-plan',
styles: ['typography', 'border'],
repeaterKey: 'plans',
itemStyles: ['typography', 'border', 'background'],
perItemStyles: true, // enables per-item overrides in the drawer
)
When enabled, the drawer adds a "Override per item" toggle next to each repeater item. Override values are stored under perItemStyles.{itemId}.{propTypeKey} in the content payload; the renderer applies them via [data-vb-id="{itemId}"] selectors automatically.
This pairs well with pricing tables, comparison matrices, and any "spotlight the recommended choice" pattern.
Defaults on sub-elements
Most sub-elements don't need explicit defaults — the user starts with the parent module's design propagation. When you DO want a sub-element to start with a baked-in look (e.g., a badge that should always have a soft drop-shadow until the editor overrides), declare them inline:
SubElement::structural(
key: 'badge',
label: __('Badge'),
selector: '.vb-badge',
styles: ['typography', 'background', 'border', 'box_shadow'],
defaults: [
'box_shadow' => DefaultsBuilder::boxShadow()->subtle(),
'background' => DefaultsBuilder::background()->color('#fff7ed'),
],
)
The defaults seed the saved JSON when a user first inserts the module, but never re-apply on subsequent renders — editor overrides win.
How the builder uses them
During metadata serialization, sub-elements are exposed to the frontend as part of the component's PropType payload.
That allows the drawer to:
- show internal element targets
- drill down into repeatable items
- apply style groups to the right selector
- keep state-specific styling explicit
Selector strategy
Selectors must be:
- stable
- intentional
- scoped to the module
Avoid generic selectors such as .title or .button.
Prefer selectors such as:
.pricing-card__title.tabs__trigger.hero-banner__cta
If selector quality is poor, the sub-element API becomes dangerous.
Best practices
- Design the final HTML selectors before exposing sub-elements.
- Keep keys stable once content exists in production.
- Link content fields when contextual editing should target a specific element.
- Define defaults for any stylable element that needs a product baseline.
- Test repeaters and states in save, reload, preview, and public render flows.
Pricing table example
The pricing table module is a concrete example of when sub-elements are worth the extra surface area.
It exposes internal parts such as:
- card shell
- featured card state
- plan name
- current price
- original price
- discount note
- badge
- CTA button
- comparison title
- comparison table
- comparison labels and values
This matters because a pricing module usually evolves faster than simpler content blocks:
- launch promos add crossed-out prices
- "most popular" badges need independent styling
- comparison matrices introduce table-level and cell-level selectors
For this kind of module, use explicit selectors and separate content keys instead of trying to infer meaning from a single text field.
Example decisions that keep the contract stable:
originalPriceis separate frompricediscountExpiresTextis separate frombadge- comparison rows map values by plan index instead of storing arbitrary nested HTML
That separation keeps the editor predictable and makes homepage-specific overrides much safer.
Common mistakes
- generic selectors that style unrelated nodes
- repeater keys that do not match the actual content schema
- undocumented state selectors
- exposing too many sub-elements for simple modules
- changing element keys after editors have already styled them
Sub-elements are powerful, but only when your module HTML contract is stable enough to support them.