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:
| Path | You provide | Compass provides | Reach for it when |
|---|---|---|---|
| SDK-rendered components (this page) | one custom-element tag + CSS | the fetch, the card UI, attribution | you want a personalized surface fast, with minimal code |
| API + custom-rendered UI | your own components and rendering | the recommendation data and a display-ready card per item | you need full control of the markup, or a framework-native component |
| PageBuilder | a PageBuilder block/feature | the integration inside your Arc XP site | you 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.
| Attribute | Values | Effect |
|---|---|---|
surface-id | any string | Opaque 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. |
count | an integer 1–50 | How 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. |
heading | any string | Section 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. |
dates | relative (default) · off | Whether 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. |
authors | on (default) · off | Whether 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-label | any string | A 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.
| Property | Default | What it controls |
|---|---|---|
--arc-compass-surface | Canvas (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-surface | currentColor | Ink on the surface: text, plus the neutral ramp (muted text, hairlines) derived from it. |
--arc-compass-accent | currentColor | Primary accent. In these components it colors the kicker above each headline. |
--arc-compass-accent-secondary | currentColor | Supporting accent. Colors the premium badge, so you can style it apart from the kicker. |
--arc-compass-on-accent | #fff | Foreground on a filled accent control. Set it when your accent is light. |
--arc-compass-font-family | inherited page font | Primary typeface — headlines and running text. |
--arc-compass-font-family-secondary | inherited page font | Secondary typeface — kickers, badges, and meta. |
--arc-compass-card-min-width | 220px | Minimum 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:
| Part | Element |
|---|---|
heading | The <h2> section title, when you set heading |
track | The <ol> the cards scroll inside |
card | One recommendation |
thumbnail | The card’s image, when the item has one |
category | The kicker above the headline, when the item has one |
premium | The premium badge, when the item is behind your paywall |
premium-icon | The padlock inside the badge — recolor it, or display:none it for a word-only badge |
headline | The line holding the title |
link | The anchor inside the headline |
byline | The author line, when the item has one or a date shows |
date | The relative published date inside the byline, when shown |
previous | The back arrow, when there is content behind |
next | The 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) imgmatches 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_exposureevent when a card becomes viewport-visible long enough to qualify (once per card), and - fires a
recommendation_clickevent 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:
| Event | Meaning |
|---|---|
arc-compass:rendered | Cards are in the DOM. |
arc-compass:empty | Nothing to show for this reader — a successful answer, not a failure. |
arc-compass:error | Something 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
- Compass Web SDK: Integration Guide — install, initialize, consent, identity, meta tags, and the render-context prerequisites (required setup).
- Compass Web SDK: Recommendation Attribution — the shared detector these components use, and attribution for host-rendered surfaces.
- Compass Web SDK: Catch Up Card — the other SDK-rendered Experience.
- Content Recommendations API Developer Guide — the API + custom-rendering path.
- PageBuilder setup guide — the PageBuilder path.