Sitecore Content Hub · React · TypeScript

Wrapping OOTB Search as a Swiper Carousel in Content Hub

·  Content Hub DAM  ·  External Components  ·  Practical Implementation

Sitecore Content Hub's OOTB Search Component renders asset cards with everything built in — Download, Quick View, permissions, lifecycle status. But its layout is a fixed grid. What if you need it as a horizontal carousel with a custom header?

This article documents a pattern we implemented in production: an External React Component that hides the OOTB Search, waits for its cards to fully render, then moves them into a Swiper carousel — keeping all native functionality intact.

Key insight: We never rebuild what the OOTB component already does. We let it do its job invisibly, then steal its output and re-present it in our own UI shell.

Architecture

CONTENT HUB PAGE YOUR EXTERNAL COMPONENT OOTB Search Component opacity:0 — invisible but active Renders cards · Permissions · Download · Quick View Filters · Lifecycle status — all OOTB ✓ Card 1 Card 2 Card 3 Card N ··· card count stable for 1s → ready ✓ index.tsx injectHideStyle() .css Swiper · Card · Nav overrides moves DOM nodes slides RecentlyAddedToolkitAssets Custom header · View All button Swiper carousel shell waitForReadyCards() polling every 100ms Slide 1 Slide 2 Slide 3 Slide N ✓ Download ✓ Quick View ✓ Permissions waitForReadyCards() polls every 100ms Stability Check 10 cycles unchanged = ready

How It Works — Step by Step

1
Inject hide style before first paint index.tsx calls injectHideStyle() synchronously — before React mounts. It prepends a <style> tag using opacity:0; pointer-events:none. We use opacity not display:none so the OOTB component still lays out and its intersection observer fires — which triggers card loading in the background.
2
React mounts the carousel shell The External Component renders the header, View All link, and Swiper with a single "Loading..." placeholder slide.
3
Poll for OOTB cards every 100ms waitForReadyCards() runs a setInterval at 100ms. It looks for [data-entity-id][data-definition-name="M.Asset"] inside search-scroll-wrapper-{identifier}. If [data-testid="no-results"] appears first, it resolves early with an empty array.
4
Wait for card count to stabilize Cards load progressively. We track lastCount and stableCycles. Once the count stays unchanged for 10 consecutive polls (1 second), rendering is complete. A final 200ms delay lets the last render flush.
5
Move cards into Swiper slides We use slide.appendChild(card) — not cloneNode. Moving the actual DOM node preserves all event listeners, so Download and Quick View work without any extra code. swiperEl.swiper.update() recalculates slide widths.
6
Hide the OOTB component permanently Set display:none on the original search component and remove the rac-hide-search style tag. The carousel is now the only visible UI.

File Structure

Component folderSTRUCTURE
📁 RecentlyAddedToolkitAssets
    ├── index.tsx                        ← entry point, injects hide style
    ├── RecentlyAddedToolkitAssets.tsx   ← carousel shell + polling logic
    └── RecentlyAddedToolkitAssets.css   ← Swiper + card size overrides

Code — index.tsx

The entry point's critical job is injecting the hide style before React renders anything — preventing any flash of the OOTB grid.

index.tsxTSX
// Runs synchronously before React mounts
function injectHideStyle(searchIdentifier: string) {
    if (document.getElementById("rac-hide-search")) return;

    const style = document.createElement("style");
    style.id = "rac-hide-search";
    // opacity:0 not display:none — OOTB must still lay out
    // so its intersection observer fires and cards load
    style.textContent = `
        [data-testid="search-component-${searchIdentifier}"],
        #search-scroll-wrapper-${searchIdentifier} {
            opacity: 0 !important;
            pointer-events: none !important;
        }
    `;
    document.head.prepend(style);
}

export default function createExternalRoot(container, _clientBuilder) {
    let root = null;
    return {
        render(context) {
            injectHideStyle(context.config?.searchIdentifier ?? "");
            if (!root) root = createRoot(container);
            root.render(
                <RecentlyAddedToolkitAssets
                    slidesPerView={context.config?.slidesPerView ?? 8}
                    title={context.config?.title}
                    viewAllUrl={context.config?.viewAllUrl}
                    searchIdentifier={context.config?.searchIdentifier ?? ""}
                />
            );
        },
        unmount() {
            root?.unmount();
            root = null;
            container.innerHTML = '';
            document.getElementById("rac-hide-search")?.remove();
        },
    };
}

Code — Polling Logic

RecentlyAddedToolkitAssets.tsx — waitForReadyCards()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, stableCycles = 0;

        const poll = setInterval(() => {

            // Early exit: OOTB no-results element detected
            const noResults = document.querySelector('[data-testid="no-results"]');
            if (noResults) {
                (noResults as HTMLElement).style.display = "none";
                clearInterval(poll);
                resolve([]);
                return;
            }

            const wrapper = document.getElementById(SCROLL_WRAPPER_ID);
            if (!wrapper) return; // not rendered yet, try again

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

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

            // Track stability — reset if count changes
            if (cards.length !== lastCount) { lastCount = cards.length; stableCycles = 0; }
            else stableCycles++;

            // 10 stable cycles (1 second) = rendering complete
            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);
            }

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

Code — Moving Cards Into Swiper

RecentlyAddedToolkitAssets.tsx — useEffectTSX
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 Loading... slide

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

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

Code — Swiper Configuration

RecentlyAddedToolkitAssets.tsx — JSXTSX
<Swiper
    spaceBetween={12}
    slidesPerView={slidesPerView}  // passed via CH component config, default 8
    slidesPerGroup={slidesPerView === 'auto' ? 1 : slidesPerView}
    pagination={{ clickable: true }}
    navigation
    modules={[Pagination, Navigation]}
    style={{ width: "100%" }}
    watchOverflow={true}
>
    <SwiperSlide>
        <div id="rac-loading" className="rac-loading">
            Loading...
        </div>
    </SwiperSlide>
</Swiper>

Key Lessons from Implementation

👁️
opacity:0 not display:nonedisplay:none stops the OOTB component loading entirely. opacity:0 keeps it invisible but functional — intersection observers fire, cards load.
📦
Move nodes, never cloneappendChild moves the DOM node with all its event listeners. cloneNode creates a copy without listeners — Download and Quick View would break silently.
⏱️
Stability beats fixed timeoutsWaiting for card count to stay unchanged for 1 second works reliably across different network speeds. A fixed setTimeout either waits too long or misses late-loading cards.
🔑
searchIdentifier is criticalEverything derives from it — the hide style, scroll wrapper ID, and component test ID. Get it from DevTools: look for data-testid="search-component-{id}".

When to Use This Pattern

✅ Use when: You need a completely different UI layout but want to keep all OOTB functionality — filters, permissions, Download, Quick View, lifecycle status — working without rebuilding any of it.

⚠️ Watch out for: Content Hub upgrades can change data-testid attributes or DOM structure. Test after every Content Hub version upgrade.
Sitecore Content Hub External Component React TypeScript Swiper DOM Hijacking OOTB DAM Carousel

Popular Posts

Fetch component-level data in Next.js app using GraphQL - Sitecore Headless Development

Generate a sitemap for Sitecore Headless Next.js app

All Blog Posts - 2024

GraphQL query to fetch the country specific state list - Sitecore Headless Development