diff --git a/README.md b/README.md
index 3e54097..5e0d433 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@ Easily create dynamic, accessible, and customizable carousels for any content ty
- **Flexible Compound Block Architecture**: Mix and match any blocks inside the carousel.
- **High Performance**: Viewport & Slide Engine powered by Embla Carousel.
- **Interactivity API**: Reactive state management with `data-wp-interactive`.
-- **Dynamic Content**: Full support for WordPress **Query Loop** block.
+- **Dynamic Content**: Full support for WordPress **Query Loop** and **Terms Query** blocks.
- **Accessibility**: W3C-compliant roles, labels, and keyboard navigation.
- **RTL Support**: Built-in support for Right-to-Left languages.
@@ -68,9 +68,9 @@ Yes! Carousel Kit is fully compatible with Full Site Editing. You can use the ca
Absolutely. Each slide is a container that accepts any WordPress block—images, paragraphs, groups, columns, and even other third-party blocks.
-### Does it support the Query Loop block?
+### Does it support the Query Loop and Terms Query blocks?
-Yes. Simply add a Query Loop block inside the Carousel Viewport, and each post in the loop becomes a slide automatically. No special configuration needed.
+Yes. Simply add a Query Loop or Terms Query block inside the Carousel Viewport, and each post or term in the loop becomes a slide automatically. No special configuration needed.
### Is it accessible?
diff --git a/docs/USAGE.md b/docs/USAGE.md
index 8918d62..fe4744f 100644
--- a/docs/USAGE.md
+++ b/docs/USAGE.md
@@ -61,6 +61,30 @@ You can create dynamic post sliders or content carousels using the WordPress Que
| Use Case | Recommended Block |
| :--- | :--- |
-| Dynamic Content (Posts, Pages, Products, Custom Post Types) | Query Loop (`core/query`) |
+| Dynamic Content (Posts, Pages, Products, Custom Post Types, Categories, Tags, Custom Taxonomies) | Query Loop (`core/query`) and Terms Query (`core/terms-query`) |
| Static Content (Hero Slider, Logo Showcase, Manual Testimonials) | Carousel Slide (`carousel-kit/carousel-slide`) |
| Mixed Content (Slide 1 is a Video, Slide 2 is Text) | Carousel Slide (`carousel-kit/carousel-slide`) |
+
+---
+
+## Using Terms Query with Carousel
+
+You can display taxonomy terms (categories, tags, or custom taxonomies) as carousel slides using the WordPress Terms Query block (`core/terms-query`). This works exactly like the Query Loop integration.
+
+### Setup Steps
+1. Add the **Carousel** block to your page.
+2. Select the inner **Carousel Viewport** block.
+3. Insert a **Terms Query** block inside the Viewport (instead of a Carousel Slide).
+4. Configure the taxonomy, order, and filters in the Terms Query settings panel.
+5. Design your slide inside the Terms Query's **Term Template**.
+
+**Note:** Each term generated by the Terms Query becomes an individual slide. The system automatically detects `.wp-block-term-template` and applies the same horizontal flex row display as for Query Loop. The `slideGap` attribute controls spacing between terms.
+
+### Block Selection Guide for Terms
+
+| Taxonomy Use Case | Recommended Setup |
+| :--- | :--- |
+| All categories of a post type | Terms Query (`core/terms-query`) with `taxonomy: category` |
+| All tags | Terms Query (`core/terms-query`) with `taxonomy: post_tag` |
+| Custom taxonomy terms | Terms Query (`core/terms-query`) with your taxonomy slug |
+| Hand-picked terms with custom markup | Carousel Slide (`carousel-kit/carousel-slide`) |
diff --git a/examples/patterns/terms-query.php b/examples/patterns/terms-query.php
new file mode 100644
index 0000000..390d804
--- /dev/null
+++ b/examples/patterns/terms-query.php
@@ -0,0 +1,49 @@
+
+
+
+
+
diff --git a/inc/Plugin.php b/inc/Plugin.php
index 25f975a..ac74edf 100644
--- a/inc/Plugin.php
+++ b/inc/Plugin.php
@@ -39,6 +39,7 @@ protected function setup_hooks(): void {
add_filter( 'block_categories_all', [ $this, 'register_block_category' ] );
add_action( 'init', [ $this, 'register_pattern_category' ] );
add_action( 'init', [ $this, 'register_block_patterns' ] );
+ add_filter( 'render_block_core/term-template', [ $this, 'add_term_template_columns_class' ], 10, 3 );
}
/**
@@ -130,6 +131,36 @@ public function register_block_patterns(): void {
}
}
+ /**
+ * Add columns-{N} class to term-template output.
+ *
+ * Gutenberg's post-template block natively adds a columns-{N} class based
+ * on its grid layout columnCount, but term-template does not. This filter
+ * bridges the gap so the carousel can detect the column count identically.
+ *
+ * @param string $block_content Rendered block content.
+ * @param array $parsed_block Parsed block data.
+ * @param \WP_Block $block Block instance.
+ *
+ * @return string
+ */
+ public function add_term_template_columns_class( string $block_content, array $parsed_block, \WP_Block $block ): string {
+ $column_count = $block->attributes['layout']['columnCount'] ?? null;
+
+ if ( empty( $column_count ) ) {
+ return $block_content;
+ }
+
+ $processor = new \WP_HTML_Tag_Processor( $block_content );
+
+ if ( $processor->next_tag( 'ul' ) ) {
+ $processor->add_class( sanitize_title( 'columns-' . $column_count ) );
+ return $processor->get_updated_html();
+ }
+
+ return $block_content;
+ }
+
/**
* Load patterns from the filesystem.
*
diff --git a/src/blocks/carousel/__tests__/view.test.ts b/src/blocks/carousel/__tests__/view.test.ts
index 0fca857..779a66b 100644
--- a/src/blocks/carousel/__tests__/view.test.ts
+++ b/src/blocks/carousel/__tests__/view.test.ts
@@ -432,6 +432,26 @@ describe( 'Carousel View Module', () => {
expect( result ).toBe( true );
} );
+
+ it( 'should work with Terms Query terms (.wp-block-term)', () => {
+ const container = document.createElement( 'div' );
+
+ const term1 = document.createElement( 'li' );
+ term1.className = 'wp-block-term';
+ const term2 = document.createElement( 'li' );
+ term2.className = 'wp-block-term';
+
+ container.appendChild( term1 );
+ container.appendChild( term2 );
+
+ const mockContext = createMockContext( { selectedIndex: 1 } );
+ ( getContext as jest.Mock ).mockReturnValue( mockContext );
+ ( getElement as jest.Mock ).mockReturnValue( { ref: term2 } );
+
+ const result = storeConfig.callbacks.isSlideActive();
+
+ expect( result ).toBe( true );
+ } );
} );
describe( 'isDotActive', () => {
diff --git a/src/blocks/carousel/hooks/useCarouselObservers.ts b/src/blocks/carousel/hooks/useCarouselObservers.ts
index 7cfef8d..d88b5da 100644
--- a/src/blocks/carousel/hooks/useCarouselObservers.ts
+++ b/src/blocks/carousel/hooks/useCarouselObservers.ts
@@ -5,16 +5,17 @@ const RESIZE_DEBOUNCE_MS = 200;
const MUTATION_DEBOUNCE_MS = 150;
/**
- * Unified observer hook that handles both resize detection and Query Loop
- * DOM mutations through a single coordinated MutationObserver.
+ * Unified observer hook that handles both resize detection and Query Loop /
+ * Terms Query DOM mutations through a single coordinated MutationObserver.
*
* **Resize detection** (viewport + first slide width changes):
* Uses `reInit()` because resize only affects measurements — the DOM structure
* (container + slides) remains unchanged, so Embla's cached references stay valid.
*
- * **Query Loop detection** (slide count changes):
- * Uses full destroy/recreate via `initEmblaRef` because Query Loop changes can
- * replace the `.wp-block-post-template` element or swap out its children entirely.
+ * **Query Loop / Terms Query detection** (slide count changes):
+ * Uses full destroy/recreate via `initEmblaRef` because Query Loop / Terms Query
+ * changes can replace the `.wp-block-post-template` or `.wp-block-term-template`
+ * element or swap out its children entirely.
* Embla caches references to container and slide elements, so when those DOM
* nodes are replaced, a fresh instance is required.
*
@@ -78,8 +79,8 @@ export function useCarouselObservers(
resizeObserver.observe( viewportEl );
const updateSlideObservation = () => {
- const container = viewportEl.querySelector( '.embla__container, .wp-block-post-template' );
- const firstSlide = container?.querySelector( '.embla__slide, .wp-block-post' ) ?? null;
+ const container = viewportEl.querySelector( '.embla__container, .wp-block-post-template, .wp-block-term-template' );
+ const firstSlide = container?.querySelector( '.embla__slide, .wp-block-post, .wp-block-term' ) ?? null;
if ( firstSlide === observedSlide ) {
return;
@@ -98,7 +99,7 @@ export function useCarouselObservers(
};
const checkQueryLoopChanges = (): boolean => {
- const postTemplate = viewportEl.querySelector( '.wp-block-post-template' );
+ const postTemplate = viewportEl.querySelector( '.wp-block-post-template, .wp-block-term-template' );
const currentCount = postTemplate ? postTemplate.children.length : 0;
const changed = currentCount !== lastSlideCount;
@@ -142,7 +143,7 @@ export function useCarouselObservers(
mutationObserver.observe( viewportEl, { childList: true, subtree: true } );
// Seed the initial slide count so the first mutation doesn't trigger a spurious init.
- const initialTemplate = viewportEl.querySelector( '.wp-block-post-template' );
+ const initialTemplate = viewportEl.querySelector( '.wp-block-post-template, .wp-block-term-template' );
lastSlideCount = initialTemplate ? initialTemplate.children.length : 0;
updateSlideObservation();
diff --git a/src/blocks/carousel/styles/_core.scss b/src/blocks/carousel/styles/_core.scss
index d35f485..a09a13b 100644
--- a/src/blocks/carousel/styles/_core.scss
+++ b/src/blocks/carousel/styles/_core.scss
@@ -13,9 +13,13 @@
overflow: hidden;
}
-/* Ensure the default container and Query Loop list are flex rows */
+/*
+ * Ensure the default container, Query Loop list and Terms Query list
+ * are flex rows
+ */
:where(.carousel-kit) .embla__container,
-:where(.carousel-kit) .embla .wp-block-post-template {
+:where(.carousel-kit) .embla .wp-block-post-template,
+:where(.carousel-kit) .embla .wp-block-term-template {
display: flex;
flex-wrap: nowrap;
width: 100%;
@@ -25,15 +29,23 @@
gap: var(--carousel-kit-gap, 0);
}
-/* Ensure intermediate wrappers (like wp-block-query) don't shrink */
-:where(.carousel-kit) .embla .wp-block-query {
+/*
+ * Ensure intermediate wrappers (like wp-block-query /
+ * wp-block-terms-query) don't shrink
+ */
+:where(.carousel-kit) .embla .wp-block-query,
+:where(.carousel-kit) .embla .wp-block-terms-query {
width: 100%;
min-width: 100%;
}
-/* Force slides (including posts) to respect a configurable width variable */
+/*
+ * Force slides (including posts and terms) to respect a configurable
+ * width variable
+ */
:where(.carousel-kit) .embla__slide,
-:where(.carousel-kit) .embla .wp-block-post-template li {
+:where(.carousel-kit) .embla .wp-block-post-template li,
+:where(.carousel-kit) .embla .wp-block-term-template li {
flex: 0 0 var(--carousel-kit-slide-width, 100%);
max-width: var(--carousel-kit-slide-width, 100%);
min-width: 0;
@@ -72,12 +84,14 @@
* We switch to margin for consistent spacing in loop mode.
*/
:where(.carousel-kit[data-loop="true"]) .embla__container,
-:where(.carousel-kit[data-loop="true"]) .embla .wp-block-post-template {
+:where(.carousel-kit[data-loop="true"]) .embla .wp-block-post-template,
+:where(.carousel-kit[data-loop="true"]) .embla .wp-block-term-template {
gap: 0;
}
:where(.carousel-kit[data-loop="true"]) .embla__slide,
-:where(.carousel-kit[data-loop="true"]) .embla .wp-block-post-template li {
+:where(.carousel-kit[data-loop="true"]) .embla .wp-block-post-template li,
+:where(.carousel-kit[data-loop="true"]) .embla .wp-block-term-template li {
margin-inline-end: var(--carousel-kit-gap, 0);
}
@@ -93,12 +107,14 @@
}
:where(.carousel-kit[data-axis="y"]) .embla__slide,
-:where(.carousel-kit[data-axis="y"]) .embla .wp-block-post-template li {
+:where(.carousel-kit[data-axis="y"]) .embla .wp-block-post-template li,
+:where(.carousel-kit[data-axis="y"]) .embla .wp-block-term-template li {
margin-inline-end: 0;
}
/* Vertical + Loop specific */
:where(.carousel-kit[data-axis="y"][data-loop="true"]) .embla__slide,
-:where(.carousel-kit[data-axis="y"][data-loop="true"]) .embla .wp-block-post-template li {
+:where(.carousel-kit[data-axis="y"][data-loop="true"]) .embla .wp-block-post-template li,
+:where(.carousel-kit[data-axis="y"][data-loop="true"]) .embla .wp-block-term-template li {
margin-block-end: var(--carousel-kit-gap, 0);
}
diff --git a/src/blocks/carousel/styles/_variants.scss b/src/blocks/carousel/styles/_variants.scss
index 87eb4fc..52d3655 100644
--- a/src/blocks/carousel/styles/_variants.scss
+++ b/src/blocks/carousel/styles/_variants.scss
@@ -4,18 +4,21 @@
/* 2 Columns */
:where(.carousel-kit).is-style-columns-2,
-:where(.carousel-kit) .embla .wp-block-post-template.columns-2 {
+:where(.carousel-kit) .embla .wp-block-post-template.columns-2,
+:where(.carousel-kit) .embla .wp-block-term-template.columns-2 {
--carousel-kit-slide-width: 50%;
}
/* 3 Columns */
:where(.carousel-kit).is-style-columns-3,
-:where(.carousel-kit) .embla .wp-block-post-template.columns-3 {
+:where(.carousel-kit) .embla .wp-block-post-template.columns-3,
+:where(.carousel-kit) .embla .wp-block-term-template.columns-3 {
--carousel-kit-slide-width: 33.333%;
}
/* 4 Columns */
:where(.carousel-kit).is-style-columns-4,
-:where(.carousel-kit) .embla .wp-block-post-template.columns-4 {
+:where(.carousel-kit) .embla .wp-block-post-template.columns-4,
+:where(.carousel-kit) .embla .wp-block-term-template.columns-4 {
--carousel-kit-slide-width: 25%;
}
diff --git a/src/blocks/carousel/types.ts b/src/blocks/carousel/types.ts
index 2c4ac8b..1f7678e 100644
--- a/src/blocks/carousel/types.ts
+++ b/src/blocks/carousel/types.ts
@@ -32,7 +32,7 @@ export type CarouselDotsAttributes = Record;
* This avoids `as any` casts while keeping dot-notation and type safety.
*/
export interface BlockEditorSelectors {
- getBlocks: ( clientId: string ) => Array<{ clientId: string }>;
+ getBlocks: ( clientId: string ) => Array<{ clientId: string; name: string; attributes: Record; innerBlocks: ReturnType }>;
getSelectedBlockClientId: () => string | null;
getBlockParents: ( clientId: string ) => string[];
}
diff --git a/src/blocks/carousel/view.ts b/src/blocks/carousel/view.ts
index acce223..b11a8a2 100644
--- a/src/blocks/carousel/view.ts
+++ b/src/blocks/carousel/view.ts
@@ -102,9 +102,9 @@ store( 'carousel-kit/carousel', {
return false;
}
- // Check for either standard slide or Query Loop post
+ // Check for either standard slide, Query Loop post, or Terms Query term
const slide = getElementRef( getElement() )?.closest?.(
- '.embla__slide, .wp-block-post',
+ '.embla__slide, .wp-block-post, .wp-block-term',
);
if ( ! slide || ! slide.parentElement ) {
@@ -115,7 +115,8 @@ store( 'carousel-kit/carousel', {
const slides = Array.from( slide.parentElement.children ).filter(
( child: Element ) =>
child.classList?.contains( 'embla__slide' ) ||
- child.classList?.contains( 'wp-block-post' ),
+ child.classList?.contains( 'wp-block-post' ) ||
+ child.classList?.contains( 'wp-block-term' ),
);
const index = slides.indexOf( slide );
@@ -158,9 +159,9 @@ store( 'carousel-kit/carousel', {
return;
}
- // Check for Query Loop container
+ // Check for Query Loop or Terms Query container
const queryLoopContainer = viewport.querySelector(
- '.wp-block-post-template',
+ '.wp-block-post-template, .wp-block-term-template',
);
const startEmbla = () => {
diff --git a/src/blocks/carousel/viewport/edit.tsx b/src/blocks/carousel/viewport/edit.tsx
index f584c06..2c2b855 100644
--- a/src/blocks/carousel/viewport/edit.tsx
+++ b/src/blocks/carousel/viewport/edit.tsx
@@ -88,6 +88,67 @@ export default function Edit( {
useCarouselObservers( viewportEl, emblaApiRef, initEmblaRef );
+ /**
+ * Gutenberg's term-template block does not add a columns-{N} class to its
+ * wrapper in the editor (unlike post-template). Mirror the class here so
+ * the carousel CSS can detect the column count identically.
+ */
+ const termTemplateColumnCount = useSelect(
+ ( select ) => {
+ const store = select( 'core/block-editor' ) as BlockEditorSelectors;
+ const find = (
+ blocks: ReturnType,
+ ): number | null => {
+ for ( const block of blocks ) {
+ if ( block.name === 'core/term-template' ) {
+ // eslint-disable-next-line dot-notation
+ const layout = block.attributes?.[ 'layout' ] as { columnCount?: number } | undefined;
+ return layout?.columnCount ?? null;
+ }
+ const found = find( block.innerBlocks ?? [] );
+ if ( found ) {
+ return found;
+ }
+ }
+ return null;
+ };
+ return find( store.getBlocks( clientId ) );
+ },
+ [ clientId ],
+ );
+
+ useEffect( () => {
+ if ( ! emblaRef.current || ! termTemplateColumnCount ) {
+ return;
+ }
+
+ const viewport = emblaRef.current;
+ const expectedClass = `columns-${ termTemplateColumnCount }`;
+
+ const applyClass = () => {
+ const el = viewport.querySelector( '.wp-block-term-template' );
+ if ( ! el || el.classList.contains( expectedClass ) ) {
+ return;
+ }
+ el.classList.forEach( ( cls ) => {
+ if ( cls.startsWith( 'columns-' ) ) {
+ el.classList.remove( cls );
+ }
+ } );
+ el.classList.add( expectedClass );
+ };
+
+ applyClass();
+
+ // Gutenberg replaces the DOM element when toggling between edit and
+ // preview (select / deselect). A MutationObserver ensures the class
+ // is re-applied on the new element.
+ const observer = new MutationObserver( applyClass );
+ observer.observe( viewport, { childList: true, subtree: true } );
+
+ return () => observer.disconnect();
+ }, [ termTemplateColumnCount ] );
+
const addSlide = useCallback( () => {
const block = createBlock( 'carousel-kit/carousel-slide' );
insertBlock( block, undefined, clientId );
@@ -115,7 +176,7 @@ export default function Edit( {
},
{
orientation: carouselOptions?.axis === 'y' ? 'vertical' : 'horizontal',
- allowedBlocks: [ 'carousel-kit/carousel-slide', 'core/query' ],
+ allowedBlocks: [ 'carousel-kit/carousel-slide', 'core/query', 'core/terms-query' ],
renderAppender: ! hasSlides ? EmptyAppender : undefined,
},
);
@@ -170,7 +231,7 @@ export default function Edit( {
}
const queryLoopContainer = viewport.querySelector(
- '.wp-block-post-template',
+ '.wp-block-post-template, .wp-block-term-template',
) as HTMLElement;
// eslint-disable-next-line @typescript-eslint/no-explicit-any