# OhMyEtch Docs
> Atomic, non-opinionated components for WordPress.
This file contains all documentation content in a single document following the llmstxt.org standard.
## OhMyEtch
OhMyEtch brings **atomic, headless-style components** to WordPress. Each component owns one job — accessible behavior, interaction logic, and the structural hooks you need — and nothing else. You compose them into any layout, any visual design, and any product surface you want.
That is the whole philosophy: **logic without opinion**.
## Atomic by design
Most WordPress component libraries ship finished UI patterns — cards, heroes, pricing tables, checkout shells — and ask you to adapt your design to theirs. OhMyEtch goes the other way.
An Accordion is not a FAQ block. A Dialog is not a modal popup template. A CartItemTitle is not a cart row layout. They are **atoms**: small, focused units that do one thing well and stay out of your way.
| What the component provides | What you provide |
|---|---|
| Keyboard navigation, ARIA, focus management | Typography, spacing, color |
| Open/close state, form scope, facet wiring | Grid, flex, breakpoints, brand |
| Consistent runtime behavior across the site | Markup structure inside slots, copy, imagery |
Because each atom has a narrow responsibility, the same component family can become wildly different experiences:
- **Accordion** → FAQ, settings panel, sidebar nav, product specs
- **Tabs** → product details, account settings, documentation sections
- **Dialog + Trigger** → newsletter signup, image lightbox entry, mobile menu
- **FacetTarget + SearchFacet** → job board filters, product catalog, map search
- **AddToCartForm + AttributeSelector** → variable product page, quick-view drawer, archive loop card
The component does not know your use case. It only guarantees the behavior works correctly every time.
## Composition is the product
OhMyEtch is built for **composition**, not configuration hacks.
Components nest inside each other. Triggers connect to targets by ID. Facets refresh targets. Woo atoms loop inside cart containers. You assemble molecules from atoms — exactly the structure your design needs, not the structure a plugin author guessed you would want.
That matters because real projects are diverse. A marketing site, a Woo store, and a filtered archive do not share one layout — but they can share the same **behavior layer**. OhMyEtch keeps that layer consistent while your Etch patterns, ACSS classes, and HTML stay entirely yours.
## Not opinionated — on purpose
Opinionated components save time until they do not. The moment you need a different markup order, a non-standard breakpoint, or a brand-specific interaction, you are fighting the block.
OhMyEtch deliberately avoids that:
- **No baked-in layouts** — slots and structure are yours to author
- **No visual theme** — default styling is minimal; design tokens and classes are yours
- **No product assumptions** — the same cart atom works in a drawer, a page, or a mini-cart dropdown
- **No dead ends** — when requirements change, you rearrange composition instead of replacing the component
This is what **headless** means in a WordPress context: the component runtime carries logic and accessibility; the presentation layer stays fully under your control.
## Why atomic wins in WordPress
WordPress sites are long-lived. Teams change. Designs evolve. Integrations multiply.
Atomic components stay stable because their contract is behavior, not appearance. When you refactor a pattern library, you restyle — you do not rewrite accordion keyboard handling. When you add WooCommerce, you compose new atoms into existing layouts instead of swapping entire page templates.
You get:
- **Predictable accessibility** — the same patterns everywhere, tested once
- **Reuse without duplication** — one Dialog family, many modal experiences
- **Freedom to scale** — from a single FAQ to a full checkout without changing philosophy
- **A system that grows with you** — new atoms join the same composition model
## What is in these docs
This site is the reference for composing OhMyEtch on real projects:
- **[UI Components](/category/ui-components)** — Accordion, Dialog, Tabs, Carousel, and more. Accessible, keyboard-navigable, composable families.
- **[Facets](/facets/overview)** — Search, filter, pagination, and map facets wired to dynamic result targets.
- **[Woo Components](/woo/overview)** — Cart, checkout, and product atoms for WooCommerce.
Pick a family, read how it composes, and build the layout you actually want.
**Building with an AI agent?** See **[LLM & agent access](/llms)** for `llms.txt`, section bundles, and every plain Markdown export.
---
## Accordion
## Overview
Use the Accordion family when you need a group of expandable sections with built-in keyboard support, ARIA wiring, and animated panel open and close behavior. The family is composed, not standalone — the root component manages shared state and keyboard navigation, while child components define each individual section. Accordion works well for FAQs, settings panels, sidebar navigation, product feature lists, and any content that benefits from progressive disclosure.
## Authoring Structure
```
Accordion
└── Accordion Item
├── Accordion Header
│ └── Accordion Trigger
└── Accordion Content
```
### Placement Rules
| Component | Placement | Role |
|---|---|---|
| **Accordion** | Top-level wrapper. | Owns state, keyboard navigation, and shared options. |
| **Accordion Item** | Direct child of `Accordion`. | The runtime looks for direct item children when it initializes. |
| **Accordion Header** | Inside `Accordion Item`. | Provides heading semantics (`` – ``) for the trigger. |
| **Accordion Trigger** | Usually inside `Accordion Header`. | Interactive control users click or focus to toggle a panel. |
| **Accordion Content** | Inside `Accordion Item`, after the header. | The panel that opens and closes. |
## Quick Start
{`
{#slot default}
{#slot default}
{#slot default}Manage your account preferences here.{/slot}
{/slot}
{/slot}
`}
When the `AccordionHeader` has an empty slot and a `content.label` value, it auto-generates a trigger for you — no need to nest `AccordionTrigger` manually.
{`
{#slot default}
{#slot default}
{#slot default}
{#slot default}Shipping{/slot}
{/slot}
{#slot default}
Shipping content goes here.
{/slot}
{/slot}
{/slot}
`}
Use the explicit structure when you need custom markup inside the trigger — icons, badges, or any content beyond a plain label.
---
## Family Components
### Accordion (Root)
The root component wraps all items and manages shared behavior: expansion mode (single or multiple), keyboard navigation, loop wrapping, and animation timing. It does not render any visible UI itself — only a container element.
HTML tag for the root element.
Expansion mode. `"single"` allows only one item open at a time ( Disclosure pattern). `"multiple"` allows any number of items open simultaneously.
Orientation of the accordion. Affects which arrow keys navigate between triggers and how focus moves.
Whether keyboard navigation wraps from the last trigger back to the first, and vice versa.
Disables all items in the accordion when `true`. Individual items can still override this with their own `disabled` prop.
Duration of the expand/collapse animation in milliseconds, as a string.
CSS class applied to the root element.
#### Expansion Modes
- **`single`** — Opening one item automatically closes the previously open item. Only one panel is visible at a time. This is the classic accordion behavior.
- **`multiple`** — Each item toggles independently. Opening one does not affect others. Use this for disclosure-style UIs where users may want several sections visible.
#### Keyboard Behavior
When a trigger has focus:
| Key | Action |
|---|---|
| `Enter` / `Space` | Toggle the associated panel. |
| `ArrowDown` | Move focus to the next trigger (vertical orientation). |
| `ArrowUp` | Move focus to the previous trigger (vertical orientation). |
| `ArrowRight` | Move focus to the next trigger (horizontal orientation). |
| `ArrowLeft` | Move focus to the previous trigger (horizontal orientation). |
| `Home` | Move focus to the first trigger. |
| `End` | Move focus to the last trigger. |
When `loop` is `true`, pressing `ArrowDown` on the last trigger wraps to the first, and `ArrowUp` on the first wraps to the last. In RTL layouts, horizontal arrow keys are automatically reversed.
#### Accessibility
The root component generates unique IDs for each item and wires up ARIA attributes automatically:
- Each trigger has `aria-expanded` and `aria-controls` pointing to its content panel.
- Each content panel has `role="region"` and `aria-labelledby` pointing to its header.
- `data-ome-state` attributes sync open/closed state for CSS targeting.
---
### Accordion Item
Each `AccordionItem` represents a single expandable section. It is the direct child of the root `Accordion` and the parent of `AccordionHeader` and `AccordionContent`. The runtime discovers items by scanning for direct item children at initialization time.
HTML tag for the item wrapper.
Disables this specific item. Overrides the root `disabled` prop on a per-item basis.
Initial open state. In `single` mode, only the last item with `open="true"` will actually render open.
Additional CSS class added to the item when it is in the open state. Useful for CSS-driven animations or visibility changes.
CSS class applied to the item element.
---
### Accordion Header
The header provides semantic heading markup (`` – ``) for the trigger. It determines the heading level and can auto-generate a trigger when its slot is empty but a `content.label` value is provided. This is the simplest way to create an accordion — just set the label and move on.
HTML tag for the header wrapper.
Heading level for the header (`"1"` through `"6"`). Renders as `` – `` accordingly.
Tag used for the auto-generated trigger's inner label wrapper when the header creates a trigger from `content.label`.
Fallback trigger label. When the header slot is empty, this value is used to generate a trigger automatically.
CSS class applied to the header element.
#### Header Fallback Rendering
When the header slot is empty and `content.label` has a value, the header auto-generates a trigger element. This means you can build a working accordion with just `AccordionHeader` and `AccordionContent` — no explicit `AccordionTrigger` needed:
```
Accordion Header (slot empty + label set)
└── Auto-generated Trigger
└── Label text
└── Chevron icon
```
---
### Accordion Trigger
The interactive element users click, tap, or focus to toggle a panel. When used inside `AccordionHeader`, it receives heading semantics from the parent. When the slot is empty, the trigger renders a label wrapper and chevron icon using its `content.label` value.
Tag for the fallback label wrapper only. **This does not change the outer button element** — the trigger always renders a `` as its interactive root.
Disables this trigger independently of the item or root `disabled` setting.
Fallback label text. Used when the trigger slot is empty to render a text label and chevron.
CSS class applied to the trigger button element.
CSS class applied to the label text element inside the trigger.
CSS class applied to the chevron icon inside the trigger.
#### Trigger Fallback Rendering
When the trigger slot is empty and `content.label` is set, the trigger renders:
```
Label text
▼
```
---
### Accordion Content
The content panel that expands and collapses. It is animated by default using the root's `animationDuration` setting. Content is hidden via `height: 0` and `overflow: hidden` when collapsed — it remains in the DOM at all times.
HTML tag for the content panel.
When `true`, the collapsed panel uses the browser's `hidden="until-found"` attribute instead of `height: 0`. This allows browser find-in-page (`Ctrl+F` / `Cmd+F`) to search inside collapsed panels and expand them automatically when a match is found. Use this when your accordion contains searchable reference content.
CSS class applied to the content element.
---
## Common Mistakes
Do not place another block between `Accordion` and `AccordionItem`. The runtime scans for **direct** item children at initialization. Adding a wrapper (like a Group or Stack block) between them breaks item discovery and the accordion will not initialize.
`AccordionTrigger` `structure.tag` only changes the inner fallback label wrapper — the outer interactive element is always a ``. If you need a different element, use the trigger slot to provide your own content instead.
`AccordionContent` `settings.hiddenUntilFound` is specifically for browser find-in-page integration. It is not a general-purpose visibility toggle and may cause unexpected behavior if used outside its intended purpose.
---
## FAQs
Can I nest accordions inside each other?
Yes. You can place an `Accordion` inside an `AccordionContent` panel. Each nested accordion manages its own state independently. Make sure to use appropriate heading levels — if the outer accordion uses `level="2"`, the inner accordion should use `level="3"` to maintain a correct document outline.
How do I style individual items differently?
Use the `class` prop on `AccordionItem` to apply a custom class, and the `toggleClass` prop to add an additional class when the item is open. You can also target items with CSS using `data-ome-state="open"` or `data-ome-state="closed"` attributes that the runtime adds automatically.
Can I start with multiple items open?
In `multiple` mode, yes — set `open="true"` on each item you want expanded on load. In `single` mode, only one item can be open at a time, so only the last item with `open="true"` will actually render expanded.
What happens if I don't include a Header?
The accordion will still function, but you will lose heading semantics and ARIA labeling. The content panel will not have an `aria-labelledby` association, which reduces accessibility. Always include an `AccordionHeader` (or ensure your custom trigger provides equivalent semantics).
---
## Breadcrumbs
## Overview
Breadcrumbs is a **server-rendered** navigation component that automatically builds a breadcrumb trail from the active WordPress query. Unlike interactive components (Accordion, Tabs, etc.), Breadcrumbs has no client-side runtime — the trail is resolved entirely in PHP during block rendering and injected into the page as final HTML.
The component automatically handles pages with parent/ancestor hierarchies, single posts with category ancestry, custom post type archives, term archives, date archives, author archives, search results, and 404 pages. It also outputs `BreadcrumbList` JSON-LD structured data for SEO when enabled.
Breadcrumbs is a single-component family — there are no sub-components. It is placed into patterns or templates and the server-side trail resolver does the rest.
## Authoring Structure
```
Breadcrumbs (OmeBreadcrumbs)
├── Home slot (optional custom home content)
└── Separator slot (optional custom separator content)
```
### Placement Rules
| Slot | Purpose |
|---|---|
| **home** | Custom content inside the home link. When empty, the `homeLabel` text is used. |
| **separator** | Custom separator between items. When empty, the `separator` text is used. |
## How It Works
Breadcrumbs uses a two-phase rendering pipeline:
1. **Editor / Preview phase** — The component renders a static preview with placeholder items ("Home", "Blog", "Current page") so the editor shows a representative breadcrumb trail.
2. **Frontend phase** — A block interceptor (`BreadcrumbsBlockInterceptor`) replaces the placeholder with a real breadcrumb trail resolved from the current WordPress query context via `BreadcrumbTrailResolver`.
This means the breadcrumb trail is always accurate for the current page — no manual item configuration is needed.
### Supported WordPress Contexts
| Context | Trail |
|---|---|
| **Front page** | Hidden by default (enable with `showOnFrontPage`). |
| **Posts index** | Home → Blog (or custom Posts Page). |
| **Single post** | Home → Blog → Category → Post. |
| **Page (with ancestors)** | Home → Parent → … → Page. |
| **Custom post type** | Home → Archive → Term (if hierarchical) → Post. |
| **Post type archive** | Home → Archive. |
| **Term archive** | Home → … → Term (with ancestor terms for hierarchical taxonomies). |
| **Date archive** | Home → Year → Month → Day (drills down as deep as the URL). |
| **Author archive** | Home → Author name. |
| **Search results** | Home → Search. |
| **404** | Home → Not Found. |
## Quick Start
{` `}
{`
{#slot separator}
→
{/slot}
`}
{`
{#slot home}
{/slot}
`}
---
## Props
Text used for the home breadcrumb. Shown when the `home` slot is empty.
Text shown between breadcrumb items. Shown when the `separator` slot is empty.
Accessible label for the `` landmark. Screen readers announce this to identify the navigation region.
Includes the home breadcrumb as the first item in the trail.
Includes the current page as the last item in the trail. When `false`, the trail ends at the parent item.
Renders the current page breadcrumb as a link (``) instead of a plain ``. The current item always has `aria-current="page"` regardless of this setting.
Keeps breadcrumbs visible on the front page. When `false` (default), the component outputs nothing on the front page.
Outputs a `BreadcrumbList` JSON-LD script tag after the navigation markup. Schema is only rendered when there are at least two items with URLs. Disable this if your theme or an SEO plugin already provides breadcrumb schema.
CSS class applied to the root `` element.
CSS class applied to the `` list element.
CSS class applied to each `` item element.
CSS class applied to each `` link element.
CSS class applied to the current page item (whether rendered as a link or span).
CSS class applied to each separator `` element.
---
## Default CSS
The default styles provide a horizontal flex layout with ACSS-aware custom properties:
```css
/* Root: inherits text color and uses small text size */
.ome-breadcrumbs-root-default {
color: var(--text-dark, currentColor);
font-size: var(--text-s, 0.875rem);
}
/* List: horizontal flexbox with wrapping */
.ome-breadcrumbs-list-default {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-xs, 0.75rem);
margin: 0;
padding: 0;
}
/* Items: inline flex, no list style */
.ome-breadcrumbs-item-default {
display: inline-flex;
align-items: center;
gap: var(--space-xs, 0.75rem);
list-style: none;
}
```
The fixed base styles enforce `box-sizing: border-box`, hide `[hidden]` elements, and provide `:focus-visible` outline styling on links — matching the same foundation used across all Etch components.
---
## Accessibility
The rendered markup uses semantic HTML:
- The root element is a `` with `aria-label` (defaults to "Breadcrumb").
- The list uses an ordered list `` for correct semantics.
- The current page item has `aria-current="page"`.
- Separators are marked `aria-hidden="true"` to avoid screen reader noise.
- `:focus-visible` outlines are provided by the fixed base styles.
---
## Common Mistakes
Breadcrumbs is entirely server-rendered. There is no JavaScript runtime for this component — the trail is resolved in PHP during `render_block`. You cannot dynamically update breadcrumbs with client-side JavaScript.
By default `showOnFrontPage` is `false`, which means the component outputs nothing on the front page. If your design calls for breadcrumbs on the homepage, set `showOnFrontPage` to `true` and `showHome` to `true`.
If you use Yoast SEO, Rank Math, or another plugin that generates `BreadcrumbList` JSON-LD, set `enableSchema` to `false` to avoid duplicate schema output.
The editor renders static placeholder items ("Home", "Blog", "Current page") so you can preview the visual design. The actual breadcrumb trail is only generated on the frontend from the real WordPress query context.
---
## FAQs
Can I customize which categories appear for single posts?
The trail resolver picks the first assigned term (lowest `term_id`) from the post's categories. To control which category appears, assign only one category to the post, or use a plugin that filters `get_the_terms`.
Does this work with custom post types?
Yes. For custom post types with `has_archive`, the resolver includes the post type archive link. If the post type has hierarchical taxonomies, the first assigned term in the first public hierarchical taxonomy is included in the trail.
Can I use a custom icon or SVG for the separator?
Yes. Use the `separator` slot to provide custom markup instead of the plain text separator:
```jsx
{#slot separator}
{/slot}
```
What happens on pages with no ancestors?
For a top-level page, the trail is simply: Home → Page Title. For nested pages, each ancestor is included in order: Home → Parent → Grandparent → Page Title.
Why is the schema not appearing?
Schema output requires at least two items with URLs in the trail. If `showHome` is `false` and only the current page remains, or if the trail has fewer than two linkable items, the JSON-LD script tag is omitted. Also check that `enableSchema` is `true`.
---
## Carousel
## Overview
Use the Carousel family when you need swipeable or keyboard-navigable slides with optional autoplay, looping, synced instances, and external navigation buttons. The root component owns the Swiper.js runtime configuration, while `Carousel Slide` defines each slide. External `Carousel Previous Button`, `Carousel Next Button`, and `Carousel Dots` components can be placed anywhere on the page and target a specific carousel by ID. Multiple carousels sharing a sync group ID stay aligned automatically — ideal for main-content-plus-thumbnail patterns.
## Authoring Structure
```
Carousel
├── Carousel Slide
│ └── slide content
├── Carousel Slide
│ └── slide content
└── Carousel Slide
└── slide content
Carousel Previous Button (anywhere on page)
Carousel Next Button (anywhere on page)
Carousel Dots (anywhere on page)
```
### Placement Rules
| Component | Placement | Role |
|---|---|---|
| **Carousel** | Top-level wrapper. | Owns the Swiper runtime, slide configuration, IDs, and sync settings. |
| **Carousel Slide** | Direct child of `Carousel`. | Each direct child becomes one navigable slide. |
| **Carousel Previous Button** | Anywhere on the page. | Targets a carousel by ID and moves it to the previous slide. |
| **Carousel Next Button** | Anywhere on the page. | Targets a carousel by ID and moves it to the next slide. |
| **Carousel Dots** | Anywhere on the page. | Renders clickable pagination bullets for a targeted carousel. |
## Quick Start
{`
{#slot default}
{#slot default}Slide 1{/slot}
{#slot default}Slide 2{/slot}
{/slot}
`}
{`
{#slot default}
{#slot default}{/slot}
{#slot default}{/slot}
{/slot}
`}
{`
{#slot default}
{#slot default}{/slot}
{#slot default}{/slot}
{/slot}
{#slot default}
{#slot default}{/slot}
{#slot default}{/slot}
{/slot}
`}
---
## Family Components
### Carousel (Root)
The root container wraps all slides and manages the Swiper.js runtime: slide layout, navigation wiring, keyboard support, autoplay, effects, and sync groups. It renders a `div` (configurable via `tag`) with the `swiper` class and a child `swiper-wrapper`. The slot accepts `Carousel Slide` children directly.
HTML tag for the carousel root element.
Stable carousel ID used by external buttons, dots, and synced carousels to target this instance. Required when using any external control.
Shared sync group ID. Carousels with the same group ID stay synchronized — sliding one advances the others.
Zero-based slide index that is active on first load.
Wraps from the last slide back to the first instead of disabling navigation at the ends.
Centers the active slide in the viewport.
Number of slides visible at once. Use `"auto"` to size slides from CSS widths.
Gap between slides in pixels.
Enables left and right arrow key navigation when the carousel root is focused.
Enables breakpoint-based slides per view and spacing overrides.
Minimum viewport width in pixels where this row starts applying.
Slides per view at this breakpoint. Use `"auto"` to size slides from CSS widths.
Gap between slides in pixels at this breakpoint.
Advances slides automatically.
Delay between autoplay transitions in milliseconds.
Stops autoplay after user interactions (swipe, click).
Pauses autoplay while the pointer is over the carousel.
Stops autoplay when the last slide is reached in non-loop mode.
Runs autoplay toward previous slides instead of next slides.
Active Swiper transition effect. `"slide"` is the lightweight default — other effects load their runtime only when selected.
**Fade effect.** Fades slides over each other instead of briefly showing content underneath.
**Coverflow effect.** Slide rotation in degrees.
**Coverflow effect.** Depth offset in pixels.
**Coverflow effect.** Spacing stretch between slides in pixels or percent.
**Coverflow effect.** Effect multiplier.
**Coverflow effect.** Slide scale effect.
**Coverflow / Flip / Cube / Cards effects.** Adds shadows to slides.
**Flip effect.** Limits edge slide rotation.
**Cube effect.** Adds a main cube shadow.
**Cube effect.** Main shadow offset in pixels.
**Cube effect.** Main shadow scale ratio.
**Cards effect.** Offset distance per slide in pixels.
**Cards effect.** Rotation angle per slide in degrees.
**Cards effect.** Enables cards rotation.
**Creative effect.** Swiper `creativeEffect` object for advanced transforms.
Uses the JSON config object as the source of truth for Swiper behavior while keeping identity-driven controls (navigation, dots, sync) wired automatically.
Custom Swiper configuration object. Effect selection and identity wiring stay controlled by Etch — the JSON refines behavior like breakpoints, autoplay, and keyboard options.
CSS class applied to the carousel root element. The `swiper` class is always included.
#### Settings and Autoplay Visibility
The **Settings** and **Autoplay** prop groups are visible only when `useCustomConfig` is `false`. When custom config mode is on, those props are hidden and the `jsonConfig` object becomes the behavior source of truth instead.
#### Sync Groups
Carousels that share the same `identity.groupId` stay synchronized via Swiper's Controller module. This is useful for main-carousel-plus-thumbnail patterns — sliding one carousel automatically advances the other. At least two carousels must share a group ID for sync to activate.
#### Navigation Boundary State
In non-loop mode, external navigation buttons receive a `data-ome-carousel-nav-disabled` attribute at the boundaries:
- The **Previous** button is disabled at the first slide.
- The **Next** button is disabled at the last slide.
When `loop` is `true`, buttons never disable — the carousel wraps around instead.
#### Keyboard Behavior
When the carousel root has focus:
| Key | Action |
|---|---|
| `ArrowRight` | Advance to the next slide. |
| `ArrowLeft` | Go back to the previous slide. |
Keyboard navigation is enabled by default (`keyboardEnabled: true`) and scoped to the viewport (`onlyInViewport: true`). Disable it by setting `keyboardEnabled` to `false`.
#### Accessibility
The root component wires up ARIA attributes via Swiper's A11y module:
- The carousel container announces `"Content carousel"` as its role message.
- Navigation buttons have `aria-label` defaults (`"Previous slide"`, `"Next slide"`), overridable via each button's `label` prop.
- Pagination dots announce `"Go to slide N"` via `aria-label`.
- The Swiper A11y module provides live-region announcements for slide changes, including `"This is the first slide"` and `"This is the last slide"` messages.
---
### Carousel Slide
Each `CarouselSlide` represents a single slide inside the carousel. It renders as a `` with the `swiper-slide` class and accepts any content in its default slot.
CSS class names applied to the slide container.
---
### Carousel Previous Button
An external `` element that navigates a targeted carousel backward. It can be placed anywhere on the page — it does not need to be inside the carousel root. When the slot is empty, a default left-arrow SVG icon is rendered automatically.
Target carousel ID. This must match `identity.id` on the carousel root you want to control.
Accessible label announced by assistive technology.
CSS class names applied to the button element.
#### Fallback Icon
When the button slot is empty, a default left-arrow SVG icon is rendered. Provide slot content only when you want custom button UI (text, icon, or both).
#### Boundary Behavior
At the first slide, the previous button receives `data-ome-carousel-nav-disabled` when looping is off. With `settings.loop="true"`, the carousel wraps instead of disabling the button.
---
### Carousel Next Button
An external `` element that navigates a targeted carousel forward. Identical behavior to the Previous Button but in the opposite direction. When the slot is empty, a default right-arrow SVG icon is rendered automatically.
Target carousel ID. This must match `identity.id` on the carousel root you want to control.
Accessible label announced by assistive technology.
CSS class names applied to the button element.
#### Fallback Icon
When the button slot is empty, a default right-arrow SVG icon is rendered. Provide slot content only when you want custom button UI.
#### Boundary Behavior
At the last slide, the next button receives `data-ome-carousel-nav-disabled` when looping is off. With `settings.loop="true"`, the carousel wraps instead of disabling the button.
---
### Carousel Dots
External pagination dots that render one clickable bullet per slide. The dots container can be placed anywhere on the page and targets a carousel by ID. At runtime, the template dot markup is extracted from the component and used to generate the correct number of bullets dynamically.
Target carousel ID. This must match `identity.id` on the carousel root.
CSS class names applied to the dots wrapper element.
CSS class names applied to each generated dot button. The active dot also receives `ome-carousel-dot-active` and `swiper-pagination-bullet-active`.
#### Dot Generation
On initialization, the dots component extracts a hidden template button (`data-ome-carousel-dot-template`) and uses it to generate the correct number of bullets via Swiper's `renderBullet` callback. Each bullet is a `` with `aria-label="Go to slide N"`. The active bullet receives the `ome-carousel-dot-active` class in addition to Swiper's default active class.
---
## CSS Custom Properties
### Navigation Buttons
```css
/* Disabled state */
[data-ome-carousel-prev][data-ome-carousel-nav-disabled],
[data-ome-carousel-next][data-ome-carousel-nav-disabled] {
cursor: not-allowed;
opacity: 0.5;
}
```
Navigation buttons receive `data-ome-carousel-nav-disabled` when the carousel is at the boundary in non-loop mode. Use this attribute to style the disabled state.
### Pagination Dots
```css
.ome-carousel-dots-default {
display: flex;
align-items: center;
gap: 0.5rem;
}
.ome-carousel-dot-default {
width: 0.75rem;
height: 0.75rem;
padding: 0;
border: 0;
border-radius: 999px;
background: currentColor;
opacity: 0.35;
cursor: pointer;
}
.ome-carousel-dot-default.ome-carousel-dot-active,
.ome-carousel-dot-default.swiper-pagination-bullet-active {
opacity: 1;
}
```
### Carousel Root
```css
[data-ome-carousel-root] {
width: 100%;
position: relative;
overflow: hidden;
}
[data-ome-carousel-root][data-ome-carousel-initialized] {
touch-action: pan-y;
}
```
The carousel root always receives `data-ome-carousel-root`. After initialization, `data-ome-carousel-initialized` is added and `touch-action: pan-y` is applied for swipe support.
---
## Common Mistakes
External buttons and dots target a carousel by ID. If `identity.id` is empty on the root, previous buttons, next buttons, and dots cannot connect. The Etch editor shows a warning callout when the ID is missing.
Only `Carousel Slide` children become navigable slides. Putting raw HTML or other blocks directly inside the carousel root (without wrapping them in a slide) breaks the Swiper layout.
In non-loop mode, the previous button disables at the first slide and the next button disables at the last slide. Enable `settings.loop="true"` if you need wrap-around navigation.
Custom JSON config can refine the selected effect's parameters but cannot activate a different effect. The `effects.effect` prop controls which effect loads. For example, setting `effect: "fade"` in the JSON has no effect when `effects.effect` is `"slide"`.
---
## FAQs
How do I sync two carousels together?
Set the same `identity.groupId` value on both carousel roots. The runtime uses Swiper's Controller module to keep them aligned. Each carousel also needs its own unique `identity.id`. This is commonly used for a main content carousel paired with a thumbnail strip.
Can I have multiple sets of navigation buttons for the same carousel?
Yes. You can place multiple `Carousel Previous Button` and `Carousel Next Button` components on the page, all targeting the same carousel ID. The runtime updates all matching buttons when the slide changes or the carousel reaches a boundary.
How do I make slides responsive at different viewport widths?
Set `enableResponsive` to `true` and add breakpoint rows. Each row specifies a minimum viewport width (`breakpoint`), `slidesPerView`, and `spaceBetween`. For example, a breakpoint at `768` with `slidesPerView: 2` shows two slides on screens 768px and wider.
When should I use custom config mode?
Use `useCustomConfig: true` when you need Swiper behavior that the built-in settings and autoplay props don't cover — for example, custom `freeMode`, `slidesPerGroup`, or advanced `breakpoints` configurations. The custom JSON becomes the source of truth for behavior, but identity-driven wiring (navigation buttons, dots, sync groups) stays connected automatically.
Do navigation buttons and dots need to be inside the carousel?
No. `Carousel Previous Button`, `Carousel Next Button`, and `Carousel Dots` can be placed anywhere on the page. They connect to the carousel by matching their `targeting.for` prop to the carousel's `identity.id`.
---
## Dialog
## Overview
Use the Dialog family when you need a modal overlay — confirmations, forms, product previews, alerts, or any content that demands focused user attention. The family is composed of five components that work together: the root `Dialog` holds the modal content, `DialogTrigger` opens it from anywhere on the page, and `DialogTitle`, `DialogDescription`, and `DialogClose` provide structure and accessibility inside.
Dialogs are unique among Etch components because **the trigger and the dialog are siblings, not parent and child**. They connect via a shared ID — `identity.dialogId` on the Dialog must match `targeting.targetDialogId` on the trigger. When opened, the dialog node is physically moved into a shared runtime host at the end of `document.body`, then restored to its authored position on close.
## Authoring Structure
```
DialogTrigger (separate from Dialog — anywhere on the page)
Dialog (the modal content)
├── DialogTitle
├── DialogDescription
├── (your content)
└── DialogClose
```
### Placement Rules
| Component | Placement | Role |
|---|---|---|
| **DialogTrigger** | Anywhere on the page — **not** inside `Dialog`. | Opens the dialog when clicked. Connects via `targetDialogId`. |
| **Dialog** | Separate from the trigger. | Root modal container. Holds all inner components. Connects via `dialogId`. |
| **DialogTitle** | Inside `Dialog`. | Provides accessible naming via `aria-labelledby`. Renders as a heading (`` by default). |
| **DialogDescription** | Inside `Dialog`. | Provides accessible context via `aria-describedby`. Renders as `` by default. |
| **DialogClose** | Inside `Dialog`. | Closes the active dialog when clicked. |
### Connection Rule
DialogTrigger and Dialog connect by ID — they are **never** nested:
- `Dialog` uses `identity.dialogId` to identify itself.
- `DialogTrigger` uses `targeting.targetDialogId` to specify which dialog it opens.
- These values **must match exactly** for the connection to work.
- Multiple triggers can target the same `dialogId`.
- Each dialog on a page must have a unique `dialogId`.
## Quick Start
{`
{#slot default}Subscribe{/slot}
{#slot default}
Stay updated
Get the latest news delivered to your inbox.
Close
{/slot}
`}
The trigger and dialog are siblings — the trigger sits wherever you need it in the page, and the dialog can be authored anywhere. They connect because both reference the same ID (`"newsletter"`).
{`
{#slot default}
Product details
Quick product overview.
Product information goes here.
{#slot default}×{/slot}
{/slot}
`}
Use the `placement` setting to position the dialog anywhere in the viewport. When the trigger slot is empty, `content.label` renders a fallback text button.
---
## Family Components
### Dialog (Root)
The root component holds all modal content and manages dialog behavior: focus trapping, scroll locking, close-on-escape, close-on-outside-click, overlay rendering, and viewport placement. It does not render visible UI itself until opened — only a hidden container. When opened, the runtime physically moves the dialog node into a shared host element at the end of `document.body`.
Unique identifier for this dialog. `DialogTrigger` components use this ID to open the dialog via their `targetDialogId` prop. Must be unique across the page.
Opens the dialog automatically when the runtime initializes. Useful for announcement or onboarding dialogs that should appear on page load.
Whether pressing `Escape` closes the active dialog.
Whether clicking the overlay backdrop closes the active dialog.
Keeps keyboard focus trapped inside the open dialog. Tab and Shift+Tab cycle through focusable elements within the dialog only.
Prevents background page scrolling while the dialog is open.
Returns focus to the element that opened the dialog (typically the trigger) after the dialog closes.
Controls where the dialog surface appears inside the viewport. `center` is the standard modal position. Other values align the dialog to the corresponding edge or corner.
CSS selector targeting a specific element inside the dialog that should receive focus when it opens. When empty, focus moves to the first focusable element in the dialog.
CSS class applied to the dialog surface element.
#### Placement Options
| Placement | Behavior |
|---|---|
| `center` | Centered both horizontally and vertically. Default modal position. |
| `top` | Aligned to the top edge, centered horizontally. |
| `bottom` | Aligned to the bottom edge, centered horizontally. |
| `left` | Aligned to the left edge, centered vertically. |
| `right` | Aligned to the right edge, centered vertically. |
| `bottom-center` | Same as `bottom`. |
| `bottom-left` | Bottom-left corner of the viewport. |
| `bottom-right` | Bottom-right corner of the viewport. |
#### CSS Custom Properties
These properties are available on the dialog surface element (`.ome-dialog-default`) for runtime customization:
| Property | Default | Description |
|---|---|---|
| `--ome-dialog-overlay-color` | `rgba(0,0,0,0.5)` | Background color of the overlay backdrop. |
| `--ome-dialog-offset-x` | `0px` | Horizontal offset from the computed placement position. |
| `--ome-dialog-offset-y` | `0px` | Vertical offset from the computed placement position. |
#### Keyboard Behavior
| Key | Action |
|---|---|
| `Escape` | Closes the dialog (when `closeOnEscape` is `true`). |
| `Tab` | Moves focus to the next focusable element inside the dialog (when `trapFocus` is `true`). |
| `Shift+Tab` | Moves focus to the previous focusable element inside the dialog (when `trapFocus` is `true`). |
#### Accessibility
The root component wires up ARIA attributes automatically:
- The dialog surface gets `role="dialog"` and `aria-modal="true"`.
- `aria-labelledby` is auto-wired to the `DialogTitle` element's generated ID.
- `aria-describedby` is auto-wired to the `DialogDescription` element's generated ID (if present).
- IDs are auto-generated when not explicitly set.
#### Behaviors
- **One dialog at a time.** Only one dialog can be active. Opening a new dialog closes the previous one without animation.
- **DOM teleport.** The dialog node is physically moved into a shared host element at the end of `document.body` while open, then restored to its authored DOM position on close. This ensures the dialog overlays all page content regardless of where it was authored.
---
### DialogTrigger
The interactive element users click to open a dialog. DialogTrigger is **always placed separately from Dialog** — they are siblings in the block tree, not parent and child. They connect via a shared ID: `targeting.targetDialogId` must match the dialog's `identity.dialogId`.
Multiple triggers can target the same dialog. When the trigger slot is empty, `content.label` renders a fallback text button.
ID of the dialog this trigger should open. Must match the `identity.dialogId` of a `Dialog` component on the page.
Fallback trigger label used when the trigger slot is empty. Renders as a plain text button.
Disables the trigger so it cannot open the dialog.
CSS class applied to the trigger button element.
#### Accessibility
- Triggers get `aria-haspopup="dialog"`, `aria-expanded`, and `aria-controls` wired automatically.
- Pressing `Enter` or `Space` on a focused trigger opens the target dialog.
---
### DialogTitle
Provides accessible naming for the dialog. The root `Dialog` auto-wires `aria-labelledby` to this element's ID, so screen readers announce the title when the dialog opens. Must be placed inside the `Dialog` slot.
HTML tag used for the title element. Common values: `"h2"`, `"h3"`, `"h4"`.
CSS class applied to the title element.
---
### DialogDescription
Provides additional context for screen readers. The root `Dialog` auto-wires `aria-describedby` to this element's ID. Must be placed inside the `Dialog` slot.
HTML tag used for the description element.
CSS class applied to the description element.
---
### DialogClose
Button that closes the active dialog. Must be placed inside the `Dialog` slot. You can include any content in its slot — text, icons, or custom markup.
Disables the close button so users cannot click it to dismiss the dialog.
CSS class applied to the close button element.
---
## Common Mistakes
`DialogTrigger` and `Dialog` are **siblings**, not parent and child. The trigger must be placed outside the dialog in the block tree. They connect by matching `targetDialogId` to `dialogId` — nesting breaks the connection and the trigger will not work.
Without matching IDs, the trigger has no way to know which dialog to open. Both `identity.dialogId` on `Dialog` and `targeting.targetDialogId` on `DialogTrigger` must be set to the same value.
Each dialog must have a unique `dialogId`. If two dialogs share the same ID, only the first one registers — the second will never open.
When a dialog opens, its DOM node is physically moved into a shared runtime host at the end of `document.body`. It does not render in-place where you authored it. This ensures the overlay appears above all page content.
---
## FAQs
Can I have multiple triggers for one dialog?
Yes. Any number of `DialogTrigger` components can target the same `dialogId`. Each trigger independently opens the same dialog. This is useful for pages that offer several entry points to the same form or overlay.
Can I have multiple dialogs on one page?
Yes. Give each dialog a unique `dialogId` and have its triggers target that specific ID. Only one dialog can be active at a time — opening a new dialog closes the previous one without animation.
Does DialogTitle have to be inside Dialog?
Yes. `DialogTitle` must be placed inside the `Dialog` slot. The runtime looks for it there to wire up `aria-labelledby`. If placed outside, the dialog will not have an accessible name.
What happens to the DOM when a dialog opens?
The dialog's DOM node is physically moved from its authored position into a shared host element appended to `document.body`. When the dialog closes, the node is moved back to its original position. This teleport ensures the overlay always renders above all other page content.
How do I position the dialog in the viewport?
Use the `settings.placement` prop on `Dialog`. Options are `center` (default), `top`, `bottom`, `left`, `right`, `bottom-center`, `bottom-left`, and `bottom-right`. For fine-tuning, use the CSS custom properties `--ome-dialog-offset-x` and `--me-dialog-offset-y` to shift the dialog from its computed position.
How do I change the overlay backdrop color?
Set the `--ome-dialog-overlay-color` CSS custom property on the dialog surface element (`.ome-dialog-default`). The default is `rgba(0,0,0,0.5)`. For a lighter backdrop: `--ome-dialog-overlay-color: rgba(0,0,0,0.2)`. For a solid backdrop: `--ome-dialog-overlay-color: rgba(0,0,0,0.85)`.
Can I auto-focus a specific element when the dialog opens?
Yes. Set `settings.initialFocusSelector` to a CSS selector matching the element you want focused (e.g. `"#email-input"`). When empty, focus moves to the first focusable element in the dialog.
---
## Drawer
## Overview
Use the Drawer family when you need a panel that slides in from the edge of the viewport — for mobile navigation, filter panels, quick settings, or any overlay content that should feel spatially connected to the screen edge. The family is composed of five components: a root panel, a remote trigger, a title, a description, and a close button. The trigger lives separately from the drawer — they connect by matching IDs, not by DOM nesting.
## Authoring Structure
**Important:** `DrawerTrigger` is always placed separately from `Drawer` — they are siblings, not nested. They connect via matching IDs: `identity.drawerId` on the Drawer must match `targeting.targetDrawerId` on the Trigger.
```
DrawerTrigger (separate, anywhere on page)
Drawer (the sliding panel)
├── DrawerTitle
├── DrawerDescription (optional)
├── (your content)
└── DrawerClose
```
### Placement Rules
| Component | Placement | Role |
|---|---|---|
| **DrawerTrigger** | Separate from `Drawer`. Anywhere on the page. | Button that opens the drawer by targeting its ID. |
| **Drawer** | Top-level. Can be anywhere in the DOM. | The sliding panel surface. Hosts title, description, content, and close. |
| **DrawerTitle** | Inside `Drawer`. | Provides accessible name via `aria-labelledby`. Renders as a heading. |
| **DrawerDescription** | Inside `Drawer`. Optional. | Provides accessible description via `aria-describedby`. |
| **DrawerClose** | Inside `Drawer`. | Button that dismisses the drawer. |
## Quick Start
{`
{#slot default}
Navigation
Home
About
Contact
Close
{/slot}
`}
The trigger and drawer are siblings connected by matching IDs (`mobile-menu`). The trigger can be placed anywhere on the page — it does not need to be near the drawer.
{`
{#slot default}Filters{/slot}
{#slot default}
Filters
Refine your search results.
{#slot default}×{/slot}
{/slot}
`}
Use custom slot content when you need icons, badges, or any markup beyond a plain label in the trigger or close button.
---
## Family Components
### Drawer (Root)
The root component renders the sliding panel surface. It is targeted by one or more `DrawerTrigger` components via a shared ID. When opened, the drawer's DOM node is physically moved into a shared runtime host — it does not render at its authored position. On close, the node is restored to its original location using a Comment anchor.
Unique ID that triggers use to target this drawer. Must match `targetDrawerId` on any trigger that should open it.
Edge the drawer slides from. `"bottom"` creates a bottom sheet with a drag handle. Other directions slide in without a handle.
Opens the drawer automatically when the runtime initializes.
When `true`, clicking the overlay or pressing `Escape` closes the drawer. When `false`, the drawer can only be closed via `DrawerClose`.
CSS class applied to the drawer surface element.
#### Direction Behavior
| Direction | Behavior |
|---|---|
| **`bottom`** | Slides up from the bottom edge. A drag handle element is auto-generated for bottom-sheet interaction. |
| **`right`** | Slides in from the right edge. No handle element. |
| **`top`** | Slides down from the top edge. No handle element. |
| **`left`** | Slides in from the left edge. No handle element. |
#### CSS Custom Properties
| Property | Default | Description |
|---|---|---|
| `--ome-drawer-overlay-color` | `rgba(0, 0, 0, 0.5)` | Background color of the overlay behind the drawer. |
| `--ome-drawer-animation-duration` | `250ms` | Duration of the slide in/out transition. |
#### Key Behaviors
- **One active drawer at a time** — opening a drawer closes any currently open drawer.
- **DOM portaling** — the drawer node is moved into a shared runtime host while open and restored on close.
- **Focus trap** — Tab/Shift+Tab is trapped inside the drawer while open.
- **Focus restoration** — focus returns to the opener element when the drawer closes.
- **Scroll lock** — body scrolling is disabled while the drawer is open, with `scrollbar-gutter: stable` to prevent layout shift.
#### Accessibility
- Drawer receives `role="dialog"` and `aria-modal="true"`.
- `aria-labelledby` is wired to the `DrawerTitle` element's ID.
- `aria-describedby` is wired to the `DrawerDescription` element's ID (if present).
- The drag handle (bottom direction only) gets `aria-hidden="true"`.
- IDs are auto-generated if not explicitly set.
---
### DrawerTrigger
The trigger is a `` that opens a specific drawer by targeting its ID. It is always placed separately from the `Drawer` — they are siblings in the editor, not nested. Multiple triggers can target the same drawer by sharing the same `targetDrawerId`.
ID of the drawer this trigger should open. Must match `identity.drawerId` on the target `Drawer`.
Fallback label text. Used when the trigger slot is empty to render a plain text label inside the button.
Disables the trigger so it cannot open the drawer.
CSS class applied to the trigger button element.
#### Trigger Accessibility
- `aria-haspopup="dialog"` on the trigger button.
- `aria-expanded` reflects whether the targeted drawer is open.
- `aria-controls` points to the drawer element's ID.
- `Enter` and `Space` open the targeted drawer.
---
### DrawerTitle
Provides an accessible name for the drawer via heading semantics. The drawer wires `aria-labelledby` to this element's ID automatically.
HTML tag for the title element. Use an appropriate heading level for your document outline.
CSS class applied to the title element.
---
### DrawerDescription
Provides an accessible description for the drawer. The drawer wires `aria-describedby` to this element's ID automatically. Optional — omit it if the drawer does not need a description.
HTML tag for the description element.
CSS class applied to the description element.
---
### DrawerClose
A `` that closes the active drawer. Place it inside the `Drawer` — typically at the top or bottom of the drawer content.
Disables the close button.
CSS class applied to the close button element.
---
## Keyboard Behavior
| Key | Context | Action |
|---|---|---|
| `Enter` / `Space` | Trigger focused | Opens the targeted drawer. |
| `Escape` | Drawer open | Closes the drawer (only when `dismissible="true"`). |
| `Tab` | Drawer open | Moves focus to the next focusable element inside the drawer (trapped). |
| `Shift` + `Tab` | Drawer open | Moves focus to the previous focusable element inside the drawer (trapped). |
| `Enter` / `Space` | Close button focused | Closes the drawer. |
---
## Common Mistakes
`DrawerTrigger` and `Drawer` must be **siblings**, not nested. The trigger targets the drawer by matching IDs (`targetDrawerId` = `drawerId`), not by DOM relationship. Nesting the trigger inside the drawer breaks the connection.
The trigger-to-drawer connection relies entirely on matching IDs. If `identity.drawerId` on the `Drawer` does not match `targeting.targetDrawerId` on the `DrawerTrigger`, the trigger will not open the drawer.
When a drawer opens, its DOM node is physically moved into a shared runtime host. It does not render where you placed it in the editor. On close, it is restored to its original position.
When `direction="bottom"`, a drag handle element is auto-generated inside the drawer for bottom-sheet interaction. This handle has `aria-hidden="true"` and is not interactive via keyboard — it is a visual and touch affordance only.
---
## FAQs
Can multiple triggers open the same drawer?
Yes. Set the same `targetDrawerId` on all triggers that should open the drawer. Each trigger independently targets the drawer by ID, so any number of triggers can point to the same drawer.
What is the difference between Dialog and Drawer?
Dialog is a centered modal surface with 9 placement options. Drawer slides in from a screen edge with 4 directions (bottom, right, top, left). Both trap focus and share the same one-active-at-a-time constraint — opening a drawer closes any open dialog, and vice versa.
Why does only the bottom direction have a handle?
The handle is a drag affordance specific to bottom-sheet interaction patterns. Users expect to be able to drag a bottom sheet down to dismiss it. Side and top drawers do not use this pattern, so no handle is generated for those directions.
Does the drawer animate?
Yes. The drawer uses a CSS transform transition for the slide-in/out animation. The duration is configurable via the `--ome-drawer-animation-duration` custom property (default: `250ms`).
Can I have a drawer inside a dialog?
Yes, but only one can be active at a time across both families. Opening a drawer will close any currently open dialog, and opening a dialog will close any currently open drawer.
---
## Lightbox
## Overview
Use the Lightbox family when you need an overlay gallery for images or custom HTML content. The root component manages gallery-wide behavior — navigation, animation, and presentation — while each Lightbox Item defines a clickable trigger and the full-size content shown when the overlay opens. Lightbox works well for image galleries, product photo viewers, portfolio showcases, and any content that benefits from a zoomable, swipeable full-screen presentation.
The component uses [PhotoSwipe](https://photoswipe.com/) under the hood and automatically detects image mode versus HTML mode based on what you place in each item's slots. Items within the same root form a gallery — arrow keys navigate between them.
## Authoring Structure
```
Lightbox
└── Lightbox Item
├── trigger slot
│ └── preview content (what users click)
└── full slot
└── opened content (what the overlay shows)
```
### Placement Rules
| Component | Placement | Role |
|---|---|---|
| **Lightbox** | Top-level gallery wrapper. | Owns gallery state, runtime configuration, and PhotoSwipe initialization. |
| **Lightbox Item** | Direct child of `Lightbox`. | Each item becomes one gallery entry. Items are matched by position within the root. |
| **trigger** slot | Inside `Lightbox Item`. | The preview content users click to open the overlay. Can be an image, text, or any markup. |
| **full** slot | Inside `Lightbox Item`. | The content rendered when the lightbox opens. Can be an image, HTML, or any markup. |
## Quick Start
{`
{#slot default}
{#slot trigger}
{/slot}
{#slot full}
{/slot}
{/slot}
`}
When both the trigger and full slots contain exactly one `` each, the runtime automatically enters **image mode** — PhotoSwipe handles pinch-to-zoom, swipe gestures, and responsive image sizing natively.
{`
{#slot default}
{#slot trigger}View Details{/slot}
{#slot full}
Product Details
Full product description rendered inside the lightbox overlay.
{/slot}
{/slot}
`}
When the full slot contains anything other than a single image, the runtime enters **HTML mode** — the content is rendered inside a centered, padded container within the overlay.
---
## Family Components
### Lightbox (Root)
The root component wraps all items and manages shared behavior: gallery navigation, animation settings, overlay appearance, and gesture handling. It does not render visible UI itself — only a container element with data attributes that the runtime reads on initialization.
Allows gallery navigation to wrap from last to first and first to last. When `false`, navigation stops at the first and last items.
Allows vertical drag gestures (swipe up or down) to close the lightbox overlay where supported.
Enables zooming with the mouse scroll wheel when the lightbox is open.
Opening and closing animation type. `"none"` opens instantly, `"fade"` uses an opacity transition, and `"zoom"` scales from the trigger element.
Show and hide animation duration in milliseconds. Only applies when `showHideAnimationType` is not `"none"`.
Background overlay opacity (0–1) used by the opened lightbox. Clamped to the 0–1 range at runtime.
Gallery item spacing as a ratio, forwarded to PhotoSwipe. Controls the gap between slides during swipe navigation.
Viewport padding in pixels applied around HTML-mode slides. Also sets the `--ome-lightbox-html-padding` CSS custom property.
CSS class applied to the root gallery wrapper element.
#### Item Detection and Mode
The runtime automatically detects what mode to use for each Lightbox Item:
- **Image mode** — When both the trigger slot and the full slot each contain exactly one `` element, and the full-slot image has explicit `width` and `height` attributes. PhotoSwipe renders the image natively with pinch-to-zoom and responsive sizing. The trigger image is automatically used as the placeholder thumbnail (`msrc`) during the open animation.
- **HTML mode** — When the full slot contains anything other than a single image (or when the image is missing `width`/`height` attributes). The full slot's inner HTML is wrapped in a centered, scrollable container inside the overlay.
- **Lazy-loaded images** — The runtime resolves lazy-loaded images by checking `data-src`, `data-lazy-src`, `data-srcset`, and `data-lazy-srcset` attributes as fallbacks. `` element source sets are also resolved automatically.
#### Keyboard Behavior
When the lightbox overlay is open:
| Key | Action |
|---|---|
| `ArrowLeft` | Navigate to the previous gallery item. |
| `ArrowRight` | Navigate to the next gallery item. |
| `Escape` | Close the lightbox overlay and return focus to the trigger. |
When `loop` is `true`, navigating past the last item wraps to the first, and vice versa. Arrow keys, Escape, focus trapping, and focus return are always enabled and cannot be disabled.
#### Accessibility
The runtime enforces accessibility automatically:
- Each trigger button has `aria-haspopup="dialog"` and an accessible label derived from `content.triggerLabel`, the trigger's text content, or an inner image's `alt` attribute.
- If no accessible name can be derived, a fallback label of `"Open lightbox item"` is applied.
- Focus is trapped inside the lightbox overlay while open (`trapFocus: true`).
- Focus returns to the triggering element when the overlay closes (`returnFocus: true`).
- Invalid items (missing trigger or full content) are marked with `data-ome-invalid="true"` and their triggers are disabled with `aria-disabled="true"`.
---
### Lightbox Item
Each `LightboxItem` represents a single gallery entry. It provides two slots — **trigger** (the clickable preview) and **full** (the content shown in the overlay). Items are ordered by their position within the root; arrow key navigation follows this order.
Accessible label for the trigger button. Also used as fallback visible text when the trigger slot is empty. If omitted and the trigger slot contains content, the label is inferred from the trigger's text or image `alt` attribute.
CSS class applied to the item wrapper element.
CSS class applied to the clickable trigger button.
CSS class applied to the hidden full-content wrapper.
#### Slot Rules
Every useful `Lightbox Item` needs content in the **full** slot — that is the content the overlay displays. The **trigger** slot can be custom markup (an image, text, icon, etc.) or fallback text from `content.triggerLabel`.
#### Trigger Fallback Rendering
When the trigger slot is empty and `content.triggerLabel` has a value, the item renders the label text as the visible trigger button content:
```
Open image one
```
When the trigger slot has content, the `content.triggerLabel` is used only as the `aria-label` for accessibility.
#### Invalid Item Handling
If an item is missing its trigger or full container, the runtime marks it as invalid:
- `data-ome-invalid="true"` is set on the item element.
- The trigger button is disabled (`disabled` + `aria-disabled="true"`).
- The trigger cursor changes to `not-allowed` via the fixed styling.
- The item is skipped during gallery initialization.
---
## CSS Custom Properties
| Property | Default | Used By | Description |
|---|---|---|---|
| `--ome-lightbox-trigger-gap` | `var(--space-xs, 0.5rem)` | Trigger button children | Controls the gap between inline-flex children inside the trigger button. |
| `--ome-lightbox-html-padding` | `24px` | HTML-mode slides | Viewport padding around HTML-mode slide content. Set from `presentation.htmlViewportPadding`. |
### PhotoSwipe CSS Custom Properties
The following PhotoSwipe variables are set by the runtime and can be overridden in your own CSS:
| Property | Default | Description |
|---|---|---|
| `--pswp-bg` | `#000` | Overlay background color. |
| `--pswp-placeholder-bg` | `#222` | Placeholder background while images load. |
| `--pswp-root-z-index` | `2147483647` | Z-index for the PhotoSwipe root element. |
| `--pswp-icon-color` | `#fff` | Icon (close, arrows, zoom) fill color. |
| `--pswp-icon-color-secondary` | `#4f4f4f` | Secondary icon color. |
| `--pswp-icon-stroke-color` | `#4f4f4f` | Icon stroke color. |
| `--pswp-icon-stroke-width` | `2px` | Icon stroke width. |
| `--pswp-error-text-color` | `var(--pswp-icon-color)` | Error message text color. |
| `--pswp-transition-duration` | _(PhotoSwipe default)_ | Transition duration for UI element fade. |
---
## Common Mistakes
The full slot is the content the overlay displays. If it is empty, the item will be marked as invalid and skipped during gallery initialization. Always add content to the full slot.
When using image mode, the full-slot `` must have explicit `width` and `height` attributes. Without deterministic dimensions, the runtime falls back to HTML mode, which loses PhotoSwipe's native pinch-to-zoom and responsive sizing. A console warning is logged when this fallback occurs.
The trigger and full slots are separate by design. The trigger is what users click to open the lightbox. The full slot is what appears inside the overlay. They are not automatically shared or duplicated.
Do not place another block between `Lightbox` and `Lightbox Item`. The runtime scans for direct `[data-ome-lightbox-item]` descendants of the root to build the gallery. Extra wrappers between the root and items will still work (the query uses `querySelectorAll`), but the positional index used for gallery navigation is based on DOM order — keep items as close to the root as possible for predictable behavior.
The Lightbox family is slot-driven. There is no separate prop for specifying the full-size image URL — place the image in the full slot and the runtime extracts `src`, `srcset`, dimensions, and alt text automatically.
---
## FAQs
How does image mode detection work?
The runtime checks if both the trigger slot and the full slot each contain exactly **one** `` element. If they do, and the full-slot image has explicit `width` and `height` attributes, it enters image mode. The full-slot image's `src` (or `data-src` / `data-lazy-src` for lazy-loaded images) becomes the lightbox source, and the trigger image's `src` becomes the placeholder thumbnail during the open animation. If any of these conditions are not met, the item falls back to HTML mode.
Can I mix image items and HTML items in the same gallery?
Yes. Each item is independently detected as image mode or HTML mode. You can have image items alongside HTML items in the same Lightbox root. Arrow key navigation works across all items regardless of mode.
How do I customize the overlay appearance?
Use the `presentation.bgOpacity` prop to change the overlay darkness, and override the PhotoSwipe CSS custom properties (like `--pswp-bg`, `--pswp-icon-color`) in your own stylesheet to change colors, z-index, and other visual aspects. The `presentation.htmlViewportPadding` prop controls the padding around HTML-mode slides.
Does Lightbox support lazy-loaded images?
Yes. The runtime resolves lazy-loaded images by checking `data-src`, `data-lazy-src`, `data-srcset`, and `data-lazy-srcset` attributes as fallbacks. This is compatible with common lazy-loading plugins like WP Rocket and BJ Lazy Load. `` element sources are also resolved automatically.
Can I open the lightbox programmatically?
The lightbox instance is stored on the root element as `element._omeLightbox`. You can use `element._omeLightbox.lightbox.loadAndOpen(index, dataSource)` to open the gallery at a specific item index. The runtime also syncs `data-ome-state` (`"open"` / `"closed"`) and `data-ome-current-index` on the root element for CSS targeting.
What happens if I have only one item?
A single-item gallery works normally — the overlay opens with the item, and navigation arrows are automatically hidden by PhotoSwipe when there is only one slide.
---
## Navigation Menu
## Overview
Use the Navigation Menu family when you need authored navigation items with optional dropdown panels, keyboard navigation, animated viewport transitions, and a mobile drawer helper that automatically derives its structure from the desktop menu. The desktop family is composed of six components that nest together, plus a separate mobile helper and an explicit mobile-only node for complex mega-menu content.
The root component manages open state, keyboard navigation, and optional animated viewport behavior. Navigation Menu Mobile is a **separate** component placed elsewhere on the page — it connects to the desktop menu by a shared `menuId` and builds a drawer-based mobile experience at runtime.
## Authoring Structure
The desktop menu follows a strict nesting hierarchy:
```
Navigation Menu (desktop root)
└── Navigation Menu List
├── Navigation Menu Item
│ ├── plain link
│ ├── OR Navigation Menu Trigger + Navigation Menu Content
│ │ └── optional Navigation Menu Mobile Item subtree inside content
│ └── OR link + Navigation Menu Trigger + Navigation Menu Content
└── Navigation Menu Item ...
Navigation Menu Mobile (separate — anywhere on the page)
```
### Desktop and Mobile Are Separate
Navigation Menu Mobile is **not** nested inside Navigation Menu. It is placed as a sibling element elsewhere on the page (typically inside the header shell alongside the desktop navigation). Both components share the same `menuId` value so the mobile helper can find and mirror the desktop structure at runtime.
In header patterns, the typical structure is:
```
Header Shell
├── Brand
├── Navigation Menu (desktop, menuId="primary-nav")
│ └── Navigation Menu List ...
├── Actions
└── Navigation Menu Mobile (separate, menuId="primary-nav")
```
### Placement Rules
| Component | Placement | Role |
|---|---|---|
| **Navigation Menu** | Top-level wrapper for the desktop navigation. | Owns orientation, open-state coordination, viewport behavior, and the menu identity. |
| **Navigation Menu List** | Direct child of `Navigation Menu`. | Groups authored top-level menu items. The runtime scans for this list when initializing. |
| **Navigation Menu Item** | Direct child of `Navigation Menu List`. | One navigation entry — a plain link, a dropdown parent, or both. |
| **Navigation Menu Trigger** | Inside `Navigation Menu Item`. | Interactive button that opens a dropdown panel. |
| **Navigation Menu Content** | Inside `Navigation Menu Item`, alongside a trigger. | Dropdown or mega-menu panel content. |
| **Navigation Menu Mobile Item** | Inside `Navigation Menu Content`. | Declares an explicit mobile tree for complex mega-menu content. |
| **Navigation Menu Mobile** | **Separate** from the desktop menu — anywhere on the page, connected by `menuId`. | Mirrors the desktop menu into a drawer-based mobile experience at runtime. |
## Quick Start
{`
{#slot default}
{#slot default}
{#slot default}
{#slot default}Products{/slot}
{#slot default}
Shop all products
{/slot}
{/slot}
{#slot default}
About
{/slot}
{/slot}
{/slot}
`}
{`
{#slot default}
{#slot default}
{#slot default}
{#slot default}Services{/slot}
{#slot default}
Design
Development
{/slot}
{/slot}
{/slot}
{/slot}
`}
The mobile helper reads the desktop menu structure at runtime and generates a drawer-based mobile navigation. No duplicate authoring is needed.
---
## Family Components
### Navigation Menu (Root)
The root component wraps all desktop navigation items and manages shared behavior: orientation, keyboard navigation, animated viewport mode, and the menu identity used by the mobile helper. It does not render significant visible UI itself — only a container element and an optional viewport.
HTML tag for the root element. Defaults to `nav` for proper landmark semantics.
Shared menu ID that `Navigation Menu Mobile` uses to find and connect to this desktop menu. Set this to the same value on both components to enable mobile mirroring.
Root orientation. Affects which arrow keys navigate between triggers and the layout direction. Use `vertical` for stacked or sidebar menu structures.
Enables the generated viewport that animates dropdown panel changes. When enabled, content panels are portaled into a viewport container with smooth height transitions and directional motion.
Makes the animated viewport align to a wider container instead of the trigger width. Requires `useAnimatedMenu` to be enabled and a `fullWidthTargetSelector` to be set.
CSS selector used to find the container element whose width the viewport should match. Only visible when `useFullWidth` is enabled. Can be a class selector like `.site-shell`.
CSS class applied to the root element.
CSS class applied to the generated animated viewport container. Only visible when `useAnimatedMenu` is enabled.
#### Animated Viewport Mode
When `useAnimatedMenu` is `true`, the root component generates a viewport container that houses all dropdown panels. This enables:
- Smooth height transitions as panels open and close.
- Directional motion animations when switching between panels (content slides in from the direction of the newly focused trigger).
- Full-width mode that aligns the viewport to a wider parent container for mega-menu layouts.
Animated mode only activates for **horizontal** orientation and is automatically disabled inside the Etch builder (where it falls back to a non-animated preview).
#### Keyboard Behavior
When a trigger has focus in a **horizontal** menu:
| Key | Action |
|---|---|
| `Enter` / `Space` | Toggle the associated panel. |
| `ArrowRight` | Move focus to the next trigger. |
| `ArrowLeft` | Move focus to the previous trigger. |
| `ArrowDown` | Open the dropdown panel. |
| `Home` | Move focus to the first trigger. |
| `End` | Move focus to the last trigger. |
| `Escape` | Close the open panel and return focus to its trigger. |
In a **vertical** menu, `ArrowDown` / `ArrowUp` navigate between triggers and `ArrowRight` opens the panel.
Navigation wraps by default — pressing `ArrowRight` on the last trigger focuses the first, and vice versa.
#### Accessibility
The runtime wires up ARIA attributes automatically:
- Each trigger has `aria-expanded` and `aria-haspopup="menu"` that update as panels open and close.
- Each content panel has `role="menu"` and syncs `aria-hidden` with its open state.
- `data-ome-state` attributes (`open` / `closed`) are set on items, triggers, and content for CSS targeting.
---
### Navigation Menu List
The list container groups top-level menu items. The runtime scans for the direct `Navigation Menu List` child when initializing the root component.
CSS class applied to the list element.
The list always renders as a `` element with `display: flex` and reset list styles. In horizontal mode the flex direction is `row`; in vertical mode it becomes `column`.
---
### Navigation Menu Item
Each `NavigationMenuItem` represents one navigation entry. It can contain a plain link, a trigger-plus-content dropdown pair, or a combination of both (a link that also opens a dropdown).
Disables this item and any trigger it contains. Disabled items cannot be opened or focused for activation.
CSS class applied to the item element.
The item always renders as an `` element. The runtime tracks open/closed state via `data-ome-state` on the item.
---
### Navigation Menu Trigger
The interactive button users click or hover to open a dropdown panel. The trigger always renders as a `` element and can optionally display a built-in chevron icon.
Prevents this trigger from opening its content panel. Overrides the item-level disabled prop.
Shows a built-in chevron icon inside the trigger button. The chevron rotates 180 degrees when the panel is open.
Preferred interaction mode. `hover` opens the panel on mouse enter with a small delay; `click` requires an explicit click. Inside the Etch builder, all triggers behave as click for reliable editing.
CSS class applied to the trigger button.
#### Trigger Interaction Modes
- **`hover`** — The panel opens when the cursor enters the trigger area (with a 50ms delay) and closes when the cursor leaves the entire navigation boundary (trigger + content + viewport). This is the default and provides the smoothest desktop experience.
- **`click`** — The panel only opens on explicit click. A document-level click listener closes the panel when clicking outside the navigation root.
Both modes support keyboard activation via `Enter` / `Space`.
---
### Navigation Menu Content
The dropdown or mega-menu panel paired with a trigger. Content panels are hidden by default and become visible when their sibling trigger opens them.
CSS class applied to the content panel.
#### Runtime Behavior
- In non-animated mode, content is positioned absolutely below its trigger item (or to the right in vertical orientation).
- In animated mode, content is portaled into the shared viewport container and animated with presence state transitions.
- Content panels use `role="menu"`, `aria-hidden`, and `data-ome-state` that sync between `open` and `closed`.
- The `hidden` attribute is toggled to control visibility.
---
### Navigation Menu Mobile
A separate component that mirrors the desktop navigation into a drawer-based mobile experience. It is **not** nested inside `Navigation Menu` — it is placed elsewhere on the page and connects to the desktop menu via a shared `menuId`.
At runtime, the mobile helper:
1. Finds the desktop `Navigation Menu` root by matching `menuId`.
2. Extracts the menu structure recursively (or reads explicit `Navigation Menu Mobile Item` nodes if present).
3. Generates a panel-based mobile drawer with sliding navigation and a back button.
In the Etch builder, the helper shows a static preview instead of live-extracted mobile navigation.
Must match `Navigation Menu.identity.menuId`. The mobile helper uses this value to find the desktop menu and wire its internal drawer.
Direction the mobile drawer slides from.
Allows overlay click and `Escape` to close the mobile drawer.
When enabled, the trigger displays visible label text. When disabled, only a hamburger icon is shown with a separate `triggerAriaLabel` for screen readers.
Visible label text and accessible name for the drawer trigger. Only shown when `showTriggerLabel` is `true`.
Screen-reader-only label for the trigger button when `showTriggerLabel` is `false`.
Fallback label used by the back button when the `backButton` slot is empty.
CSS class applied to the mobile helper wrapper.
CSS class applied to the drawer trigger button.
CSS class applied to the back button.
CSS class applied to the icon-only close button.
CSS class applied to the internal drawer root.
CSS class applied to the drawer header row.
CSS class applied to the drawer content wrapper.
CSS class applied to generated root and child lists.
CSS class applied to generated list items.
CSS class applied to generated links.
CSS class applied to generated submenu trigger buttons.
CSS class applied to the chevron icon shown on generated submenu triggers.
CSS class applied to the wrapper around the `before` slot.
CSS class applied to the wrapper around the `after` slot.
#### Slots
| Slot | Description |
|---|---|
| `before` | Renders before generated items in the root panel only. |
| `after` | Renders after generated items in the root panel only. |
| `backButton` | Replaces the default back button content. If omitted, the button falls back to a back icon plus `backButtonLabel`. |
#### Mobile Keyboard Behavior
Inside the mobile drawer, when a menu item has focus:
| Key | Action |
|---|---|
| `ArrowDown` | Move focus to the next menu item. |
| `ArrowUp` | Move focus to the previous menu item. |
| `Home` | Move focus to the first menu item. |
| `End` | Move focus to the last menu item. |
| `Enter` / `Space` | Activate the focused item (follow link or open submenu). |
| `ArrowRight` | Open a submenu (when focused on a submenu trigger). |
| `Escape` / `ArrowLeft` | Go back to the previous panel. |
#### How Content Extraction Works
The mobile helper extracts the navigation tree from the desktop menu in one of two ways:
1. **Explicit mobile items** — If a `Navigation Menu Content` subtree contains at least one `Navigation Menu Mobile Item`, the helper uses only those explicit nodes for that panel and ignores regular auto-generated links.
2. **Automatic extraction** — If no explicit mobile items exist, the helper recursively extracts the authored structure: links become mobile links, items with triggers become submenu branches, and nested navigation roots are traversed.
---
### Navigation Menu Mobile Item
An explicit mobile-only node authored inside `Navigation Menu Content` when complex mega-menu content needs a custom mobile tree that differs from the auto-extracted structure. If a content panel contains at least one `Navigation Menu Mobile Item`, the mobile helper uses only those explicit nodes.
HTML tag for the authored wrapper element. Common values are `div`, `ul`, `li`, or `a` depending on the structural role.
Mobile node mode. `trigger` creates a submenu branch that navigates to a child panel. `link` creates a direct navigation link.
Label shown in the generated mobile drawer UI for this node.
Destination URL. Required when `useAs` is `link`. Ignored for `trigger` nodes.
Optional CSS class list for the authored wrapper element.
#### When to Use Mobile Items
Use `Navigation Menu Mobile Item` when your desktop mega-menu has complex content (cards, images, multi-column layouts) that should not be naively extracted into the mobile drawer. By wrapping mobile-relevant content in explicit mobile items, you control exactly what appears in the mobile navigation while keeping the desktop layout free to include richer visual content.
---
## Common Mistakes
Do not place `Navigation Menu Item` blocks directly inside `Navigation Menu`. The runtime scans for a direct `Navigation Menu List` child at initialization. Always nest items inside a list.
`Navigation Menu Mobile` is a **separate** component. It must not be placed inside the desktop `Navigation Menu`. Place it elsewhere on the page and connect it via `menuId`.
If an item has `Navigation Menu Content`, it must also have a sibling `Navigation Menu Trigger`. Content without a trigger will not be openable.
The `menuId` on `Navigation Menu Mobile.identity.menuId` must exactly match `Navigation Menu.identity.menuId`. If the IDs do not match, the mobile helper will not find the desktop menu and the drawer will be empty.
Do not author a separate second menu tree for mobile. `Navigation Menu Mobile` derives its structure from the desktop menu automatically. Only use `Navigation Menu Mobile Item` when you need to customize the mobile tree for complex content.
---
## FAQs
Can I use Navigation Menu without the mobile helper?
Yes. `Navigation Menu Mobile` is entirely optional. The desktop navigation family works standalone. Only add the mobile helper when you need a drawer-based mobile experience that mirrors the desktop menu.
How does the animated viewport work?
When `useAnimatedMenu` is enabled on the root component, dropdown content panels are portaled into a shared viewport container. The viewport smoothly transitions its height when panels open and close, and content panels animate with directional motion (slide in from the direction of the newly focused trigger). For full-width mega-menus, enable `useFullWidth` and set `fullWidthTargetSelector` to the container the viewport should align to.
What happens when both a link and a trigger are in the same item?
The item renders as a "link-branch" — the link is clickable for navigation and the trigger opens a dropdown. In the mobile drawer, this becomes a row with both a tappable link and a submenu chevron button.
Can I nest navigation menus?
Yes. You can place a `Navigation Menu` inside a `Navigation Menu Content` panel for multi-level navigation. Nested menus manage their own state independently. The animated viewport is automatically disabled for nested menus to avoid conflicts.
How do I customize the mobile drawer trigger?
Use `showTriggerLabel` to toggle between a labeled trigger and an icon-only trigger. When `showTriggerLabel` is `false`, set `triggerAriaLabel` for screen reader accessibility. Style the trigger using `controlStyling.triggerClass`. For the hamburger icon itself, the component renders a built-in SVG.
When should I use Navigation Menu Mobile Item?
Use it when your desktop mega-menu has complex visual content (cards, multi-column layouts, featured sections) that should not be auto-extracted into the mobile drawer. By wrapping mobile-relevant content in explicit `Navigation Menu Mobile Item` nodes, you control exactly what appears in the mobile navigation. Simple dropdown menus do not need explicit mobile items — the auto-extraction handles them well.
---
## Table of Contents
## Overview
The Table of Contents component automatically discovers headings in your page content, generates a clickable nested list of anchor links, and highlights the currently visible heading as the user scrolls. It requires no manual link management — drop the component on the page, configure which heading levels to include, and it builds itself at runtime.
The component is a single self-contained element (`OmeTableOfContents`). It is not a family — there are no child blocks to compose. All behavior is controlled through props on the root.
## How It Works
1. **Template phase** — The PHP builder renders a hidden template (``) containing a ``, ``, and `` skeleton. These are cloned at runtime to build the live list.
2. **Target discovery** — The runtime reads `targetSelector` to find the content container. If empty or invalid, it falls back to `main`, `article`, `[role='main']`, `.entry-content`, `.wp-block-post-content`, then `body`. The TOC root itself is always excluded from heading scanning.
3. **Heading extraction** — Headings from H2 through the configured depth are collected. Existing IDs are preserved; headings without IDs receive auto-generated slugs. Duplicate slugs are de-duplicated with numeric suffixes (e.g. `repeated-heading-2`).
4. **Tree building** — Headings are organized into a nested ``/`` tree that mirrors the heading hierarchy.
5. **Active tracking** — An `IntersectionObserver` watches all collected headings. As the user scrolls, the first visible heading's corresponding link receives `aria-current="location"`, and the item gets `data-ome-active`. The root H2 branch item receives `data-ome-active-branch`.
6. **Mutation watching** — A `MutationObserver` watches the target container. If headings are added or removed after load, the TOC automatically rebuilds itself after a short debounce.
## Quick Start
{` `}
This produces a `` that scans the page for H2–H3 headings, shows an "On this page" label, and highlights the active heading on scroll.
{` `}
---
## Props
### Structure
HTML tag used for the root element. Change to `"div"` if you need a generic container, but `"nav"` is recommended for accessibility since the TOC is a navigation landmark.
### Content
Shows a label above the generated table of contents. When `false`, no label text is rendered and the list starts immediately.
Text shown as the table of contents label. When mobile accordion is enabled, this same text is used as the disclosure button label.
### Settings
Includes H2 through the selected heading level. H1 is always skipped. `"3"` captures H2 and H3 — the most common range for blog posts and documentation. Use `"2"` for a flat H2-only list, or `"4"`–`"6"` for deeply structured content.
Pixel offset applied to target headings through `scroll-margin-top`. Set this to match your sticky header height so headings land below the header when scrolled to.
### Targeting
CSS selector for the content container to scan for headings. When empty, the runtime falls back to `main`, `article`, `[role='main']`, `.entry-content`, `.wp-block-post-content`, then `body` — whichever it finds first that is not inside the TOC root itself. Set this when your content lives in a specific container and you want to exclude headings from sidebars, headers, or footers.
### Behavior
Controls nested branch visibility. `"expanded"` shows all heading levels at all times. `"active-branch"` reveals nested links only under the currently active H2 branch — all other sub-lists are hidden. Use `"active-branch"` for long pages with many headings to reduce visual noise.
### Mobile
Turns the label into a disclosure button below the configured breakpoint. The TOC list collapses into an accordion panel that users can expand and collapse.
Viewport width in pixels where mobile accordion mode starts. Below this width, the label is replaced with a toggle button and the panel collapses.
Initial mobile accordion state when the page loads below the breakpoint. `"collapsed"` hides the list until the user taps the button. `"expanded"` shows it immediately.
### Styling
CSS class applied to the TOC root element.
CSS class applied to the label element.
CSS class applied to all generated `` branch lists.
CSS class applied to all generated `` items.
### Mobile Styling
CSS class applied to the mobile disclosure button (visible only when mobile accordion is enabled and viewport is below the breakpoint).
CSS class applied to the mobile disclosure panel that wraps the TOC list in mobile mode.
CSS class applied to the chevron SVG icon inside the mobile disclosure button. The chevron rotates 180 degrees when expanded.
### Advanced Styles
Reveals H2–H6 level-specific class controls up to the configured depth. When enabled, you can style each heading level's branches and items independently.
When Advanced Styles is enabled, the following per-level props become available for each heading level up to the configured depth:
| Level | Branch CSS Class | Item CSS Class |
|---|---|---|
| H2 | `ome-toc-h2-branch-default` | `ome-toc-h2-item-default` |
| H3 | `ome-toc-h3-branch-default` | `ome-toc-h3-item-default` |
| H4 | `ome-toc-h4-branch-default` | `ome-toc-h4-item-default` |
| H5 | `ome-toc-h5-branch-default` | `ome-toc-h5-item-default` |
| H6 | `ome-toc-h6-branch-default` | `ome-toc-h6-item-default` |
Each level-specific class sets a `--ome-toc-level` custom property on the element, useful for writing level-aware CSS.
---
## Active State Attributes
The runtime uses **data attributes** (not classes) to mark active state. Target these in your CSS:
| Attribute | Applied to | When |
|---|---|---|
| `data-ome-active` | `` | The currently active heading's list item. |
| `data-ome-active-branch` | `` | The root H2 branch item containing the active heading (only one at a time). |
| `aria-current="location"` | `` | The link pointing to the currently active heading. |
### CSS Example
```css
/* Style the active link */
[data-ome-toc-item][data-ome-active] > a {
color: var(--primary, #2563eb);
font-weight: 700;
}
/* Dim inactive branches in active-branch mode */
[data-ome-toc-item]:not([data-ome-active-branch]) {
opacity: 0.5;
}
```
---
## Mobile Accordion
When `mobile.enabled` is `true`, the component transforms below the breakpoint:
- The static label is replaced with a `` that toggles the TOC list.
- The button includes a chevron icon that rotates when expanded.
- The panel is hidden/shown via the `hidden` attribute.
- ARIA attributes (`aria-expanded`, `aria-controls`) are managed automatically.
- When the viewport crosses back above the breakpoint, the button is removed and the static label is restored.
---
## Heading Discovery Details
### Slug Generation
Headings without an existing `id` attribute receive an auto-generated slug:
1. Text is trimmed, lowercased, and Unicode-normalized (NFKD).
2. Diacritics are stripped.
3. Quotes and apostrophes are removed.
4. Non-alphanumeric characters are replaced with hyphens.
5. Leading/trailing hyphens are removed.
6. If the result is empty, the fallback slug `"section"` is used.
Duplicate IDs are resolved by appending a numeric suffix: `heading`, `heading-2`, `heading-3`, etc. Existing IDs on headings are always preserved.
### Content Container Fallbacks
When `targetSelector` is empty or does not match a valid element outside the TOC root, the runtime tries these selectors in order:
1. `main`
2. `article`
3. `[role='main']`
4. `.entry-content`
5. `.wp-block-post-content`
6. `body`
The first element found that is **not inside** the TOC root is used as the heading source.
---
## Common Mistakes
If the TOC root is inside the element it targets for headings, the runtime skips it correctly — but the `targetSelector` must resolve to an element outside the TOC root. If your selector accidentally targets the TOC itself, no headings will be found and the component hides itself. Use a specific content selector (like `article.post-content`) when the TOC is nested inside a shared layout.
Active state is communicated through `data-ome-active`, `data-ome-active-branch`, and `aria-current="location"` — not CSS classes. Do not write selectors like `.is-active` or `.active-link`. Use `[data-ome-active] > a` in your CSS instead.
H1 is always excluded. The minimum heading level is H2. Setting `depth` to `"2"` produces a flat list of H2 headings only — no nesting.
If your site has a sticky header, headings will scroll behind it when the user clicks a TOC link. Set `offset` to your header height (e.g. `"96"`) so `scroll-margin-top` pushes the heading below the header.
---
## FAQs
What happens when there are no headings?
The TOC root is hidden (`hidden` attribute) and no links are rendered. It becomes completely invisible — no empty container or label is shown. If headings are later added via JavaScript (e.g. dynamic content), the MutationObserver triggers a rebuild and the TOC reappears automatically.
Can I have multiple TOC components on the same page?
Yes. Each TOC instance manages its own heading collection, active tracking, and mobile accordion independently. Use `targetSelector` on each instance to point to different content containers so they don't scan each other's headings.
How does the component handle duplicate heading text?
Headings with identical text receive unique auto-generated IDs: the first gets `some-heading`, the second `some-heading-2`, and so on. Headings that already have an `id` attribute keep that ID unchanged — even if it duplicates another heading's slug.
Does the TOC update when content changes dynamically?
Yes. A `MutationObserver` watches the target container's subtree. When headings are added or removed, the TOC rebuilds itself after a 50ms debounce. The IntersectionObserver for active tracking is also re-created during the rebuild.
How do I style each heading level differently?
Enable **Advanced Styles** (`advanced_styles.enabled = true`), then set per-level branch and item classes. Each level's default class sets `--ome-toc-level` on the element. You can use this custom property to write level-aware CSS:
```css
[data-ome-toc-item] {
padding-inline-start: calc((var(--ome-toc-level, 2) - 2) * 1rem);
}
```
Can I use the TOC without the label?
Yes. Set `content.showLabel` to `false`. The list renders without any label text. If mobile accordion is enabled, the disclosure button uses the `aria-label` on the root nav element instead.
---
## Tabs
## Overview
Use the Tabs family when you need a tabbed interface where one content panel is visible at a time, selected by clicking its corresponding trigger. The family is composed, not standalone — the root component manages shared state and keyboard navigation, while child components define the trigger list and content panels. Tabs work well for product feature sections, settings panels, pricing tiers, and any content that benefits from organizing information into mutually exclusive views.
## Authoring Structure
```
Tabs
├── TabsList
│ ├── TabsTrigger (1st)
│ ├── TabsTrigger (2nd)
│ └── TabsTrigger (3rd)
├── TabsContent (1st)
├── TabsContent (2nd)
└── TabsContent (3rd)
```
### Placement Rules
| Component | Placement | Role |
|---|---|---|
| **Tabs** | Top-level wrapper. | Owns state, keyboard navigation, and shared options. |
| **TabsList** | Direct child of `Tabs`. | Container for trigger buttons. Receives `role="tablist"`. |
| **TabsTrigger** | Inside `TabsList`. | Interactive button that activates its paired content panel. |
| **TabsContent** | Direct child of `Tabs`, sibling to `TabsList`. | The content panel that shows or hides based on the active trigger. |
### Pairing Rule
Triggers and content panels are paired by **position** — the first `TabsTrigger` inside `TabsList` pairs with the first `TabsContent` that is a direct child of `Tabs`. The second pairs with the second, and so on. This is purely positional; labels and IDs are not used for matching. If trigger and content counts mismatch, extra triggers are disabled and extra content panels stay hidden.
## Quick Start
{`
{#slot default}
{#slot default}
{/slot}
{#slot default}Overview content goes here.{/slot}
{#slot default}Details content goes here.{/slot}
{#slot default}Changelog content goes here.{/slot}
{/slot}
`}
{`
{#slot default}
{#slot default}
{#slot default}Features{/slot}
{#slot default}Pricing{/slot}
{#slot default}FAQ{/slot}
{/slot}
{#slot default}Features content.{/slot}
{#slot default}Pricing content.{/slot}
{#slot default}FAQ content.{/slot}
{/slot}
`}
---
## Family Components
### Tabs (Root)
The root component wraps all lists and panels, and manages shared behavior: active tab state, keyboard navigation, orientation, and optional responsive accordion mode. It does not render any visible UI itself — only a container element.
HTML tag for the root element.
Controls layout direction and keyboard behavior. `"horizontal"` uses ArrowLeft/ArrowRight for navigation. `"vertical"` uses ArrowUp/ArrowDown.
Zero-based index of the tab that should be active on initial load. If the index is invalid or points to a disabled trigger, it falls back to the first enabled trigger.
Disables the entire tabs interface when `true`. Individual triggers can still override this with their own `disabled` prop.
CSS class applied to the root element.
Enables responsive accordion mode. When the viewport width is below `accordionBreakpoint`, the runtime restructures the tabs into an accordion.
Viewport width in pixels below which accordion mode activates. Only applies when `enableResponsiveAccordion` is `true`.
Controls Etch editor preview behavior. `"auto"` follows the canvas width. `"tabs"` always shows tabs. `"accordion"` always shows the accordion. Only applies when responsive accordion is enabled.
Animation duration in milliseconds for accordion panel expand/collapse. Only applies when responsive accordion is enabled.
Adds indicator spans to triggers in accordion mode. Only applies when responsive accordion is enabled.
CSS class applied to each generated accordion item wrapper. Only applies when responsive accordion is enabled.
CSS class applied to each generated accordion header wrapper. Only applies when responsive accordion is enabled.
CSS class applied to generated accordion indicators. Only applies when `showAccordionIndicator` is `true`.
#### Keyboard Behavior — Tabs Mode
When a trigger has focus in tabs mode:
| Key | Action |
|---|---|
| `Enter` / `Space` | Activate the focused trigger. |
| `ArrowRight` | Move focus to the next trigger and activate it (horizontal). Wraps to first. RTL-aware. |
| `ArrowLeft` | Move focus to the previous trigger and activate it (horizontal). Wraps to last. RTL-aware. |
| `ArrowDown` | Move focus to the next trigger and activate it (vertical). Wraps to first. |
| `ArrowUp` | Move focus to the previous trigger and activate it (vertical). Wraps to last. |
| `Home` | Focus and activate the first enabled trigger. |
| `End` | Focus and activate the last enabled trigger. |
In horizontal tabs, arrow keys automatically reverse in RTL layouts.
#### Keyboard Behavior — Accordion Mode
When the responsive accordion is active, keyboard behavior changes to match the Accordion component:
| Key | Action |
|---|---|
| `Enter` / `Space` | Toggle the associated panel. |
| `ArrowDown` | Move focus to the next trigger (does **not** activate). |
| `ArrowUp` | Move focus to the previous trigger (does **not** activate). |
| `Home` | Move focus to the first trigger (does **not** activate). |
| `End` | Move focus to the last trigger (does **not** activate). |
Focus movement in accordion mode does **not** auto-activate — you must click or press `Enter`/`Space` to open a panel.
#### Responsive Accordion
When enabled and the viewport drops below `accordionBreakpoint`, the runtime:
1. Creates an accordion list element.
2. Moves each trigger-content pair into accordion item wrappers.
3. Replaces original positions with comment placeholders.
4. Changes ARIA roles (`tab` → `button`, `tabpanel` → `region`).
5. Adds indicators if `showAccordionIndicator` is `true`.
When the viewport exceeds the breakpoint, everything is restored to the original tabs DOM.
#### Accessibility
The root component generates unique IDs for each trigger-content pair and wires up ARIA attributes automatically:
- **TabsList**: `role="tablist"`, `aria-orientation` synced to the `orientation` setting by the runtime.
- **TabsTrigger**: `role="tab"`, `aria-selected`, `aria-controls` pointing to its content panel, `tabIndex` (`0` when active, `-1` when inactive).
- **TabsContent**: `role="tabpanel"`, `aria-labelledby` pointing to its trigger, `tabIndex="0"`.
- **Disabled triggers**: `aria-disabled="true"`, `tabIndex="-1"`.
- In accordion mode, triggers use `role="button"` and `aria-expanded`; content panels use `role="region"`.
---
### TabsList
The `TabsList` is the container for all `TabsTrigger` elements. It receives `role="tablist"` and manages focus roving for its child triggers. Place it as a direct child of the root `Tabs` component.
CSS class applied to the list element.
---
### TabsTrigger
The interactive button that activates its paired content panel. When the slot is empty, the trigger renders its `content.label` value as text. Triggers are paired with content panels by position — the first trigger in `TabsList` activates the first `TabsContent` child of `Tabs`.
Disables this trigger independently of the root `disabled` setting. A disabled trigger cannot receive focus or become active, and receives `aria-disabled="true"`.
Fallback label text. Used when the trigger slot is empty.
CSS class applied to the trigger element.
#### Trigger Slot Content
When the trigger slot is populated, the slot content replaces the fallback label. Use this for rich trigger content — icons, badges, or any markup beyond plain text:
```
{#slot default}
⚙ Settings
{/slot}
```
---
### TabsContent
The content panel that shows or hides based on the active trigger. Only one panel is visible at a time — Tabs always keeps exactly one panel active. Place content panels as direct children of the root `Tabs` component, as siblings to `TabsList`.
CSS class applied to the content panel.
---
## CSS Custom Properties
The default tab styles reference these CSS custom properties. Override them to customize appearance without replacing the default classes:
| Variable | Used For | Fallback |
|---|---|---|
| `--space-s` | Gap between list and content panels. | `1rem` |
| `--btn-padding-block` | Trigger padding (block axis). | — |
| `--btn-padding-inline` | Trigger padding (inline axis). | — |
| `--border-color-dark` | Inactive trigger border color. | — |
| `--text-dark-muted` | Inactive trigger text color. | — |
| `--primary` | Active trigger border color. | — |
| `--heading-color` | Active trigger text color. | — |
| `--ome-tabs-accordion-animation-duration` | Accordion animation duration (set inline from the `accordionAnimationDuration` prop). | — |
---
## Pattern Examples
### Vertical Tabbed Timeline
Used by the **Testimonial8** pattern — a vertical tabbed timeline with custom styled trigger cards:
- `orientation="vertical"`
- 4 triggers (Discovery, Onboarding, Building, Scaling) inside custom styled cards
- 4 content panels with testimonials
- Two-column grid layout (`0.35fr / 0.65fr`)
### Vertical Tabbed Features with Images
Used by the **FeatureSection20** pattern — vertical tabs with image content panels:
- `orientation="vertical"`
- 4 triggers as feature cards with icons
- 4 content panels showing images
- Two-column grid layout (`0.45fr / 0.55fr`)
Both patterns use vertical orientation and custom styling classes to override the default trigger appearance.
---
## Common Mistakes
`TabsContent` panels must be **direct children of the root `Tabs` component**, not inside `TabsList`. The runtime pairs triggers and content by scanning `TabsList` children for triggers and `Tabs` children (excluding `TabsList`) for content panels. Placing content inside `TabsList` breaks pairing.
If you have more triggers than content panels, the extra triggers are disabled. If you have more content panels than triggers, the extra panels stay hidden. Always ensure the counts match.
In tabs mode, arrow keys move focus **and** activate the tab. In accordion mode (when responsive accordion is active), arrow keys only move focus — you must click or press `Enter`/`Space` to open a panel.
Triggers and content panels are paired by **position only**. The second `TabsTrigger` always pairs with the second `TabsContent`, regardless of labels or any other attribute. Reordering one without the other breaks the pairing.
---
## FAQs
Can I have nested tabs?
Yes. You can place a `Tabs` component inside a `TabsContent` panel. Each nested tabs instance manages its own state independently. Be mindful of the visual complexity — deeply nested tabs can be difficult to navigate.
How does the responsive accordion work?
When `enableResponsiveAccordion` is `true` and the viewport drops below `accordionBreakpoint`, the runtime restructures the DOM into accordion items. Triggers become accordion headers with `role="button"` and `aria-expanded`, and content panels become regions. When the viewport exceeds the breakpoint, the original tabs DOM is restored. The transition is seamless — no page reload required.
What if I need all panels collapsed?
Tabs always keeps exactly one panel active. If you need the ability to collapse all panels, use the Accordion component with `type="single"` instead.
Can I change the default active tab?
Yes. Use the `defaultIndex` setting on the root `Tabs` component. It is zero-based — `"0"` activates the first tab, `"1"` the second, and so on. If the index is invalid or points to a disabled trigger, it falls back to the first enabled trigger.
How do vertical tabs work with keyboard navigation?
In vertical orientation, `ArrowUp` and `ArrowDown` navigate between triggers instead of `ArrowLeft` and `ArrowRight`. `Home` and `End` still jump to the first and last enabled triggers. The arrow keys auto-activate the focused tab, same as horizontal mode.
---
## Argument Modes
# Argument Modes
Argument mode decides what a facet value becomes in the backend query. Select, SearchSelect, Radio List, and Checkbox List share the `FacetQueryProperties` mode system. Search Facet implements the same mode names separately because text search needs partial-match aliases and multi-argument text bundling.
## Mode Summary
| Mode | Runtime facet type | Used by | Value meaning |
| --- | --- | --- | --- |
| `default` | selected query argument, such as `category_name` or `post_type` | Search and option facets | The value is assigned to a standard WP query argument. |
| `meta` | `meta_value` | Search and option facets | The value is compared against a configured post meta key. |
| `tax` | `tax_query` for option facets, `tax_query_like` for Search Facet | Search and option facets | The value is interpreted as a term field for a configured taxonomy. |
| `date` | `date_query` | Search and option facets | The value is interpreted as a date range for a configured post date column. |
| `acf_relationship` | `acf_relationship` | Search and option facets | The value is a related post ID, or comma-separated IDs, matched against a serialized relationship meta field. |
| multi-argument Search Facet | `query_bundle` | Search Facet only | One typed text value expands into multiple query clauses. |
| multi-argument option facet | `mapped_bundle` | Select, SearchSelect, Radio List, Checkbox List | One authored option expands into multiple query clauses with per-option mapped values. |
## Default Query Argument
Default mode writes a standard query argument.
Option facets use values from `FacetQueryProperties`, including:
```text
search, p, name, title, post_type, order, orderby, category_name, tag, author, author_name, year, monthnum, posts_per_page
```
Search Facet has a filter-oriented set and uses partial aliases for text fields:
```text
search, p, title_like, name_like, post_type, category_name_like, tag_like, author, author_name_like, year, monthnum
```
Use default mode for common query arguments like post type, category slug, tag slug, author, year, month, ordering, and posts per page.
## Meta Argument
Meta mode uses:
- `meta_key`
- `meta_compare`
- `meta_type`
Option facets default `meta_compare` to `=`, while Search Facet defaults it to `LIKE`. Supported compares are:
```text
=, !=, >, >=, <, <=, LIKE, NOT LIKE, IN, NOT IN, BETWEEN, NOT BETWEEN, EXISTS, NOT EXISTS
```
Use comma-separated values for `IN`, `NOT IN`, `BETWEEN`, and `NOT BETWEEN`. E2E coverage verifies exact numeric matches, char matches, bool matches, range compares, substring compares, set compares, and values with spaces.
## Taxonomy Argument
Tax mode uses:
- `taxonomy`
- `tax_field`
Supported fields are:
```text
slug, term_id, name, term_taxonomy_id
```
Option facets produce exact `tax_query` clauses. Search Facet produces `tax_query_like`, so it can search term names or slugs by typed text where supported by the backend clause handling.
The taxonomy E2E scenario verifies `facet_color` with all four fields and also covers multiple taxonomy filters.
## Date Query
Date mode uses a configured date column:
```text
post_date, post_modified, post_date_gmt, post_modified_gmt
```
Authored option values should use:
```text
YYYY-MM-DD,YYYY-MM-DD
```
The first date is the start, and the second is the end. Search Facet sends the typed input through the same `date_query` facet type.
## ACF Relationship
ACF relationship mode uses `acf_relationship_argument.meta_key`, which is the relationship field stored on the posts being filtered.
The value must be a numeric related post ID, or a comma-separated list of related post IDs. It does not reverse-resolve a bidirectional relationship stored only on the related posts.
Example rule:
```text
If you filter Events by Speaker, the Event posts must store the selected Speaker IDs in the configured relationship field.
```
The `wp_query_args` E2E scenario verifies select and checkbox ACF relationship filters, counts, unavailable state, comma-list option values, and direct URL hydration.
## Search Facet Query Bundle
When `target_multiple_query_arguments` is enabled on Search Facet, the control renders as `query_bundle`.
One typed search value is applied to every row in `query_argument_mappings`. The `query_argument_relation` setting controls whether the generated clauses are joined with `AND` or `OR`. Search mappings normalize common exact types to partial search aliases, for example `title` becomes `title_like` and `category_name` becomes `category_name_like`.
Use this when one text field should search several places, such as title or priority, or title or category.
## Option Facet Mapped Bundle
When `target_multiple_query_arguments` is enabled on Select, SearchSelect, Radio List, or Checkbox List, the root facet type becomes `mapped_bundle`.
The parent facet defines `query_argument_mappings`. Each option defines `query_value_mappings`. At runtime, an option is valid only when its value mappings can be matched to the parent mapping keys.
Example:
```text
Parent mappings:
- category -> default category_name
- priority -> meta facet_priority
Option "News + High priority":
- category = facets-e2e-news
- priority = high
```
The mapped bundle E2E coverage verifies filtering, URL restore, counts, unavailable state, and availability updates after other filters change.
---
## Availability, Counts, and Options
# Availability, Counts, and Options
Availability is the runtime metadata that tells option facets which values currently produce results. It is computed per target after a facet request and then applied by the client to Select, SearchSelect, Radio List, and Checkbox List controls.
## Options Behavior
`options_behavior` controls what happens to unavailable options.
| Value | Behavior |
| --- | --- |
| `all` | Keep every authored option visible and enabled. Counts can still update. |
| `disable` | Keep unavailable options visible, but mark them disabled and prevent selection. |
| `hide` | Hide unavailable options from the UI. |
The option behavior E2E scenario verifies all three modes across Select, SearchSelect, Radio List, and Checkbox List.
## Dynamic Counts
`show_counts` enables result counts on option values when availability data exists.
Counts are based on the current target scope and the other active filters. For example, after a search narrows results to one post, category options can update to `News category (1)` and `Guides category (0)`.
Count rendering depends on each option's `count_display`:
- `inline` appends the count to the label text.
- `span` writes the count into a separate counter span.
## Cross-Facet Behavior
Facet availability is cross-facet. A change in one control can change the option state of another control connected to the same target.
Example from E2E behavior:
```text
Search "Foo" narrows results to Alpha Foo.
Category "news" remains available with count 1.
Category "guides" becomes unavailable with count 0.
```
This works for single-mode facets and for mapped bundles. Mapped bundle availability is keyed by a generated control scope so each authored bundle can be counted independently.
## Selected Values That Become Unavailable
Select and SearchSelect can reset a selected value when that value becomes unavailable in `disable` or `hide` mode. The option behavior tests cover auto-reset for selected categories after a search makes the selected category unavailable.
Radio and checkbox behavior also syncs disabled, hidden, ARIA, and selected state through their runtimes so stale state is not left in the DOM after an update.
## Default Selections
Authored default options initialize controls only when there is no URL, runtime, or restored value already present.
- Select supports authored default options and has a built-in empty option using the `default_label`.
- Radio List supports default options and can also render a built-in reset option.
- Checkbox List supports multiple authored defaults.
- SearchSelect options do not expose `is_default` in the current PHP component.
Direct URL state wins over authored defaults. The URL parameter E2E suite verifies direct-link hydration for several facet families.
## Performance Boundary
Availability computation may fan out across descriptors, including mapped bundle descriptors. The endpoint limits expensive paths with query budgets, descriptor truncation metadata, and sampled post IDs. Authoring a large number of mapped bundles is supported, but it has a real availability cost because every bundle may need a count.
Use `show_counts` and `disable` or `hide` when users benefit from availability feedback. Leave `options_behavior` as `all` and `show_counts` as false when the UI does not need dynamic option state.
---
## Checkbox List Facet
# Checkbox List Facet
`Checkbox List Facet` is a multi-select option list. It sends all selected option values for the configured target and argument mode.
## Authoring Structure
```text
Checkbox List Facet
└── Checkbox List Facet Option
```
## Parent Props
| Group | Prop | Default | Meaning |
| --- | --- | --- | --- |
| Settings | `target` | empty | Shared target ID. |
| Settings | `argument_mode` | `default` | Single argument mode. |
| Settings | `target_multiple_query_arguments` | `false` | Enables mapped bundle mode. |
| Settings | `options_behavior` | `all` | Availability behavior. |
| Settings | `show_counts` | `false` | Enables dynamic counts. |
| Settings | `aria_label` | `Filter options` | Accessible name for the checkbox group. |
| Mode groups | mode-specific props | varies | See [Argument Modes](../argument-modes). |
| Styling | `class` | `ome-checkbox-list-facet-root-default` | Root list class. |
## Option Props
| Group | Prop | Default | Meaning |
| --- | --- | --- | --- |
| Content | `value` | empty | Sent value in single-argument mode. |
| Content | `label` | `Option` | Visible label. |
| Metadata | `key`, `value` | empty | Optional rows for custom JavaScript integrations. |
| Query Value Mappings | `mapping_key`, `value` | empty | Values used when parent mapped bundle mode is enabled. |
| Settings | `disabled` | `false` | Prevents selection. |
| Settings | `is_default` | `false` | Preselects the option when no restored value exists. |
| Settings | `mode` | `checkbox` | Option presentation: `checkbox` or `button`. |
| Settings | `used_with_multiple_arguments` | `false` | Switches option authoring to query value mappings. |
| Settings | `count_display` | `inline` | Inline count text or separate count span. |
| Styling | option, button, indicator, tick, label, input, count classes | component defaults | Option presentation classes. |
## Runtime Notes
- Multiple options can be selected at the same time.
- `mode = button` changes presentation only; the facet remains multi-select.
- The runtime syncs `data-ome-selected-values`, hidden transport inputs, `aria-checked`, disabled state, hidden state, and count displays.
## Example
```text
Checkbox List Facet:
- target = posts
- argument_mode = default
- facet_key = name
Options:
- beta-bar
- gamma-baz
```
---
## Facet Target
# Facet Target
`Facet Target` is the result container. Facet controls do not replace content directly; they send state for a target ID, and the target receives refreshed loop markup from the facet endpoint.
## Structure
```text
Facet Target
├── loop slot
└── fallback slot
```
The loop slot renders the normal results. The fallback slot renders when the refreshed loop has no items.
## Props
| Group | Prop | Default | Meaning |
| --- | --- | --- | --- |
| Structure | `tag` | `div` | HTML tag for the live result wrapper. |
| Structure | `fallback_tag` | `div` | HTML tag for the fallback wrapper. |
| Settings | `target` | empty | Shared target ID used by connected facets. |
| Settings | `rerun_scripts` | `true` | Reruns inline scripts returned with refreshed markup. |
| Settings | `preview_fallback` | `false` | Shows fallback in Etch preview instead of loop content. |
| Settings | `scroll_to_top` | `false` | Scrolls to this target after connected facet updates. |
| Settings | `used_with_map` | `false` | Marks the target as paired with a Map Facet. |
| Settings | `load_more_on_scroll` | `false` | Enables infinite scroll controls inside the target. |
| Settings | `load_more_batch_size` | `10` | Batch size for infinite scroll. |
| Styling | `class` | `ome-facet-target-default` | Root target class. |
## Runtime Contract
The rendered target uses `data-ome-facet-target`, `data-ome-facet-loop`, `aria-live="polite"`, and target-specific data attributes for scripts, scrolling, map pairing, and infinite scroll.
If `load_more_on_scroll` is enabled, the component renders hidden controls for `offset` and `posts_per_page`. Those controls are part of the same facet request flow as the visible Load More Facet.
## Nested Loop Rule
If a target lives inside another Etch loop, the outer loop params are resolved during the original render before the template is stored. The nested loop E2E scenario verifies that a Search Facet refresh keeps the correct outer-loop context after AJAX refresh.
## Example
```text
Target ID: article-results
Facet Target:
- target = article-results
- loop slot = WP Query loop
- fallback slot = "No posts found."
Search Facet:
- target = article-results
- default query argument = search
```
---
## Load More Facet
# Load More Facet
`Load More Facet` requests the next batch of items for a connected target. It renders hidden `offset` and `posts_per_page` controls plus a visible button.
## Props
| Group | Prop | Default | Meaning |
| --- | --- | --- | --- |
| Settings | `target` | empty | Shared target ID. |
| Settings | `batch_size` | `10` | Number of additional items requested per click. |
| Content | `label` | `Load more` | Fallback label when the default slot is empty. |
| Styling | `class` | `ome-load-more-facet-default` | Button class. |
## Runtime Notes
- The button uses the same target-scoped facet request flow as other controls.
- It updates hidden offset state before requesting the next batch.
- Normal facet changes reset load-more controls for the target.
- Infinite scroll on `Facet Target` uses the same offset and batch-size concepts without a visible button.
Use Load More Facet when users should deliberately request another batch. Use target `load_more_on_scroll` when the page should fetch more automatically near the bottom of the target.
---
## Map Facet
# Map Facet
`Map Facet` filters a target by the visible map bounds. It sends a `geo_bbox` facet value and reads `Map POI` elements from the connected target loop.
## Authoring Structure
```text
Map Facet
Facet Target
└── loop slot
└── Map POI
```
The map and target must share the same target ID.
## Map Facet Props
| Group | Prop | Default | Meaning |
| --- | --- | --- | --- |
| Settings | `target` | empty | Shared target ID. |
| Settings | `lat_meta_key` | `latitude` | Backend meta key for latitude. |
| Settings | `lng_meta_key` | `longitude` | Backend meta key for longitude. |
| Settings | `center_lat` | `0` | Initial map center latitude. |
| Settings | `center_lng` | `0` | Initial map center longitude. |
| Settings | `zoom` | `4` | Initial Leaflet zoom. |
| Settings | `min_zoom` | `2` | Minimum Leaflet zoom. |
| Settings | `max_zoom` | `18` | Maximum Leaflet zoom. |
| Settings | `tile_url` | empty | Optional custom tile layer URL. |
| Settings | `fit_bounds_on_filter` | `true` | Allows runtime to fit map bounds from filtered POIs. |
| Settings | `bbox_debounce_ms` | `300` | Delay before map movement updates the bbox value. |
| Styling | `class` | `ome-map-facet-root-default` | Map root class. |
| Styling | `bubble_class` | empty | Optional class added to generated cluster bubbles. |
## Map POI Props
| Group | Prop | Default | Meaning |
| --- | --- | --- | --- |
| Content | `value` | empty | Stable POI identifier. |
| Content | `lat` | empty | POI latitude. |
| Content | `lng` | empty | POI longitude. |
| Styling | `class` | empty | Optional class for the hidden POI carrier. |
## Runtime Notes
- `Map Facet` renders `data-ome-facet="geo_bbox"`.
- The bbox value serializes as `south,west,north,east`.
- `Map POI` is hidden DOM data, not visible listing content.
- POI hover and selected slots can provide marker popup content.
- Large responses can switch to lightweight coordinate-only POI data.
## Example
```text
Map Facet:
- target = stores
- lat_meta_key = store_lat
- lng_meta_key = store_lng
Facet Target:
- target = stores
- used_with_map = true
Map POI inside loop:
- value = current post ID or slug
- lat = current item store_lat
- lng = current item store_lng
```
---
## Pagination Facet
# Pagination Facet
`Pagination Facet` controls the current page for a target. It renders hidden `offset` state and receives pagination metadata from facet responses.
## Props
| Group | Prop | Default | Meaning |
| --- | --- | --- | --- |
| Settings | `target` | empty | Shared target ID. |
| Settings | `sibling_count` | `1` | Numeric page buttons shown on each side of the current page. |
| Settings | `tag` | `nav` | Root wrapper tag. |
| Settings | `aria_label` | `Pagination` | Accessible navigation label. |
| Settings | `hide_when_single_page` | `true` | Hides pagination when only one page exists. |
| Settings | `scroll_to_top` | `false` | Scrolls to the target after pagination changes. |
| Labels | `prev_label` | `Previous` | Previous button label. |
| Labels | `next_label` | `Next` | Next button label. |
| Range | `show_range` | `true` | Shows the live range summary. |
| Range | `range_label` | `Showing` | Prefix for the range summary. |
| Styling | `class`, `range_class` | component defaults | Root and range summary classes. |
## Runtime Notes
- Pagination sends `offset` for the target.
- Responses update current page, total pages, total items, per-page, and offset.
- Changing a normal facet resets pagination to page 1.
- Pagination hides on zero results even when `hide_when_single_page` is false.
The multi-facet E2E scenario verifies pagination reset after a facet value changes.
---
## Radio List Facet
# Radio List Facet
`Radio List Facet` is a single-select option list. It renders a `radiogroup`, stores the selected value in a hidden input, and can include a built-in reset item before authored options.
## Authoring Structure
```text
Radio List Facet
└── Radio List Facet Option
```
## Parent Props
| Group | Prop | Default | Meaning |
| --- | --- | --- | --- |
| Settings | `target` | empty | Shared target ID. |
| Settings | `argument_mode` | `default` | Single argument mode. |
| Settings | `target_multiple_query_arguments` | `false` | Enables mapped bundle mode. |
| Settings | `options_behavior` | `all` | Availability behavior. |
| Settings | `show_counts` | `false` | Enables dynamic counts. |
| Settings | `aria_label` | `Filter options` | Accessible name for the radiogroup. |
| Reset | `show_reset_option` | `false` | Adds a reset choice before authored options. |
| Reset | `reset_option_mode` | `radio` | Reset item presentation: `radio` or `button`. |
| Reset | `reset_label` | `Reset selection` | Reset item label. |
| Mode groups | mode-specific props | varies | See [Argument Modes](../argument-modes). |
| Styling | `class` | `ome-radio-list-facet-root-default` | Root list class. |
## Option Props
| Group | Prop | Default | Meaning |
| --- | --- | --- | --- |
| Content | `value` | empty | Sent value in single-argument mode. |
| Content | `label` | `Option` | Visible label. |
| Metadata | `key`, `value` | empty | Optional rows for custom JavaScript integrations. |
| Query Value Mappings | `mapping_key`, `value` | empty | Values used when parent mapped bundle mode is enabled. |
| Settings | `disabled` | `false` | Prevents selection. |
| Settings | `is_default` | `false` | Authored default or reset choice depending on reset setup. |
| Settings | `mode` | `radio` | Option presentation: `radio` or `button`. |
| Settings | `used_with_multiple_arguments` | `false` | Switches option authoring to query value mappings. |
| Settings | `count_display` | `inline` | Inline count text or separate count span. |
| Styling | option, button, indicator, label, count classes | component defaults | Option presentation classes. |
## Runtime Notes
- Only one option can be selected.
- The built-in reset option uses an empty value.
- `mode = button` changes presentation only; the facet remains single-select.
- The runtime manages `aria-checked`, roving focus, disabled state, selected state, and highlighted state.
---
## Reset Facet
# Reset Facet
`Reset Facet` clears active controls for a target and requests the unfiltered target state.
## Props
| Group | Prop | Default | Meaning |
| --- | --- | --- | --- |
| Settings | `target` | empty | Shared target ID. |
| Settings | `label` | `Reset filters` | Fallback button label when the default slot is empty. |
| Styling | `class` | `ome-reset-facet-default` | Button class. |
## Runtime Notes
- Reset Facet clears all registered facet controls for the same target.
- It also clears URL state for that target.
- Radio List's built-in reset option clears only that radio list selection. Reset Facet clears the whole target scope.
Use Reset Facet when users need one clear action for a complete faceted interface.
---
## Search Facet
# Search Facet
`Search Facet` renders a search input. Unlike option facets, it sends the typed value from the input instead of an authored option value.
## Props
| Group | Prop | Default | Meaning |
| --- | --- | --- | --- |
| Settings | `target` | empty | Shared target ID. |
| Settings | `placeholder` | `Search...` | Input placeholder. |
| Settings | `argument_mode` | `default` | Single argument mode when multi-argument search is disabled. |
| Settings | `target_multiple_query_arguments` | `false` | Renders a `query_bundle` search input when enabled. |
| Settings | `query_argument_relation` | `AND` | Relation for multi-argument search mappings. |
| Default Query Argument | `facet_key` | `search` | Query argument for default mode. |
| Meta Argument | `meta_key`, `meta_compare`, `meta_type` | ``, `LIKE`, `char` | Meta query settings. |
| Taxonomy Argument | `taxonomy`, `tax_field` | ``, `slug` | Taxonomy search settings. |
| Date Argument | `column` | `post_date` | Date query column. |
| ACF Relationship Argument | `meta_key` | empty | Relationship field stored on filtered posts. |
| Query Argument Mappings | `mapping_key`, mode-specific props | empty | Multi-argument search rows. |
| Styling | `class` | `ome-search-facet-default` | Input class. |
## Default Query Keys
Search Facet supports filter-focused keys:
```text
search, p, title_like, name_like, post_type, category_name_like, tag_like, author, author_name_like, year, monthnum
```
Use Search Facet for text input and partial matching. Use an option facet when users should choose from known values.
## Multi-Argument Search
When `target_multiple_query_arguments` is true, the rendered input type is `query_bundle`. The typed value is copied into every row in `query_argument_mappings`.
```text
target_multiple_query_arguments = true
query_argument_relation = OR
query_argument_mappings:
- native-search -> default search
- priority -> meta facet_priority LIKE char
```
This makes one input search across native search and a meta field.
## ACF Relationship Search
ACF relationship mode expects numeric related post IDs. It filters the relationship field stored on the posts being filtered.
---
## SearchSelect Facet
# SearchSelect Facet
`SearchSelect Facet` is a searchable option selector. It is useful when the authored option set is large enough to need filtering inside the dropdown.
## Authoring Structure
```text
SearchSelect Facet
└── SearchSelect Facet Option
```
## Parent Props
| Group | Prop | Default | Meaning |
| --- | --- | --- | --- |
| Settings | `target` | empty | Shared target ID. |
| Settings | `argument_mode` | `default` | Single argument mode. |
| Settings | `target_multiple_query_arguments` | `false` | Enables mapped bundle mode. |
| Settings | `options_behavior` | `all` | Availability behavior. |
| Settings | `show_counts` | `false` | Enables dynamic counts. |
| Settings | `selection_mode` | `single` | `single` or `multiple`. |
| Settings | `placeholder` | `Search...` | Search input placeholder. |
| Settings | `default_label` | `Select option...` | Text shown when nothing is selected. |
| Mode groups | mode-specific props | varies | See [Argument Modes](../argument-modes). |
| Styling | `class`, `control_class`, `trigger_class`, `input_class`, `content_class`, `selection_class` | component defaults | Classes for the searchable select shell. |
## Option Props
| Group | Prop | Default | Meaning |
| --- | --- | --- | --- |
| Content | `value` | empty | Sent value in single-argument mode. |
| Content | `label` | `Option Label` | Visible label, search text, and pill label. |
| Metadata | `key`, `value` | empty | Optional rows for custom JavaScript integrations. |
| Query Value Mappings | `mapping_key`, `value` | empty | Values used when parent mapped bundle mode is enabled. |
| Settings | `disabled` | `false` | Prevents selection. |
| Settings | `used_with_multiple_arguments` | `false` | Switches option authoring to query value mappings. |
| Settings | `count_display` | `inline` | Inline count text or separate count span. |
| Styling | `class`, span count classes | component defaults | Option and count classes. |
## Runtime Notes
- The root stores selected state with `data-ome-selected-value` and `data-ome-selected-values`.
- `selection_mode = multiple` renders multiple selected values as removable selections.
- SearchSelect uses the shared search-select runtime plus facet-specific query binding.
- SearchSelect options do not expose an authored `is_default` prop in the current PHP component.
## Example
```text
SearchSelect Facet:
- target = posts
- argument_mode = default
- facet_key = name
- placeholder = Search by slug
Option:
- value = beta-bar
- label = Beta Bar Post
```
---
## Select Facet
# Select Facet
`Select Facet` renders a custom listbox-style dropdown. It can select one value or multiple values, and it can either send one query clause or expand an option into a mapped bundle.
## Authoring Structure
```text
Select Facet
└── Select Facet Option
```
## Parent Props
| Group | Prop | Default | Meaning |
| --- | --- | --- | --- |
| Settings | `target` | empty | Shared target ID. |
| Settings | `argument_mode` | `default` | Single argument mode. |
| Settings | `target_multiple_query_arguments` | `false` | Enables mapped bundle mode. |
| Settings | `options_behavior` | `all` | Availability behavior. |
| Settings | `show_counts` | `false` | Enables dynamic counts. |
| Settings | `selection_mode` | `single` | `single` or `multiple`. |
| Settings | `tag` | `div` | Root wrapper tag. |
| Settings | `default_label` | `Select option...` | Trigger label and built-in clear option label. |
| Mode groups | mode-specific props | varies | See [Argument Modes](../argument-modes). |
| Styling | `class`, `trigger_class`, `trigger_label_class`, `content_class`, `item_class` | component defaults | Classes for the dropdown shell and items. |
## Option Props
| Group | Prop | Default | Meaning |
| --- | --- | --- | --- |
| Content | `value` | empty | Sent value in single-argument mode. |
| Content | `label` | `Option Label` | Visible option label. |
| Metadata | `key`, `value` | empty | Optional rows for custom JavaScript integrations. |
| Query Value Mappings | `mapping_key`, `value` | empty | Values used when parent mapped bundle mode is enabled. |
| Settings | `disabled` | `false` | Prevents selection. |
| Settings | `is_default` | `false` | Initial selected option when no restored value exists. |
| Settings | `used_with_multiple_arguments` | `false` | Switches option authoring to query value mappings. |
| Settings | `count_display` | `inline` | Inline count text or separate count span. |
| Styling | `class`, span count classes | component defaults | Option and count classes. |
## Runtime Notes
- The root stores selected state in `data-ome-selected-value` and `data-ome-selected-values`.
- Single selection uses one value. Multiple selection serializes multiple values for the same facet key.
- A built-in empty option uses `__ome-empty__` internally and clears the selection.
- In mapped bundle mode, option `query_value_mappings` must match parent `query_argument_mappings` by `mapping_key`.
## Example
```text
Select Facet:
- target = products
- argument_mode = default
- facet_key = category_name
- selection_mode = single
Option:
- value = shoes
- label = Shoes
```
---
## How Facets Work
# How Facets Work
Facet runtime is target-centric. Every control identifies a `target`, and every `Facet Target` with the same target ID participates in that target scope.
## Render and Refresh Lifecycle
1. During the original page render, `FacetBlockInterceptor` detects `Facet Target` components.
2. The target's loop and fallback slots are normalized and stored as the refresh template.
3. Facet controls register query descriptors for the same target ID.
4. On interaction, the client builds scoped facet clauses and calls the facet REST endpoint.
5. The endpoint applies those clauses to the target loop config, re-renders the stored target template, computes pagination and availability metadata, and returns markup.
6. The client replaces the target content, updates URL state, updates option availability, applies pagination metadata, and reruns returned scripts when the target allows it.
## Target ID Rules
- The `target` string is the connection between controls and result containers.
- Controls with different target IDs do not affect each other.
- Multiple target containers can share a target ID, but they share the same scoped request state.
- Empty target IDs are not useful at runtime; the PHP components show inline builder guidance when target is missing.
## Facet Target Behavior
`Facet Target` owns the content that gets replaced. Its loop slot is the normal result state, and its fallback slot is shown when the refreshed loop produces no rendered items.
Important target props:
| Prop | Meaning |
| --- | --- |
| `target` | Shared target ID. |
| `rerun_scripts` | Allows scripts returned by refreshed markup to run again. Defaults to `true`. |
| `preview_fallback` | Shows the fallback slot in Etch preview instead of the loop slot. |
| `scroll_to_top` | Scrolls to the target after connected facet updates. |
| `used_with_map` | Marks the target as map-paired, so map and result updates coordinate correctly. |
| `load_more_on_scroll` | Adds internal offset and posts-per-page controls for infinite scroll. |
| `load_more_batch_size` | Batch size used by infinite scroll. |
Nested loops have a specific rule: if a Facet Target lives inside another Etch loop, outer loop params must be resolved during the original render pass. The current implementation freezes those params through `FacetLoopParamResolver` before the target template is stored, and the E2E `nested_loop_context` scenario verifies that refreshed results keep the correct outer-loop context.
## URL State
Facet state is serialized under the target ID. The E2E URL tests cover direct-link hydration for search, checkbox, radio, select, pagination, map state, ACF relationship, and mapped bundle filters.
Examples:
```text
ome[products][category_name]=shoes
ome[products][meta_value:price]=50
ome[products][tax_query:facet_color]=red
ome[products][acf_relationship:facet_related_posts]=123
ome[products][geo_bbox]=40.5,-74.3,40.9,-73.7
```
The exact URL key depends on the facet clause type and mode-specific options such as `meta_key`, `taxonomy`, or date `column`.
## Pagination, Load More, and Reset
- `Pagination Facet` writes pagination state for a target and receives total pages, total items, current page, per-page, and offset metadata from responses.
- `Load More Facet` writes hidden offset and posts-per-page controls, then appends another batch through the same target request path.
- `load_more_on_scroll` on `Facet Target` uses the same internal offset mechanism without requiring a visible button.
- `Reset Facet` clears all controls connected to the target and returns the target to its unfiltered state.
When any normal facet value changes, pagination resets to page 1. The multi-facet E2E scenario verifies that changing `category_name` after navigating to page 2 resets pagination before showing the narrowed result set.
## Map Pairing
`Map Facet` is a facet control with type `geo_bbox`. It sends the map viewport as a bounding box and reads Map POI elements from the connected target loop.
Use a map-paired setup when:
- the target loop renders listings,
- each listing includes a `Map POI`,
- the map and result target share the same target ID,
- the target sets `used_with_map` when map coordination is needed.
The map runtime can use detailed POI markup for smaller sets and lightweight coordinate responses for large sets.
---
## Facet Overview
# Facet Overview
Facets are controls that update one or more `Facet Target` result containers. A facet does not own the results. It sends a value for a shared target ID, the backend re-renders that target's stored Etch loop template, and the runtime swaps the returned markup into the target.
The basic structure is:
```text
Search Facet, Select Facet, Radio List Facet, Checkbox List Facet, Map Facet, Pagination Facet, Load More Facet, Reset Facet
|
| shared target ID
v
Facet Target
|
v
Etch loop slot + fallback slot
```
One target can be controlled by many facets. The E2E scenarios combine `post_type`, `category_name`, `tag`, meta queries, date queries, ACF relationship filters, pagination, reset, load more, and map bounds against the same target. The combined result is an intersection unless a specific mode documents a different behavior.
## What Authors Configure
Every useful facet setup needs three decisions:
| Decision | Where it lives | What it controls |
| --- | --- | --- |
| Target binding | Facet `target` and Facet Target `target` | Which result container receives updates. |
| Argument mode | Facet settings and mode-specific groups | Which query clause the selected value becomes. |
| Authored values | Option values, search input value, map bounds, pagination controls | The value sent for that query clause. |
The component family pages describe the markup and props for each family. The system pages explain how those props work together across families.
## Component Families
- [Facet Target](./component-families/facet-target) stores and refreshes the result template.
- [Search Facet](./component-families/search-facet) sends text input values.
- [Select Facet](./component-families/select-facet) sends single or multiple selected option values.
- [SearchSelect Facet](./component-families/search-select-facet) sends searchable single or multiple option values.
- [Radio List Facet](./component-families/radio-list-facet) sends one selected option value and can include a reset option.
- [Checkbox List Facet](./component-families/checkbox-list-facet) sends multiple selected option values.
- [Pagination Facet](./component-families/pagination-facet) sends page offsets for a target.
- [Load More Facet](./component-families/load-more-facet) requests another batch for a target.
- [Reset Facet](./component-families/reset-facet) clears all active filters for a target.
- [Map Facet](./component-families/map-facet) sends a geographic bounding box and reads Map POI data from the target loop.
## Start Here
Read these pages in order when building a facet interface:
1. [How Facets Work](./how-facets-work) for the request and target lifecycle.
2. [Argument Modes](./argument-modes) for the exact meaning of each mode.
3. [Shared Props](./shared-props) for props shared across select, search select, radio, checkbox, and search.
4. [Availability, Counts, and Options](./availability-counts-and-options) for dynamic option state.
5. [Recipes](./recipes) for codebase-backed examples from the E2E scenarios.
---
## Facet Recipes
# Facet Recipes
These recipes use real scenarios from the test suite. They describe authoring intent and the important prop choices rather than copying fixture-only helper code.
## CPT and Taxonomy Filter
Use this when a result loop lists a custom post type and users need to filter by a custom taxonomy.
Example data:
```text
Post type: facettestcpt
Taxonomy: facet_color
Terms: red, blue, green
Target ID: catalog-results
```
Authoring setup:
1. Add a `Facet Target` with `target = catalog-results`.
2. Place the WP Query loop in the target loop slot. The loop should include the desired post type.
3. Add a `Select Facet`, `Radio List Facet`, or `Checkbox List Facet` with the same `target`.
4. Set `argument_mode = tax`.
5. Set `taxonomy = facet_color`.
6. Set `tax_field = slug` when option values are slugs like `red`.
7. Add options with values matching the selected field.
The taxonomy E2E suite verifies `slug`, `term_id`, `name`, and `term_taxonomy_id`.
## Multiple Facets to One Target
Use this when users need to narrow the same result grid with several independent controls.
Example:
```text
Target ID: multi-combo-target
Facet 1: Select post_type = post
Facet 2: Select category_name = facets-e2e-news
Facet 3: Select tag = facets-e2e-beta
Facet 4: Radio meta facet_priority = high
```
Each facet uses the same `target`. The backend combines the active clauses, and the result is the intersection. The E2E suite verifies two-way, three-way, and four-way intersections, fallback on zero results, recovery after clearing one facet, and pagination reset after changing a facet.
## Meta Filter
Use meta mode when the filtered value lives in post meta.
Example data:
```text
Meta key: facet_price
Values: 10, 25, 50, 75, 100
```
Authoring setup:
```text
argument_mode = meta
meta_key = facet_price
meta_compare = BETWEEN
meta_type = numeric
option value = 25,75
```
The meta query E2E suite verifies exact matches, numeric ranges, char values, bool values, `LIKE`, `NOT LIKE`, `IN`, `NOT IN`, `BETWEEN`, and `NOT BETWEEN`.
## Date Range Filter
Use date mode when authored options represent date ranges.
```text
argument_mode = date
column = post_date
option value = 2024-01-01,2024-12-31
```
The first date is the start date and the second date is the end date. Supported columns are `post_date`, `post_modified`, `post_date_gmt`, and `post_modified_gmt`.
## ACF Relationship Filter
Use ACF relationship mode when the posts being filtered store related post IDs in a relationship field.
```text
Filtered posts: Projects
Related posts: People
Relationship field on Projects: project_people
Facet option value: numeric Person post ID
```
Authoring setup:
```text
argument_mode = acf_relationship
acf_relationship_argument.meta_key = project_people
option value = 123
```
The directional rule is important: the relationship field must be stored on the posts being filtered. The facet does not reverse-resolve a relationship stored only on the related posts.
Comma-separated option values are supported for matching any of several related IDs:
```text
option value = 123,456
```
## Mapped Bundle Option
Use mapped bundle when one option should apply several query clauses.
Example option:
```text
Label: News + High priority
category mapping: category_name = facets-e2e-news
priority mapping: facet_priority = high
```
Parent facet:
```text
target_multiple_query_arguments = true
query_argument_mappings:
- mapping_key = category, argument_mode = default, facet_key = category_name
- mapping_key = priority, argument_mode = meta, meta_key = facet_priority, meta_compare = =, meta_type = char
```
Option:
```text
used_with_multiple_arguments = true
query_value_mappings:
- mapping_key = category, value = facets-e2e-news
- mapping_key = priority, value = high
```
The runtime expands the selected option into both clauses.
## Search Across Multiple Arguments
Use Search Facet multi-argument mode when one input should search more than one field.
```text
target_multiple_query_arguments = true
query_argument_relation = OR
query_argument_mappings:
- mapping_key = native-search, argument_mode = default, facet_key = search
- mapping_key = priority, argument_mode = meta, meta_key = facet_priority, meta_compare = LIKE, meta_type = char
```
Typing one value creates a `query_bundle` and applies the typed value to each mapped row. Search mappings normalize common text fields to partial aliases, so `title` behaves like `title_like`.
## Map and POIs
Use Map Facet when the target loop renders listings with coordinates.
Authoring setup:
1. Add `Map Facet` with `target = map-results`.
2. Configure `lat_meta_key` and `lng_meta_key`.
3. Add `Facet Target` with the same `target` and `used_with_map = true`.
4. Inside the target loop, add `Map POI`.
5. Set POI `value`, `lat`, and `lng` from the current loop item.
The map runtime reads initial POIs from the DOM and sends `geo_bbox` values as the map moves.
## Nested Loop Target
If the target is inside an outer Etch loop, keep the target template inside the original render context. The current implementation stores resolved outer-loop params before AJAX refresh, and the `nested_loop_context` E2E scenario verifies that searching for `Alice` still returns `Alice Alpha` and `Alice Beta` from the correct outer-loop rows.
---
## Shared Props
# Shared Props
Many facet families share the same logic even when their markup is different. The most important distinction is whether a prop belongs to the parent facet or to an authored option.
## Parent Facet Props
These props apply to Select, SearchSelect, Radio List, and Checkbox List through `FacetQueryProperties`. Search Facet has matching props implemented in its own component file.
| Prop | Applies to | Why it matters |
| --- | --- | --- |
| `target` | All facet controls | Connects the control to the Facet Target with the same target ID. |
| `argument_mode` | Search and option facets when `target_multiple_query_arguments` is false | Chooses the single query mode. |
| `target_multiple_query_arguments` | Search and option facets | Switches from one query target to multi-argument behavior. |
| `options_behavior` | Select, SearchSelect, Radio List, Checkbox List | Controls unavailable option behavior after availability data changes. |
| `show_counts` | Select, SearchSelect, Radio List, Checkbox List | Enables dynamic result counts on options. |
| `selection_mode` | Select and SearchSelect | Chooses single or multiple selected values. |
| `aria_label` | Radio List and Checkbox List | Names the `radiogroup` or `group` for assistive technology. |
| `default_label` | Select and SearchSelect | Text shown when nothing is selected, and the built-in clear item label for Select. |
| `placeholder` | Search and SearchSelect | Text shown in the input before a value is typed. |
## Single-Argument Groups
When `target_multiple_query_arguments` is false, the selected `argument_mode` decides which group is active:
| Group | Active when | Required values |
| --- | --- | --- |
| `default_query_argument` | `argument_mode` is `default` | `facet_key` |
| `meta_argument` | `argument_mode` is `meta` | `meta_key`, `meta_compare`, `meta_type` |
| `taxonomy_argument` | `argument_mode` is `tax` | `taxonomy`, `tax_field` |
| `date_argument` | `argument_mode` is `date` | `column` |
| `acf_relationship_argument` | `argument_mode` is `acf_relationship` | `meta_key` |
## Multi-Argument Parent Props
When `target_multiple_query_arguments` is true, the parent facet uses `query_argument_mappings`.
Each row needs:
| Prop | Meaning |
| --- | --- |
| `mapping_key` | Stable row key referenced by option value mappings. |
| `argument_mode` | Query mode for this mapped row. |
| `facet_key` | Used when row mode is `default`. |
| `meta_key`, `meta_compare`, `meta_type` | Used when row mode is `meta`. |
| `taxonomy`, `tax_field` | Used when row mode is `tax`. |
| `column` | Used when row mode is `date`. |
For Search Facet, the typed input becomes the value for every mapped row. For option facets, each option supplies its own values through `query_value_mappings`.
## Option Props
Select, SearchSelect, Radio List, and Checkbox List option components share these concepts:
| Prop | Applies to | Meaning |
| --- | --- | --- |
| `content.value` | Single-argument option mode | Value sent when the option is selected. |
| `content.label` | All option modes | Visible label and count base text. |
| `metadata` | All option modes | Optional key/value rows serialized for custom JavaScript integrations. |
| `query_value_mappings` | Multi-argument option mode | Per-option values keyed by parent `mapping_key`. |
| `disabled` | All option families | Prevents authored selection. |
| `is_default` | Select, Radio List, Checkbox List | Marks initial selection when no URL or runtime value already exists. |
| `used_with_multiple_arguments` | Option components | Shows mapping-driven authoring instead of manual `content.value`. |
| `count_display` | Option components | Chooses inline count text or a separate count span. |
`used_with_multiple_arguments` is an authoring switch for option components. It should match the parent facet's multi-argument setup: enable it when the parent has `target_multiple_query_arguments` enabled and the option needs `query_value_mappings`.
## Count Display
`count_display` supports:
| Value | Behavior |
| --- | --- |
| `inline` | Runtime appends counts to the visible label text. |
| `span` | Runtime writes counts into a separate `data-ome-facet-option-count` span. |
Use `span` when the count needs separate styling or layout. The PHP components include Etch preview count props for span mode so authors can style the counter before runtime data exists.
## Reset Props
Radio List has built-in reset props:
| Prop | Meaning |
| --- | --- |
| `show_reset_option` | Adds a reset choice before authored options. |
| `reset_option_mode` | Renders the reset choice as radio-style or button-style. |
| `reset_label` | Label for the built-in reset choice. |
Reset Facet is a separate component. It clears all controls for the target rather than only one radio list selection.
---
## Checkout Fields and Validation
# Checkout Fields and Validation
Billing and shipping address forms are authored components, but their field rules come from WooCommerce.
## Field Schema
`CheckoutFieldSchema::build()` produces:
| Schema key | Meaning |
| --- | --- |
| `billing.countries` | Allowed billing countries. |
| `shipping.countries` | Allowed shipping countries. |
| `billing.fields` | Woo billing field rules normalized without the `billing_` prefix. |
| `shipping.fields` | Woo shipping field rules normalized without the `shipping_` prefix. |
| `locales` | Country-specific label, required, hidden, type, validation, priority, and autocomplete overrides. |
| `states` | State/province options by country. |
| `defaults` | Base billing country/state and shipping defaults. |
The runtime normalizes this schema before applying it, so missing or malformed schema data becomes empty field lists rather than runtime crashes.
## Address Form Props
Both address forms use the shared address property builder.
| Prop group | Meaning |
| --- | --- |
| `content.legend` and `content.show_legend` | Fieldset legend text and visibility. |
| Per-field `use_woo_defaults` | When true, Woo field copy and validation copy win. |
| Per-field custom copy | Label, field hint text, and local validation message used only when Woo defaults are disabled. |
| `accessibility.aria_label` | Accessible name for the fieldset. |
| `preview.state` | Builder preview state for fields. |
Billing fields include first name, last name, company, address lines, city, state, postcode, country, email, and phone. Shipping fields include the same address fields except email and phone.
## Country and State Controls
Country and state are UIChoice-backed controls with hidden transport inputs. Runtime behavior:
1. Render country options from the schema.
2. Select a default from the current value or Woo defaults.
3. Render state as a select when the selected country has predefined states.
4. Render state as a text input when the country requires free text.
5. Hide state when Woo marks it hidden.
6. Re-apply field labels, priorities, hidden flags, required flags, input types, and autocomplete tokens after country changes.
E2E covers changing billing country from `US` to `PL` and verifies that state switches from ready to hidden with an empty value.
## Different Shipping Address
`ShippingAddressForm` includes a different-shipping toggle. Runtime hides or shows the shipping fields based on that checkbox.
Checkout serialization follows the toggle:
| Toggle state | Payload behavior |
| --- | --- |
| Unchecked | Billing address is copied to shipping. |
| Checked | Rendered shipping fields are serialized separately. |
## Local Validation
`CheckoutForm` validates rendered fields before sending Store API requests.
| Validation | Behavior |
| --- | --- |
| Required fields | Checked on submit; required checks can be overridden by rendered `data-ome-required`. |
| Email | Billing email must match the local email pattern. |
| Country | Country value must exist in schema options. |
| State | State value must exist when the selected country has predefined states. |
| Hidden fields | Hidden Woo fields are not validated. |
| Rendered field scope | Only fields present in the authored checkout scope are validated. |
On local validation failure, no Store API checkout request is sent. E2E verifies invalid email blocks before `/wc/store/v1/checkout`.
## Store API Field Errors
If Woo returns checkout errors, the runtime maps them to fields when possible:
- It reads `data.params` field keys from Store API errors.
- It falls back to known Woo error codes such as missing email, first name, last name, address, country, postcode, phone, city, and state.
- It marks matching inputs with `aria-invalid`, updates field error text, and links fields to error messages with `aria-describedby`.
## Terms and Notices
`TermsCheckbox` tells the checkout store whether terms are required and accepted. If required terms are unchecked, the checkout store emits a local `terms_required` error and `CheckoutNotices` renders the message.
`CheckoutNotices` renders text-only checkout notices from local validation and Store API errors. E2E verifies the terms-blocked flow and that notice contents are safe text.
---
## Cart Items
# Cart Items
This family renders the editable cart line-item list. `CartItems` owns the row loop. The atoms inside the default slot read the current row.
## Components
| Component key | Role |
| --- | --- |
| `OmeWooCartItems` | Renders cart rows and an authored empty slot. |
| `OmeWooCartItemImage` | Displays line product image. |
| `OmeWooCartItemTitle` | Displays product title or composed variant title. |
| `OmeWooCartItemPrice` | Displays unit price. |
| `OmeWooCartItemSubtotal` | Displays line subtotal. |
| `OmeWooCartItemQuantity` | Interactive quantity input or display quantity. |
| `OmeWooCartItemRemove` | Removes the current cart item. |
| `OmeWooCartAttributeList` | Loops the selected variation attributes for the current line. |
| `OmeWooCartAttributeName` | Displays attribute name inside `CartAttributeList`. |
| `OmeWooCartAttributeValue` | Displays attribute value inside `CartAttributeList`. |
## Authoring Structure
```text
CartItems
default slot
CartItemImage
CartItemTitle
CartAttributeList
CartAttributeName
CartAttributeValue
CartItemPrice
CartItemQuantity
CartItemSubtotal
CartItemRemove
empty slot
authored empty-cart content
```
## CartItems Props
| Prop | Meaning |
| --- | --- |
| `structure.root_tag` | Root wrapper tag. |
| `structure.item_tag` | Row tag. |
| `structure.empty_tag` | Empty state wrapper tag. |
| `preview.rows` | Number of builder preview rows. |
| `preview.empty_state` | Shows the empty slot in builder preview. |
The runtime sets `data-ome-state` to `has-items` or `empty`.
## Cart Item Atom Props
| Component | Important props |
| --- | --- |
| `CartItemImage` | `content.alt_fallback` is used when Woo image alt text is empty. |
| `CartItemTitle` | `content.include_attributes` and `content.variant_separator` control composed variant title text. |
| `CartItemQuantity` | `configuration.mode` is `interactive` or `preview`; content and accessibility props control label text. |
| `CartItemRemove` | `content.label` and `accessibility.aria_label` control button text and accessible name. |
`CartItemPrice` and `CartItemSubtotal` display Store API formatted price text from the current row.
## Attribute Props
`CartAttributeList` exposes:
| Prop | Meaning |
| --- | --- |
| `structure.root_tag` | Attribute list wrapper tag. |
| `structure.item_tag` | Attribute row tag. |
| `preview.rows` | Number of builder preview attributes. |
`CartAttributeName` and `CartAttributeValue` are row atoms; they need the current attribute row from `CartAttributeList`.
## Hydration Rules
Server render uses `CartItemAtomHydrator` when current cart data exists. Runtime uses `cart-items.ts` after Store API responses. Both update:
- Row keys and indexes.
- Text fields.
- Image attributes.
- Quantity input values and cart item keys.
- Remove button cart item keys.
- Attribute rows cloned from the attribute row template.
Rows inside `` elements are ignored until cloned.
## Scenario Coverage
| Scenario | Covered behavior |
| --- | --- |
| `cart_items_dynamic_source` | Etch loop can render `cartItems` source directly. |
| `cart_items_component_preview_contract` | `CartItems` renders seeded cart rows through cart item atoms. |
| `cart_totals_taxed_cart` | Quantity updates and item removal refresh rows and totals. |
| `cart_page_update_and_remove` | Cart page row actions update state and empty slot. |
---
## Cart State and Notices
# Cart State and Notices
This family renders global cart feedback: item count and cart notices. Both can be used outside a cart page.
## Components
| Component key | Role |
| --- | --- |
| `OmeWooCartCount` | Displays the current cart item quantity and state. |
| `OmeWooCartNotices` | Renders Store API and Woo cart notices as safe text. |
## CartCount
`CartCount` subscribes to `WooCartStore` and displays `cartSummary.item_count`. It sets:
| State | Meaning |
| --- | --- |
| `empty` | Cart has no lines. |
| `has-items` | Cart has one or more lines. |
It is useful in product archive headers, single product templates, mini-cart indicators, and cart pages.
## CartNotices
`CartNotices` is a runtime notice region. It renders notices from cart actions:
| Source | Typical action |
| --- | --- |
| `add-to-cart` | Product add failed. |
| `quantity` | Quantity update failed. |
| `remove` | Remove item failed. |
| `coupon` | Apply or remove coupon failed. |
| `shipping` | Shipping rate selection failed. |
| `stock` or `variation` | Woo Store API product constraints. |
Messages are normalized and rendered as text, so Woo error content is not injected as HTML.
## Props
| Component | Prop | Meaning |
| --- | --- | --- |
| Cart notices | `preview.show_in_builder` | Shows a sample notice in Etch builder preview. |
| Cart notices | `preview.notice_type` | Preview notice type: `error`, `success`, `info`, or `warning`. |
| Cart notices | `accessibility.aria_label` | Accessible label for the notice region. |
| Cart notices | `accessibility.live_region` | Live region politeness, defaulting to `polite`. |
`CartCount` is mostly display and styling; its runtime content comes from the cart store.
## Scenario Coverage
| Scenario | Covered behavior |
| --- | --- |
| `cart_count_add_to_cart` | Count changes from `0` to `1` after add. |
| `out_of_stock_cart_notice` | Out-of-stock add renders cart notice as text with error type and code. |
| `lifecycle_add_to_cart` | Native add-to-cart validation can block mutation and render a Woo notice. |
| `cart_coupons` | Invalid coupon renders cart notice without encoded text leaking into display. |
---
## Cart Totals
# Cart Totals
This family renders cart total fields from `cartTotals` and live Store API cart totals.
## Components
| Component key | Field |
| --- | --- |
| `OmeWooCartTotalsList` | Container for total atoms on cart pages. |
| `OmeWooTotalsSubtotal` | `total_items` |
| `OmeWooTotalsDiscount` | `total_discount` |
| `OmeWooTotalsShipping` | `total_shipping` |
| `OmeWooTotalsTax` | `total_tax` |
| `OmeWooTotalsTotal` | `total_price` |
The same total atoms are also used in `OrderSummaryTotalsList` on checkout pages.
## Authoring Structure
```text
CartTotalsList
TotalsSubtotal
TotalsDiscount
TotalsShipping
TotalsTax
TotalsTotal
```
## List Props
| Prop | Meaning |
| --- | --- |
| `structure.root_tag` | Wrapper element for the list. |
| `styling.list_class` | Class prop for the list wrapper. |
## Atom Props
All total atoms share props from `CartTotalProperties`.
| Prop | Meaning |
| --- | --- |
| `content.show_label` | Renders label and value in a wrapper when true. |
| `content.label` | Label text when labels are enabled. |
| `content.label_position` | `prefix` or `suffix`. |
| `structure.html_tag` | Wrapper tag when label is enabled. |
| `styling.value_class` | Class prop for the value span. |
| `styling.class` | Wrapper class when label is enabled. |
| `styling.label_class` | Label class when label is enabled. |
## Runtime Behavior
`cart-totals.ts` formats minor-unit Store API totals using currency metadata from the response. It updates every matching `data-ome-woo-cart-total-field`, except fields inside an order summary when the cart page binding is running. `OrderSummary` owns its own summary total rendering.
## Scenario Coverage
| Scenario | Covered behavior |
| --- | --- |
| `cart_totals_taxed_cart` | Tax, subtotal, and final total render for a seeded taxable cart. |
| `cart_totals_and_empty_slot` | Empty cart keeps totals at zero. |
| `cart_page_update_and_remove` | Quantity and removal refresh totals. |
| `shipping_selector` | Shipping rate selection updates shipping and total. |
| `lifecycle pricing` | Native Woo pricing hooks change rendered totals. |
---
## Checkout Address Forms
# Checkout Address Forms
This family renders Woo billing and shipping fields. Field definitions are authored in components, but runtime field rules come from Woo's checkout schema.
## Components
| Component key | Role |
| --- | --- |
| `OmeWooBillingAddressForm` | Renders billing address fields. |
| `OmeWooShippingAddressForm` | Renders shipping address fields plus the different-shipping toggle. |
## Shared Field Props
Both address forms expose the same field-copy pattern:
| Prop | Meaning |
| --- | --- |
| `content.legend` | Fieldset legend text. |
| `content.show_legend` | Renders or hides the legend. |
| `{field}.use_woo_defaults` | Uses Woo label, hint text, and validation copy. |
| `{field}.label` | Custom label when Woo defaults are disabled. |
| Field hint copy | Custom input hint copy when Woo defaults are disabled. |
| `{field}.error_message` | Custom local validation copy when Woo defaults are disabled. |
| `accessibility.aria_label` | Fieldset accessible label. |
| `preview.state` | Builder preview state. |
Billing includes email and phone. Shipping does not.
## Shipping Toggle Props
`ShippingAddressForm` adds:
| Prop | Meaning |
| --- | --- |
| `shipping_toggle.label` | Visible toggle label. |
| `shipping_toggle.use_aria_label` | Uses a separate ARIA label when true. |
| `shipping_toggle.aria_label` | Accessible toggle label when enabled. |
| `shipping_toggle.preview_checked` | Builder preview checked state. |
Runtime hides shipping fields until the toggle is checked.
## Runtime Field Rules
Runtime applies the Woo schema:
| Rule | Runtime result |
| --- | --- |
| `hidden` | Field root becomes hidden and controls are disabled. |
| `required` | Field gets `data-ome-required`, and checkout validation uses it. |
| `label` | Label text is updated unless custom copy is enabled. |
| Field hint copy | Input hint copy is updated unless custom copy is enabled. |
| `type` | Native input type is updated. |
| `priority` | Field roots are sorted by Woo priority. |
| `autocomplete` | Autocomplete token is applied. |
Country and state controls are UIChoice-backed. State can become select, text, or hidden depending on the selected country.
## Scenario Coverage
| Scenario | Covered behavior |
| --- | --- |
| `checkout_success` | Billing fields, country/state choices, and payment data produce a real order. |
| `checkout_validation_error` | Required and invalid billing fields are marked locally. |
| `checkout_terms_blocked` | Checkout notices mark error state without unsafe HTML. |
| `checkout country changes refresh state control` | State changes from ready to hidden after country change. |
| `checkout shipping address fields follow toggle` | Shipping fields show and hide with the embedded toggle. |
---
## Checkout Shell
# Checkout Shell
The checkout shell owns checkout scope, form serialization, notices, and order submission.
## Components
| Component key | Role |
| --- | --- |
| `OmeWooCheckoutProvider` | Layout and state scope for checkout components. It does not submit data. |
| `OmeWooCheckoutForm` | Real checkout form shell. Serializes authored checkout fields and submits Store API checkout. |
| `OmeWooCheckoutNotices` | Checkout notice region. |
| `OmeWooPlaceOrderButton` | Submit button for the checkout form. |
## Authoring Structure
```text
CheckoutProvider
CheckoutForm
BillingAddressForm
ShippingAddressForm
ShippingMethodSelector
PaymentMethodSelector
TermsCheckbox
CheckoutNotices
PlaceOrderButton
OrderSummary
```
`OrderSummary` can sit inside the provider but does not have to be inside the `