Assets example

How it works

One component for images, videos and lotties. It writes the markup the framework's handlers and lazy loading expect.

Asset is a component of @terrahq/astro-core. It takes one payload whose type (Image, Video or Lottie) picks the component that renders, and the rest of the payload are that component's options. You never write an <img> with data-src, an embed URL or a lottie container by hand: the component outputs them the way the JS of the framework expects to find them.

Import it once in the frontmatter of the page or component:

import { Asset } from "@terrahq/astro-core";

Each type meets a different piece of JS once the page is on screen:

  • Image meets Lazy (@terrahq/lazy, created by Core.js on every page) through the g--lazy-01 class.
  • Video meets Boostify: with isBoostify it outputs the hook and the data-video-* attributes a click handler needs to build the player on demand.
  • Lottie meets the Lotties handler (src/js/handler/lotties) through the js--lottie-element class.

The markup

The three calls, one per type. The options of each one are documented in its block below.

<!-- type picks the component; the rest of the payload is that component's options -->
<Asset payload={{ type: "Image", url: "/img/cover.webp", alt: "Cover", sizes: "100vw", lazy: true, attributes: { width: 1600, height: 900 } }} />
<Asset payload={{ type: "Video", video_type: "url", video_url: "https://www.youtube.com/watch?v=…", customClass: "g--video-01" }} />
<Asset payload={{ type: "Lottie", url: "/lotties/terraform-1.json", customClass: "c--lottie-a", name: "hero", loop: true, autoplay: true }} />

The handler

Images and videos need no handler of their own. Lotties do: Handler.js registers the Lottie library, and its config is the element alone, since every option travels on the element's data-* attributes:

this.config = ({element}) => ({
    element,
});

What is special about this handler is the assignment. Most handlers pass one group of elements with one Boostify setting; Lotties builds one group per element, reading the method from data-trigger (scroll when absent) and the distance from data-distance (30 when absent). That is how one page can mix lotties that load on click, on scroll or when seen:

this.DOM = this.updateTheDOM; // Re-query elements each time this is called
if (!this.DOM.lottieElement.length) return;

await super.assignInstances({
    elementGroups: Array.from(this.DOM.lottieElement).map((element) => ({
        elements: [element],
        config: this.config,
        boostify: {
            method: element.dataset.trigger ?? "scroll",
            distance: parseInt(element.dataset.distance, 10) || 30,
        },
    })),
});

Lotties.js then hands the element to preloadLotties from @terrahq/helpers, which reads data-path, data-animType, data-loop and data-autoplay, creates the animation with lottie-web and stores it in window.WL[data-name]. On destroy the instance is destroyed and its key removed from window.WL.

What the framework does for you

  • Lazy loading. Images with lazy: true only download when they enter the viewport. Lottie instances are built lazily per element, with the trigger the element asks for; lottie-web itself is imported once and cached by the Manager.
  • Page transitions. Every lottie instance is destroyed before swup replaces the content and rebuilt on the new page. The Lazy instance is destroyed and created again by Core.js.
  • Height changes. Lottie is registered with modifyHeight: true in resources.js, so AnchorTo fires Lottie:load before it scrolls. The handler answers by re-running the assignment for every lottie element on the page, which builds the ones in the viewport, so an anchor below them lands in the right place.
  • Unique names. preloadLotties throws if two elements share a data-name, because window.WL is keyed by it. Give every lottie on a page its own name.

Image

An img with srcset, sizes, a fixed aspect ratio and, when asked, lazy loading.

Default type: "Image"lazy: trueattributes: { width, height }

url is the picture. Give attributes.width and attributes.height and two things happen: the component writes style="aspect-ratio: width / height" on the element, so the page keeps the room for the picture before it loads (no layout shift), and it generates a srcset from the width. The candidates are the width itself plus the standard widths 1300, 1024, 810 and 580 that fit under it, each as url?w=…. Pass sizes whenever that happens: without it the component prints a red "No sizes attribute provided" warning next to the image. A sizes that is a single px value, such as "100px", switches to exactly that width plus a 2x candidate for high-DPI screens.

lazy: true moves the picture to data-src and data-srcset, puts placeholderUrl in src, and adds the lazyClass, g--lazy-01 by default. That class is the selector of the Lazy instance Core.js creates on every page: when the image enters the viewport, Lazy copies data-src and data-srcset into src and srcset and adds g--lazy-01--is-loaded (or g--lazy-01--is-error), which your SCSS can use to fade the picture in. attributes.lazy, when present, wins over lazy.

Two more details worth knowing: alt is cleaned before it is written (lowercased, dashes turned into spaces, a file extension dropped, first letter capitalised), and URLs served by the Sanity image CDN get auto=format appended so the CDN serves WebP where supported. SVGs never get srcset, sizes or URL params.

<Asset payload={{
  type: "Image",
  url: "http://placeholder.terrahq.com/img-16by9.webp",
  placeholderUrl: "/img/terra-placeholder.webp",
  alt: "Sixteen by nine placeholder",
  sizes: "(max-width: 768px) 100vw, 800px",
  customClass: "u--width-100",
  lazy: true,
  attributes: { width: 1600, height: 900 },
}} />
<!-- What the component renders. The real picture sits in data-src and data-srcset until Lazy swaps it in -->
<img
  src="/img/terra-placeholder.webp"
  data-src="http://placeholder.terrahq.com/img-16by9.webp"
  srcset="http://placeholder.terrahq.com/img-16by9.webp?w=1600 1600w, …?w=1300 1300w, …?w=1024 1024w, …?w=810 810w, …?w=580 580w"
  data-srcset="http://placeholder.terrahq.com/img-16by9.webp?w=1600 1600w, …?w=1300 1300w, …?w=1024 1024w, …?w=810 810w, …?w=580 580w"
  sizes="(max-width: 768px) 100vw, 800px"
  alt="Sixteen by nine placeholder"
  class="u--width-100 g--lazy-01"
  decoding="auto"
  fetchpriority="auto"
  style="aspect-ratio: 1600 / 900;"
  width="1600"
  height="900"
/>

Options

OptionDefaultWhat it does
urlrequiredThe image URL
placeholderUrlnoneWhat goes in src while the image is lazy and not yet loaded
alt"Asset Not Found"The alt text, cleaned as described above
sizesnoneThe sizes attribute. Required whenever a srcset is generated; a single px value gives a fixed-width srcset
srcsettruefalse leaves the generated srcset and data-srcset out
customClassnoneClasses written on the <img>
lazyfalseLazy loading: data-src and data-srcset instead of src and srcset, plus the lazy class
lazyClass"g--lazy-01"The class added when lazy. Keep the default unless the Lazy selector in Main.js changes too
aspectRatiotrueWrites aspect-ratio: width / height inline when both are in attributes
decodingAsync"auto"The decoding attribute: "sync", "async" or "auto". true means "async", false means "auto"
fetchPriority"auto"The fetchpriority attribute: "high", "low" or "auto". true means "high", false means "auto"
attributesnoneAny other attribute of the <img>. width drives the srcset, width and height the aspect ratio, lazy overrides the option above
Live example
type: "Image"lazy: trueattributes: { width, height }
Sixteen by nine placeholder

Video

A YouTube or Vimeo embed, a video file, or only the data for a player built on click.

Variation type: "Video"video_type: "url" | "file"isBoostify

video_type says what video_url or video_file is. With "url" the component reads the id out of a YouTube (watch?v= or youtu.be/) or Vimeo page URL and renders an <iframe> on the embed URL, with allowfullscreen and the usual allow list. With "file" it renders a <video controls> with poster_url as poster and one <source> typed by format. Either way the player is the customClass__media element inside a customClass wrapper, so one SCSS component sizes both; g--video-01 on this page gives the media a 16:9 box.

<!-- video_type "url": a YouTube or Vimeo page URL, turned into its embed URL -->
<Asset payload={{
  type: "Video",
  video_type: "url",
  video_url: "https://www.youtube.com/watch?v=aqz-KE-bpKQ",
  customClass: "g--video-01",
  width: 1920,
  height: 1080,
}} />

<!-- Output -->
<div class="g--video-01">
  <iframe src="https://www.youtube.com/embed/aqz-KE-bpKQ" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen width="1920" height="1080" class="g--video-01__media"></iframe>
</div>
<!-- video_type "file": a <video> with controls, the poster and one <source> -->
<Asset payload={{
  type: "Video",
  video_type: "file",
  video_file: "https://placeholder.terrahq.com/1-min-video.mp4",
  format: "mp4",
  poster_url: "http://placeholder.terrahq.com/img-16by9.webp",
  customClass: "g--video-01",
  width: 1920,
  height: 1080,
}} />

<!-- Output -->
<div class="g--video-01">
  <video controls class="g--video-01__media" poster="http://placeholder.terrahq.com/img-16by9.webp" width="1920" height="1080">
    <source src="https://placeholder.terrahq.com/1-min-video.mp4" type="video/mp4" />
    Your browser does not support the video tag.
  </video>
</div>

isBoostify

The embed above loads the YouTube player, and everything it brings, as soon as the page renders. isBoostify: true is the alternative for pages that should stay light: the component renders no player at all, only a wrapper with the boostifyClass hook (js--boostify-video by default) and three data attributes, data-video-src (the embed URL, or the file), data-video-type and data-video-poster. What you put in the component's slot, a poster and a play button, is what the visitor sees. The player is built on click through Boostify, which has videoPlayer for files and videoEmbed for iframes: the Modal handler's video variation does exactly that with boostify.videoPlayer. Punky does not register a handler for js--boostify-video yet, so a project that uses this mode writes one, reading those three attributes.

<!-- isBoostify: no player in the HTML, only the data a click handler needs. The slot is your poster -->
<Asset payload={{
  type: "Video",
  isBoostify: true,
  boostifyClass: "js--boostify-video",
  video_type: "url",
  video_url: "https://www.youtube.com/watch?v=aqz-KE-bpKQ",
  poster_url: "/img/poster.webp",
  customClass: "c--video-a",
}}>
  <img class="c--video-a__media" src="/img/poster.webp" alt="Poster" />
  <button class="c--video-a__artwork" aria-label="play video">…</button>
</Asset>

<!-- Output -->
<div class="js--boostify-video c--video-a" data-video-src="https://www.youtube.com/embed/aqz-KE-bpKQ" data-video-type="url" data-video-poster="/img/poster.webp">
  <img class="c--video-a__media" src="/img/poster.webp" alt="Poster" />
  <button class="c--video-a__artwork" aria-label="play video">…</button>
</div>

Note that isBoostify must be set explicitly: a payload without it renders the embed or the file. The slot is only rendered when poster_url is set.

Options

OptionValuesWhat it does
video_type"url" or "file"Embed from a page URL, or a <video> from a file
video_urlYouTube or Vimeo page URLTurned into the embed URL. Any other URL renders the <video> branch with no source
video_filepath or URL of the fileThe <source> of the <video>, or data-video-src with isBoostify
formate.g. "mp4"The type="video/…" of the source. Required with "file"
poster_urlimage URLThe poster of the <video>; data-video-poster with isBoostify, where it also enables the slot
customClassclass nameThe wrapper's class. The player gets customClass__media
width, heightnumbersThe width and height attributes of the iframe or video
isBoostifytrue or unsetRender only the hook, the data attributes and the slot, for a player built on click
boostifyClass"js--boostify-video" by conventionThe hook class written with isBoostify. Set it explicitly: the default only applies when no payload is given
hasPosterImgbooleanListed in the JSDoc, not read by the template in 0.1.16
Live example
type: "Video"video_type: "url" | "file"isBoostify

Lottie

A div the Lotties handler turns into a lottie-web animation. Autoplay and loop are attributes.

Variation type: "Lottie"loop: trueautoplay: truename

The component renders a <div> with the js--lottie-element hook and one data-* per option: the JSON in data-path, the renderer in data-animType, data-loop and data-autoplay as "true" or "false", and the instance name in data-name. Nothing else: the animation is created by the handler when the element is reached, and lottie-web is only imported then.

name matters more than it looks. It is the key under which preloadLotties stores the animation in window.WL, which is how anything else on the page, the LottiesOnScroll handler below for instance, finds it to play or pause it. It must be unique per page: the default "lottie-asset" works for one lottie, not for two. Give the element a customClass with a size, c--lottie-a here, since the div is empty until the SVG is rendered into it.

The Asset does not expose the trigger. The handler reads data-trigger and data-distance from the element, but the Lottie component of astro-core 0.1.16 has no option that writes them, so every Asset lottie loads with the scroll method. The next block writes the element by hand to get the other two.

<Asset payload={{
  type: "Lottie",
  url: "/lotties/terraform-1.json",
  customClass: "c--lottie-a",
  animType: "svg",
  loop: true,
  autoplay: true,
  name: "assetLottie",
}} />

<!-- Output: the hook of the Lotties handler plus the attributes preloadLotties reads -->
<div class="js--lottie-element c--lottie-a" data-path="/lotties/terraform-1.json" data-animType="svg" data-loop="true" data-autoplay="true" data-name="assetLottie"></div>

Options

OptionDefaultWhat it does
urlrequiredThe lottie JSON, written to data-path
customClassnoneClasses written next to js--lottie-element
idnoneThe id attribute of the div
animType"svg"The lottie-web renderer: "svg", "canvas" or "html". Written to data-animType
looptrueWritten to data-loop
autoplaytrueWritten to data-autoplay. The JSDoc of 0.1.16 says false in one place; the code defaults to true
name"lottie-asset"Written to data-name: the key of the animation in window.WL. Unique per page
Live example
type: "Lottie"loop: trueautoplay: truename

Lottie triggers

The same element written by hand, plus data-trigger: load on click, on scroll, or when half of it is visible.

Variation data-trigger="click | scroll | observer"data-distance

The Lotties handler asks Boostify for one of its three methods per element, which is what CoreHandler.assignInstances does with boostify.method. The three examples below are the markup the Asset outputs with two attributes added. They are not supported by the Asset component yet (astro-core 0.1.16), so until they are, this is the markup to write when a lottie must not load on scroll.

  • click: a click listener on the element. Nothing is imported until the first click; then lottie-web is loaded, the instance built and, with data-autoplay="true", played. With autoplay off, the click would only create a paused animation.
  • scroll, the default: if the element is in the viewport when the page loads, the instance is built right away. Otherwise Boostify waits for the page to have scrolled data-distance pixels (window.scrollY, 30 by default) and builds it then. A page whose height changes (an accordion opening) re-runs the viewport check through CoreHandler:heightModified.
  • observer: an IntersectionObserver with a 0.5 threshold. The first time half of the element is visible the instance is built; the following times the handler calls play() on it, once.
<!-- Nothing is imported until the element is clicked. autoplay true: it plays as soon as it is built -->
<div class="c--lottie-a js--lottie-element" data-path="/lotties/terraform-1.json" data-animType="svg" data-loop="false" data-autoplay="true" data-name="lottieOnClick" data-trigger="click"></div>
<!-- The default. In the viewport at load: built right away. Otherwise: built once the page has scrolled data-distance pixels -->
<div class="c--lottie-a js--lottie-element" data-path="/lotties/terraform-2.json" data-animType="svg" data-loop="true" data-autoplay="true" data-name="lottieOnScroll" data-trigger="scroll" data-distance="30"></div>
<!-- Built the first time half of the element is visible -->
<div class="c--lottie-a js--lottie-element" data-path="/lotties/terraform-3.json" data-animType="svg" data-loop="false" data-autoplay="true" data-name="lottieOnObserver" data-trigger="observer"></div>
Live example
data-trigger="click | scroll | observer"data-distance

Click: the square loads and plays once on click.

Scroll: built on load or after 30px of scroll, loops.

Observer: built and played once when half visible.

Lottie on scroll

A second handler on the same element: the animation runs while it is on screen and pauses when it leaves.

Variation js--lottie-on-scrolldata-name

LottiesOnScroll (src/js/handler/lotties-on-scroll) does not create animations. It takes data-name from its element, looks the animation up in window.WL and creates a GSAP ScrollTrigger on the element, taken from the Manager, that calls play() when the element enters the viewport and pause() when it leaves, in both directions. So the element needs both hooks: js--lottie-element for the Lotties handler, which creates the animation and registers it under the name, and js--lottie-on-scroll for this one. With data-autoplay="true" the animation runs as soon as it exists, whichever handler gets there first, and from then on the ScrollTrigger pauses it off screen and resumes it on the way back.

<!-- Both hooks on the same element: Lotties creates the animation, LottiesOnScroll plays and pauses it by data-name -->
<div class="c--lottie-a js--lottie-element js--lottie-on-scroll" data-path="/lotties/terraform-2.json" data-animType="svg" data-loop="true" data-autoplay="true" data-name="lottieInView"></div>

The config passes the Manager, for ScrollTrigger, and the name:

this.config = ({element}) => ({
    element,
    Manager: this.Manager,
    lottieName: element.getAttribute('data-name'),
});

The class only ever touches the animation through a lookup, because the Lotties handler creates it asynchronously and it may not exist yet when the trigger fires:

return window.WL && window.WL[this.lottieName];
}

/**
 * Safely play the Lottie animation.

The ScrollTrigger starts when the top of the element is 80px above the bottom of the viewport. It is created with markers on, in blue and red, which is what you see in the live example; drop the markers option in a project.

this.scrollTrigger = this.ScrollTrigger.create({
  trigger: this.DOM.element,

  // Start the animation only when the element
  // is almost fully inside the viewport
  start: "top bottom-=80",

  /**
   * When debugging multiple ScrollTriggers,
   * always use an `id`.
   *
   * This label will appear next to the markers
   * and makes it much easier to understand
   * which trigger is firing.
   */
  id: `Lottie-${this.lottieName}`,

  // Markers are useful during development.
  // Remove them in production.
  markers: {
    startColor: "blue",
    endColor: "red",
    indent: 20,
  },

  onEnter: () => this.play(),
  onLeave: () => this.pause(),
  onEnterBack: () => this.play(),
  onLeaveBack: () => this.pause(),
});

The handler assigns its instances with the scroll method at a distance of 120 and destroys them before every page transition, which kills the trigger. The animation itself belongs to the Lotties handler, which destroys it on its side.

Live example
js--lottie-on-scrolldata-name

Options reference

The options of each Asset type are in its block. Here: what the two lottie handlers read from the HTML.

Reference

Lotties (js--lottie-element)

AttributeDefaultWhat it does
data-patha placeholder lottieThe JSON of the animation. The Asset writes url here
data-animTypesvgThe lottie-web renderer: svg, canvas or html
data-looptrueAnything but the string "false" loops
data-autoplaytrueAnything but the string "false" plays as soon as the animation is created
data-namelottie in preloadLotties, myLottie in Lotties.jsThe key in window.WL. Unique per page, or preloadLotties throws
data-triggerscrollThe Boostify method: click, scroll or observer. Not written by the Asset
data-distance30Pixels the page must have scrolled before a scroll instance is built. Not written by the Asset

The handler also listens for Lottie:load through the event system: any handler can fire it to re-run the assignment, and AnchorTo does, since Lottie is registered with modifyHeight: true.

LottiesOnScroll (js--lottie-on-scroll)

AttributeDefaultWhat it does
data-namerequiredThe name of an animation created by the Lotties handler, looked up in window.WL. Without it the class warns and does nothing

The config in Handler.js passes element, Manager and lottieName (read from the same attribute). The instance exposes play() and pause(), both safe to call before the animation exists.