Sitecore Content Hub · React · TypeScript
Wrapping OOTB Search as a Swiper Carousel in Content Hub
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
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
⚠️ Watch out for: Content Hub upgrades can change
data-testid attributes or DOM structure. Test after every Content Hub version upgrade.