Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Dynamic SVG image placeholders

Dynamic SVG image placeholders

How it works

  1. Loads the Potrace Lib (6KB min+gzip)
  2. Starts loading the full res source image
  3. Meanwhile...
  4. Grabs a low res thumbnail from Cloudinary (~5KB)
  5. Uses Potrace to generate an svg from the thumbnail
  6. Colours and fades the SVG in as a crisp, full-size placeholder
  7. Once full res loaded, overlays the svg with a nice fadein transition

Note: This demo artificially delays image loading by 1 second

FAQs:

  • 🚫 No build step required!
  • 🦉 The SVG is generated on the fly from the source image
  • 🎒 It requires an extra network round trip (to fetch the thumb from @Cloudinary)
  • 🌈 You can pick any colour for the svg
  • 💅 The SVG can be animated / styled however you want

Created by @jesstelford

A Pen by Jess Telford on CodePen.

License.

<h1>Cloudinary SVG Placeholder</h1>
<div class="column">
<p>Dynamic SVG image placeholders, powered by <a href="https://cloudinary.com/">Cloudinary</a> & <a href="https://github.com/kilobtye/potrace">Potrace</a></p>
</div>
<cloudinary-svg-placeholder
src="https://images.unsplash.com/photo-1529933037705-0d537317ae7b"
alt="Hello world"
color="lightgray"
width=500
cloudinary="demo"
animatedStroke=true
></cloudinary-svg-placeholder>
<div class="column">
<p><small><center>(Image from <a href="https://unsplash.com/">unsplash.com</a>)</small></center></p>
<p>Try these images:</p>
<p><ul>
<li><a class="alternateImg" href="https://images.unsplash.com/photo-1529932702015-d79f7e363168">Camera</a></li>
<li><a class="alternateImg" href="https://images.unsplash.com/photo-1529933037705-0d537317ae7b">Surprised Cat</a></li>
<li><a class="alternateImg" href="https://images.unsplash.com/photo-1529840180348-efd52969a4ce">Mountains</a></li>
</ul></p>
<h2>How it works</h2>
<p><ol>
<li>Loads the <a href="https://github.com/kilobtye/potrace">Potrace Lib</a> (6KB min+gzip)</li>
<li>Starts loading the full res source image</li>
<li>Meanwhile...</li>
<li><ol>
<li>Grabs a <a href="https://res.cloudinary.com/demo/image/fetch/q_10,f_jpg,w_150/https://images.unsplash.com/photo-1529933037705-0d537317ae7b">low res thumbnail from Cloudinary</a> (~5KB)</li>
<li>Uses Potrace to generate an svg from the thumbnail</li>
<li>Colours and fades the SVG in as a crisp, full-size placeholder</li>
</ol></li>
<li>Once full res loaded, overlays the svg with a nice fadein transition</li>
</ol></p>
<h2>FAQs</h2>
<p><ul>
<li>🚫 No build step required!</li>
<li>🦉 The SVG is generated on the fly from the source image</li>
<li>🎒 It requires an extra network round trip (to fetch the thumb from Cloudinary)</li>
<li>🌈 You can pick any colour for the svg</li>
<li>💅 The SVG can be animated / styled however you want</li>
</ul></p>
<p><i>Note: This demo artificially delays image loading by 1 second</i></p>
<hr />
<p>Created by <a href="https://mobile.twitter.com/jesstelford">@jesstelford</a></p>
</div>
const ARTIFICIAL_DELAY = 2000;
function createElementFromHTML(htmlString) {
const div = document.createElement('div');
div.innerHTML = htmlString;
return div.firstChild;
}
class CloudinaryImg extends HTMLElement {
static get observedAttributes() {
return ['src', 'animatedStroke'];
}
constructor() {
super();
this.lowResWidth = 150;
this.cloudName = this.getAttribute('cloudinary');
this.attachShadow({mode: 'open'});
this.initialise();
this.render();
}
initialise() {
this.imageLoaded = false;
this.svg = undefined;
this.svgElement = undefined;
this.width = this.getAttribute('width') || this.lowResWidth;
this.height = this.getAttribute('height') || this.lowResWidth;
this.shadowRoot.innerHTML = `
<style>
:host {
width: ${this.width}px;
}
.background {
position: absolute;
z-index: -1;
}
.fade-in {
opacity: 1;
animation-name: fadeInOpacity;
animation-iteration-count: 1;
animation-timing-function: ease-in;
animation-duration: 0.2s;
}
.animate-dash {
stroke-dasharray: var(--path-length);
stroke-dashoffset: var(--path-length);
animation: dash 5s linear forwards;
}
@keyframes fadeInOpacity {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes dash {
to {
stroke-dashoffset: 0;
}
}
</style>
`;
}
render() {
this.traceImage(
`https://res.cloudinary.com/${this.cloudName}/image/fetch/q_10,f_jpg,w_${this.lowResWidth}/${this.getAttribute('src')}`,
(svg) => {
this.svg = svg;
this.showSVGWhenConnected();
}
);
this.loadFullImage(() => {
this.imageLoaded = true;
this.showFullImageWhenLoaded();
});
}
// Only called for the disabled and open attributes due to observedAttributes
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'src' && oldValue !== newValue) {
this.initialise();
this.render();
}
}
traceImage(url, callback) {
// Enable CORS support
window.Potrace.img.crossOrigin = 'Anonymous';
window.Potrace.loadImageFromUrl(url);
window.Potrace.process(() => {
callback(window.Potrace.getSVG(this.width / this.lowResWidth, "curve"));
});
}
loadFullImage(done) {
this.fullImage = createElementFromHTML(`<img alt="${this.getAttribute('alt') || ''}" width=${this.width} />`)
this.fullImage.onload = () => {
done();
};
// artificial delay for fast networks
window.setTimeout(() => {
this.fullImage.src = this.getAttribute('src');
}, ARTIFICIAL_DELAY);
}
showSVGWhenConnected() {
// Only proceed if we're mounted in the DOM, the svg is loaded, and we haven't already shown the image
if (!this.connected || !this.svg || this.imageLoaded) {
return;
}
this.svgElement = createElementFromHTML(this.svg);
this.svgElement.classList.add('fade-in');
if (this.getAttribute('animatedStroke')) {
const pathEl = this.svgElement.querySelector('path');
pathEl.setAttribute('stroke-width', this.getAttribute('stroke-width') || '3');
pathEl.setAttribute('stroke', this.getAttribute('color') || 'gray');
pathEl.classList.add('animate-dash');
const pathLength = Math.ceil(pathEl.getTotalLength());
pathEl.style.setProperty('--path-length', pathLength);
} else {
pathEl.setAttribute('fill', this.getAttribute('color') || 'gray');
}
this.shadowRoot.appendChild(this.svgElement);
}
showFullImageWhenLoaded() {
// Only proceed if we're mounted in the DOM, and the image is loaded
if (!this.connected || !this.imageLoaded) {
return;
}
if (this.svgElement) {
this.svgElement.classList.add('background');
}
this.fullImage.classList.add('fade-in');
this.shadowRoot.appendChild(this.fullImage);
}
connectedCallback() {
this.connected = true;
this.showSVGWhenConnected();
this.showFullImageWhenLoaded();
}
}
customElements.define('cloudinary-svg-placeholder', CloudinaryImg);
const imgEL = document.querySelector('cloudinary-svg-placeholder');
document.querySelectorAll('a.alternateImg').forEach((node) => {
node.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
imgEL.setAttribute('src', event.target.getAttribute('href'));
})
})
<script src="https://kilobtye.github.io/potrace/potrace.js"></script>
:root {
font-family: Menlo, monospace;
font-size: calc( 0.8em + 1vmin );
}
cloudinary-svg-placeholder {
min-height: 504px;
}
img, svg, cloudinary-svg-placeholder {
display: block;
margin: 0 auto;
}
h1 {
text-align: center;
}
.column {
max-width: 20em;
margin: 0 auto;
}
.column a {
cursor: pointer;
color: inherit;
}
.column a:hover {
text-decoration: underline;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment