Common Patterns
Recurring solutions to everyday problems: conditional rendering, dynamic content, forms, lazy init, and Zero Framework integration.
Conditional Rendering
Shadow DOM doesn't have built-in conditionals. Use JavaScript in connectedCallback or attributeChangedCallback to show and hide elements:
class TogglePanel extends ShadowComponent {
static observables = ["open"];
connectedCallback() {
super.connectedCallback();
this._content = this.shadowRoot.querySelector(".content");
this._updateVisibility();
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === "open") {
this._updateVisibility();
}
}
_updateVisibility() {
if (!this._content) return;
const isOpen = this.hasAttribute("open");
this._content.hidden = !isOpen;
}
}
The hidden attribute is the cleanest way to toggle visibility. It applies display: none without needing a CSS class. For transitions, toggle a class and use CSS animations instead.
Dynamic Content Updates
For components that display data which changes at runtime (tables, lists, dashboards), the pattern is: set a data property, then re-render.
class DataList extends ShadowComponent {
static observables = [];
connectedCallback() {
super.connectedCallback();
this._container = this.shadowRoot.querySelector(".items");
this._data = [];
}
set data(items) {
this._data = items;
this._render();
}
get data() {
return this._data;
}
_render() {
if (!this._container) return;
this._container.innerHTML = "";
for (const item of this._data) {
const el = document.createElement("div");
el.className = "item";
el.textContent = item.label;
el.dataset.id = item.id;
this._container.appendChild(el);
}
}
}
customElements.define("data-list", DataList);
// Usage:
const list = document.querySelector("data-list");
list.data = [
{ id: 1, label: "First item" },
{ id: 2, label: "Second item" }
];
This pattern separates "data in" from "rendering" and avoids the overhead of attribute serialization for complex objects.
Form Handling
Components that wrap form inputs need to expose their value and participate in form submission. The pattern uses a hidden input or direct value property:
<template id="star-rating">
<style>
:host { display: inline-flex; gap: 0.25rem; cursor: pointer; }
.star { font-size: 1.5rem; color: var(--border-color); transition: color 0.15s; }
.star.active { color: var(--accent-primary, #e85d26); }
</style>
<span class="star" data-value="1">★</span>
<span class="star" data-value="2">★</span>
<span class="star" data-value="3">★</span>
<span class="star" data-value="4">★</span>
<span class="star" data-value="5">★</span>
</template>
<script>
class StarRating extends ShadowComponent {
static observables = ["value", "name"];
connectedCallback() {
super.connectedCallback();
this._stars = this.shadowRoot.querySelectorAll(".star");
this.shadowRoot.addEventListener("click", (e) => {
const star = e.target.closest("[data-value]");
if (!star) return;
this.value = star.dataset.value;
this._highlight(this.value);
this.dispatchEvent(new CustomEvent("change", {
bubbles: true,
composed: true,
detail: { value: this.value }
}));
});
// Set initial highlight
if (this.value) this._highlight(this.value);
}
_highlight(upTo) {
this._stars.forEach(star => {
star.classList.toggle("active",
parseInt(star.dataset.value) <= parseInt(upTo)
);
});
}
}
customElements.define("star-rating", StarRating);
</script>
Lazy Initialization
Heavy components (canvas renderers, data visualizations) should defer initialization until they're actually visible:
class HeavyChart extends ShadowComponent {
static observables = [];
connectedCallback() {
super.connectedCallback();
this._initialized = false;
// Use IntersectionObserver for lazy init
this._observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !this._initialized) {
this._init();
this._initialized = true;
this._observer.disconnect();
}
});
this._observer.observe(this);
}
disconnectedCallback() {
if (this._observer) {
this._observer.disconnect();
}
}
_init() {
// Heavy setup: create canvas, load data, start rendering
this._canvas = this.shadowRoot.querySelector("canvas");
this._ctx = this._canvas.getContext("2d");
this._fetchAndRender();
}
}
Use this pattern for components that are below the fold or inside collapsed sections. For components that are immediately visible, the overhead of IntersectionObserver is not worth it — just initialize in connectedCallback.
Responsive Components
Components can adapt to their own size using ResizeObserver instead of media queries (which only see the viewport):
connectedCallback() {
super.connectedCallback();
this._resizeObserver = new ResizeObserver((entries) => {
const width = entries[0].contentRect.width;
if (width < 400) {
this.setAttribute("layout", "compact");
} else if (width < 700) {
this.setAttribute("layout", "medium");
} else {
this.setAttribute("layout", "full");
}
});
this._resizeObserver.observe(this);
}
disconnectedCallback() {
this._resizeObserver.disconnect();
}
Then use :host([layout]) selectors in your CSS to adapt the layout:
:host([layout="compact"]) .grid {
grid-template-columns: 1fr;
}
:host([layout="medium"]) .grid {
grid-template-columns: 1fr 1fr;
}
:host([layout="full"]) .grid {
grid-template-columns: 1fr 1fr 1fr;
}
Integration with Zero Framework
In a Zero Framework application, ShadowComponents are used in PHP views. Server-rendered data flows into components through slots:
<!-- Server data flows through slots -->
<user-card>
<span slot="name"><?php echo htmlspecialchars($this->data['name']); ?></span>
<span slot="email"><?php echo htmlspecialchars($this->data['email']); ?></span>
</user-card>
<!-- Loop to create multiple components -->
<?php foreach ($this->data['items'] as $item): ?>
<item-card>
<span slot="title"><?php echo htmlspecialchars($item['title']); ?></span>
<span slot="description"><?php echo htmlspecialchars($item['description']); ?></span>
</item-card>
<?php endforeach; ?>
Let PHP handle initial data rendering through slots. Let JavaScript handle interactivity. This gives you fast first paint (no JS required for initial content) with full interactivity once the component hydrates.
Component Loading
Zero Framework auto-loads component files by naming convention. Place your .html component files in the module's assets/components/ directory and they are loaded automatically when the module renders.
modules/
MyModule/
MyModule.php
assets/
css/my-module.css ← auto-loaded
js/my-module.js ← auto-loaded
components/
my-widget.html ← auto-loaded
view/
index.php ← uses <my-widget>
Global components (used across modules) live in the shared components directory and are loaded site-wide.