Last active
February 3, 2026 11:58
-
-
Save jcayzac/eb18b65bd34856948b077729b2f21d20 to your computer and use it in GitHub Desktop.
Swap inline SVG contents in/out automatically when they enter/leave the viewport
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| --- | |
| /** | |
| * @file Custom element that wraps an inline SVG and swaps its content in or | |
| * out as the element enters or leaves the viewport, thus keeping the | |
| * DOM lean even when a lot of big-ass SVGs are used on a single page | |
| * (article lists with SVG heroes, for instance). | |
| */ | |
| import type { ImageMetadata } from 'astro' | |
| import { getImage } from 'astro:assets' | |
| import { experimental_AstroContainer as Container } from 'astro/container' | |
| import { AstroError } from 'astro/errors' | |
| import { HTML } from '/server/HTML' | |
| interface Props { | |
| image: ImageMetadata | |
| alt: string | |
| fit: 'contain' | 'cover' | |
| } | |
| const { image, alt, fit } = Astro.props | |
| type Component = Parameters<typeof Container.prototype.renderToString>[0] | |
| function isComponent(image: unknown): image is Component { | |
| return typeof image === 'function' && 'isAstroComponentFactory' in image && image.isAstroComponentFactory === true | |
| } | |
| if (!isComponent(image) || image.format !== 'svg') { | |
| const error = new AstroError(`<big-ass-svg> only works with imported SVG images.`) | |
| error.hint = `Either use getImage(), an import directive, or the image() schema in your content layer loader.` | |
| throw error | |
| } | |
| // Grab the attributes of the <svg> element | |
| const container = await Container.create() | |
| const result = await container.renderToString(image) | |
| const { attributes } = HTML.fromString(result, true) | |
| const preserveAspectRatio = `xMidYMid ${{ cover: 'slice', contain: 'meet' }[fit]}` | |
| const { src } = image | |
| --- | |
| <big-ass-svg {src}> | |
| <svg {...attributes} {preserveAspectRatio} aria-label={alt} role="img" /> | |
| </big-ass-svg> | |
| <style> | |
| @layer components { | |
| big-ass-svg { | |
| display: contents; | |
| @media (update: fast) and (not (prefers-reduced-motion)) { | |
| svg > * { | |
| opacity: 0; | |
| transition: opacity .450s ease-in; | |
| &:where(.mounted *) { | |
| opacity: 1; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| </style> | |
| <script> | |
| const parser = new DOMParser() | |
| const io = new IntersectionObserver((entries) => { | |
| for (const entry of entries) { | |
| const el = entry.target.parentElement as BigAssSVG | |
| el.viewportVisibilityChanged(entry.isIntersecting) | |
| } | |
| }) | |
| class BigAssSVG extends HTMLElement { | |
| #loading: AbortController | null = null | |
| #svg: SVGSVGElement | null = null | |
| #src: string | null = null | |
| get src() { return this.#src } | |
| viewportVisibilityChanged(isVisible: boolean) { | |
| if (isVisible) { | |
| this.#enterViewport() | |
| } | |
| else { | |
| this.#leaveViewport() | |
| } | |
| } | |
| async #enterViewport() { | |
| const { src } = this | |
| if (!this.#loading && typeof src === 'string') { | |
| this.#loading = new AbortController() | |
| while (!this.#loading.signal.aborted) { | |
| try { | |
| const response = await fetch(src, { | |
| priority: 'low', | |
| signal: AbortSignal.any([ | |
| this.#loading.signal, | |
| AbortSignal.timeout(10_000), | |
| ]), | |
| }) | |
| if (response.ok) { | |
| const { children } = parser.parseFromString(await response.text(), 'image/svg+xml').documentElement | |
| const adopted = [...children].map(el => this.ownerDocument.adoptNode(el)) | |
| this.#svg?.replaceChildren(...adopted) | |
| requestAnimationFrame(() => this.#svg?.classList.add('mounted')) | |
| break | |
| } | |
| } | |
| catch (error) { | |
| if (error instanceof Error && error.name === 'AbortError') { | |
| break | |
| } | |
| } | |
| await new Promise(resolve => setTimeout(resolve, 1_000)) | |
| } | |
| this.#loading = null | |
| } | |
| } | |
| async #leaveViewport() { | |
| // Abort loading if still in progress | |
| this.#loading?.abort() | |
| // Clear the SVG content | |
| this.#svg?.replaceChildren() | |
| requestAnimationFrame(() => this.#svg?.classList.remove('mounted')) | |
| } | |
| connectedCallback() { | |
| this.#src = this.getAttribute('src') ?? null | |
| this.#svg = this.querySelector('svg') | |
| if (this.#svg) { | |
| io.observe(this.#svg) | |
| } | |
| } | |
| disconnectedCallback() { | |
| if (this.#svg) { | |
| io.unobserve(this.#svg) | |
| } | |
| } | |
| } | |
| customElements.define('big-ass-svg', BigAssSVG) | |
| </script> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment