# Asset component

`CalmLensAsset` shows any asset from its [asset URL](/docs/guides/delivering-assets#asset-urls), public or signed, natively in your page, in the way its moderation decision allows for the viewer: the content when it is available, a blurred placeholder and a reveal button when it is age-restricted, and an unavailable notice otherwise. [Widgets](/docs/widgets) covers installing it, styling and translating.

## Show an asset

Pass the asset URL as `src`: `client.cdn.getAssetUrl({ assetId })` for a public asset, or one your server signs with `client.cdn.signAssetUrl({ assetId })` for a private one. Set `viewerClaims.ageConfirmed` only after your app confirms the viewer passed the required age check:

**React**

```tsx
import { CalmLensAsset } from "calmlens/react";

type PostPhotoProps = {
  assetId: string;
  /** The asset URL your server sent with the post: public, or signed for a private asset. */
  src: string;
  alt: string;
  /** Width divided by height, which you store when the image is uploaded. */
  aspectRatio: number;
  ageConfirmed: boolean;
  onReveal: () => void;
};

// Your server signs a private asset's URL again with cdn.signAssetUrl once it expires.
async function refreshUrl(assetId: string): Promise<string> {
  const response = await fetch(`/api/photos/${assetId}/url`);
  if (!response.ok) throw new Error(`Couldn't refresh the photo (${response.status})`);
  return (await response.json()).url;
}

export function PostPhoto({
  assetId,
  src,
  alt,
  aspectRatio,
  ageConfirmed,
  onReveal,
}: PostPhotoProps) {
  return (
    <CalmLensAsset
      src={src}
      onExpired={() => refreshUrl(assetId)}
      viewerClaims={{ ageConfirmed }}
      onAgeConfirmRequest={onReveal}
      alt={alt}
      aspectRatio={aspectRatio}
      image={{ widths: [400, 800], sizes: "(min-width: 768px) 50vw, 100vw" }}
    />
  );
}
```

**Vue**

```vue
<script setup lang="ts">
import { CalmLensAsset } from "calmlens/vue";

const props = defineProps<{
  assetId: string;
  /** The asset URL your server sent with the post: public, or signed for a private asset. */
  src: string;
  alt: string;
  /** Width divided by height, which you store when the image is uploaded. */
  aspectRatio: number;
  ageConfirmed: boolean;
}>();
const emit = defineEmits<{ reveal: [] }>();

// Your server signs a private asset's URL again with cdn.signAssetUrl once it expires.
async function refreshUrl(): Promise<string> {
  const response = await fetch(`/api/photos/${props.assetId}/url`);
  if (!response.ok) throw new Error(`Couldn't refresh the photo (${response.status})`);
  return (await response.json()).url;
}
</script>

<template>
  <CalmLensAsset
    :src="src"
    :on-expired="refreshUrl"
    :viewer-claims="{ ageConfirmed }"
    :alt="alt"
    :aspect-ratio="aspectRatio"
    :image="{ widths: [400, 800], sizes: '(min-width: 768px) 50vw, 100vw' }"
    @age-confirm-request="emit('reveal')"
  />
</template>
```

**Svelte**

```svelte
<script lang="ts">
  import { CalmLensAsset } from "calmlens/svelte";

  type Props = {
    assetId: string;
    /** The asset URL your server sent with the post: public, or signed for a private asset. */
    src: string;
    alt: string;
    /** Width divided by height, which you store when the image is uploaded. */
    aspectRatio: number;
    ageConfirmed: boolean;
    onReveal: () => void;
  };
  let { assetId, src, alt, aspectRatio, ageConfirmed, onReveal }: Props = $props();

  // Your server signs a private asset's URL again with cdn.signAssetUrl once it expires.
  async function refreshUrl(): Promise<string> {
    const response = await fetch(`/api/photos/${assetId}/url`);
    if (!response.ok) throw new Error(`Couldn't refresh the photo (${response.status})`);
    return (await response.json()).url;
  }
</script>

<CalmLensAsset
  {src}
  onExpired={refreshUrl}
  viewerClaims={{ ageConfirmed }}
  onAgeConfirmRequest={onReveal}
  {alt}
  {aspectRatio}
  image={{ widths: [400, 800], sizes: "(min-width: 768px) 50vw, 100vw" }}
/>
```

**Plain JavaScript**

```javascript
import "calmlens/elements";

// src is the asset URL your server sent with the post: public, or signed for a private asset.
// aspectRatio is width divided by height, which you store when the image is uploaded.
export function renderPostPhoto(
  container,
  { assetId, src, alt, aspectRatio, ageConfirmed, onReveal },
) {
  const photo = document.createElement("calmlens-asset");
  photo.setAttribute("src", src);
  photo.setAttribute("alt", alt);
  photo.setAttribute("aspect-ratio", String(aspectRatio));
  photo.setAttribute("widths", "400,800");
  photo.setAttribute("sizes", "(min-width: 768px) 50vw, 100vw");
  photo.setAttribute("viewer-claims", JSON.stringify({ ageConfirmed }));
  // Your server signs a private asset's URL again with cdn.signAssetUrl once it expires.
  photo.onExpired = async () => {
    const response = await fetch(`/api/photos/${assetId}/url`);
    if (!response.ok) throw new Error(`Couldn't refresh the photo (${response.status})`);
    return (await response.json()).url;
  };
  photo.addEventListener("calmlens-age-confirm-request", onReveal);
  container.replaceChildren(photo);
}
```

The component reads the asset's [display info](/docs/api/cdn#display-info) from `{src}/info` once it is mounted, then shows what the info describes. Nothing is requested until it has a `src`.

| `availability` for the viewer | What the component shows                                                                 |
| ----------------------------- | ---------------------------------------------------------------------------------------- |
| `available`                   | The content, over its blurred placeholder until it loads                                 |
| `age-restricted`              | The placeholder with a notice, and a reveal button when you supply `onAgeConfirmRequest` |
| `unavailable`                 | An unavailable notice, without requesting the content                                    |

The CDN has the last word: a resource it refuses switches the component to the notice that fits, whatever the info said. Replace the notices with `renderUnavailable` and `renderAgeGate` (in Vue, the `unavailable` and `age-gate` slots), which receive the placeholder URL and a `confirm` callback for your own reveal button.

## Render on the server

Pass the display info as `info`, read on your server with `client.cdn.getAssetInfo(assetUrl)` (`getAssetInfo(url)` from `calmlens/app` in JavaScript), and the first render is the real element, including during server rendering. Images, websites and the age and unavailable notices then work before your scripts load; video shows its file with its poster and documents their first page's still with a link that opens them, until the page hydrates.

Without `info`, the component renders a placeholder box until the info arrives. Set `aspectRatio` (width divided by height) to give it its final size.

## Age confirmation

| Prop                        | Default | What your app supplies                                                                                                    |
| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------- |
| `viewerClaims.ageConfirmed` | `false` | `true` after the viewer passes the age check required for this content and region.                                        |
| `onAgeConfirmRequest`       | Not set | A callback that starts your age-check flow when the viewer presses **View**. Without it, the notice has no reveal button. |

Pressing **View** calls `onAgeConfirmRequest`; it does not confirm age or reveal the content itself. Keep `viewerClaims.ageConfirmed` false while the check is pending, declined, expired or identifies a minor. Once it passes, render the component with `viewerClaims.ageConfirmed` set to `true`. Reset it when the viewer or their eligibility changes.

The component adds the claims to every request it makes, including the info and responsive variants; placeholder URLs carry only `region`. Claims you pass replace any the `src` carries, and default claims are left out. Any claim makes the CDN's response private and uncacheable, so set them only when your app needs them. In plain HTML, set `viewer-claims` to a JSON object; Vue uses `:viewer-claims="{ region, ageConfirmed }"`. CalmLens accepts the assertions without verifying them; they never override a block or grant private access.

A public asset URL works for anyone who has it, with any claims. When your backend must approve each viewer, make the asset private and give the component a short-lived signed asset URL issued after your own check. See [Age-restricted delivery](/docs/guides/delivering-assets#age-restricted-assets) and [Native age checks](/docs/guides/native-age-checks).

## Viewer region

Pass the viewer's country as `viewerClaims.region`, since regional rules apply to every project. Without it, the CDN uses the country of the connection that requests the content. [Viewer region](/docs/guides/jurisdiction-rules#viewer-region) covers where the country should come from, proxies and signed URLs.

**React**

```tsx
import { CalmLensAsset } from "calmlens/react";

// Claims are optional customer assertions evaluated by the CDN.
// ageConfirmed stays false until your app completes the required age check.
export function RegionalPhoto({
  src,
  region,
  ageConfirmed,
}: {
  /** The asset URL: public, or signed by your server for a private asset. */
  src: string;
  region: string;
  ageConfirmed: boolean;
}) {
  return <CalmLensAsset src={src} viewerClaims={{ region, ageConfirmed }} alt="Uploaded photo" />;
}
```

**Vue**

```vue
<script setup lang="ts">
import { CalmLensAsset } from "calmlens/vue";

// Your app supplies optional viewer context to the CDN. src is the asset URL: public, or signed by
// your server for a private asset.
defineProps<{ src: string; region: string; ageConfirmed: boolean }>();
</script>

<template>
  <CalmLensAsset :src="src" :viewer-claims="{ region, ageConfirmed }" alt="Uploaded photo" />
</template>
```

**Svelte**

```svelte
<script lang="ts">
  import { CalmLensAsset } from "calmlens/svelte";

  // Your app supplies optional viewer context to the CDN. src is the asset URL: public, or signed
  // by your server for a private asset.
  let { src, region, ageConfirmed }: { src: string; region: string; ageConfirmed: boolean } = $props();
</script>

<CalmLensAsset {src} viewerClaims={{ region, ageConfirmed }} alt="Uploaded photo" />
```

**Plain JavaScript**

```javascript
import "calmlens/elements";

// Your app supplies optional viewer context to the CDN. src is the asset URL: public, or signed by
// your server for a private asset.
export function renderRegionalPhoto(container, { src, region, ageConfirmed }) {
  const photo = document.createElement("calmlens-asset");
  photo.setAttribute("src", src);
  photo.setAttribute("viewer-claims", JSON.stringify({ region, ageConfirmed }));
  photo.setAttribute("alt", "Uploaded photo");
  container.replaceChildren(photo);
}
```

The info's `availability` is decided for the same claims, so an asset blocked in the viewer's region shows the unavailable notice without requesting the content. When you read `info` on your server, read it for the viewer's claims too.

## What it shows

| Asset                                              | What the component shows                                                                                                  |
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| Image                                              | The image, with `srcset` widths from `image.widths`                                                                       |
| `website`                                          | The page's screenshot, with a link to the page                                                                            |
| Video                                              | A video player, with the preview as its poster                                                                            |
| Audio                                              | An audio player under the waveform, drawn from `content.rendered.peaks` with its playback progress, or the waveform image |
| PDF, or a document or presentation rendered as one | The document viewer, a page at a time                                                                                     |
| `plaintext-document`                               | The text, always shown as text                                                                                            |
| Spreadsheet                                        | The spreadsheet viewer                                                                                                    |
| Nothing displayable yet                            | The preview, or a file-type icon when there is none                                                                       |

Documents, text and spreadsheets (preview)

Documents, text and spreadsheets are shown by the [asset viewers](/docs/widgets/asset-viewers), whose options the component takes in groups: `document.initialPage` and `document.pdf`, and `spreadsheet.pageSize` and `spreadsheet.getText`. A spreadsheet's sheets are in its text child asset, not beneath its own asset URL, so pass `spreadsheet.getText` to read them; without it the viewer shows its failure notice.

Set `display="preview"` to show only the asset's still, for grids and lists: the preview, or the image itself, or a file-type icon.

Video and audio play the [browser-safe file](/docs/guides/audio-and-video#show-audio-and-video-in-your-app) CalmLens makes once the asset is decided. `video` sets `autoPlay`, `muted`, `loop` and `controls` (on by default).

### Adaptive streams

Adaptive streams (preview)

When the asset has an [adaptive stream](/docs/guides/streaming) (`content.hls`), video and audio play it instead of the file, so playback adapts to the viewer's connection. Browsers that play HLS themselves play it natively; elsewhere the component loads a small HLS player the first time it plays a stream, so pages without streams don't download it. Browsers that can play neither, and a stream that fails to play, fall back to the browser-safe file. Set `video.stream` to `false` (the `no-stream` attribute on `calmlens-asset`) to always play the file.

## Private assets

Pass a signed asset URL as `src`. Every resource the component loads, such as the rendered file, a video's poster or its stream, is a path beneath it, so one URL covers them all. Pass `onExpired`, which asks your server for a fresh signed asset URL once the one in use expires; the content loads again beneath it, and video and audio resume where they stopped. Without `onExpired`, an expired URL shows the unavailable notice. The example under [Show an asset](#show-an-asset) refreshes its URL this way.

The `calmlens-asset` element takes `onExpired` as a property.

## Options

- `image.lens` and `image.transform` apply a named lens or a direct transform to images, website screenshots and preview stills. `image.widths` and `image.sizes` add a `srcset`, so browsers fetch the smallest image that fits. Each width is a separate image transform; see [Lenses and transforms](/docs/guides/lenses).
- Images come from the asset's rendered resource, so the CDN serves the best format the browser accepts without a list of formats.
- `fit` is `contain` (the default) or `cover`, and `aspectRatio` keeps the box its final size before the info arrives.
- `lazy` (on by default) loads images only when they near the viewport; `fetchPriority` sets their priority.
- The placeholder is drawn in the browser from the info's `content.preview.thumbhash`, with no request; without one, the component loads the asset's public placeholder from the CDN.

| Callback               | Vue event             | Element event                     | When                                                              |
| ---------------------- | --------------------- | --------------------------------- | ----------------------------------------------------------------- |
| `onLoad`               | `load`                | `calmlens-load`                   | The content shown loaded                                          |
| `onError`              | `error`               | `calmlens-error`                  | The content couldn't be shown, after any refresh of the asset URL |
| `onAvailabilityChange` | `availability-change` | `calmlens-availability-change`    | The viewer's availability became known or changed                 |
| `onAgeConfirmRequest`  | `age-confirm-request` | `calmlens-age-confirm-request`    | The viewer pressed the reveal button                              |
| `onPlay`, `onEnded`    | `play`, `ended`       | `calmlens-play`, `calmlens-ended` | Video or audio started or finished                                |
| `onTimeUpdate`         | `timeupdate`          | `calmlens-timeupdate`             | Video or audio played on, with its position in seconds            |

The `calmlens-asset` element takes `src`, `display`, `viewer-claims`, `alt`, `fit`, `aspect-ratio`, `eager`, `fetch-priority`, `lens`, `transform`, `widths` (comma-separated), `sizes`, `no-controls`, `autoplay`, `muted`, `loop`, `no-stream`, `initial-page` and `page-size` as attributes, and `info`, `onExpired`, `messages`, `pdf`, `getText`, `renderUnavailable` and `renderAgeGate` as properties.

## React Native

`CalmLensAsset` from `calmlens/react-native` shows assets the same way with React Native's own views. React Native has no `srcset`, so the component measures itself and loads the smallest of `image.widths` that covers its width in device pixels. Give it a size with `aspectRatio` or a `style`; otherwise it keeps a minimum height while the image loads.

React Native has no built-in video or audio player. Pass `renderPlayer` to play them, for example with `expo-video`; it receives what to play as `src`, with `streaming: true` when it is the adaptive stream (an HLS playlist) and the rendered file otherwise, the `poster` and audio's `peaks`. Call its `onError` when playback fails, with where it was: a stream gives way to the file, and a file is retried beneath a fresh URL from `onExpired`, with `resumeFrom` saying where to resume. Without `renderPlayer`, video and audio show their preview with a button that opens the file in the device's player. Documents, text, spreadsheets and websites show their preview still with a button that opens them:

```tsx
import { CalmLensAsset, type MediaPlayerSource } from "calmlens/react-native";
import { useVideoPlayer, VideoView } from "expo-video";
import { StyleSheet } from "react-native";

function Player({ src, streaming }: MediaPlayerSource) {
  const player = useVideoPlayer({ uri: src, contentType: streaming ? "hls" : "auto" });
  return <VideoView player={player} style={StyleSheet.absoluteFill} nativeControls />;
}

export function PostMedia({ src, alt }: { src: string; alt: string }) {
  return (
    <CalmLensAsset
      src={src}
      image={{ widths: [320, 640, 1080] }}
      alt={alt}
      renderPlayer={(source) => <Player {...source} />}
    />
  );
}
```

`lazy`, `fetchPriority`, `image.sizes`, `document` and `spreadsheet` are web-only and not available in React Native.
