# Compass Web SDK: Recommendations Components

Source: https://dev.arcxp.com/content-recommendations/sdk/recommendations-components/

Render personalized recommendations with the SDK's arc-compass-recommendations-carousel and arc-compass-recommendations-list Experiences: their attributes, theming tokens and ::part() hooks, automatic exposure/click attribution, and loading/empty/error behavior.

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.

> **Note:**
> 
> This page covers the recommendation components specifically. Installing the SDK, initializing `ArcCompass`, resolving consent, and setting reader identity and the item/site meta tags are shared setup, documented once on the canonical [Compass Web SDK: Integration Guide](https://dev.arcxp.com/content-recommendations/sdk/integration-guide/). Complete that integration first — these Experiences render nothing until the SDK is initialized and consent is granted. The **render-context prerequisite** — the Experiences read recommendations from the browser through the same delivery token and `init()` you already configure (there is no separate key), and that read needs a resolved `site_id` (required even on a single-site tenant) and a reader identity — is documented there too, and is not repeated here.

## 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 [Compass API Developer Guide](https://dev.arcxp.com/content-recommendations/content-recommendations-api-developer-guide/), and its attribution helpers in [Recommendation Attribution](https://dev.arcxp.com/content-recommendations/sdk/attribution/). The PageBuilder path is covered in the [PageBuilder setup guide](https://dev.arcxp.com/content-recommendations/content-recommendations-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`:

```html
<!-- 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](#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 after 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.                                                                                                                                                                                                                             |

```html
<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 `is_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** and is themed through the `--arc-compass-*` CSS custom properties shared by every SDK-rendered Experience. The token table, a copy-paste brand block, and the light and dark recipe live once, in the [Compass Web SDK: Theming Reference](https://dev.arcxp.com/content-recommendations/sdk/theming/).

In these two components, `--arc-compass-accent` colors the **kicker** above each headline, and the cards are transparent and draw text in your page’s own color. The premium badge takes the same accent unless your site still sets the deprecated `--arc-compass-accent-secondary`, which keeps coloring the badge — so changing the primary accent alone does not recolor it. Remove the secondary token to have one accent drive both (see [The secondary accent](https://dev.arcxp.com/content-recommendations/sdk/theming/#the-secondary-accent)):

```css
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, and the badge when no secondary accent is set */
}
```

One token is specific to the recommendations Experiences: `--arc-compass-card-min-width`, covered in the next section.

### 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 when 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.

```css
/* 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)` ([Styling internals with ::part()](#styling-internals-with-part)) 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`.

```css
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. The [Theming Reference](https://dev.arcxp.com/content-recommendations/sdk/theming/#setting-tokens-and-parts-from-tailwind)has the `@apply` block and the `[&::part(...)]` arbitrary-variant recipe, and explains what the shadow boundary does and does not protect.

## Attribution

Attribution is **automatic**. After cards render, 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](https://dev.arcxp.com/content-recommendations/sdk/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 **after 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 consent caution in [Reserving space above the fold](#reserving-space-above-the-fold)). 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:

```html
<div class="rec-slot">
  <arc-compass-recommendations-carousel
    surface-id="homepage-rail"
    heading="Recommended for you"
  ></arc-compass-recommendations-carousel>
</div>
```

```css
.rec-slot {
  min-height: 320px;
}
.rec-slot.settled {
  min-height: 0;
}
```

```js
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.

> **Caution:**
> 
> **Also release the slot when consent is denied or never answered.** These lifecycle events fire only _after_ the reader has consented — an Experience the reader has not consented to renders nothing and dispatches none of the three (see [Attribution](#attribution) and the consent gate). If you reserve space, a reader who never grants consent would otherwise be left with a permanent gap. Nothing in the SDK collapses it for you, so handle the two non-granted outcomes yourself:
> 
> - **Denied** — your CMP reports the decision; collapse the reserved slots from that callback.
> - **Never answered** — the reader ignores the banner, so _no_ callback ever fires. Fall back to a timeout that collapses any slot still reserved, so an unanswered decision cannot leave a permanent gap.
> 
> ```js
> // Denied: the SDK has no consent-denied event of its own, so 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*. A granted decision (even one whose cards
> // are still loading) or a denial is already handled above, so bail if the reader
> // has since answered; otherwise you risk yanking a slot a slow-but-granted render
> // is about to fill.
> 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);
> ```

## 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:

```html
<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](https://dev.arcxp.com/content-recommendations/sdk/integration-guide/) — install, initialize, consent, identity, meta tags, and the render-context prerequisites (required setup).
- [Compass Web SDK: Recommendation Attribution](https://dev.arcxp.com/content-recommendations/sdk/attribution/) — the shared detector these components use, and attribution for host-rendered surfaces.
- [Compass Web SDK: Catch Up Card](https://dev.arcxp.com/content-recommendations/sdk/catch-up-card/) — the other SDK-rendered Experience.
- [Compass Web SDK: Theming Reference](https://dev.arcxp.com/content-recommendations/sdk/theming/) — the tokens, brand block, and light and dark recipe shared by every Experience.
- [Compass API Developer Guide](https://dev.arcxp.com/content-recommendations/content-recommendations-api-developer-guide/) — the API + custom-rendering path.
- [PageBuilder setup guide](https://dev.arcxp.com/content-recommendations/content-recommendations-pagebuilder-setup-guide/) — the PageBuilder path.
