Blur Up/Fade In Images Without Gatsby
You can check out the post in the blur up example gif here if interested.
Blurring up can be seen across many sites like Medium and Facebook. Gatsby.js has this ability with a plugin, which is extremely helpful in building jank-free sites with images. If you didn’t know, I am not using Gatsby. I just switched from Gatsby for Scully. You can learn more about my switch here. Since I’m not using Gatsby any more, I need a way to blur up my photos within Scully.
Here is the game plan
- Figure out how to make small inline-able images from existing images
- Generate the markup needed to blur up without JavaScript
- Use JavaScript to make the transition to look nicer
- Integrate within Scully via render plugin
- Move JavaScript to an Angular directive
I figured out most of this by just reading the gatsby-remark-images, gatsby-image-sharp plugins and the Sharp documentation. So it’s not full proof, but works for my needs and hopefully someone can learn from my journey of figuring this out.
Resizing Image
Sharp is a node package we’ll be using for generating our small images, it’s also what is used by Gatsby.
Install sharp using npm. npm i -D sharp
.
We’ll be using only a few APIs from Sharp. You can check out Sharps documentation here if you want to see everything it can do.
Alright, so making a 20px wide image is pretty straight forward with sharp.
Sharp retains the aspect ratio by default for our images.
/**
* @param {Buffer} data
* @return {Promise<Buffer>}
*/
async function resize(data) {
console.log('resizing');
return await sharp(data)
.resize(20) // size in px to change width
.toBuffer();
}
I use a Buffer
as an input into the resize function because my images are on a
cdn already. If you’re images are local then just pass the local path to the
image as the data
input
I use Axios to download my images via url to an array buffer for usage in this process
// blurUp.helper.plugin.js
/**
* @param {string} url
* @return {Promise<AxiosResponse<any>>}
*/
async function download(url) {
console.log('downloading');
return axios.get(url, { responseType: 'arraybuffer' });
}
✅ Step 1.
Generate Markup
Now we need to generate the markup for our new blur up image abilities.
This is the markup we are trying to create, with of course the correct image content
<!--example.html-->
<span
class="img-wrapper"
style="padding-bottom: SOME_PERCENT%;
position: relative;
bottom: 0;
left: 0;
display: block;
background-size: cover;
background-image: url('data:image/png;base64, BASE64_ENCODED_STRING');"
>
<img
class="img-sharp"
alt="some-alt"
title="some-title"
src="data:image/png;base64, BASE64_ENCODED_STRING"
srcset="https://SOMEIMAGE.PNG"
sizes="1080"
style="width: 100%;
height: 100%;
margin: 0;
vertical-align: middle;
position: absolute;
top: 0;
left: 0;"
/>
</span>
Before we can generate the markup, we need to get all the pieces of data to build the markup
- A base64 encoded string of the small image
- Screen percentage the original image would take
Luckily Sharp makes this easy as well.
Base64 Image
First, we’ll need to convert our small image into a base 64 string.
// blurUp.helper.plugin.js
/**
* @param {Buffer} data
* @return {string}
*/
function toBase64(data) {
console.log('making base 64 image');
return Buffer.from(data).toString('base64');
}
Great! Now we have a base64 string representing our tiny image. Time to get our image screen percentage.
Get Image Percentage
Now we need to get the percentage of the screen the image would have taken up. This will make sure our small element fills the same space as our normal-sized image. To do this we need to take the height divided by the width and multiple it by 100. Here is that helper function.
// blurUp.helper.plugin.js
/**
* @param {Buffer} data
* @return {Promise<number>}
*/
async function getImgPadding(data) {
console.log('getting metadata');
const { height, width } = await sharp(data).metadata();
return (height / width) * 100;
}
Now we can build our markup.
Build Markup
There was an issue when I tried doing this by interpolating values into an HTML string. I kept getting escaped HTML markup instead of the actual HTML, so here I make an object representing what I want the markup to be. You can try string interpolating the data into the string of HTML and see if that works for you. Potentially could be easier depending on your build system workflow. Since I’m using Scully, it’s just as easy to use this abstract representation.
// blurUp.helper.plugin.js
/**
* @param {number} paddingBottom
* @param {string}b64
* @param {string}caption
* @param {string}imgUrl
* @return {{children: [{tagName: string, props: {sizes: string, src: string, alt: string, style: string, title: string, srcset: string, class: string}}], tagName: string, props: {style: string, class: string}}}
*/
function buildAst({ paddingBottom, b64, caption, imgUrl } = data) {
return {
tagName: 'span',
props: {
class: 'img-wrapper',
style: `padding-bottom: ${paddingBottom}%;
position: relative;
bottom: 0;
left: 0;
display: block;
background-size: cover;
background-image: url('data:image/png;base64,${b64}');`,
},
children: [
{
tagName: 'img',
props: {
class: 'img-sharp',
src: `data:image/png;base64,${b64}`,
alt: `${caption}`,
title: `${caption}`,
srcset: `${imgUrl}`,
sizes: '1080',
style: `width: 100%;
height: 100%;
margin: 0;
vertical-align: middle;
position: absolute;
top: 0;
left: 0;`,
},
},
],
};
}
If you were going to interpolate the values this is what it would look like
// blurUp.helper.plugin.js
/**
* @param {number} paddingBottom
* @param {string}b64
* @param {string}caption
* @param {string}imgUrl
* @return {{children: [{tagName: string, props: {sizes: string, src: string, alt: string, style: string, title: string, srcset: string, class: string}}], tagName: string, props: {style: string, class: string}}}
*/
function buildMakrup({ paddingBottom, b64, caption, imgUrl } = data) {
return `<span class="img-wrapper"
style="padding-bottom: ${paddingBottom}%;
position: relative;
bottom: 0;
left: 0;
display: block;
background-size: cover;
background-image: url('data:image/png;base64, ${b64}');"
>
<img
class="img-sharp"
alt="${caption}"
title="${caption}"
src="data:image/png;base64, ${b64}"
srcset="${imgUrl}"
sizes="1080"
style="width: 100%;
height: 100%;
margin: 0;
vertical-align: middle;
position: absolute;
top: 0;
left: 0;"
/>
</span>`;
}
Putting it all together
The entire pipeline should look something like this
// blurUp.helper.plugin.js
const axios = require('axios');
const sharp = require('sharp');
/**
* @param {string} url
* @return {Promise<AxiosResponse<any>>}
*/
async function download(url) {
console.log('downloading');
return axios.get(url, { responseType: 'arraybuffer' });
}
/**
* @param {Buffer} data
* @return {Promise<Buffer>}
*/
async function resize(data) {
console.log('resizing');
return sharp(data).resize(20).toBuffer();
}
/**
* @param {Buffer} data
* @return {string}
*/
function toBase64(data) {
console.log('making base 64 image');
return Buffer.from(data).toString('base64');
}
/**
* @param {Buffer} data
* @return {Promise<number>}
*/
async function getImgPadding(data) {
console.log('getting metadata');
const { height, width } = await sharp(data).metadata();
return (height / width) * 100;
}
/**
* @param {number} paddingBottom
* @param {string}b64
* @param {string}caption
* @param {string}imgUrl
* @return {{children: [{tagName: string, props: {sizes: string, src: string, alt: string, style: string, title: string, srcset: string, class: string}}], tagName: string, props: {style: string, class: string}}}
*/
function buildAst({ paddingBottom, b64, caption, imgUrl } = data) {
const markup = {
tagName: 'span',
props: {
class: 'img-wrapper',
style: `padding-bottom: ${paddingBottom}%;
position: relative;
bottom: 0;
left: 0;
display: block;
background-size: cover;
background-image: url('data:image/png;base64,${b64}');`,
},
children: [
{
tagName: 'img',
props: {
class: 'img-sharp',
src: `data:image/png;base64,${b64}`,
alt: `${caption}`,
title: `${caption}`,
srcset: `${imgUrl}`,
sizes: '1080',
style: `width: 100%;
height: 100%;
margin: 0;
vertical-align: middle;
position: absolute;
top: 0;
left: 0;`,
},
},
],
};
return markup;
}
async function newImgMarkUp(imgUrl, caption) {
const { data } = await download(imgUrl);
if (!data) {
throw Error('no image found');
}
const resized = await resize(data);
const b64 = toBase64(resized);
const paddingBottom = await getImgPadding(resized);
return buildAst({ b64, paddingBottom, caption, imgUrl });
}
module.exports.newImgMarkUp = newImgMarkUp;
The caption property is for setting alt and title attributes on the image tag
If you were to run the code right now you’d see the stretched small image replaced by the browser, this is how it’ll work when the end-user has javascript disabled. which is great it works without javascript, but we can provide a better UX by adding our transition to the image.
You might need to throttle your network speed in the dev tools to see the image switched out. Using ‘Good 3G’ or ‘Regular 4G/LTE’ presets should be enough to see the change happen.
✅ Step 2.
Add a Transition
Now let’s apply some JS to allow for a nice transition.
This is pretty much taken straight from the Gatsby source code
const imageWrappers = document.querySelectorAll(`.img-wrapper`);
for (let i = 0; i < imageWrappers.length; i++) {
const imgWrap = imageWrappers[i];
const imgEl = imgWrap.querySelector('img');
const onImageComplete = () => {
imgEl.style.opacity = 1;
imgEl.style.filter = null;
imgEl.style.color = `inherit`;
imgEl.style.boxShadow = `inset 0 0 0 400px white`;
imgEl.removeEventListener('load', onImageLoad);
imgEl.removeEventListener('error', onImageComplete);
};
const onImageLoad = () => {
imgEl.style.transition = `opacity .4s cubic-bezier(0.4, 0.0, 0.2, 1)`;
onImageComplete();
};
imgEl.style.opacity = 0;
imgEl.style.filter = `blur(50px)`;
// make safari have crisp edges
imgEl.style.transform = `scale(1)`;
imgEl.addEventListener('load', onImageLoad);
imgEl.addEventListener('error', onImageComplete);
if (imgEl.complete) {
onImageComplete();
}
}
Walking through the code
- We get all the image wrappers
- Looping over and grabbing images from the wrapper
- We set up two functions that alter the styles for the different states,
onLoad
andonComplete
- We set some default stiles on the image
- Applying a blur to smooth our small image a little
- We check if the image already loaded, if so, we run our completion function.
- Profit
That’s pretty much it for sites not using Scully, there are a couple of gaps you’ll have to figure out if you’re using a single page application such as making sure the plain js blur up is ran for every route change. Otherwise, your images will not have the transition animation unless the page is visited directly each time. i.e. not via frontend routing.
✅ Step 3.
Scully Time
Even if you’re not using Scully you might find it helpful, as the Scully plugin is where we turn the AST into actual markup.
First, we need to make sure jsdom is
installed. npm i -D jsdom
Let’s make a placeholder function and register the plugin
// blurUp.plugin.js
const { registerPlugin } = require('@scullyio/scully');
const { log, yellow } = require('@scullyio/scully/utils/log');
const jsdom = require('jsdom');
const { JSDOM } = jsdom;
const { newImgMarkUp } = require('./blurUp.healper.plugin');
/**
* @param {string} html
* @return {Promise<string>}
*/
const blurUp = async (html) => {
// we'll be using this in a minute
const dom = new JSDOM(html);
return dom.serialize();
};
const validator = async (conf) => [];
registerPlugin('render', 'blurUp', blurUp, validator);
module.exports.blurUp = blurUp;
Scully Config
Now head over to your Scully config file, it should be the root of your repo.
In the config file, we are going to register a new render plugin like so
// scully.{your-site}.config.js
require('./plugins/blurUp.plugin.js'); // or where ever your plugin in stored
exports.config = {
projectRoot: './src',
projectName: 'your-site-com',
outDir: './dist/static',
routes: {
// Make sure your paths are correct
'/blog/:slug': {
type: 'contentFolder',
postRenderers: ['blurUp'], // blur up plugin goes here
slug: {
folder: './blog',
},
},
},
};
Now we should be able to run a Scully build and nothing should break. If it does, make sure your blur up plugin is registered correctly and returning the passed in HTML.
Parsing AST
Now let’s parse the AST to give our pages our blur up effect. Back in our
blurUp.plugin.js
file.
// blurUp.config.js
const { registerPlugin } = require('@scullyio/scully');
const { log, yellow } = require('@scullyio/scully/utils/log');
const jsdom = require('jsdom');
const { JSDOM } = jsdom;
const { newImgMarkUp } = require('./blurUp.healper.plugin');
/**
* @param {string} html
* @return {Promise<string>}
*/
const blurUp = async (html) => {
const dom = new JSDOM(html);
const { window } = dom;
const imgs = window.document.querySelectorAll('img');
log(yellow(`found ${imgs.length} images`));
for (let i = 0; i < imgs.length; i++) {
const mediaUrl = imgs[i].src;
// use a default if no alt is found
const caption = imgs[i].alt || 'Media by Caleb Ukle';
const markupAST = await newImgMarkUp(mediaUrl, caption);
const span = window.document.createElement(markupAST.tagName);
span.classList.add(markupAST.props.class);
span.style = markupAST.props.style;
markupAST.children.forEach((c) => {
const el = window.document.createElement(c.tagName);
el.classList.add(c.props.class);
el.style = c.props.style;
el.src = c.props.src;
el.srcset = c.props.srcset;
el.alt = c.props.alt;
el.title = c.props.title;
el.sizes = c.props.sizes;
span.appendChild(el);
});
imgs[i].replaceWith(span);
}
return dom.serialize();
};
const validator = async (conf) => [];
registerPlugin('render', 'blurUp', blurUp, validator);
module.exports.blurUp = blurUp;
What we are doing here is parsing the html string scully passes in with JSDom. This allows us to query and modify the DOM like we would in a browser.
- Get all the image tags
- Loop over the tags and run our newImageMarkup function from
blog.helper.plugin.js
- Set the properties returned from our AST
- replace the old image tag with our new markup
- Send the new HTML back to Scully
Alright we on the home stretch, the last part is moving our plain js event listeners to an Angular directive.
✅ Step 4.
Angular Time
First, let’s start by making an angular directive. ng g d your-module/blur-up
Our blur up directive class will need
- To implment the AfterViewChecked and OnDestroy interfaces
- To have a private property to hold our event listeners
- To inject Render2 and ElementRef
Your directive should look something like this
// blur-up.directive.ts
import {
AfterViewChecked,
Directive,
ElementRef,
OnDestroy,
Renderer2,
} from '@angular/core';
@Directive({
selector: '[appBlurUp]',
})
export class BlurUpDirective implements AfterViewChecked, OnDestroy {
private listeners = [];
constructor(private render: Renderer2, private el: ElementRef) {}
ngAfterViewChecked(): void {
// Attach event listeners
}
ngOnDestroy(): void {
// Clean up logic
}
}
Now in our ngAfterViewCheck method, we will add the following code
We use ngAfterViewCheck, because we need to run when the Scully transfer state completes which can be anywhere after OnInit. AfterViewInit is not a guarantee for content to be there. This could change pending if Scully makes changes on how transfer state works.
// blur-up.directive.ts
ngAfterViewChecked(): void {
const imgs = this.el.nativeElement.querySelectorAll('.img-sharp');
// tslint:disable-next-line:prefer-for-of
for (let i = 0; i < imgs.length; i++) {
const imgEl = imgs[i];
const onImageComplete = () => {
imgEl.style.opacity = 1;
imgEl.style.filter = null;
imgEl.style.color = 'inherit';
imgEl.style.boxShadow = 'inset 0 0 0 400px white';
// imgEl.removeEventListener('load', onImageLoad);
// imgEl.removeEventListener('error', onImageComplete);
};
const onImageLoad = () => {
imgEl.style.transition = 'opacity .4s cubic-bezier(0.4, 0.0, 0.2, 1)';
onImageComplete();
};
imgEl.style.opacity = 0;
imgEl.style.filter = 'blur(10px)';
imgEl.style.transform = 'scale(1)';
this.listeners.push(this.render.listen(imgEl, 'load', onImageLoad));
this.listeners.push(this.render.listen(imgEl, 'error', onImageComplete));
// imgEl.addEventListener('load', onImageLoad);
// imgEl.addEventListener('error', onImageComplete);
if (imgEl.complete) {
onImageComplete();
}
}
}
In our ngOnDestroy method, we’ll need to remove our listeners
// blur-up.directive.ts
ngOnDestroy(): void {
this.listeners.forEach(remove => remove());
}
Now to use in our page template.
<!--your-template.component.html-->
<section class="post-content" appBlurUp>
<scully-content></scully-content>
</section>
✅ Step 5.
You now have blur up images with your Scully, or other, site by using Sharp and some good ol developer skills of reading other peoples source code. A truly invaluable skill. Congrats! 🎉
Parting Thoughts
While not as easy as just installing a package, building your own plugin is pretty satisfying. There are still improvements to my implementation specifically the need of fluid images. right now images are 100% of their container, so if your images are small then the full size image could be stretched and pixelated. I might revist this as the source for fluid images is also in the gatsby repo, but for a v1 I am very happy with the outcome.
If you’re looking for more Scully plugins, I have written plugins for
- Adding Links to header tags
- Updating Algolia Search Indexes
- Adding Firebase Firestore documents for each route
Check them out here
Have a nice day 😀!