Building a Carousel from the OOTB Search Component in Sitecore Content Hub

Hello Sitecorians! 👋


Recently, in one of my projects, we got an interesting requirement: display the 20 most recently added assets from a collection in a horizontal carousel on the homepage.


Sounds like a simple UI enhancement, right?


But if you've worked with Sitecore Content Hub long enough, you probably already know where this is going. 😄


Out of the box, the Content Hub Search component supports multiple view types such as Grid, List, and Table. However, a Carousel isn't one of the available view types. There is no OOTB configuration that allows us to display search results as a horizontally scrollable set of asset thumbnails while still leveraging the Search component and its underlying functionality.


So the obvious question was:

How can we build a carousel without rebuilding the OOTB Content Hub asset cards?


That second part is important.


The Search component was already doing exactly what we needed from a data and presentation perspective. It was returning the right assets and rendering the familiar Content Hub asset cards.


We didn't want to replace that. We only wanted to change how those cards were laid out. So instead of building a new asset card from scratch, we took a different approach:


Let Content Hub render the cards. Then reuse those same rendered cards inside a custom Swiper carousel.


In this blog, I'll walk through how we achieved this, the challenges we encountered, and the important details behind making the OOTB cards work inside a custom carousel.


Let's get started!




Understanding the Challenge


The requirement wasn't simply to display 20 images. The business wanted the assets to behave like the standard Content Hub asset cards users were already familiar with.


That meant we wanted to retain things such as:

  • Thumbnail rendering
  • Asset navigation
  • Existing card actions
  • Content Hub's native card structure
  • Any functionality already associated with the OOTB asset card


The problem was purely the layout. The OOTB Search component could give us the cards, but it couldn't arrange those cards in the horizontal carousel experience we needed. So we didn't really need a new asset component.


We needed a way to reuse the component Content Hub had already rendered.


The First Approach

The first solution that came to mind was, naturally, to build everything ourselves.


We could use the Content Hub SDK to query the required assets, retrieve their properties, construct the required preview URLs, and then create our own React card component. Once we had the data, putting those cards inside Swiper would be straightforward.


After looking more closely at the OOTB asset cards, it became clear that rebuilding them wasn't really necessary. The Content Hub card already contains the UI and interactions we wanted.


Why create another version of something that Content Hub is already rendering for us? That led us to a different approach.


The Approach: DOM Manipulation

The core idea is simple once you see it:

  • Let Content Hub render the Search component normally
  • Grab those rendered cards from the DOM
  • Move them into a Swiper carousel
  • Hide the original Search grid


 Three phases. Let me walk through each one.


Phase 1 - Hide the Search Grid Before the User Sees It

The first challenge was making sure users never see the search grid, not even for a fraction of a second.


React's useEffect hook runs after the browser paints. So if you try to hide the grid inside a useEffect, there's always a window, however brief, where the grid is visible before your code kicks in. Not acceptable.


The fix is to inject a <style> tag synchronously at module evaluation time, before React mounts, before the browser's first paint. We do this in index.tsx before calling root.render():

// index.tsx
function injectHideStyle(searchIdentifier: string) {
    if (document.getElementById("rac-hide-search")) return;

    const searchComponentTestId = `search-component-${searchIdentifier}`;
    const scrollWrapperId = `search-scroll-wrapper-${searchIdentifier}`;

    const style = document.createElement("style");
    style.id = "rac-hide-search";
    style.textContent = `
        [data-testid="${searchComponentTestId}"],
        #${scrollWrapperId} {
            opacity: 0 !important;
            pointer-events: none !important;
        }
    `;
    document.head.prepend(style);
}

// Called before root.render()
render(context: Context) {
    injectHideStyle(context.config?.searchIdentifier ?? "");

    if (!root) root = createRoot(container);
    root.render(
        <RecentlyAddedCollectionAssets
            slidesPerView={context.config?.slidesPerView ?? 8}
            title={context.config?.title}
            viewAllUrl={context.config?.viewAllUrl}
            searchIdentifier={context.config?.searchIdentifier ?? ""}
        />
    );
}

Notice we use opacity: 0 and not display: none. This is critical.


Content Hub's search component uses an IntersectionObserver internally to lazy-render card content. When an element has display: none, it's removed from the layout entirely, the intersection observer sees it as out of viewport and never fires. Cards stay as skeleton loaders indefinitely.


With opacity: 0, the element is fully in the document flow, fully observed, just invisible. The OOTB component renders all its cards happily in the background. Took us a while to figure that one out. 😅


Phase 2 - Wait for the Cards to Render

With the grid hidden and rendering silently in the background, we need to know when the cards are actually ready.


The natural approach was to listen for the SEARCH_FINISHED event that Content Hub dispatches when a query completes. We tried this. The problem: the event fires when the query returns data, not when the DOM is actually updated with rendered card content. On top of that, the event payload's items property came through as undefined on the first dispatch, causing an immediate crash.


So we went with a polling approach with a stability check instead. Here's the actual function we used:

// RecentlyAddedCollectionAssets.tsx
const MAX_ITEMS = 20;
const POLL_MS = 100;
const TIMEOUT_MS = 30000;

function waitForReadyCards(searchIdentifier: string): Promise<HTMLElement[]> {
    const SCROLL_WRAPPER_ID = `search-scroll-wrapper-${searchIdentifier}`;
    const SEARCH_COMPONENT_ID = `search-component-${searchIdentifier}`;

    return new Promise((resolve) => {
        const start = Date.now();
        let lastCount = -1;
        let stableCycles = 0;

        const poll = setInterval(() => {

            // Handle the no-results case gracefully
            const noResultsElement = document.querySelector<HTMLElement>(
                '[data-testid="no-results"]'
            );
            if (noResultsElement) {
                const loadingEl = document.getElementById("rac-loading");
                if (loadingEl) loadingEl.textContent = "No results";
                noResultsElement.style.display = "none";
                clearInterval(poll);
                resolve([]);
                return;
            }

            const scrollWrapper = document.getElementById(SCROLL_WRAPPER_ID);
            if (!scrollWrapper) return; // not in DOM yet

            const grid = scrollWrapper.querySelector<HTMLElement>(
                '[data-testid="search-grid-wrapper"]'
            ) ?? scrollWrapper;

            const cards = Array.from(
                grid.querySelectorAll<HTMLElement>(
                    '[data-entity-id][data-definition-name="M.Asset"]'
                )
            );

            if (cards.length !== lastCount) {
                lastCount = cards.length;
                stableCycles = 0;
            } else {
                stableCycles++;
            }

            // Stable for 1 second (10 × 100ms) → we're done
            if (cards.length > 0 && stableCycles >= 10) {
                clearInterval(poll);
                setTimeout(() => {
                    const finalCards = Array.from(
                        grid.querySelectorAll<HTMLElement>(
                            '[data-entity-id][data-definition-name="M.Asset"]'
                        )
                    ).slice(0, MAX_ITEMS);

                    const comp = document.querySelector<HTMLElement>(
                        `[data-testid="${SEARCH_COMPONENT_ID}"]`
                    );
                    if (comp) comp.style.display = "none";
                    document.getElementById("rac-hide-search")?.remove();
                    resolve(finalCards);
                }, 200);
                return;
            }

            if (Date.now() - start >= TIMEOUT_MS) {
                clearInterval(poll);
                resolve(cards.slice(0, MAX_ITEMS));
            }

        }, POLL_MS);
    });
}


One important thing to understand here: cards that fall below the viewport fold will permanently remain as skeleton loaders. The IntersectionObserver in the OOTB component only resolves cards that are actually visible on screen. Since our grid is hidden with opacity: 0 but still in the normal page layout, cards within the visible area do render, anything below the fold does not.


So instead of waiting for all skeletons to clear (which never happens for below-fold cards), we wait for the count to stabilise. Once the number of rendered cards stops changing for 10 consecutive polls, 1 second of stability, we know this batch is final and we take up to 20.


Phase 3 - Move the Cards Into the Carousel

Once we have the cards, we move them into Swiper slides inside the useEffect:

// RecentlyAddedCollectionAssets.tsx
useEffect(() => {
    if (startedRef.current) return;
    startedRef.current = true;

    waitForReadyCards(searchIdentifier ?? "").then((cards) => {
        if (!swiperRef.current || cards.length === 0) return;

        const swiperWrapper = swiperRef.current
            .querySelector<HTMLElement>(".swiper-wrapper");
        if (!swiperWrapper) return;

        swiperWrapper.innerHTML = ""; // clear the loading placeholder

        cards.forEach((card) => {
            const slide = document.createElement("div");
            slide.className = "swiper-slide";
            slide.style.cssText =
                "width:auto !important; height:auto; box-sizing:border-box;";
            slide.appendChild(card); // move — not clone
            swiperWrapper.appendChild(slide);
        });

        const swiperEl = swiperRef.current.querySelector<any>(".swiper");
        if (swiperEl?.swiper) swiperEl.swiper.update();
    });
}, []);


Key detail: we use appendChild(card) which moves the DOM node, not cloneNode(true) which copies it.


This distinction matters a lot. The OOTB cards are managed by Content Hub's React tree. Moving the DOM node keeps all live JavaScript event listeners and Redux connections intact. Cloning gives you a copy of the HTML structure but all the JavaScript bindings are dead, Quick View and Download silently stop working.


And here's the JSX that sets up the Swiper with a loading placeholder while we wait:

return (
    <div className="rac-container">
        <div className="rac-header">
            <h2 className="rac-title">{title}</h2>
            <a href={viewAllUrl} className="rac-view-all">View All</a>
        </div>

        <div ref={swiperRef} className="rac-swiper">
            <Swiper
                spaceBetween={12}
                slidesPerView={slidesPerView}
                slidesPerGroup={slidesPerView === 'auto' ? 1 : slidesPerView}
                pagination={{ clickable: true }}
                navigation
                modules={[Pagination, Navigation]}
                watchOverflow={true}
            >
                <SwiperSlide>
                    <div id="rac-loading" className="rac-loading">
                        Loading...
                    </div>
                </SwiperSlide>
            </Swiper>
        </div>
    </div>
);


Styling the Carousel

All the carousel styling lives in a dedicated RecentlyAddedCollectionAssets.css file, scoped under the .rac-swiper class to avoid conflicts with the OOTB component styles:

/* Card sizing */
.rac-swiper .swiper-slide > * {
    width: 200px !important;
    height: 320px !important;
    display: flex !important;
    flex-direction: column !important;
    overflow: hidden !important;
    box-sizing: border-box !important;
}

/* Navigation arrows */
.rac-swiper .swiper-button-next,
.rac-swiper .swiper-button-prev {
    width: 32px !important;
    height: 32px !important;
    background: #fff !important;
    border-radius: 50% !important;
    box-shadow: 0 2px 8px rgba(0,0,0,0.2) !important;
    z-index: 20 !important;
    top: 40% !important;
}

/* Pagination dots */
.rac-swiper .swiper-pagination {
    position: relative !important;
    margin-top: 14px !important;
    bottom: auto !important;
}

.rac-swiper .swiper-pagination-bullet {
    background: #1D4ED8 !important;
    opacity: 0.3 !important;
}

.rac-swiper .swiper-pagination-bullet-active {
    opacity: 1 !important;
}



A Tricky Bug: Navigation Arrows Opening Asset Pages


After getting the carousel working, we noticed something frustrating: clicking the Swiper prev/next navigation arrows was opening the asset detail page instead of sliding the carousel.


The reason: the OOTB card wraps its entire content inside an <a> tag linking to the asset detail page. Swiper renders its navigation arrows inside the same container. When you click an arrow, the click event bubbles up through the arrow into the surrounding <a> tag, triggering navigation.


Two event listeners together solve this:

// Stop clicks on nav buttons from bubbling into card links
swiperRef.current
    .querySelectorAll(".swiper-button-next, .swiper-button-prev")
    .forEach((btn) => {
        btn.addEventListener("click", (e) => e.stopPropagation());
    });

// Also catch cases where the <a> tag intercepts first
swiperRef.current
    .querySelectorAll(".swiper-slide a")
    .forEach((a) => {
        a.addEventListener("click", (e) => {
            if ((e.target as HTMLElement).closest(
                ".swiper-button-next, .swiper-button-prev"
            )) {
                e.preventDefault();
                e.stopPropagation();
            }
        });
    });

The Final File Structure


For reference, here's how the external component is structured:

RecentlyAddedCollectionAssets/
├── index.tsx                       ← Entry point, injects hide style, mounts React root
├── RecentlyAddedCollectionAssets.tsx  ← Component, polling logic, Swiper setup
└── RecentlyAddedCollectionAssets.css ← Scoped styles for carousel and cards


And the config you set in the Content Hub external component settings:

{
    "searchIdentifier": "your-search-component-id",
    "title": "Recently Added to Collection",
    "viewAllUrl": "/en-us/media-assets-search",
    "slidesPerView": "auto"
}


The searchIdentifier is the ID embedded in the OOTB Search component's data-testid attribute. For example, if the DOM shows data-testid="search-component-testid", the identifier is "testid".


Key Takeaways


Use opacity: 0, not display: none, when hiding components with lazy observers.
display: none removes the element from layout and breaks IntersectionObserver. Keep the element in the document flow — just make it invisible.


DOM stability polling beats event listeners when timing is uncertain.
When you depend on a third-party component's internal rendering cycle, waiting for a stable DOM state is more reliable than events whose payload and timing you don't control.


Move DOM nodes, don't clone them.
appendChild moves the node along with all its live event bindings. cloneNode(true) copies only the HTML. For interactive OOTB components, always move.


Inject CSS before React mounts to eliminate any flash.
Code at module level in index.tsx runs before the browser paints. useEffect runs after. Anything that must be true before the first frame goes at module level.


External components can do more than just render React trees.
createExternalRoot gives you a React root inside the platform page — but nothing stops you from also reading from the surrounding DOM, injecting global styles, or relocating elements rendered by other components. The platform is a browser, and the browser is yours to work with.


Wrapping Up

What started as a simple "show assets in a carousel" request turned into a deep dive into browser rendering, intersection observers, React lifecycles, and DOM manipulation. The final solution is under 200 lines of custom code, retains every bit of OOTB card functionality, and works reliably on page load with no visible flash.


If you run into a similar requirement, any situation where you need OOTB search results in a layout that Content Hub doesn't natively support, this pattern is a solid starting point. Hide the source, let it render, move the output, show it where you need it.


Hope this helps someone out there. Happy coding! 🚀


Post a Comment (0)
Previous Post Next Post