Skip to content

Compass Web SDK: Recommendations Components

The Compass Web SDK ships two SDK-rendered recommendation Experiences<arc-compass-recommendations-carousel> and <arc-compass-recommendations-list>. Each is a custom element you drop into your markup: the SDK fetches a recommendation set for the current reader, renders the cards inside a shadow root, gates the whole thing on reader consent, and wires up exposure and click attribution for you. You place one tag and style it with CSS — there is no data to fetch and no card component to build.

The two components

Both Experiences render the same card from the same recommendation set and answer to the same attributes. They differ only in the container around the cards, so choosing between them is a layout decision and nothing else:

  • <arc-compass-recommendations-carousel> — one horizontal row that scrolls, with previous/next arrows that appear when the cards overflow their container (and step aside on touch devices, where the native swipe is the affordance).
  • <arc-compass-recommendations-list> — the same cards in a grid that wraps onto as many rows as it needs. Nothing scrolls and there are no arrows.

Each card carries a thumbnail, an optional kicker (category), the headline linked to the content, an optional byline with a relative publish date, and a premium badge on paywalled items. The order of the cards is the recommendation — it is the reranked score, not an arbitrary arrangement.

Where SDK rendering fits

SDK-rendered components are one of three coexisting ways to put Compass recommendations on a page. None replaces another — pick per surface:

PathYou provideCompass providesReach for it when
SDK-rendered components (this page)one custom-element tag + CSSthe fetch, the card UI, attributionyou want a personalized surface fast, with minimal code
API + custom-rendered UIyour own components and renderingthe recommendation data and a display-ready card per itemyou need full control of the markup, or a framework-native component
PageBuildera PageBuilder block/featurethe integration inside your Arc XP siteyou build on PageBuilder and want a configured, in-editor surface

The API + custom-rendering path is documented in the Content Recommendations API Developer Guide, and its attribution helpers in Recommendation Attribution. The PageBuilder path is covered in the PageBuilder setup guide. The SDK-rendered components are simply the lowest-code option of the three.

Minimal render

With the SDK initialized and consent resolved (see the Integration Guide), each Experience is a single tag. Give it a surface-id for the placement and, usually, a heading:

<!-- A scrolling row -->
<arc-compass-recommendations-carousel
surface-id="homepage-rail"
heading="Recommended for you"
></arc-compass-recommendations-carousel>
<!-- The same cards in a wrapping grid -->
<arc-compass-recommendations-list
surface-id="article-footer"
heading="More for you"
></arc-compass-recommendations-list>

On mount, the element requests the current reader’s recommendations for that surface and renders the cards. If the recommender has nothing to return for this reader — or the reader has not consented — it renders nothing and collapses to zero height (see Loading, empty, and error).

Attributes

Both Experiences accept the same attributes. Each is read once, when the element first mounts — changing it afterward has no effect, so render the tag with the values you want. To re-request — against a new reader, a new surface, or new attribute values — either replace the element or call its refresh() method.

refresh() remounts the Experience in place: it aborts any in-flight request, re-reads every attribute, re-reads the current reader identity, and issues a fresh request, passing back through the consent gate exactly as a first mount does (so it cannot bypass consent). It is a no-op on an element that is not connected to the DOM. To refresh several at once without holding element references, call ArcCompass.refreshExperiences(selector?), which calls refresh() on each matching Experience — every one on the page when you pass no selector.

ArcCompass.refreshExperiences() rides the loader stub just like init() and track(), so it is safe to call before the bundle attaches — an early call is queued and replayed in order once the bundle loads (the common pattern is setUserId() then refreshExperiences() from the same login handler). It also guards each element internally, calling refresh() only on Experiences that have already upgraded and skipping the rest, so it never throws on a not-yet-upgraded tag.

Calling an element’s refresh() method directly has no such guard. Before the SDK upgrades the tag it is a plain HTMLElement with no refresh() method, so an early element.refresh() throws a TypeError — it is not a no-op. If you hold an element reference, wait until the SDK has upgraded it before calling refresh(); or simply use ArcCompass.refreshExperiences(selector?), which handles the timing for you.

AttributeValuesEffect
surface-idany stringOpaque id for the placement. The SDK forwards it on the recommendation request and the response echoes it back in its attribution, so Confirmed-CTR reporting can tell surfaces apart. Use a distinct value per surface.
countan integer 150How many items to request. A hint — the recommender returns up to this many, so a reader with fewer quality results still gets fewer. Omit it for the server default. A non-integer or a value below 1 is ignored (the request is sent with no count, so the server default applies); a value above 50 is capped to 50.
headingany stringSection title, rendered as an <h2>. No default — omit it and no heading renders. Use heading, never the global title attribute, which the browser turns into a tooltip over the whole element.
datesrelative (default) · offWhether each card shows its publish date as relative time (“2 days ago”). The wording comes from the response locale, so it is already the reader’s language. off hides it.
authorson (default) · offWhether each card shows its byline author (“by A. Reporter”). off hides it while leaving the date (the two share the byline). Only hides an author the API sent; it never invents one.
premium-labelany stringA word shown beside the lock on a premium card, e.g. Subscriber. Omit it and the badge is the lock alone.
<arc-compass-recommendations-carousel
surface-id="homepage-rail"
heading="Recommended for you"
count="12"
premium-label="Subscriber"
></arc-compass-recommendations-carousel>

The premium badge appears on any item the API marks premium. That flag describes the content — that it sits behind your paywall — not this reader: the SDK does not know whether the current reader can open it, so the badge marks premium content rather than asserting “locked for you.”

A heading renders only when at least one card does, so an empty or failed response never leaves a title standing over nothing.

Theming

Each Experience renders inside a shadow root: your page’s CSS cannot reach its internals, and its internals are not part of the API. You theme it through a set of --arc-compass-* CSS custom properties, which cascade through the shadow boundary — set them on the tag, a wrapping selector, or :root.

PropertyDefaultWhat it controls
--arc-compass-surfaceCanvas (system)Background where Compass owns the surface. The recommendation Experiences are transparent and inherit your page, so this only matters if you also use the full-screen Catch Up takeover. Must be opaque.
--arc-compass-on-surfacecurrentColorInk on the surface: text, plus the neutral ramp (muted text, hairlines) derived from it.
--arc-compass-accentcurrentColorPrimary accent. In these components it colors the kicker above each headline.
--arc-compass-accent-secondarycurrentColorSupporting accent. Colors the premium badge, so you can style it apart from the kicker.
--arc-compass-on-accent#fffForeground on a filled accent control. Set it when your accent is light.
--arc-compass-font-familyinherited page fontPrimary typeface — headlines and running text.
--arc-compass-font-family-secondaryinherited page fontSecondary typeface — kickers, badges, and meta.
--arc-compass-card-min-width220pxMinimum card width in the grid — the one density knob (see below).

With no tokens set, the Experiences render “native”: they inherit your page’s font throughout, and both accents fall back to the surrounding text color, so an unthemed component is monochrome but never clashes. Set the tokens to bring in your brand:

arc-compass-recommendations-carousel {
--arc-compass-font-family: Georgia, serif;
--arc-compass-font-family-secondary: "Helvetica Neue", Arial, sans-serif;
--arc-compass-accent: #b00020; /* the kicker */
--arc-compass-accent-secondary: #5f6368; /* the premium badge */
}

The SDK never ships or @font-faces a typeface — load your fonts as you already do and point the tokens at them. Set only --arc-compass-font-family and the components use that one face throughout.

Sizing the cards with one value

--arc-compass-card-min-width (default 220px) is the minimum column width both Experiences share, and it is the one knob for card density. Each column is at least this wide and shares whatever the container gives beyond it, so the column count follows the element’s width on its own — no breakpoint, no media query. Set it to a larger length for fewer, bigger cards or a smaller one for more, denser cards. It is a CSS length, so 240px, 16rem, even a clamp() all work.

Where the two Experiences differ is what happens once the columns no longer fit: the list wraps the overflow onto new rows (repeat(auto-fill, …)), while the carousel keeps a single row and lets it overflow into the horizontal scroller. The token means the same thing in both — it just sizes the columns.

/* Bigger cards everywhere, still fully responsive — no media queries. */
arc-compass-recommendations-list {
--arc-compass-card-min-width: 280px;
}

Reach for ::part(list) or ::part(track) (below) only when you need something the token cannot express — a fixed column count, a capped card width, or per-breakpoint counts. For “just make the cards this size,” the token is the whole job.

Styling internals with ::part()

When you need to restyle an element outright — a different thumbnail aspect ratio, your own type scale — use the part hooks. Part names are versioned public surface: they will not be removed or moved to a different element within a major version. Everything else inside the shadow root — the markup between the parts, class names, and internal custom properties (anything prefixed --_) — is internal and may change in any release.

<arc-compass-recommendations-carousel> exposes:

PartElement
headingThe <h2> section title, when you set heading
trackThe <ol> the cards scroll inside
cardOne recommendation
thumbnailThe card’s image, when the item has one
categoryThe kicker above the headline, when the item has one
premiumThe premium badge, when the item is behind your paywall
premium-iconThe padlock inside the badge — recolor it, or display:none it for a word-only badge
headlineThe line holding the title
linkThe anchor inside the headline
bylineThe author line, when the item has one or a date shows
dateThe relative published date inside the byline, when shown
previousThe back arrow, when there is content behind
nextThe forward arrow, when there is content ahead

<arc-compass-recommendations-list> exposes the same card parts, with its container named for what it is and no arrow parts — it renders none, so there is nothing for ::part(previous) or ::part(next) to select. Its container part is list (the <ol> holding the grid) in place of the carousel’s track.

arc-compass-recommendations-carousel::part(thumbnail) {
aspect-ratio: 16 / 9;
border-radius: 8px;
}
arc-compass-recommendations-carousel::part(card) {
flex-direction: column; /* image above the text */
}

Two things to know before you rely on parts:

  • ::part() selects the named element and nothing below it. So ::part(card) img matches nothing — style ::part(thumbnail) instead. Every element you can restyle has its own name; if something you need is not in the table, it is not stylable.
  • They are plain CSS, so they work from Tailwind or any utility framework too (via a stylesheet block with @apply, or the [&::part(...)] arbitrary variant). Utility resets do not leak into the shadow tree, so you never need to scope your framework’s preflight away from the element.

Attribution

Attribution is automatic. Once cards are rendered, the SDK binds them to its recommendation detector and, with no work from you:

  • fires a recommendation_exposure event when a card becomes viewport-visible long enough to qualify (once per card), and
  • fires a recommendation_click event when the reader clicks anywhere in a card.

These use the same qualification detector as host-rendered recommendations, so an SDK-rendered surface and a hand-built one produce the identical Confirmed CTR for the same reader behavior. The identifiers the SDK sends are the response’s own, forwarded unchanged — the position is the slot the recommender served, not the index the card happened to render at.

You do not tag anything, add data-arc-compass-* attributes, or call track() for these components — they attribute themselves. The mechanics of the shared detector, the qualification thresholds, and how the same loop works for host-rendered recommendations are documented in Compass Web SDK: Recommendation Attribution.

Loading, empty, and error

An Experience never shows a reader a spinner, a skeleton, or an error message. It renders its content or it renders nothing, collapsing to zero height. A single request is made per mount, with a five-second deadline and no retry — a recommendation set decays in value while a reader waits, so a late retry is worse than nothing.

Because there is no visual signal for your code to read, the SDK dispatches lifecycle events. They bubble, so you can listen on a container and read event.target to tell which tag fired:

EventMeaning
arc-compass:renderedCards are in the DOM.
arc-compass:emptyNothing to show for this reader — a successful answer, not a failure.
arc-compass:errorSomething went wrong; the element is collapsed.

Exactly one fires per mount once the reader has consented — an Experience renders behind the consent gate, so until consent resolves to granted (and never, if it is denied) it stays silent and dispatches none of the three. Do not wait for a terminal event before consent resolves: in the denied or never-answered case it never comes (see the caution below). The events carry no detail: the reason a request failed is a fact about a reader’s request that has no place in your page logs, so the SDK keeps its diagnostics on the debug channel instead.

Reserving space above the fold

A collapsed element occupies no space, so it causes no layout shift on its own. The shift comes from content that paints after the fold and pushes everything below it down. If the tag sits above the fold, reserve its space yourself and release it on the terminal event so an empty or failed response leaves no gap:

<div class="rec-slot">
<arc-compass-recommendations-carousel
surface-id="homepage-rail"
heading="Recommended for you"
></arc-compass-recommendations-carousel>
</div>
.rec-slot {
min-height: 320px;
}
.rec-slot.settled {
min-height: 0;
}
document.addEventListener("arc-compass:empty", release);
document.addEventListener("arc-compass:error", release);
function release(event) {
event.target.closest(".rec-slot")?.classList.add("settled");
}

Release on empty and error only — on rendered the content is already filling the slot. Note the list’s height depends on how many rows it wraps onto, so a fixed reservation fits it less exactly than it fits the single-row carousel.

A realistic integration

A themed carousel with a heading, above-the-fold space reserved and released on the terminal events and on the two non-granted consent outcomes (so a reader who denies or ignores consent is not left with a permanent gap), and attribution firing automatically:

<div class="rec-slot">
<arc-compass-recommendations-carousel
surface-id="homepage-rail"
heading="Recommended for you"
count="12"
></arc-compass-recommendations-carousel>
</div>
<style>
.rec-slot {
min-height: 320px;
}
.rec-slot.settled {
min-height: 0;
}
arc-compass-recommendations-carousel {
--arc-compass-font-family: "Publico", Georgia, serif;
--arc-compass-font-family-secondary: "Franklin", Arial, sans-serif;
--arc-compass-accent: #b00020;
--arc-compass-card-min-width: 260px;
}
</style>
<script>
document.addEventListener("arc-compass:empty", release);
document.addEventListener("arc-compass:error", release);
function release(event) {
event.target.closest(".rec-slot")?.classList.add("settled");
}
// Those events never fire when consent is denied or never answered, so also
// collapse the reserved slot in both of those cases (see the consent caution
// above). The SDK has no consent-denied event — define a handler and wire it
// to your CMP's denial callback (the exact API differs per CMP).
function collapseReservedSlots() {
document.querySelectorAll(".rec-slot").forEach((s) => s.classList.add("settled"));
}
// yourCmp.onDenied(collapseReservedSlots); // ← wire this up to your consent manager
// Never answered: after a grace period, collapse whatever is left — but only
// while consent is still unresolved, so a slow-but-granted render isn't yanked.
setTimeout(() => {
if (yourCmp.consentAnswered) return; // ← your CMP's "reader has responded" flag
document
.querySelectorAll(".rec-slot:not(.settled)")
.forEach((s) => s.classList.add("settled"));
}, 10000);
</script>

That is the whole integration for the surface: one tag, some CSS, a release hook, and the two consent fallbacks that collapse the slot when the reader denies or never answers. The SDK requests the reader’s recommendations, renders the cards, and fires recommendation_exposure and recommendation_click as the reader sees and taps them — no per-card wiring on your part.

Known limitation: right-to-left

These Experiences render left-to-right regardless of the host page’s or the response’s language. The carousel in particular pins its arrows and scroll origin to the physical left/right, so an RTL locale would get a forward arrow pointing the wrong way. Relative dates are localized (they come from the response locale), but the surrounding layout is not mirrored. If you serve an RTL audience, hold off on these components for now — direction-aware rendering is planned.

See also