├── .eleventy.js ├── .gitignore ├── README.md ├── netlify.toml ├── netlify └── functions │ └── focalpoint │ └── index.js ├── package-lock.json ├── package.json └── src ├── _data └── meta.js ├── _includes └── base.njk ├── generate.njk ├── img └── object-fit-focal-point.png ├── index.md └── style.css /.eleventy.js: -------------------------------------------------------------------------------- 1 | const { EleventyServerlessBundlerPlugin } = require("@11ty/eleventy"); 2 | const syntaxHighlight = require("@11ty/eleventy-plugin-syntaxhighlight"); 3 | 4 | const sharp = require("sharp"); 5 | const fetch = require("node-fetch"); 6 | 7 | const imgPos = async (image, width, height) => { 8 | const input = await fetch(image).then((resp) => resp.buffer()); 9 | 10 | const baseImage = sharp(input); 11 | const imageInfo = {}; 12 | 13 | return await baseImage.metadata().then(function (metadata) { 14 | imageInfo["trueHeight"] = metadata.height; 15 | imageInfo["trueWidth"] = metadata.width; 16 | 17 | return baseImage 18 | .resize(width, height, { 19 | position: sharp.strategy.entropy, 20 | }) 21 | .toBuffer({ resolveWithObject: true }) 22 | .then(({ info }) => { 23 | imageInfo["x"] = info.cropOffsetLeft; 24 | imageInfo["y"] = info.cropOffsetTop; 25 | 26 | return imageInfo; 27 | }); 28 | }); 29 | }; 30 | 31 | const defaultAspectRatio = "5/3"; 32 | const defaultWidth = 800; 33 | const defaultHeight = 480; 34 | 35 | module.exports = function (eleventyConfig) { 36 | eleventyConfig.addPassthroughCopy("./src/style.css"); 37 | eleventyConfig.addPassthroughCopy("./src/img/"); 38 | eleventyConfig.addWatchTarget("./src/style.css"); 39 | 40 | eleventyConfig.addPlugin(syntaxHighlight); 41 | eleventyConfig.addPlugin(EleventyServerlessBundlerPlugin, { 42 | name: "focalpoint", 43 | inputDir: "./src/", 44 | functionsDir: "./netlify/functions/", 45 | }); 46 | 47 | eleventyConfig.addNunjucksAsyncShortcode( 48 | "focusedAspectRatioImg", 49 | async function (image, width, height, ratio) { 50 | ratio = !ratio && !width && !height ? defaultAspectRatio : ratio; 51 | width = parseFloat(width) || defaultWidth; 52 | height = parseFloat(height) || defaultHeight; 53 | 54 | const baseWidth = width; 55 | const baseHeight = height; 56 | 57 | if (ratio) { 58 | const aspectRatio = ratio.split("/"); 59 | width = aspectRatio[0] * 100; 60 | height = aspectRatio[1] * 100; 61 | } 62 | 63 | let { x, y, trueWidth, trueHeight } = await imgPos(image, width, height); 64 | 65 | x = x >= 0 ? x : x * -1; 66 | y = y >= 0 ? y : y * -1; 67 | 68 | let percentX = 0; 69 | let percentY = 0; 70 | 71 | if (x > 0) { 72 | percentX = 73 | x > width ? ((baseWidth / trueWidth) * x) / baseWidth : x / width; 74 | percentX = (percentX * 100).toFixed(2); 75 | } 76 | 77 | if (y > 0) { 78 | percentY = 79 | y > height 80 | ? ((baseHeight / trueHeight) * y) / baseHeight 81 | : y / height; 82 | percentY = (percentY * 100).toFixed(2); 83 | } 84 | 85 | const focalPoint = `object-position: ${percentX}% ${percentY}%;`; 86 | const ratioProps = ratio ? `height: auto; aspect-ratio: ${ratio};` : ""; 87 | 88 | return ` 89 |
90 |
91 | reference for original image 92 |
View full original image
93 |
94 | 95 | ${"```css"} 96 | .image { 97 | max-width: 100%;${ratio ? `\n height: auto;\n aspect-ratio: ${ratio};` : ""} 98 | object-fit: cover; 99 | ${focalPoint} 100 | } 101 | ${"```"} 102 |
`; 103 | } 104 | ); 105 | 106 | eleventyConfig.addShortcode("year", () => `${new Date().getFullYear()}`); 107 | 108 | return { 109 | dir: { 110 | input: "src", 111 | output: "public", 112 | }, 113 | }; 114 | }; 115 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies installed by npm 2 | node_modules 3 | 4 | # build artefacts 5 | public 6 | netlify/functions/focalpoint/** 7 | !netlify/functions/focalpoint/index.js 8 | 9 | # secrets and errors 10 | .env 11 | .log 12 | 13 | # macOS related files 14 | .DS_Store 15 | 16 | 17 | # Local Netlify folder 18 | .netlify -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](https://objectfit-focalpoint.netlify.app/img/object-fit-focal-point.png) 2 | 3 | # Object-Fit Focal Point 4 | 5 | > Generate the `object-position` value to capture an image's focal point given a custom aspect-ratio. Created by Stephanie Eckles ([@5t3ph](https://twitter.com/5t3ph)). 6 | 7 | **[Try it out and learn more >](https://objectfit-focalpoint.netlify.app/)** 8 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [[redirects]] 2 | from = "/generate/" 3 | to = "/.netlify/functions/focalpoint" 4 | status = 200 5 | force = true 6 | _generated_by_eleventy_serverless = "focalpoint" 7 | -------------------------------------------------------------------------------- /netlify/functions/focalpoint/index.js: -------------------------------------------------------------------------------- 1 | const { EleventyServerless } = require("@11ty/eleventy"); 2 | 3 | // Explicit dependencies for the bundler from config file and global data. 4 | // The file is generated by the Eleventy Serverless Bundler Plugin. 5 | require("./eleventy-bundler-modules.js"); 6 | 7 | async function handler(event) { 8 | let elev = new EleventyServerless("focalpoint", { 9 | path: event.path, 10 | query: event.queryStringParameters, 11 | inputDir: "./src/", 12 | functionsDir: "./netlify/functions/", 13 | }); 14 | 15 | try { 16 | return { 17 | statusCode: 200, 18 | headers: { 19 | "Content-Type": "text/html; charset=UTF-8", 20 | }, 21 | body: await elev.render(), 22 | }; 23 | } catch (error) { 24 | // Only console log for matching serverless paths 25 | // (otherwise you’ll see a bunch of BrowserSync 404s for non-dynamic URLs during --serve) 26 | if (elev.isServerlessUrl(event.path)) { 27 | console.log("Serverless Error:", error); 28 | } 29 | 30 | return { 31 | statusCode: error.httpStatusCode || 500, 32 | body: JSON.stringify( 33 | { 34 | error: error.message, 35 | }, 36 | null, 37 | 2 38 | ), 39 | }; 40 | } 41 | } 42 | 43 | // Choose one: 44 | // * Runs on each request: AWS Lambda (or Netlify Function) 45 | // * Runs on first request only: Netlify On-demand Builder 46 | // (don’t forget to `npm install @netlify/functions`) 47 | 48 | exports.handler = handler; 49 | 50 | //const { builder } = require("@netlify/functions"); 51 | //exports.handler = builder(handler); 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "objectfit-focalpoint", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.html", 6 | "scripts": { 7 | "start": "npx @11ty/eleventy --serve", 8 | "build": "npx @11ty/eleventy" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/5t3ph/objectfit-focalpoint.git" 13 | }, 14 | "keywords": [], 15 | "author": "5t3ph", 16 | "license": "ISC", 17 | "bugs": { 18 | "url": "https://github.com/5t3ph/objectfit-focalpoint/issues" 19 | }, 20 | "dependencies": { 21 | "@11ty/eleventy": "^1.0.0-beta.9", 22 | "@11ty/eleventy-plugin-syntaxhighlight": "^3.1.3", 23 | "@netlify/functions": "^0.10.0", 24 | "node-fetch": "2.6.6", 25 | "sharp": "^0.29.3" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/_data/meta.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | url: process.env.URL || "http://localhost:8080", 3 | siteName: "Object-Fit Focal Point", 4 | siteDescription: 5 | "Generate the `object-position` value to capture an image's focal point given a custom aspect-ratio. Created by Stephanie Eckles (@5t3ph).", 6 | twitterUsername: "5t3ph", 7 | }; 8 | -------------------------------------------------------------------------------- /src/_includes/base.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | {%- set pageTitle %}{{ meta.siteName }}{% endset -%} 4 | {%- set pageDescription %}{{ meta.siteDescription }}{% endset -%} 5 | {%- set pageSocialImg %}{{ meta.url }}/img/object-fit-focal-point.png{% endset -%} 6 | 7 | 8 | 9 | {{pageTitle}} 10 | 14 | 18 | 19 | 20 | {% if meta.twitterUsername %} 21 | 22 | {% endif %} 23 | 27 | 31 | 32 | 36 | 40 | 41 | 42 | 43 | 44 |
45 | {% if not page.fileSlug %} 46 |

Object-Fit Focal Point

47 |

Generate the object-position value to capture an image's focal point given a custom aspect-ratio.

48 | {% else %} 49 |

Object-Fit Focal Point

50 |

Positioning Results

51 | API Options 52 | {% endif %} 53 |
54 |
55 | {{ content | safe }} 56 |
57 | 62 | 63 | -------------------------------------------------------------------------------- /src/generate.njk: -------------------------------------------------------------------------------- 1 | --- 2 | layout: base 3 | permalink: 4 | focalpoint: "/generate/" 5 | eleventyComputed: 6 | image: "{{ eleventy.serverless.query.image }}" 7 | width: "{{ eleventy.serverless.query.width }}" 8 | height: "{{ eleventy.serverless.query.height }}" 9 | ratio: "{{ eleventy.serverless.query.ratio }}" 10 | templateEngineOverride: njk, md 11 | --- 12 | 13 | {% focusedAspectRatioImg image, width, height, ratio %} -------------------------------------------------------------------------------- /src/img/object-fit-focal-point.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/5t3ph/objectfit-focalpoint/5f9109ee6d97ec5de4188b2bdced15832050d14d/src/img/object-fit-focal-point.png -------------------------------------------------------------------------------- /src/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: base 3 | --- 4 | 5 | 6 | 7 |
8 |
9 | reference for original image 10 |
View full original image
11 |
12 | 13 | ```css 14 | .image { 15 | max-width: 100%; 16 | height: auto; 17 | aspect-ratio: 5/3; 18 | object-fit: cover; 19 | object-position: 0% 86%; 20 | } 21 | ``` 22 | 23 |
24 | 25 |
26 | 27 | ## About 28 | 29 | This utility was built with the static site generator [Eleventy](https://11ty.dev) and was created by [Stephanie Eckles](https://twitter.com/5t3ph) who is both [a big fan of 11ty](https://11ty.rocks) and an advocate for [modern CSS](https://moderncss.dev). 30 | 31 | The primary dependency is the [sharp package resize API](https://sharp.pixelplumbing.com/api-resize) to help calculate the focal point using [Shannon entropy](https://en.wikipedia.org/wiki/Entropy_%28information_theory%29). Dynamically generated results are possible thanks to [Eleventy Serverless](https://www.11ty.dev/docs/plugins/serverless/). 32 | 33 | The examples using a `ratio` require browser support for [`aspect-ratio`](https://caniuse.com/mdn-css_properties_aspect-ratio) (available in all evergreen browsers once Safari 15 is released). 34 | 35 | **Unfamilar with `object-fit`?** [Check out my 2.5 minute free egghead video >](https://egghead.io/lessons/css-apply-aspect-ratio-sizing-to-images-with-css-object-fit?af=2s65ms) 36 | 37 | Credit for demo photo goes to Joshua Oyebanji on Unsplash. 38 | 39 |

API Options

40 | 41 | Send a full, absolute image path as the `image` URL parameter to `{{ meta.url }}/generate/` to receive the default adjustments based on an aspect-ratio of `5/3`. 42 | 43 | [{{ meta.url }}/generate/?image=https://source.unsplash.com/0kCrlrs8gXg/700x900]({{ meta.url }}/generate/?image=https://source.unsplash.com/0kCrlrs8gXg/700x900) 44 | 45 | ### Custom Ratio 46 | 47 | To customize the ratio used, add `&ratio=x/y`. 48 | 49 | [{{ meta.url }}/generate/?image=https://source.unsplash.com/0kCrlrs8gXg/700x900&ratio=8/3]({{ meta.url }}/generate/?image=https://source.unsplash.com/0kCrlrs8gXg/700x900&ratio=8/3) 50 | 51 | _Note:_ if you'd like a square, pass `&ratio=1/1`. 52 | 53 | ### Use Image Dimensions 54 | 55 | Optionally, pass a desired image width and height to be used _instead of_ an aspect ratio for determining the `object-position` value. 56 | 57 | [{{ meta.url }}/generate/?image=https://source.unsplash.com/0kCrlrs8gXg/700x900&width=700&height=500]({{ meta.url }}/generate/?image=https://source.unsplash.com/0kCrlrs8gXg/700x900&width=700&height=500) 58 | 59 | ## Eleventy Plugin 60 | 61 | A plugin is available for install into your own Eleventy project to include this functionality as a Nunjucks shortcode. **[Get the plugin >](https://www.npmjs.com/package/@11tyrocks/eleventy-plugin-objectfit-focalpoint)** 62 | 63 |
64 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --color-surface: hsl(221, 29%, 15%); 3 | --color-shadow: hsl(221, 29%, 5%); 4 | --color-code-surface: hsl(221, 29%, 23%); 5 | --color-on-surface: hsl(221, 29%, 85%); 6 | --color-red: #f78a84; 7 | --color-yellow: #e7d170; 8 | --color-blue: #81b3f1; 9 | --color-green: #90d0af; 10 | --color-purple: #af90e8; 11 | } 12 | 13 | * { 14 | box-sizing: border-box; 15 | margin: 0; 16 | } 17 | 18 | body { 19 | min-height: 100vh; 20 | font-family: system-ui, sans-serif; 21 | line-height: 1.5; 22 | font-size: 1.05rem; 23 | background-color: var(--color-surface); 24 | color: var(--color-on-surface); 25 | } 26 | 27 | header, 28 | main { 29 | margin-right: auto; 30 | margin-left: auto; 31 | padding-top: 5vh; 32 | padding-bottom: 5vh; 33 | } 34 | 35 | main { 36 | display: grid; 37 | justify-items: center; 38 | gap: 2rem; 39 | } 40 | 41 | main, 42 | footer { 43 | width: min(80ch, 100vw - 3rem); 44 | } 45 | 46 | header { 47 | width: min(60ch, 100vw - 3rem); 48 | } 49 | 50 | header, 51 | footer { 52 | text-align: center; 53 | } 54 | 55 | footer { 56 | padding-top: 1rem; 57 | padding-bottom: 1rem; 58 | margin-left: auto; 59 | margin-right: auto; 60 | } 61 | 62 | footer p { 63 | display: inline-grid; 64 | gap: 1rem; 65 | align-items: center; 66 | } 67 | 68 | @media (min-width: 60ch) { 69 | footer p { 70 | grid-auto-flow: column; 71 | } 72 | } 73 | 74 | header h2 { 75 | font-weight: normal; 76 | } 77 | 78 | header p { 79 | font-size: 1.35rem; 80 | margin-top: 1.25em; 81 | } 82 | 83 | header a { 84 | display: inline-block; 85 | margin-top: 1rem; 86 | } 87 | 88 | article { 89 | display: grid; 90 | gap: 1rem; 91 | } 92 | 93 | a { 94 | color: inherit; 95 | text-underline-offset: 0.15em; 96 | word-break: break-all; 97 | overflow-wrap: anywhere; 98 | } 99 | 100 | a:focus { 101 | outline: 2px solid currentColor; 102 | outline-offset: 0.15em; 103 | } 104 | 105 | h2:not(:first-of-type) { 106 | margin-top: 5vh; 107 | } 108 | 109 | figure { 110 | display: grid; 111 | justify-items: center; 112 | } 113 | 114 | figcaption { 115 | font-size: 0.8em; 116 | text-align: center; 117 | margin-top: 0.5rem; 118 | } 119 | 120 | .meta { 121 | display: grid; 122 | grid-template-columns: fit-content(25vw) 1fr; 123 | gap: 1rem; 124 | align-items: center; 125 | } 126 | 127 | img { 128 | display: block; 129 | max-width: 100%; 130 | box-shadow: 0.15rem 0.15rem 0.35rem -0.08rem var(--color-shadow); 131 | } 132 | 133 | .image { 134 | object-fit: cover; 135 | } 136 | 137 | .reference { 138 | max-height: 10rem; 139 | } 140 | 141 | ::selection { 142 | color: var(--color-surface); 143 | background-color: var(--color-blue); 144 | } 145 | 146 | /* Prism */ 147 | pre[class*="language-"] { 148 | word-wrap: normal; 149 | background: none; 150 | color: var(--color-on-surface); 151 | font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace; 152 | -webkit-hyphens: none; 153 | -ms-hyphens: none; 154 | hyphens: none; 155 | line-height: 1.5; 156 | -moz-tab-size: 4; 157 | -o-tab-size: 4; 158 | tab-size: 4; 159 | text-align: left; 160 | white-space: pre; 161 | word-break: normal; 162 | word-spacing: normal; 163 | margin: 0; 164 | overflow: auto; 165 | padding: 1em; 166 | border-radius: 0.5rem; 167 | user-select: all; 168 | } 169 | 170 | code:not([class]) { 171 | font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace; 172 | font-size: 0.95em; 173 | color: var(--color-purple); 174 | overflow-wrap: anywhere; 175 | } 176 | 177 | :not(pre) > code[class*="language-"], 178 | pre[class*="language-"] { 179 | background: var(--color-code-surface); 180 | } 181 | 182 | :not(pre) > code[class*="language-"] { 183 | border-radius: 0.3em; 184 | padding: 0.1em; 185 | white-space: normal; 186 | } 187 | 188 | .token.cdata, 189 | .token.comment, 190 | .token.doctype, 191 | .token.prolog { 192 | color: #d4d0ab; 193 | } 194 | .token.punctuation { 195 | color: #f9f9f9; 196 | } 197 | .token.constant, 198 | .token.deleted, 199 | .token.property, 200 | .token.symbol, 201 | .token.tag { 202 | color: var(--color-blue); 203 | } 204 | .token.boolean, 205 | .token.number { 206 | color: var(--color-green); 207 | } 208 | .token.attr-name, 209 | .token.builtin, 210 | .token.char, 211 | .token.inserted, 212 | .token.selector, 213 | .token.string { 214 | color: var(--color-purple); 215 | } 216 | .language-css .token.string, 217 | .language-scss .token.string, 218 | .style .token.string, 219 | .token.entity, 220 | .token.operator, 221 | .token.url, 222 | .token.variable { 223 | color: var(--color-red); 224 | } 225 | .token.function { 226 | color: var(--color-green); 227 | } 228 | .token.atrule, 229 | .token.attr-value { 230 | color: var(--color-purple); 231 | } 232 | .token.keyword { 233 | color: var(--color-blue); 234 | } 235 | .token.important, 236 | .token.regex { 237 | color: var(--color-yellow); 238 | } 239 | .token.bold, 240 | .token.important { 241 | font-weight: 700; 242 | } 243 | .token.italic { 244 | font-style: italic; 245 | } 246 | .token.entity { 247 | cursor: help; 248 | } 249 | @media screen and (-ms-high-contrast: active) { 250 | code[class*="language-"], 251 | pre[class*="language-"] { 252 | background: window; 253 | color: windowText; 254 | } 255 | :not(pre) > code[class*="language-"], 256 | pre[class*="language-"] { 257 | background: window; 258 | } 259 | .token.important { 260 | background: highlight; 261 | color: window; 262 | font-weight: 400; 263 | } 264 | .token.atrule, 265 | .token.attr-value, 266 | .token.function, 267 | .token.keyword, 268 | .token.operator, 269 | .token.selector { 270 | font-weight: 700; 271 | } 272 | .token.attr-value, 273 | .token.comment, 274 | .token.doctype, 275 | .token.function, 276 | .token.keyword, 277 | .token.operator, 278 | .token.property, 279 | .token.string { 280 | color: highlight; 281 | } 282 | .token.attr-value, 283 | .token.url { 284 | font-weight: 400; 285 | } 286 | } 287 | --------------------------------------------------------------------------------