├── .gitignore ├── .npmignore ├── .babelrc ├── demo └── demo.gif ├── LICENSE ├── package.json ├── README.md └── src └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | ./index.js 2 | .idea 3 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | .idea 3 | demo 4 | .babelrc 5 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react"] 3 | } 4 | -------------------------------------------------------------------------------- /demo/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattjarzeb/react-cloudinary-lazy-image/HEAD/demo/demo.gif -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-cloudinary-lazy-image", 3 | "version": "1.3.4", 4 | "description": "Lazy-loading React image component based on Cloudinary api", 5 | "main": "index.js", 6 | "repository": "https://github.com/mattjarzeb/react-cloudinary-lazy-image", 7 | "homepage": "https://github.com/mattjarzeb/react-cloudinary-lazy-image#readme", 8 | "scripts": { 9 | "test": "echo \"Error: no test specified\" && exit 1", 10 | "build": "./node_modules/.bin/babel src --out-dir .", 11 | "prepublish": "npm run build" 12 | }, 13 | "keywords": [ 14 | "react-component", 15 | "cloudinary", 16 | "lazy-loading", 17 | "images" 18 | ], 19 | "author": "Mateusz Jarzębski ", 20 | "license": "MIT", 21 | "dependencies": { 22 | "@babel/polyfill": "^7.4.4", 23 | "prop-types": "^15.7.2" 24 | }, 25 | "devDependencies": { 26 | "@babel/cli": "^7.4.4", 27 | "@babel/core": "^7.4.5", 28 | "@babel/preset-env": "^7.4.5", 29 | "@babel/preset-react": "^7.0.0", 30 | "standard": "^12.0.1", 31 | "react": "^16.2.0" 32 | }, 33 | "peerDependencies": { 34 | "react": "^16.x.x" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-cloudinary-lazy-image 2 | 3 | Optimised images with Cloudinary. 4 | 5 | 'react-cloudinary-lazy-image' is React component which cover "blur-up" effect, lazy-loading and formatting. 6 | The component is based on [Gatsby image](https://github.com/gatsbyjs/gatsby/blob/master/packages/gatsby-image) by Kyle Mathew, 7 | however instead of GraphQL and Gatsby it uses Cloudinary API. Have a speed and optimized gatsby-images without gatsby. 8 | 9 | ![](./demo/demo.gif) 10 | 11 | ## Covers 12 | 13 | 1) Downsize larger images to the size needed by your design - even on desktop there is no need to get as big image as possible. 14 | 2) Remove metadata from delivered images - by default images contain a lot of information useful for cameras and graphics applications, but not for web users. 15 | 3) Format images to newer formats like JPEG-XR and WebP - common formats like PNG, JPG or GIF are not optimised to be send wireless. 16 | 4) Lower image quality - many images have extra-high resolution, however it’s possible to lower quality without a significant visual impact. 17 | 5) Downsize images on smaller device - display images for mobile users faster as there is probably slower internet connection. 18 | 6) Lazy load images - allow images to download only when user scroll to it allows to speed up initial page load. 19 | 7) Hold position of element - page doesn’t jump while images load. 20 | 8) “Blur-up” technique - show very low resolution image before the original loads. 21 | 22 | Points 1-4 are handled by Cloudinary. 23 | 24 | # Install 25 | 26 | `npm install react-cloudinary-lazy-image --save` 27 | 28 | ## How to use 29 | 30 | Fixed example: 31 | ```jsx 32 | import React from 'react' 33 | import Img from 'react-cloudinary-lazy-image' 34 | 35 | export default ({publicId}) => ( 36 |
37 |

Lazy-image with Cloudinary

38 | 47 |
48 | ) 49 | ``` 50 | 51 | Fluid example: 52 | ```jsx 53 | import React from 'react' 54 | import Img from 'react-cloudinary-lazy-image' 55 | 56 | export default ({publicId}) => ( 57 |
58 |

Lazy-image with Cloudinary

59 | 71 |
72 | ) 73 | ``` 74 | 75 | ## Two types 76 | 77 | Same as in gatsby-image there are two types of responsive images. _Fixed_ and _fluid_. 78 | 1. Images with _fixed_ height and width. Cover double pixel density for retina display. 79 | 2. Images in _fluid_ container. Takes smallest possible picture to fill container. Configurable step allow you to have control over breakpoints. 80 | 81 | 82 | ## Image transformation 83 | 84 | You can set image transformation according to Cloudinary [documentation](https://cloudinary.com/documentation/image_transformation_reference), 85 | by setting `urlParams`. You can also find all formats that can be passed to `imgFormat` prop or get more info about `quality` prop. 86 | 87 | 88 | ## Props 89 | 90 | | Name | Type | Description | 91 | | ------------------ | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | 92 | | `fixed` | `object` | Object with 'width' and 'height' properties | 93 | | `fluid` | `object` | Object with 'maxWidth' required property. Optionally step, _default_=150 and 'height'. If height not set, uses 'c_scale' otherwise 'c_lfill' | 94 | | `fadeIn` | `bool` | Defaults to fading in the image on load | 95 | | `cloudName` | `string` | Cloudinary cloud name, _default_=process.env.CLOUD_NAME or process.env.REACT_APP_CLOUD_NAME | 96 | | `imageName` | `string` | Cloudinary publicId | 97 | | `urlParams` | `string` | Cloudinary image transformations params. Overrides default 'c_lfill' or 'c_scale'. If both weight and height (w_, h_) params are set srcSet will not be created.| 98 | | `title` | `string` | Passed to the `img` element | 99 | | `alt` | `string` | Passed to the `img` element | 100 | | `style` | `object` | Spread into the default styles of the wrapper element | 101 | | `imgStyle` | `object` | Spread into the default styles of the actual `img` element | 102 | | `placeholderStyle` | `object` | Spread into the default styles of the placeholder `img` element | 103 | | `backgroundColor` | `string` / `bool` | Set a colored background placeholder instead of "blur-up". If true, uses _default_ "lightgray" color. You can also pass in any valid color string. | 104 | | `onLoad` | `func` | A callback that is called when the full-size image has loaded. | 105 | | `onError` | `func` | A callback that is called when the image fails to load. | 106 | | `imgFormat` | `string` / `bool` | Allow Cloudinary to format image. By default is set to 'f_auto'. Can be switch off by passing 'false' or be formatted to specific format (ex. 'webp') | 107 | | `quality` | `string` / `bool` | Allow Cloudinary to change quality of image. By default is set to 'q_auto'. Can be switch off by passing 'false' or to specific value (ex. 'best') | 108 | | `version` | `string` | Set Cloudinary optional version param. [Doc](https://cloudinary.com/documentation/advanced_url_delivery_options#asset_versions). | 109 | | `blurSize` | `number` | Width of the low quality image. Default = 20. | 110 | | `blurUrlParams` | `string` | Cloudinary image transformations params for blur version. | 111 | | `useUrlParamsToBlur`| `bool` | Flag to use `urlParams` for blur version. Overrides `blurUrlParams`. | 112 | | `IOParams` | `object` | Passed to window.intersectionObserver options. Default: `{ rootMargin: '200px' }` | 113 | 114 | 115 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const makeUrlParams = props => { 5 | let { urlParams, imgFormat, quality, fluid, blurUrlParams, useUrlParamsToBlur, blurSize } = props 6 | imgFormat = typeof imgFormat === `boolean` 7 | ? imgFormat 8 | ? `f_auto` : '' 9 | : imgFormat 10 | quality = typeof quality === `boolean` 11 | ? quality 12 | ? `q_auto` : '' 13 | : typeof quality === `string` && quality.includes(`q_auto`) 14 | ? quality 15 | : `q_auto:${quality}` 16 | if (!urlParams || !urlParams.length) { 17 | urlParams = 'c_lfill' 18 | if (fluid && !fluid.height) urlParams = 'c_scale' 19 | } 20 | if (!blurUrlParams || !blurUrlParams.length) { 21 | blurUrlParams = `c_scale,w_${blurSize}` 22 | } else { 23 | blurUrlParams = `${blurUrlParams},w_${blurSize}` 24 | } 25 | if (useUrlParamsToBlur) { 26 | blurUrlParams = `${urlParams},w_${blurSize}` 27 | } 28 | const toUrl = [imgFormat, quality, urlParams].filter(e => e && e.length) 29 | const toBlurUrl = [imgFormat, quality, blurUrlParams].filter(e => e && e.length) 30 | 31 | return { 32 | urlParams: toUrl.join(','), 33 | blurUrlParams: toBlurUrl.join(',') 34 | } 35 | 36 | } 37 | const detectWithAndHeight = (text) => { 38 | const widthReg = new RegExp(/w_\d+/) 39 | const heightReg = new RegExp(/h_\d+/) 40 | return !!(text.match(widthReg) && text.match(heightReg)) 41 | } 42 | 43 | // Cache if we've seen an image before so we don't both with 44 | // lazy-loading & fading in on subsequent mounts. 45 | const inImageCache = props => { 46 | const image = props.fluid || props.fixed 47 | let { urlParams } = makeUrlParams(props) 48 | urlParams = props.fluid 49 | ? `${urlParams},w_${image.maxWidth}${image.height ? `,h_${image.height}` : ''}` 50 | : `${urlParams},w_${image.width},h_${image.height}` 51 | // Find src 52 | const src = `https://res.cloudinary.com/${props.cloudName}/image/upload/${urlParams}/${props.version}/${props.imageName}` 53 | 54 | try { 55 | const cache = JSON.parse(window.sessionStorage.getItem('seen_images')) || {} 56 | if (cache[src]) { 57 | return true 58 | } else { 59 | cache[src] = true 60 | window.sessionStorage.setItem('seen_images', JSON.stringify(cache)) 61 | return false 62 | } 63 | } catch (e) { 64 | return false 65 | } 66 | } 67 | 68 | let io 69 | const listeners = [] 70 | 71 | function getIO (IOParams) { 72 | if ( 73 | typeof io === `undefined` && 74 | typeof window !== `undefined` && 75 | window.IntersectionObserver 76 | ) { 77 | io = new window.IntersectionObserver( 78 | entries => { 79 | entries.forEach(entry => { 80 | listeners.forEach(l => { 81 | if (l[0] === entry.target) { 82 | // Edge doesn't currently support isIntersecting, so also test for an intersectionRatio > 0 83 | if (entry.isIntersecting || entry.intersectionRatio > 0) { 84 | io.unobserve(l[0]) 85 | l[1]() 86 | } 87 | } 88 | }) 89 | }) 90 | }, 91 | { ...IOParams } 92 | ) 93 | } 94 | 95 | return io 96 | } 97 | 98 | const listenToIntersections = (el, IOParams, cb) => { 99 | getIO(IOParams).observe(el) 100 | listeners.push([el, cb]) 101 | } 102 | 103 | const noscriptImg = props => { 104 | // Check if prop exists before adding each attribute to the string output below to prevent 105 | // HTML validation issues caused by empty values like width="" and height="" 106 | const src = props.src ? `src="${props.src}" ` : `src="" ` // required attribute 107 | const title = props.title ? `title="${props.title}" ` : `` 108 | const alt = `alt="${props.alt}"` // required attribute 109 | const width = props.width ? `width="${props.width}" ` : `` 110 | const height = props.height ? `height="${props.height}" ` : `` 111 | const opacity = props.opacity ? props.opacity : `1` 112 | const transitionDelay = props.transitionDelay ? props.transitionDelay : `0.5s` 113 | return `` 114 | } 115 | 116 | const Img = React.forwardRef((props, ref) => { 117 | const { style, onLoad, onError, ...otherProps } = props 118 | 119 | return ( 120 | 136 | ) 137 | }) 138 | 139 | Img.propTypes = { 140 | style: PropTypes.object, 141 | onError: PropTypes.func, 142 | onLoad: PropTypes.func 143 | } 144 | 145 | class Image extends React.Component { 146 | constructor (props) { 147 | super(props) 148 | 149 | // If this browser doesn't support the IntersectionObserver API 150 | // we default to start downloading the image right away. 151 | let isVisible = true 152 | let imgLoaded = true 153 | let IOSupported = false 154 | let fadeIn = props.fadeIn 155 | 156 | // If this image has already been loaded before then we can assume it's 157 | // already in the browser cache so it's cheap to just show directly. 158 | const seenBefore = inImageCache(props) 159 | 160 | if ( 161 | !seenBefore && 162 | typeof window !== `undefined` && 163 | window.IntersectionObserver 164 | ) { 165 | isVisible = false 166 | imgLoaded = false 167 | IOSupported = true 168 | } 169 | 170 | // Always don't render image while server rendering 171 | if (typeof window === `undefined`) { 172 | isVisible = false 173 | imgLoaded = false 174 | } 175 | 176 | const hasNoScript = this.props.fadeIn 177 | 178 | this.state = { 179 | isVisible, 180 | imgLoaded, 181 | IOSupported, 182 | fadeIn, 183 | hasNoScript, 184 | seenBefore 185 | } 186 | 187 | this.imageRef = React.createRef() 188 | this.handleImageLoaded = this.handleImageLoaded.bind(this) 189 | this.handleRef = this.handleRef.bind(this) 190 | } 191 | 192 | handleRef (ref) { 193 | if (this.state.IOSupported && ref) { 194 | listenToIntersections(ref, this.props.IOParams, () => { 195 | this.setState({ isVisible: true }) 196 | }) 197 | } 198 | } 199 | 200 | handleImageLoaded () { 201 | this.setState({ imgLoaded: true }) 202 | if (this.state.seenBefore) { 203 | this.setState({ fadeIn: false }) 204 | } 205 | this.props.onLoad && this.props.onLoad() 206 | } 207 | 208 | createBrakePointsFixed (urlCore) { 209 | const results = [] 210 | const image = this.props.fixed 211 | for (let i = 1; i < 3; i++) { 212 | const params = `${urlCore},w_${image.width * i},h_${image.height * i}` 213 | results.push(`https://res.cloudinary.com/${this.props.cloudName}/image/upload/${params}/${this.props.version}/${this.props.imageName} ${i}x`) 214 | } 215 | return results.join(',') 216 | } 217 | 218 | createBrakePointsFluid (urlCore) { 219 | const image = this.props.fluid 220 | const step = image.step || 150 221 | let size = 150 222 | const results = [] 223 | while (size < image.maxWidth) { 224 | const params = `${urlCore},w_${size}${image.height ? `,h_${Math.ceil(size * this.getAspectRatio(image))}` : ''}` 225 | results.push(`https://res.cloudinary.com/${this.props.cloudName}/image/upload/${params}/${this.props.version}/${this.props.imageName} ${size}w`) 226 | size = size + step 227 | } 228 | 229 | results.push( 230 | `https://res.cloudinary.com/${this.props.cloudName}/image/upload/${urlCore},w_${image.maxWidth}${image.height ? `,h_${image.height}` : ''}/${this.props.version}/${this.props.imageName} ${image.maxWidth}w` 231 | ) 232 | return results.join(',') 233 | } 234 | 235 | getAspectRatio(image) { 236 | return image.height > image.maxWidth 237 | ? image.height / image.maxWidth 238 | : image.maxWidth / image.height 239 | } 240 | 241 | render () { 242 | const { 243 | title, 244 | alt, 245 | cloudName, 246 | imageName, 247 | style = {}, 248 | imgStyle = {}, 249 | placeholderStyle = {}, 250 | fluid, 251 | fixed, 252 | backgroundColor 253 | } = this.props 254 | 255 | let {urlParams, blurUrlParams} = makeUrlParams(this.props) 256 | 257 | const bgColor = typeof backgroundColor === `boolean` ? `lightgray` : backgroundColor 258 | 259 | const imagePlaceholderStyle = { 260 | opacity: this.state.imgLoaded ? 0 : 1, 261 | transition: `opacity 0.5s`, 262 | transitionDelay: this.state.imgLoaded ? `0.5s` : `0.25s`, 263 | ...imgStyle, 264 | ...placeholderStyle 265 | } 266 | 267 | const imageStyle = { 268 | position: 'relative', 269 | opacity: this.state.imgLoaded || this.state.fadeIn === false ? 1 : 0, 270 | transition: this.state.fadeIn === true ? `opacity 0.5s` : `none`, 271 | ...imgStyle 272 | } 273 | 274 | const placeholderImageProps = { 275 | title, 276 | alt: !this.state.isVisible ? alt : ``, 277 | style: imagePlaceholderStyle 278 | } 279 | let image 280 | let divStyle 281 | let bgPlaceholderStyles 282 | let srcSet 283 | if (fluid) { 284 | image = fluid 285 | divStyle = { 286 | position: `relative`, 287 | overflow: `hidden`, 288 | width: '100%', 289 | height: '100%', 290 | ...style 291 | } 292 | bgPlaceholderStyles = { 293 | backgroundColor: bgColor, 294 | position: `absolute`, 295 | top: 0, 296 | bottom: 0, 297 | opacity: !this.state.imgLoaded ? 1 : 0, 298 | transitionDelay: `0.35s`, 299 | right: 0, 300 | left: 0 301 | } 302 | srcSet = !detectWithAndHeight(urlParams) && this.createBrakePointsFluid(urlParams) 303 | urlParams = detectWithAndHeight(urlParams) ? urlParams : `${urlParams},w_${image.maxWidth}${image.height ? `,h_${image.height}` : ''}` 304 | } 305 | if (fixed) { 306 | image = fixed 307 | divStyle = { 308 | position: `relative`, 309 | overflow: `hidden`, 310 | display: `inline-block`, 311 | width: image.width, 312 | height: image.height, 313 | ...style 314 | } 315 | bgPlaceholderStyles = { 316 | backgroundColor: bgColor, 317 | width: image.width, 318 | height: image.height, 319 | opacity: !this.state.imgLoaded ? 1 : 0, 320 | transitionDelay: `0.25s` 321 | } 322 | srcSet = !detectWithAndHeight(urlParams) && this.createBrakePointsFixed(urlParams) 323 | urlParams = detectWithAndHeight(urlParams) ? urlParams : `${urlParams},w_${image.width},h_${image.height}` 324 | } 325 | 326 | if (style.display === `inherit`) { 327 | delete divStyle.display 328 | } 329 | 330 | if (fluid || fixed) { 331 | return ( 332 |
336 | {/* Show a blurred version. */} 337 | {!bgColor && 338 | 342 | } 343 | 344 | {/* Show a solid background color. */} 345 | {bgColor && ( 346 |
350 | )} 351 | 352 | {/* Once the image is visible (or the browser doesn't support IntersectionObserver), start downloading the image */} 353 | {this.state.isVisible && ( 354 | {alt} 364 | )} 365 | 366 | {/* Show the original image during server-side rendering if JavaScript is disabled */} 367 | {this.state.hasNoScript && ( 368 |
375 | ) 376 | } 377 | return null 378 | } 379 | } 380 | 381 | Image.defaultProps = { 382 | cloudName: process.env.CLOUD_NAME || process.env.REACT_APP_CLOUD_NAME, 383 | fadeIn: true, 384 | alt: ``, 385 | version: ``, 386 | imgFormat: true, 387 | quality: true, 388 | blurSize: 20, 389 | useUrlParamsToBlur: false, 390 | IOParams: { 391 | rootMargin: '200px' 392 | } 393 | } 394 | 395 | const fixedObject = PropTypes.shape({ 396 | width: PropTypes.number.isRequired, 397 | height: PropTypes.number.isRequired 398 | }) 399 | 400 | const fluidObject = PropTypes.shape({ 401 | maxWidth: PropTypes.number.isRequired, 402 | height: PropTypes.number, 403 | step: PropTypes.number, 404 | }) 405 | 406 | Image.propTypes = { 407 | fixed: fixedObject, 408 | fluid: fluidObject, 409 | urlParams: PropTypes.string, 410 | fadeIn: PropTypes.bool, 411 | title: PropTypes.string, 412 | alt: PropTypes.string, 413 | cloudName: PropTypes.string, 414 | imageName: PropTypes.string.isRequired, 415 | style: PropTypes.object, 416 | imgStyle: PropTypes.object, 417 | placeholderStyle: PropTypes.object, 418 | backgroundColor: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), 419 | onLoad: PropTypes.func, 420 | onError: PropTypes.func, 421 | imgFormat: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), 422 | quality: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), 423 | version: PropTypes.string, 424 | blurSize: PropTypes.number, 425 | blurUrlParams: PropTypes.string, 426 | useUrlParamsToBlur: PropTypes.bool, 427 | IOParams: PropTypes.object 428 | } 429 | 430 | export default Image 431 | --------------------------------------------------------------------------------