├── .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 |
`;
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 | 
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 |
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 |
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 |
--------------------------------------------------------------------------------