@xodesign/xoframe
v0.14.1
Published
Zero-CLS progressive media loading in a ~2.9 KB core. Reserve space, show placeholder, load smart, reveal smoothly. Helps CLS, LCP and INP.
Maintainers
Readme
@xodesign/xoframe
Zero-CLS progressive media loading for modern websites.
Make image-heavy pages feel instant: reserve space → show placeholder → choose priority → load media → reveal smoothly → prevent layout shift.
- ~2.9 KB gzip core, zero dependencies, framework-agnostic (every extra feature is a separate entry point you only pay for if you import it)
- Native browser features first:
IntersectionObserver,loading,decoding,fetchpriority,aspect-ratio - LCP-aware: never lazy-loads your hero image
- Zero CLS with correct
width/heightordata-ratio - Images,
<picture>/srcset, background images, content blocks - SEO/noscript-safe: real
<img>tags stay in the DOM, nothing is hidden without JS
Install
npm install @xodesign/xoframeimport { XOframe } from '@xodesign/xoframe'
import '@xodesign/xoframe/styles.css'
XOframe.init()Or via CDN (add data-xo-auto for automatic init):
<link rel="stylesheet" href="https://unpkg.com/@xodesign/xoframe/dist/xoframe.css">
<script src="https://unpkg.com/@xodesign/xoframe/dist/xoframe.min.js" data-xo-auto></script>Quick start
<img
data-xo
data-src="image-large.jpg"
data-color="#d8c6a4"
width="1200"
height="800"
alt="Example image"
>The image gets a dominant-color placeholder, loads when it approaches the viewport, and fades in — without moving the layout by a single pixel.
Responsive images
<picture data-xo>
<source data-srcset="image-mobile.webp" media="(max-width: 768px)" type="image/webp">
<source data-srcset="image-desktop.webp" type="image/webp">
<img data-src="image.jpg" width="1200" height="800" alt="Responsive image">
</picture>4-corner gradient placeholder
A blur-like preview from ~30 bytes of markup — no decoder, no canvas, zero JS cost. Pass up to four corner colors (top-left, top-right, bottom-right, bottom-left):
<img data-xo data-src="image.jpg" data-gradient="#7a6a52,#b8a88f,#4f5d6b,#8fa3b8"
width="1200" height="800" alt="">ThumbHash / BlurHash placeholders
Hash placeholders live in separate entry points — import only the one you use, the other never reaches your bundle (and the core stays decoder-free):
<img data-xo data-thumbhash="5CgGNYp1d4eAiIh3h3iId3B0B/eI" data-src="image.jpg" width="1200" height="800" alt="">
<img data-xo data-blurhash="LEHV6nWB2yk8pyo0adR*.7kCMdnj" data-src="image.jpg" width="1200" height="800" alt="">import { XOframeThumbhash } from '@xodesign/xoframe/thumbhash' // ~1.5 KB, alpha support, no canvas
// or
import { XOframeBlurhash } from '@xodesign/xoframe/blurhash' // ~1 KB, resolution/punch options
XOframeThumbhash.init() // render placeholders first…
XOframe.init() // …then start the loaderAn invalid hash falls back to data-gradient/data-color — the page never breaks. The decoded
placeholder is cleared shortly after the real image loads to free memory. The reference MIT
decoders (evanw/thumbhash, wolt/blurhash) are bundled at build time, so the package keeps zero
runtime dependencies.
Tiny blurred placeholder (LQIP)
Put your thumbnail in src — it shows immediately and the full image paints over it:
<img data-xo src="tiny-blur.jpg" data-src="image-large.jpg" width="1200" height="800" alt="">Hero images / LCP
With lcpAware: true (default) the first large above-the-fold image is loaded eagerly with fetchpriority="high". To mark it explicitly:
<img data-xo data-xo-priority="high" data-src="hero.jpg" width="1600" height="900" alt="">Auto mode — zero markup
Existing site, CMS output, no way to change templates? One call manages every plain <img> on the page:
XOframe.auto()- a wrongly lazy-loaded above-the-fold hero is upgraded to
loading="eager"+fetchpriority="high"; - below-the-fold images get native
loading="lazy"; - every image gets reveal classes and
xo:*events; - nothing else in your HTML changes. Exclude an image with
data-xo-skip.
INP guard (built in)
Classic lazy-loaders scan every <img> in one synchronous pass — on a gallery with hundreds of
images that is a long task that blocks interaction and hurts INP. XOframe processes elements
in chunks, yielding to the main thread between them via scheduler.yield() (with a setTimeout
fallback). The first chunk runs synchronously so the LCP hero is still found immediately; the long
tail is spread across later tasks. On a 300-image page this cut the scan's synchronous work from
~148 ms to ~2 ms in testing.
XOframe.init({ batchSize: 50 }) // default; set 0 to force fully synchronous scanningResilient sources (data-fallback)
If the primary source fails, XOframe tries each comma-separated fallback before giving up — format failover (AVIF → WebP → JPG) or a backup CDN:
<img data-xo data-src="hero.avif" data-fallback="hero.webp, hero.jpg" width="1600" height="900" alt="">.xo-error is only set once the whole chain is exhausted.
Auto-preconnect
When a priority image (data-xo-priority="high" / the detected LCP) is served from a cross-origin
host, XOframe injects a <link rel="preconnect"> so the connection is warm before the request —
faster LCP. On by default; disable with preconnect: false.
Video and iframes
The same pipeline handles <video> and <iframe>:
<video data-xo data-poster="poster.jpg" data-src="clip.mp4" data-ratio="16/9" controls muted playsinline></video>
<iframe data-xo data-src="https://example.com/widget" data-ratio="16/9" title="Widget"></iframe>The poster reserves the box and stays visible; the file loads near the viewport.
Embed facades — the biggest byte win
A YouTube embed costs ~1 MB of JavaScript before the user presses play. The facade module
(@xodesign/xoframe/embed, separate ~1.7 KB file, never part of the core bundle) shows a poster +
play button and injects the real iframe only on click, with automatic preconnect on hover:
<div data-xo-embed="youtube" data-video="aqz-KE-bpKQ" data-title="Big Buck Bunny"></div>
<div data-xo-embed="vimeo" data-video="76979871" data-poster="poster.jpg"></div>
<!-- non-video providers use data-embed-id -->
<div data-xo-embed="maps" data-embed-id="Eiffel Tower, Paris"></div>
<div data-xo-embed="spotify" data-embed-id="track/4cOdK2wGLETKBW3PvgPWqT"></div>
<div data-xo-embed="calendly" data-embed-id="acme/intro"></div>
<div data-xo-embed data-embed-src="https://example.com/embed" data-poster="poster.jpg"></div>Providers: youtube, vimeo, maps, spotify, calendly, or a generic data-embed-src.
import { XOframeEmbed } from '@xodesign/xoframe/embed'
XOframeEmbed.init()Keyboard-accessible (role="button", Enter/Space), YouTube goes through youtube-nocookie.com,
fires a bubbling xo:embed event on activation.
Background video (play only on screen)
@xodesign/xoframe/video (~0.9 KB, separate entry) autoplays a muted background video only while
it is in the viewport and pauses it when scrolled away — saving CPU, battery and main-thread work.
It also lazily applies data-src/data-poster on first view.
<video data-xo-video muted loop playsinline poster="poster.jpg" data-src="clip.mp4"></video>import { XOframeVideo } from '@xodesign/xoframe/video'
XOframeVideo.init() // { threshold: 0.25, respectReducedMotion: true }Autoplay rejections (browser policy) are swallowed; reduced-motion users get the poster only.
Intent strategy
data-xo-strategy="intent" loads media on the first hover/focus/touch instead of scroll —
ideal for tab panels, dropdown previews and galleries behind interaction:
<img data-xo data-xo-strategy="intent" data-src="preview.jpg" width="1200" height="600" alt="">Masonry gallery — zero CLS
@xodesign/xoframe/masonry (~1.3 KB, separate entry) computes an Unsplash/Pinterest-style
layout from each item's known aspect ratio (width/height or data-ratio) — positions
are final before any image loads, so the grid never reflows:
<div data-xo-masonry>
<img data-xo data-src="1.jpg" width="1200" height="800" alt="">
<img data-xo data-src="2.jpg" width="900" height="1200" alt="">
</div>import { XOframeMasonry } from '@xodesign/xoframe/masonry'
XOframeMasonry.init('[data-xo-masonry]', { minColumnWidth: 260, gap: 16 })Responsive column count (via ResizeObserver), shortest-column packing, and XOframeMasonry.layout()
to re-flow after appending items (load-more). Fires a bubbling xo:layout event.
Skeleton presets
@xodesign/xoframe/skeleton (~2 KB, separate entry) fills a block with an animated placeholder
that occupies the same box the real content will — zero layout shift when content swaps in:
<section data-xo-skeleton="cards"></section>
<section data-xo-skeleton="article" data-xo-min-height="480px"></section>import { XOframeSkeleton } from '@xodesign/xoframe/skeleton'
XOframeSkeleton.init()Presets: hero, cards, products, gallery, article, testimonial, profile, video,
map, pricing, media-text. The skeleton fades out when the block fires xo:visible/xo:reveal,
when you call XOframeSkeleton.reveal(el), or after the autoHide safety timeout. Style it with
--xo-skeleton-color, --xo-skeleton-shine, --xo-skeleton-radius. Respects reduced motion.
Content-visibility manager
@xodesign/xoframe/visibility (~1 KB, separate entry) skips rendering work for off-screen blocks
with content-visibility: auto while reserving their height, so the scrollbar never jumps:
<section data-xo-visibility data-xo-intrinsic-size="800px">…</section>
<section data-xo-visibility data-xo-intrinsic-size="600px 900px">…</section>import { XOframeVisibility } from '@xodesign/xoframe/visibility'
XOframeVisibility.init({ debug: true }) // warns when a reserved height is far offFeature-detected (safe no-op where unsupported). Opt a critical/above-the-fold block out with
data-xo-visibility="off".
XOlightbox
@xodesign/xoframe/lightbox (~2.1 KB, separate entry) is a lightbox built on the native
<dialog> element (free focus trap, Esc, backdrop, aria-modal) and the View Transitions API
for a smooth zoom — vs. ~50 KB for Fancybox:
<a href="full-1.jpg" data-xo-lightbox="portfolio"><img src="thumb-1.jpg" alt="One"></a>
<a href="full-2.jpg" data-xo-lightbox="portfolio" data-caption="Two"><img src="thumb-2.jpg"></a>
<!-- or straight on an image -->
<img data-xo-lightbox data-full="full.jpg" src="thumb.jpg" alt="Caption">import { XOlightbox } from '@xodesign/xoframe/lightbox'
XOlightbox.init()Images sharing a data-xo-lightbox value form a group with prev/next and a counter. Keyboard:
Esc to close, ←/→ to navigate. Captions from data-caption or the thumbnail's alt. Neighbors
are preloaded. Reduced motion disables the transition. XOlightbox.close() / .destroy() too.
XOcarousel — horizontal & vertical
@xodesign/xoframe/carousel (~3.2 KB, separate entry) lets the browser do the hard part:
native CSS scroll-snap provides momentum, touch and trackpad scrolling, so there is no animation
loop, no transform math and no scroll listener. Active tracking uses IntersectionObserver,
keeping INP low.
| | XOcarousel | Embla | keen-slider | Splide | Swiper | | --- | --- | --- | --- | --- | --- | | Size (gzip) | ~3.2 KB | ~7 KB | ~7 KB | ~27 KB | ~47 KB (~20 tree-shaken) | | Arrows/dots/ARIA included | yes | no (headless) | partial | yes | yes | | Scrolls without JS | yes (opt-in) | no | no | no | no | | WCAG 2.2.2 pause control | yes | no | no | no | no |
<div data-xo-carousel>
<div><img data-xo data-src="1.jpg" width="1200" height="800" alt=""></div>
<div><img data-xo data-src="2.jpg" width="1200" height="800" alt=""></div>
</div>import { XOcarousel } from '@xodesign/xoframe/carousel'
XOcarousel.init('[data-xo-carousel]', {
axis: 'x', // or 'y' for a vertical carousel
slidesPerView: 1, gap: 16,
height: '420px', // vertical track height
arrows: true, dots: true, loop: true,
autoplay: 0, // ms; 0 = off (the accessible default)
drag: true, // mouse drag; touch/trackpad stay native
announce: true, // live-region slide announcements
label: 'Photos' // don't include the word "carousel"
})Returns instances with next(), prev(), goTo(i), index, destroy(), and fires a bubbling
xo:slide event.
Accessibility. Full carousel pattern: role="region" + aria-roledescription="carousel",
labelled slides, arrow-key navigation, and a polite live region announcing “Slide 2 of 5”.
Autoplay is off by default; when enabled it ships a real pause/play button, pauses on hover and
focus, and — as WCAG 2.2.2 requires — once the user presses pause it stays paused. Reduced motion
disables autoplay and smooth scrolling.
Scrolling without JavaScript (opt-in). Because the scrolling is pure CSS, link the stylesheet and pre-render the track, and the carousel is a usable snapping strip before (or without) JS — which then only upgrades it with arrows, dots and autoplay:
<link rel="stylesheet" href="/node_modules/@xodesign/xoframe/dist/xoframe-carousel.css" data-xo-carousel-css>
<div data-xo-carousel>
<div class="xo-car-track"><div>slide</div><div>slide</div></div>
</div>loop wraps at the ends rather than cloning slides.
Web font stability
@xodesign/xoframe/fonts (~1.2 KB, separate entry) tackles the #2 cause of CLS after images:
a web font swapping in with different metrics than the fallback reflows every line of text. It
registers a metric-adjusted fallback @font-face (size-adjust measured at runtime, or from
precomputed metrics) so the fallback occupies the same space as the web font — the swap shifts
nothing. It also preloads fonts and flags <html> once fonts are ready:
import { XOframeFonts } from '@xodesign/xoframe/fonts'
XOframeFonts.init({
fonts: [{
family: 'Inter',
fallback: 'Arial, sans-serif',
selector: 'body',
preload: '/fonts/inter.woff2',
// optional — supply precomputed metrics (e.g. from Fontaine) for pixel-perfect zero CLS:
sizeAdjust: '107%', ascentOverride: '90%', descentOverride: '22%'
}]
})Without precomputed metrics the width-based size-adjust is measured at runtime (handles the
dominant reflow); for pixel-perfect vertical metrics pass ascentOverride/descentOverride.
.xo-fonts-loaded is added to <html> on document.fonts.ready for a controlled reveal.
Background images
<div data-xo-bg data-bg="section.jpg" data-ratio="16/9" data-color="#e9e2d8"></div>Responsive sources per breakpoint (mobile <768, tablet ≥768, desktop ≥1024; data-bg is the fallback):
<div data-xo-bg
data-bg-mobile="s-mobile.jpg" data-bg-tablet="s-tablet.jpg" data-bg-desktop="s-desktop.jpg"
data-bg="s-desktop.jpg" data-ratio="16/9" data-color="#e9e2d8"></div>Content blocks
<section data-xo-block>…</section>Gets .xo-visible (and .is-xo-visible) when it enters the viewport. The reveal animation is yours to define in CSS — the library never hides content from search engines.
Options
XOframe.init({
imageSelector: '[data-xo]',
bgSelector: '[data-xo-bg]',
blockSelector: '[data-xo-block]',
rootMargin: '300px', // preload distance for media
blockRootMargin: '0px', // reveal offset for blocks
threshold: 0.01,
fade: true,
lcpAware: true,
nativeLazy: true, // add loading="lazy" to native-src images
debug: false, // warn about images that may cause CLS
auto: false, // zero-markup mode: manage plain <img> tags too
networkAware: true, // Save-Data / 2G: no fade, load only in-viewport
batchSize: 50, // INP guard: elements per task (0 = fully sync)
preconnect: true, // warm the LCP image's cross-origin host
onBeforeLoad: (el) => {},
onLoad: (el) => {},
onError: (el, error) => {},
onReveal: (el) => {},
onVisible: (el) => {}
})Methods
XOframe.init(options)
XOframe.refresh() // scan for new elements (after DOM updates)
XOframe.load(element) // force-load one element
XOframe.loadAll() // force-load everything pending
XOframe.loadInside('#modal') // load all media inside a container
XOframe.observe(element) // observe a single new element
XOframe.unobserve(element)
XOframe.pause() // stop loading (keeps queue)
XOframe.resume()
XOframe.destroy()Events
All events bubble, so you can listen on document:
document.addEventListener('xo:load', (e) => console.log(e.detail.element))xo:beforeload · xo:load · xo:error · xo:reveal · xo:visible
Attributes
| Attribute | Applies to | Meaning |
| --- | --- | --- |
| data-xo | img, picture, video, iframe | Managed media element |
| data-poster | video | Deferred poster image |
| data-src / data-srcset | img, source | Deferred sources |
| data-sizes | img | sizes value, or auto to compute from layout width |
| data-fallback | img | Comma-separated backup sources tried on error |
| data-ratio | any | Aspect ratio, e.g. 16/9 |
| data-color | any | Dominant-color placeholder |
| data-gradient | any | 4-corner gradient placeholder (#c1,#c2,#c3,#c4) |
| data-thumbhash / data-blurhash | any | Hash placeholders (thumbhash/blurhash modules) |
| data-bg-mobile/tablet/desktop | data-xo-bg | Responsive background sources |
| data-xo-priority="high" | img | Eager + fetchpriority=high |
| data-xo-strategy="manual" | any | Load only via API |
| data-xo-strategy="intent" | any | Load on first hover/focus/touch |
| data-xo-embed + data-video | any | Click-to-load embed facade (embed module) |
| data-xo-masonry | container | Zero-CLS masonry gallery (masonry module) |
| data-xo-skeleton | block | Animated placeholder preset (skeleton module) |
| data-xo-visibility + data-xo-intrinsic-size | block | content-visibility (visibility module) |
| data-xo-lightbox | a, img | Lightbox trigger; shared value = group (lightbox module) |
| data-xo-bg + data-bg | any | Lazy background image |
| data-xo-block | any | Content block reveal |
CSS
State classes: .xo, .xo-loading, .xo-loaded, .xo-error, .xo-visible, .xo-placeholder, .xo-lqip, .xo-bg, .xo-block.
Custom properties: --xo-duration, --xo-ease, --xo-radius, --xo-bg-size, --xo-bg-position.
prefers-reduced-motion is respected out of the box.
Debug overlay — Core Web Vitals guard
A separate dev-only module (never part of the core bundle) that shows live CLS and LCP in an on-page panel, flashes the elements that caused each layout shift, flags images without dimensions, and warns when your LCP image was lazy-loaded:
import { XOframeDebug } from '@xodesign/xoframe/debug'
XOframeDebug.init()Or as a classic script: <script src=".../xoframe-debug.min.js" data-xo-auto></script>
React
@xodesign/xoframe/react (~1.7 KB, separate entry, React is an optional peer dependency) provides
thin wrappers — no logic is duplicated, they render the same data-xo* markup and register with
the core:
import { XOframe } from '@xodesign/xoframe'
import { XOImage, XOBackground, XOBlock } from '@xodesign/xoframe/react'
XOframe.init() // once, at app startup
<XOImage src="/hero.jpg" width={1600} height={900} priority alt="Hero" />
<XOImage src="/photo.jpg" color="#d8c6a4" fallback="/photo.webp, /photo.jpg" width={1200} height={800} alt="" />
<XOBackground bg="/section.jpg" bgMobile="/m.jpg" ratio="16/9" color="#e9e2d8" />
<XOBlock>…revealed on scroll…</XOBlock>Components mounted before init() are picked up by its initial scan; ones mounted later register
themselves on mount and release on unmount.
Production vitals reporting
The debug overlay is dev-only. @xodesign/xoframe/vitals (~1 KB, separate entry) is the
production-safe reporter — it emits LCP, CLS, INP (plus FCP, TTFB) with Google ratings to your
callback, so you can send field data to analytics:
import { XOframeVitals } from '@xodesign/xoframe/vitals'
XOframeVitals.init({
onReport: (m) => navigator.sendBeacon('/vitals', JSON.stringify(m)) // { name, value, rating }
})Values finalize when the page is hidden/unloaded (or per change with reportAllChanges: true).
CLS uses the session-window algorithm; INP approximates as the worst interaction latency.
Zero-CLS checklist
- Always set
width+height(ordata-ratio) — the browser reserves the box before anything loads. - Never lazy-load the LCP image — keep
lcpAware: trueor mark the hero withdata-xo-priority="high". - Use
data-coloror an LQIP so users never stare at empty white boxes. - Run with
debug: truein development — XOframe warns about images that can shift the layout.
Browser support
Latest Chrome, Safari, Firefox, Edge, iOS Safari, Android Chrome. If IntersectionObserver is missing, all media loads immediately (graceful fallback). Unsupported hints (fetchpriority etc.) are simply ignored by the browser.
Migrating from lazysizes / vanilla-lazyload
data-src/data-srcsetwork the same way — usually a class rename (lazyload→data-xo) is enough.- Unlike classic lazyloaders, XOframe will refuse to sabotage your LCP: above-the-fold heroes load eagerly.
- Placeholders, aspect-ratio locking, and reveal transitions are built in — remove your custom CSS hacks.
data-sizes="auto"replaces lazysizes' automaticsizescalculation.- Large images are
decode()d off the paint path before reveal, and Save-Data users automatically get a lighter experience.
License
MIT. The optional placeholder entries bundle the reference decoders thumbhash (© Evan Wallace, MIT) and blurhash (© Wolt, MIT).
