Tag: Create

How to Create a Browser Extension

I’ll bet you are using browser extensions right now. Some of them are extremely popular and useful, like ad blockers, password managers, and PDF viewers. These extensions (or “add-ons”) are not limited to those purposes — you can do a lot more with them! In this article, I will give you an introduction on how to create one. Ultimately, we’ll make it work in multiple browsers.

What we’re making

We’re making an extension called “Transcribers of Reddit” and it’s going to improve Reddit’s accessibility by moving specific comments to the top of the comment section and adding aria- attributes for screen readers. We will also take our extension a little further with options for adding borders and backgrounds to comments for better text contrast.

The whole idea is that you’ll get a nice introduction for how to develop a browser extension. We will start by creating the extension for Chromium-based browsers (e.g. Google Chrome, Microsoft Edge, Brave, etc.). In a future post we will port the extension to work with Firefox, as well as Safari which recently added support for Web Extensions in both the MacOS and iOS versions of the browser.

Ready? Let’s take this one step at a time.

Create a working directory

Before anything else, we need a working space for our project. All we really need is to create a folder and give it a name (which I’m calling transcribers-of-reddit). Then, create another folder inside that one named src for our source code.

Define the entry point

The entry point is a file that contains general information about the extension (i.e. extension name, description, etc.) and defines permissions or scripts to execute.

Our entry point can be a manifest.json file located in the src folder we just created. In it, let’s add the following three properties:

{   "manifest_version": 3,   "name": "Transcribers of Reddit",   "version": "1.0" }

The manifest_version is similar to version in npm or Node. It defines what APIs are available (or not). We’re going to work on the bleeding edge and use the latest version, 3 (also known as as mv3).

The second property is name and it specifies our extension name. This name is what’s displayed everywhere our extension appears, like Chrome Web Store and the chrome://extensions page in the Chrome browser.

Then there’s version. It labels the extension with a version number. Keep in mind that this property (in contrast to manifest_version) is a string that can only contain numbers and dots (e.g. 1.3.5).

More manifest.json information

There’s actually a lot more we can add to help add context to our extension. For example, we can provide a description that explains what the extension does. It’s a good idea to provide these sorts of things, as it gives users a better idea of what they’re getting into when they use it.

In this case, we’re not only adding a description, but supplying icons and a web address that Chrome Web Store points to on the extension’s page.

{   "description": "Reddit made accessible for disabled users.",   "icons": {     "16": "images/logo/16.png",     "48": "images/logo/48.png",     "128": "images/logo/128.png"   },   "homepage_url": "https://lars.koelker.dev/extensions/tor/" }
  • The description is displayed on Chrome’s management page (chrome://extensions) and should be brief, less than 132 characters.
  • The icons are used in lots of places. As the docs state, it’s best to provide three versions of the same icon in different resolutions, preferably as a PNG file. Feel free to use the ones in the GitHub repository for this example.
  • The homepage_url can be used to connect your website with the extension. A button including the link will be displayed when clicking on “More details” on the management page.
Our opened extension card inside the extension management page.

Setting permissions

One major advantage extensions have is that their APIs allow you to interact directly with the browser. But we have to explicitly give the extension those permissions, which also goes inside the manifest.json file.

 {   "manifest_version": 3,   "name": "Transcribers of Reddit",   "version": "1.0",   "description": "Reddit made accessible for disabled users.",   "icons": {     "16": "images/logo/16.png",     "48": "images/logo/48.png",     "128": "images/logo/128.png"   },   "homepage_url": "https://lars.koelker.dev/extensions/tor/",    "permissions": [     "storage",     "webNavigation"   ] }

What did we just give this extension permission to? First, storage. We want this extension to be able to save the user’s settings, so we need to access the browser’s web storage to hold them. For example, if the user wants red borders on the comments, then we’ll save that for next time rather than making them set it again.

We also gave the extension permission to look at how the user navigated to the current screen. Reddit is a single-page application (SPA) which means it doesn’t trigger a page refresh. We need to “catch” this interaction, as Reddit will only load the comments of a post if we click on it. So, that’s why we’re tapping into webNavigation.

We’ll get to executing code on a page later as it requires a whole new entry inside manifest.json.

/explanation Depending on which permissions are allowed, the browser might display a warning to the user to accept the permissions. It’s only certain ones, though, and Chrome has a nice outline of them.

Managing translations

Browser extensions have a built-in internalization (i18n) API. It allows you to manage translations for multiple languages (full list). To use the API, we have to define our translations and default language right in the manifest.json file:

"default_locale": "en"

This sets English as the language. In the event that a browser is set to any other language that isn’t supported, the extension will fall back to the default locale (en in this example).

Our translations are defined inside the _locales directory. Let’s create another folder in there each language you want to support. Each subdirectory gets its own messages.json file.

src   └─ _locales      └─ en         └─ messages.json      └─ fr         └─ messages.json

A translation file consists of multiple parts:

  • Translation key (“id”): This key is used to reference the translation.
  • Message: The actual translation content
  • Description (optional): Describes the translation (I wouldn’t use them, they just bloat up the file and your translation key should be descriptive enough)
  • Placeholders (optional): Can be used to insert dynamic content inside a translation

Here’s an example that pulls all that together:

{   "userGreeting": { // Translation key ("id")     "message": "Good $ daytime$ , $ user$ !" // Translation     "description": "User Greeting", // Optional description for translators     "placeholders": { // Optional placeholders       "daytime": { // As referenced inside the message         "content": "$ 1",         "example": "morning" // Example value for our content       },       "user": {          "content": "$ 1",         "example": "Lars"       }     }   } }

Using placeholders is a bit more challenging. At first we need to define the placeholder inside the message. A placeholder needs to be wrapped in between $ characters. Afterwards, we have to add our placeholder to the “placeholder list.” This is a bit unintuitive, but Chrome wants to know what value should be inserted for our placeholders. We (obviously) want to use a dynamic value here, so we use the special content value $ 1 which references our inserted value.

The example property is optional. It can be used to give translators a hint what value the placeholder could be (but is not actually displayed).

We need to define the following translations for our extension. Copy and paste them into the messages.json file. Feel free to add more languages (e.g. if you speak German, add a de folder inside _locales, and so on).

{   "name": {     "message": "Transcribers of Reddit"   },   "description": {     "message": "Accessible image descriptions for subreddits."   },   "popupManageSettings": {     "message": "Manage settings"   },   "optionsPageTitle": {     "message": "Settings"   },   "sectionGeneral": {     "message": "General settings"   },   "settingBorder": {     "message": "Show comment border"   },   "settingBackground": {     "message": "Show comment background"   } }

You might be wondering why we registered the permissions when there is no sign of an i18n permission, right? Chrome is a bit weird in that regard, as you don’t need to register every permission. Some (e.g. chrome.i18n) don’t require an entry inside the manifest. Other permissions require an entry but won’t be displayed to the user when installing the extension. Some other permissions are “hybrid” (e.g. chrome.runtime), meaning some of their functions can be used without declaring a permission—but other functions of the same API require one entry in the manifest. You’ll want to take a look at the documentation for a solid overview of the differences.

Using translations inside the manifest

The first thing our end user will see is either the entry inside the Chrome Web Store or the extension overview page. We need to adjust our manifest file to make sure everything os translated.

{   // Update these entries   "name": "__MSG_name__",   "description": "__MSG_description__" }

Applying this syntax uses the corresponding translation in our messages.json file (e.g. _MSG_name_ uses the name translation).

Using translations in HTML pages

Applying translations in an HTML file takes a little JavaScript.


That code returns our defined translation (which is Transcribers of Reddit). Placeholders can be done in a similar way.

chrome.i18n.getMessage('userGreeting', {   daytime: 'morning',   user: 'Lars' });

It would be a pain in the butt to apply translations to all elements this way. But we can write a little script that performs the translation based on a data- attribute. So, let’s create a new js folder inside the src directory, then add a new util.js file in it.

src   └─ js      └─ util.js

This gets the job done:

const i18n = document.querySelectorAll("[data-intl]"); i18n.forEach(msg => {   msg.innerHTML = chrome.i18n.getMessage(msg.dataset.intl); });  chrome.i18n.getAcceptLanguages(languages => {   document.documentElement.lang = languages[0]; });

Once that script is added to an HTML page, we can add the data-intl attribute to an element to set its content. The document language will also be set based on the user language.

<!-- Before JS execution --> <html>   <body>     <button data-intl="popupManageSettings"></button>   </body> </html>

<!-- After JS execution --> <html lang="en">   <body>     <button data-intl="popupManageSettings">Manage settings</button>   </body> </html>

Adding a pop-up and options page

Before we dive into actual programming, we we need to create two pages:

  1. An options page that contains user settings
  2. A pop-up page that opens when interacting with the extension icon right next to our address bar. This page can be used for various scenarios (e.g. for displaying stats or quick settings).
The options page containg our settings.

The pop-up containg a link to the options page.

Here’s an outline of the folders and files we need in order to make the pages:

src   ├─ css  |    └─ paintBucket.css  ├─ popup  |    ├─ popup.html  |    ├─ popup.css  |    └─ popup.js  └─ options       ├─ options.html       ├─ options.css       └─ options.js

The .css files contain plain CSS, nothing more and nothing less. I won’t into detail because I know most of you reading this are already fully aware of how CSS works. You can copy and paste the styles from the GitHub repository for this project.

Note that the pop-up is not a tab and that its size depends on the content in it. If you want to use a fixed popup size, you can set the width and height properties on the html element.

Creating the pop-up

Here’s an HTML skeleton that links up the CSS and JavaScript files and adds a headline and button inside the <body>.

<!doctype html> <html lang="en">   <head>     <meta charset="UTF-8">     <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">     <meta http-equiv="X-UA-Compatible" content="ie=edge">     <title data-intl="name"></title>      <link rel="stylesheet" href="../css/paintBucket.css">     <link rel="stylesheet" href="popup.css">      <!-- Our "translation" script -->     <script src="../js/util.js" defer></script>     <script src="popup.js" defer></script>   </head>   <body>     <h1 id="title"></h1>     <button data-intl="popupManageSettings"></button>   </body> </html>

The h1 contains the extension name and version; the button is used to open the options page. The headline will not be filled with a translation (because it lacks a data-intl attribute), and the button doesn’t have any click handler yet, so we need to populate our popup.js file:

const title = document.getElementById('title'); const settingsBtn = document.querySelector('button'); const manifest = chrome.runtime.getManifest();  title.textContent = `$ {manifest.name} ($ {manifest.version})`;  settingsBtn.addEventListener('click', () => {   chrome.runtime.openOptionsPage(); });

This script first looks for the manifest file. Chrome offers the runtime API which contains the getManifest method (this specific method does not require the runtime permission). It returns our manifest.json as a JSON object. After we populate the title with the extension name and version, we can add an event listener to the settings button. If the user interacts with it, we will open the options page using chrome.runtime.openOptionsPage() (again no permission entry needed).

The pop-up page is now finished, but the extension doesn’t know it exists yet. We have to register the pop-up by appending the following property to the manifest.json file.

"action": {   "default_popup": "popup/popup.html",   "default_icon": {     "16": "images/logo/16.png",     "48": "images/logo/48.png",     "128": "images/logo/128.png"   } },

Creating the options page

Creating this page follows a pretty similar process as what we just completed. First, we populate our options.html file. Here’s some markup we can use:

<!doctype html> <html lang="en"> <head>   <meta charset="UTF-8">   <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">   <meta http-equiv="X-UA-Compatible" content="ie=edge">   <title data-intl="name"></title>    <link rel="stylesheet" href="../css/paintBucket.css">   <link rel="stylesheet" href="options.css">    <!-- Our "translation" script -->   <script src="../js/util.js" defer></script>   <script src="options.js" defer></script> </head> <body>   <header>     <h1>       <!-- Icon provided by feathericons.com -->       <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" role="presentation">         <circle cx="12" cy="12" r="3"></circle>         <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>       </svg>       <span data-intl="optionsPageTitle"></span>     </h1>   </header>    <main>     <section id="generalOptions">       <h2 data-intl="sectionGeneral"></h2>        <div id="generalOptionsWrapper"></div>     </section>   </main>    <footer>     <p>Transcribers of Reddit extension by <a href="https://lars.koelker.dev" target="_blank">lars.koelker.dev</a>.</p>     <p>Reddit is a registered trademark of Reddit, Inc. This extension is not endorsed or affiliated with Reddit, Inc. in any way.</p>   </footer> </body> </html>

There are no actual options yet (just their wrappers). We need to write the script for the options page. First, we define variables to access our wrappers and default settings inside options.js. “Freezing” our default settings prevents us from accidentally modifying them later.

const defaultSettings = Object.freeze({   border: false,   background: false, }); const generalSection = document.getElementById('generalOptionsWrapper'); 

Next, we need to load the saved settings. We can use the (previously registered) storage API for that. Specifically, we need to define if we want to store the data locally (chrome.storage.local) or sync settings through all devices the end user is logged in to (chrome.storage.sync). Let’s go with local storage for this project.

Retrieving values needs to be done with the get method. It accepts two arguments:

  1. The entries we want to load
  2. A callback containing the values

Our entries can either be a string (e.g. like settings below) or an array of entries (useful if we want to load multiple entries). The argument inside the callback function contains an object of all entries we previously defined in { settings: ... }:

chrome.storage.local.get('settings', ({ settings }) => {   const options = settings ?? defaultSettings; // Fall back to default if settings are not defined   if (!settings) {     chrome.storage.local.set({      settings: defaultSettings,     });  }    // Create and display options   const generalOptions = Object.keys(options).filter(x => !x.startsWith('advanced'));      generalOptions.forEach(option => createOption(option, options, generalSection)); });

To render the options, we also need to create a createOption() function.

function createOption(setting, settingsObject, wrapper) {   const settingWrapper = document.createElement("div");   settingWrapper.classList.add("setting-item");   settingWrapper.innerHTML = `   <div class="label-wrapper">     <label for="$ {setting}" id="$ {setting}Desc">       $ {chrome.i18n.getMessage(`setting$ {setting}`)}     </label>   </div>    <input type="checkbox" $ {settingsObject[setting] ? 'checked' : ''} id="$ {setting}" />   <label for="$ {setting}"     tabindex="0"     role="switch"     aria-checked="$ {settingsObject[setting]}"     aria-describedby="$ {setting}-desc"     class="is-switch"   ></label>   `;    const toggleSwitch = settingWrapper.querySelector("label.is-switch");   const input = settingWrapper.querySelector("input");    input.onchange = () => {     toggleSwitch.setAttribute('aria-checked', input.checked);     updateSetting(setting, input.checked);   };    toggleSwitch.onkeydown = e => {     if(e.key === " " || e.key === "Enter") {       e.preventDefault();       toggleSwitch.click();     }   }    wrapper.appendChild(settingWrapper); }

Inside the onchange event listener of our switch (aká radio button) we call the function updateSetting. This method will write the updated value of our radio button inside the storage.

To accomplish this, we will make use of the set function. It has two arguments: The entry we want to overwrite and an (optional) callback (which we don’t use in our case). As our settings entry is not a boolean or a string but an object containing different settings, we use the spread operator () and only overwrite our actual key (setting) inside the settings object.

function updateSetting(key, value) {   chrome.storage.local.get('settings', ({ settings }) => {     chrome.storage.local.set({       settings: {         ...settings,         [key]: value       }     })   }); }

Once again, we need to “inform” the extension about our options page by appending the following entry to the manifest.json:

"options_ui": {   "open_in_tab": true,   "page": "options/options.html" },

Depending on your use case you can also force the options dialog to open as a popup by setting open_in_tab to false.

Installing the extension for development

Now that we’ve successfully set up the manifest file and have added both the pop-up and options page to the mix, we can install our extension to check if our pages actually work. Navigate to chrome://extensions and enable “Developer mode.” Three buttons will appear. Click the one labeled “Load unpacked” and select the src folder of your extension to load it up.

The extension should now be successfully installed and our “Transcribers of Reddit” tile should be on the page.

We can already interact with our extension. Click on the puzzle piece (🧩) icon right next to the browser’s address bar and click on the newly-added “Transcribers of Reddit” extension. You should now be greeted by a small pop-up with the button to open the options page.

Lovely, right? It might look a bit different on your device, as I have dark mode enabled in these screenshots.

If you enable the “Show comment background” and “Show comment border” settings, then reload the page, the state will persist because we’re saving it in the browser’s local storage.

Adding the content script

OK, so we can already trigger the pop-up and interact with the extension settings, but the extension doesn’t do anything particularly useful yet. To give it some life, we will add a content script.

Add a file called comment.js inside the js directory and make sure to define it in the manifest.json file:

"content_scripts": [   {     "matches": [ "*://www.reddit.com/*" ],     "js": [ "js/comment.js" ]   } ],

The content_scripts is made up of two parts:

  • matches: This array holds URLs that tell the browser where we want our content scripts to run. Being an extension for Reddit and all, we want this to run on any page matching ://www.redit.com/*, where the asterisk is a wild card to match anything after the top-level domain.
  • js: This array contains the actual content scripts.

Content scripts can’t interact with other (normal) JavaScripts. This means if a website’s scripts defines a variable or function, we can’t access it. For example:

// script_on_website.js const username = 'Lars';  // content_script.js console.log(username); // Error: username is not defined

Now let’s start writing our content script. First, we add some constants to comment.js. These constants contain RegEx expressions and selectors that will be used later on. The CommentUtils is used to determine whether or not a post contains a “tor comment,” or if comment wrappers exists.

const messageTypes = Object.freeze({   COMMENT_PAGE: 'comment_page',   SUBREDDIT_PAGE: 'subreddit_page',   MAIN_PAGE: 'main_page',   OTHER_PAGE: 'other_page', });  const Selectors = Object.freeze({   commentWrapper: 'div[style*="--commentswrapper-gradient-color"] > div, div[style*="max-height: unset"] > div',   torComment: 'div[data-tor-comment]',   postContent: 'div[data-test-id="post-content"]' });  const UrlRegex = Object.freeze({   commentPage: //r/.*/comments/.*/,   subredditPage: //r/.*// });  const CommentUtils = Object.freeze({   isTorComment: (comment) => comment.querySelector('[data-test-id="comment"]') ? comment.querySelector('[data-test-id="comment"]').textContent.includes('m a human volunteer content transcriber for Reddit') : false,   torCommentsExist: () => !!document.querySelector(Selectors.torComment),   commentWrapperExists: () => !!document.querySelector('[data-reddit-comment-wrapper="true"]') });

Next, we check whether or not a user directly opens a comment page (“post”), then perform a RegEx check and update the directPage variable. This case occurs when a user directly opens the URL (e.g. by typing it into the address bar or clicking on<a> element on another page, like Twitter).

let directPage = false; if (UrlRegex.commentPage.test(window.location.href)) {   directPage = true;   moveComments(); }

Besides opening a page directly, a user normally interacts with the SPA. To catch this case, we can add a message listener to our comment.js file by using the runtime API.

chrome.runtime.onMessage.addListener(msg => {   if (msg.type === messageTypes.COMMENT_PAGE) {     waitForComment(moveComments);   } });

All we need now are the functions. Let’s create a moveComments() function. It moves the special “tor comment” to the start of the comment section. It also conditionally applies a background color and border (if borders are enabled in the settings) to the comment. For this, we call the storage API and load the settings entry:

function moveComments() {   if (CommentUtils.commentWrapperExists()) {     return;   }    const wrapper = document.querySelector(Selectors.commentWrapper);   let comments = wrapper.querySelectorAll(`$ {Selectors.commentWrapper} > div`);   const postContent = document.querySelector(Selectors.postContent);    wrapper.dataset.redditCommentWrapper = 'true';   wrapper.style.flexDirection = 'column';   wrapper.style.display = 'flex';    if (directPage) {     comments = document.querySelectorAll("[data-reddit-comment-wrapper='true'] > div");   }    chrome.storage.local.get('settings', ({ settings }) => { // HIGHLIGHT 18     comments.forEach(comment => {       if (CommentUtils.isTorComment(comment)) {         comment.dataset.torComment = 'true';         if (settings.background) {           comment.style.backgroundColor = 'var(--newCommunityTheme-buttonAlpha05)';         }         if (settings.border) {           comment.style.outline = '2px solid red';         }         comment.style.order = "-1";         applyWaiAria(postContent, comment);       }     });   }) }

The applyWaiAria() function is called inside the moveComments() function—it adds aria- attributes. The other function creates a unique identifier for use with the aria- attributes.

function applyWaiAria(postContent, comment) {   const postMedia = postContent.querySelector('img[class*="ImageBox-image"], video');   const commentId = uuidv4();    if (!postMedia) {     return;   }    comment.setAttribute('id', commentId);   postMedia.setAttribute('aria-describedby', commentId); }  function uuidv4() {   return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {     var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);     return v.toString(16);   }); }

The following function waits for the comments to load and calls the callback parameter if it finds the comment wrapper.

function waitForComment(callback) {   const config = { childList: true, subtree: true };   const observer = new MutationObserver(mutations => {     for (const mutation of mutations) {       if (document.querySelector(Selectors.commentWrapper)) {         callback();         observer.disconnect();         clearTimeout(timeout);         break;       }     }   });    observer.observe(document.documentElement, config);   const timeout = startObservingTimeout(observer, 10); }  function startObservingTimeout(observer, seconds) {   return setTimeout(() => {     observer.disconnect();   }, 1000 * seconds); }

Adding a service worker

Remember when we added a listener for messages inside the content script? This listener isn’t currently receiving messages. We need to send it to the content script ourselves. For this purpose we need to register a service worker.

We have to register our service worker inside the manifest.json by appending the following code to it:

"background": {   "service_worker": "sw.js" }

Don’t forget to create the sw.js file inside the src directory (service workers always need to be created in the extension’s root directory, src.

Now, let’s create some constants for the message and page types:

const messageTypes = Object.freeze({   COMMENT_PAGE: 'comment_page',   SUBREDDIT_PAGE: 'subreddit_page',   MAIN_PAGE: 'main_page',   OTHER_PAGE: 'other_page', });  const UrlRegex = Object.freeze({   commentPage: //r/.*/comments/.*/,   subredditPage: //r/.*// });  const Utils = Object.freeze({   getPageType: (url) => {     if (new URL(url).pathname === '/') {       return messageTypes.MAIN_PAGE;     } else if (UrlRegex.commentPage.test(url)) {       return messageTypes.COMMENT_PAGE;     } else if (UrlRegex.subredditPage.test(url)) {       return messageTypes.SUBREDDIT_PAGE;     }      return messageTypes.OTHER_PAGE;   } });

We can add the service worker’s actual content. We do this with an event listener on the history state (onHistoryStateUpdated) that detects when a page has been updated with the History API (which is commonly used in SPAs to navigate without a page refresh). Inside this listener, we query the active tab and extract its tabId. Then we send a message to our content script containing the page type and URL.

chrome.webNavigation.onHistoryStateUpdated.addListener(async ({ url }) => {   const [{ id: tabId }] = await chrome.tabs.query({ active: true, currentWindow: true });    chrome.tabs.sendMessage(tabId, {     type: Utils.getPageType(url),     url   }); });

All done!

We’re finished! Navigate to Chrome’s extension management page ( chrome://extensions) and hit the reload icon on the unpacked extension. If you open a Reddit post that contains a “Transcribers of Reddit” comment with an image transcription (like this one), it will be moved to the start of the comment section and be highlighted as long as we’ve enabled it in the extension settings.

The “Transcribers of Reddit” extension highlights a particular comment by moving it to the top of the Reddit thread’s comment list and giving it a bright red border


Was that as hard as you thought it would be? It’s definitely a lot more straightforward than I thought before digging in. After setting up the manifest.json and creating any page files and assets we need, all we’re really doing is writing HTML, CSS, and JavaScript like normal.

If you ever find yourself stuck along the way, the Chrome API documentation is a great resource to get back on track.

Once again, here’s the GitHub repo with all of the code we walked through in this article. Read it, use it, and let me know what you think of it!

How to Create a Browser Extension originally published on CSS-Tricks


, ,

How to Create an Animated Chart of Nested Squares Using Masks

We have many well-known chart types: bar, donut, line, pie, you name it. All popular chart libraries support these. Then there are the chart types that do not even have a name. Check out this dreamt-up chart with stacked (nested) squares that can help visualize relative sizes, or how different values compare to one another:

What we’re making

Without any interactivity, creating this design is fairly straightforward. One way to do it is is to stack elements (e.g. SVG <rect> elements, or even HTML divs) in decreasing sizes, where all of their bottom-left corners touch the same point.

But things get trickier once we introduce some interactivity. Here’s how it should be: When we move our mouse over one of the shapes, we want the others to fade out and move away.

We’ll create these irregular shapes using rectangles and masks — literal <svg> with <rect> and <mask> elements. If you are entirely new to masks, you are in the right place. This is an introductory-level article. If you are more seasoned, then perhaps this cut-out effect is a trick that you can take with you.

Now, before we begin, you may wonder if a better alternative to SVG to using custom shapes. That’s definitely a possibility! But drawing shapes with a <path> can be intimidating, or even get messy. So, we’re working with “easier” elements to get the same shapes and effects.

For example, here’s how we would have to represent the largest blue shape using a <path>.

<svg viewBox="0 0 320 320" width="320" height="320">   <path d="M320 0H0V56H264V320H320V0Z" fill="#264653"/> </svg>

If the 0H0V56… does not make any sense to you, check out “The SVG path Syntax: An Illustrated Guide” for a thorough explanation of the syntax.

The basics of the chart

Given a data set like this:

type DataSetEntry = {   label: string;   value: number; };  type DataSet = DataSetEntry[];  const rawDataSet: DataSet = [   { label: 'Bad', value: 1231 },   { label: 'Beginning', value: 6321 },   { label: 'Developing', value: 10028 },   { label: 'Accomplished', value: 12123 },   { label: 'Exemplary', value: 2120 } ];

…we want to end up with an SVG like this:

<svg viewBox="0 0 320 320" width="320" height="320">   <rect width="320" height="320" y="0" fill="..."></rect>   <rect width="264" height="264" y="56" fill="..."></rect>   <rect width="167" height="167" y="153" fill="..."></rect>   <rect width="56" height="56" y="264" fill="..."></rect>   <rect width="32" height="32" y="288" fill="..."></rect> </svg>

Determining the highest value

It will become apparent in a moment why we need the highest value. We can use the Math.max() to get it. It accepts any number of arguments and returns the highest value in a set.

const dataSetHighestValue: number = Math.max(   ...rawDataSet.map((entry: DataSetEntry) => entry.value) );

Since we have a small dataset, we can just tell that we will get 12123.

Calculating the dimension of the rectangles

If we look at the design, the rectangle representing the highest value (12123) covers the entire area of the chart.

We arbitrarily picked 320 for the SVG dimensions. Since our rectangles are squares, the width and height are equal. How can we make 12123 equal to 320? How about the less “special” values? How big is the 6321 rectangle?

Asked another way, how do we map a number from one range ([0, 12123]) to another one ([0, 320])? Or, in more math-y terms, how do we scale a variable to an interval of [a, b]?

For our purposes, we are going to implement the function like this:

const remapValue = (   value: number,   fromMin: number,   fromMax: number,   toMin: number,   toMax: number ): number => {   return ((value - fromMin) / (fromMax - fromMin)) * (toMax - toMin) + toMin; };  remapValue(1231, 0, 12123, 0, 320); // 32 remapValue(6321, 0, 12123, 0, 320); // 167 remapValue(12123, 0, 12123, 0, 320); // 320

Since we map values to the same range in our code, instead of passing the minimums and maximums over and over, we can create a wrapper function:

const valueRemapper = (   fromMin: number,   fromMax: number,   toMin: number,   toMax: number ) => {   return (value: number): number => {     return remapValue(value, fromMin, fromMax, toMin, toMax);   }; };  const remapDataSetValueToSvgDimension = valueRemapper(   0,   dataSetHighestValue,   0,   svgDimension );

We can use it like this:

remapDataSetValueToSvgDimension(1231); // 32 remapDataSetValueToSvgDimension(6321); // 167 remapDataSetValueToSvgDimension(12123); // 320

Creating and inserting the DOM elements

What remains has to do with DOM manipulation. We have to create the <svg> and the five <rect> elements, set their attributes, and append them to the DOM. We can do all this with the basic createElementNS, setAttribute, and the appendChild functions.

Notice that we are using the createElementNS instead of the more common createElement. This is because we are working with an SVG. HTML and SVG elements have different specs, so they fall under a different namespace URI. It just happens that the createElement conveniently uses the HTML namespace! So, to create an SVG, we have to be this verbose:

document.createElementNS('http://www.w3.org/2000/svg', 'svg') as SVGSVGElement;

Surely, we can create another helper function:

const createSvgNSElement = (element: string): SVGElement => {   return document.createElementNS('http://www.w3.org/2000/svg', element); };

When we are appending the rectangles to the DOM, we have to pay attention to their order. Otherwise, we would have to specify the z-index explicitly. The first rectangle has to be the largest, and the last rectangle has to be the smallest. Best to sort the data before the loop.

const data = rawDataSet.sort(   (a: DataSetEntry, b: DataSetEntry) => b.value - a.value );  data.forEach((d: DataSetEntry, index: number) => {   const rect: SVGRectElement = createSvgNSElement('rect') as SVGRectElement;   const rectDimension: number = remapDataSetValueToSvgDimension(d.value);    rect.setAttribute('width', `$ {rectDimension}`);   rect.setAttribute('height', `$ {rectDimension}`);   rect.setAttribute('y', `$ {svgDimension - rectDimension}`);    svg.appendChild(rect); });

The coordinate system starts from the top-left; that’s where the [0, 0] is. We are always going to draw the rectangles from the left side. The x attribute, which controls the horizontal position, defaults to 0, so we don’t have to set it. The y attribute controls the vertical position.

To give the visual impression that all of the rectangles originate from the same point that touches their bottom-left corners, we have to push the rectangles down so to speak. By how much? The exact amount that the rectangle does not fill. And that value is the difference between the dimension of the chart and the particular rectangle. If we put all the bits together, we end up with this:

We already added the code for the animation to this demo using CSS.

Cutout rectangles

We have to turn our rectangles into irregular shapes that sort of look like the number seven, or the letter L rotated 180 degrees.

If we focus on the “missing parts” then we can see they cutouts of the same rectangles we’re already working with.

We want to hide those cutouts. That’s how we are going to end up with the L-shapes we want.

Masking 101

A mask is something you define and later apply to an element. Typically, the mask is inlined in the <svg> element it belongs to. And, generally, it should have a unique id because we have to reference it in order to apply the mask to an element.

<svg>   <mask id="...">     <!-- ... -->   </mask> </svg>

In the <mask> tag, we put the shapes that serve as the actual masks. We also apply the mask attribute to the elements.

<svg>   <mask id="myCleverlyNamedMask">     <!-- ... -->   </mask>   <rect mask="url(#myCleverlyNamedMask)"></rect> </svg>

That’s not the only way to define or apply a mask, but it’s the most straightforward way for this demo. Let’s do a bit of experimentation before writing any code to generate the masks.

We said that we want to cover the cutout areas that match the sizes of the existing rectangles. If we take the largest element and we apply the previous rectangle as a mask, we end up with this code:

<svg viewBox="0 0 320 320" width="320" height="320">   <mask id="theMask">     <rect width="264" height="264" y="56" fill=""></rect>   </mask>   <rect width="320" height="320" y="0" fill="#264653" mask="url(#theMask)"></rect> </svg>

The element inside the mask needs a fill value. What should that be? We’ll see entirely different results based on the fill value (color) we choose.

The white fill

If we use a white value for the fill, then we get this:

Now, our large rectangle is the same dimension as the masking rectangle. Not exactly what we wanted.

The black fill

If we use a black value instead, then it looks like this:

We don’t see anything. That’s because what is filled with black is what becomes invisible. We control the visibility of masks using white and black fills. The dashed lines are there as a visual aid to reference the dimensions of the invisible area.

The gray fill

Now let’s use something in-between white and black, say gray:

It’s neither fully opaque or solid; it’s transparent. So, now we know we can control the “degree of visibility” here by using something different than white and black values which is a good trick to keep in our back pockets.

The last bit

Here’s what we’ve covered and learned about masks so far:

  • The element inside the <mask> controls the dimension of the masked area.
  • We can make the contents of the masked area visible, invisible, or transparent.

We have only used one shape for the mask, but as with any general purpose HTML tag, we can nest as many child elements in there as we want. In fact, the trick to achieve what we want is using two SVG <rect> elements. We have to stack them one on top of the other:

<svg viewBox="0 0 320 320" width="320" height="320">   <mask id="maskW320">     <rect width="320" height="320" y="0" fill="???"></rect>     <rect width="264" height="264" y="56" fill="???"></rect>   </mask>   <rect width="320" height="320" y="0" fill="#264653" mask="url(#maskW320)"></rect> </svg>

One of our masking rectangles is filled with white; the other is filled with black. Even if we know the rules, let’s try out the possibilities.

<mask id="maskW320">   <rect width="320" height="320" y="0" fill="black"></rect>   <rect width="264" height="264" y="56" fill="white"></rect> </mask>

The <mask> is the dimension of the largest element and the largest element is filled with black. That means everything under that area is invisible. And everything under the smaller rectangle is visible.

Now let’s do flip things where the black rectangle is on top:

<mask id="maskW320">   <rect width="320" height="320" y="0" fill="white"></rect>   <rect width="264" height="264" y="56" fill="black"></rect> </mask>

This is what we want!

Everything under the largest white-filled rectangle is visible, but the smaller black rectangle is on top of it (closer to us on the z-axis), masking that part.

Generating the masks

Now that we know what we have to do, we can create the masks with relative ease. It’s similar to how we generated the colored rectangles in the first place — we create a secondary loop where we create the mask and the two rects.

This time, instead of appending the rects directly to the SVG, we append it to the mask:

data.forEach((d: DataSetEntry, index: number) => {   const mask: SVGMaskElement = createSvgNSElement('mask') as SVGMaskElement;    const rectDimension: number = remapDataSetValueToSvgDimension(d.value);   const rect: SVGRectElement = createSvgNSElement('rect') as SVGRectElement;    rect.setAttribute('width', `$ {rectDimension}`);   // ...setting the rest of the attributes...    mask.setAttribute('id', `maskW$ {rectDimension.toFixed()}`);    mask.appendChild(rect);    // ...creating and setting the attributes for the smaller rectangle...    svg.appendChild(mask); });  data.forEach((d: DataSetEntry, index: number) => {     // ...our code to generate the colored rectangles... });

We could use the index as the mask’s ID, but this seems a more readable option, at least to me:

mask.setAttribute('id', `maskW$ {rectDimension.toFixed()}`); // maskW320, masW240, ...

As for adding the smaller rectangle in the mask, we have easy access the value we need because we previously ordered the rectangle values from highest to lowest. That means the next element in the loop is the smaller rectangle, the one we should reference. And we can do that by its index.

// ...previous part where we created the mask and the rectangle...  const smallerRectIndex = index + 1;  // there's no next one when we are on the smallest if (data[smallerRectIndex] !== undefined) {   const smallerRectDimension: number = remapDataSetValueToSvgDimension(     data[smallerRectIndex].value   );   const smallerRect: SVGRectElement = createSvgNSElement(     'rect'   ) as SVGRectElement;    // ...setting the rectangle attributes...    mask.appendChild(smallerRect); }  svg.appendChild(mask);

What is left is to add the mask attribute to the colored rectangle in our original loop. It should match the format we chose:

rect.setAttribute('mask', `url(#maskW$ {rectDimension.toFixed()})`); // maskW320, maskW240, ...

The final result

And we are done! We’ve successfully made a chart that’s made out of nested squares. It even comes apart on mouse hover. And all it took was some SVG using the <mask> element to draw the cutout area of each square.

The post How to Create an Animated Chart of Nested Squares Using Masks appeared first on CSS-Tricks. You can support CSS-Tricks by being an MVP Supporter.


, , , , , ,

Create Your Own Automated Social Images With Resoc

There has been a lot of talk about automated social images lately. GitHub has created its own. A WordPress plugin has been acquired by Jetpack. There is definitely interest! People like Ryan Filler and Zach Leatherman have implemented social images on their websites. They had to code a lot of things on their own. But the landscape is changing and tools are available to smooth the process.

In this tutorial, we are going to create our own automated social images with HTML and CSS, integrate them to an Eleventy blog — mostly by configuration — and deploy our site to Netlify.

If you really, really can’t wait, check the result or browse the project!

What are social images again?

In the <head> section of HTML, we insert a few Open Graph markups:

<meta property="og:title" content="The blue sky strategy" /> <meta property="og:description" content="Less clouds, more blue" /> <meta property="og:image" content="/sky-with-clouds.jpg" />

When we share this page on Facebook, we and our friends see this:

LinkedIn, Twitter, WhatsApp, Slack, Discord, iMessage… All these sites behave pretty much the same way: they provide a visual “card” that accompanies the link, giving it more space and context.

Twitter has its own set of markups with its Twitter Cards, but they are very similar. And Twitter falls back to Open Graph when it can’t find them.

It is natural for our pages to have a title and a description. But in the screenshot above, they are quite small compared to the space and attention the picture of sky and clouds gets — not to mention the size of the clickable area. That’s the power of the social image. It’s easy to understand the impact these images can have when a link is shared.

From Level 0 to Level 3

Not all social images are created equal. These are not official terms, but let’s consider numbered “levels” on how impactful these social image cards can be.

Level 0

The most basic social image is no image. The link might be lost in a sea of content with the small area and not much visual.

Level 1

A classic technique is to create a site-wide social image. While this solution might seem to offer a good outcome-to-effort ratio, one could argue this is worse than no image at all. Sure, we get some attention, but the reaction might be negative, especially if people see a lot of links to this website that all look the same. It risks feeling repetitive and unnecessary.

Level 2

The next level is standard in blogs and media sites: the social image of a post. Each post has its own featured image, and they differ from one post to another. This practice is totally legitimate for a news site, where the photo complements the page content. The potential drawback here is that it requires effort to find and create artwork for each and every published post.

That might lead to a bit of laziness. We’ve all been exposed to images that are obviously stock photos. It might get attention, but perhaps not the kind of attention you actually want.

Need an image of an intentionally diverse group of people meeting around a table foe work? There’s a ton of them out there!

Level 3

The final level: per-page, content-rich, meaningful social images. CSS-Tricks is doing just this. The team’s social images are branded. They share the same layout. They mention the post title, along with the author’s name and profile picture, something the regular title and description could not show. They grab attention and are memorable.

The CSS-Tricks social card incorporates information related to the post worth looking at.

There is an obvious requirement with this approach: automation. It is out of question to create unique images for every possible link. Just think of the overhead. We’d need some programmatic solution to help with the heavy lifting.

Let’s start a blog with blog posts that have unique social images

To give ourselves a nice little excuse (and sandbox) to build out unique social images, we’ll put together a quick blog. When I write and publish an article to this blog, I follow a quick two-step process:

  1. Write and publish the article
  2. Post the published URL to my social network accounts

This is when social images must shine. We want to give our blog its best shot at being noticed. But that’s not our only goal. This blog should establish our personal brand. We want our friends, colleagues, and followers to remember us when they see our social posts. We want something that’s repeatable, recognizable, and representative of ourselves.

Creating a blog is a lot or work. Although automated social images are cool, it’s unwise to spend too much time on them. (Chris came to the same conclusion at the end of 2020). So, in the interest of efficiency, we’re making an Eleventy site. Eleventy is a simple static site generator. Instead of starting from scratch, let’s use one of the starter projects. In fact, let’s pick the first one, eleventy-base-blog.

This is just the base template. We’re only using it to make sure we have posts to share.

Visit the eleventy-base-blog GitHub page and use it as a template:

Use eleventy-base-blog as a template

Let’s create the repository, and set a repository name, description. We can make it public or private, it doesn’t matter.

Next, we clone our repository locally, install packages, and run the site:

git clone [your repo URL] cd my-demo-blog ### Or whatever you named it npm install npm run serve

Our site running is running at http://localhost:8080.

Now let’s deploy it. Netlify makes this a super quick (and free!) task. (Oh, and spoiler alert: our social images automation relies on a Netlify Function.)

So, let’s go to Netlify and create an account, that is, if you don’t already have one. Either way, create a new site:

Click the “New site from Git” button to link up the project repo for hosting and deployment.

Go through the process of allowing Netlify to access the blog repository.

Simply leave the default values as they are and click the “Deploy site” button

Netlify deploys our site:

After a minute or so, the blog is deployed:

The site is deployed — we’re all set!

One image template to rule them all

Our social images are going to be based on an image template. To design this template, we are going to use the technologies we already know and love: HTML and CSS. HTML doesn’t turn itself into images auto-magically, but there are tools for this, the most famous being headless Chrome with Puppeteer.

However, instead of building our social image stack ourselves, we use the Resoc Image Template Development Kit. So, from the project root we can run this in the terminal:

npx itdk init resoc-templates/default -m title-description

This command creates a new image template in the resoc-templates/default directory. It also opens up in a new browser window.

The viewer provides a browser preview of the template configuration, as well as UI to change the values.

We could use this template as-is, but that only gets us to Level 2 on “impactful” spectrum. What we need to make this go all the way up to Level 3 and match the CSS-Tricks template is:

  • the page title aligned to the right with a bit of negative space on the left.
  • a footer at the bottom that contains a background gradient made from two colors we are going to use throughout the blog
  • the post author’s name and profile picture

If we head back to the browser, we can see in the Parameters panel of the template viewer that the template expects two parameters: a title and description. That’s just the template we chose when we ran -m title-description in the terminal as we set things up. But we can add more parameters by editing resoc-templates/default/resoc.manifest.json. Specifically, we can remove the second parameter to get:

{   "partials": {     "content": "./content.html.mustache",     "styles": "./styles.css.mustache"   },   "parameters": [     {       "name": "title",       "type": "text",       "demoValue": "A picture is worth a thousand words"     }   ] }

The viewer reflects the change in the browser:

Now the description is gone.

It’s time to design the image itself, which we can do in resoc-templates/default/content.html.mustache:

<div class="wrapper">   <main>     <h1>{{ title }}</h1>   </main>   <footer>     <img src="profil-pic.jpg" />     <h2>Philippe Bernard</h2>   </footer> </div>

That’s just regular HTML. Well, except {{ title }}. This is Mustache, the templating framework Resoc uses to inject parameter values into the template. We can even type some text in the “Title” field to see it working:

Looking at the previews, notice that we’re missing an image, profil-pic.jpg. Copy your best profile picture to resoc-templates/default/profil-pic.jpg:

The profile picture is now set.

It’s time to write the CSS in resoc-templates/default/styles.css.mustache. The point of this post isn’t how to style the template, but here’s what I ended up using:

@import url('https://fonts.googleapis.com/css2?family=Anton&family=Raleway&display=swap');  .wrapper {   display: flex;   flex-direction: column; }  main {   flex: 1;   display: flex;   flex-direction: column;   justify-content: center;   position: relative; }  h1 {   text-align: right;   margin: 2vh 3vw 10vh 20vw;   background: rgb(11,35,238);   background: linear-gradient(90deg, rgba(11,35,238,1) 0%, rgba(246,52,12,1) 100%);   -webkit-text-fill-color: transparent;   -webkit-background-clip: text;   font-family: 'Anton';   font-size: 14vh;   text-transform: uppercase;   text-overflow: ellipsis;   display: -webkit-box;   -webkit-line-clamp: 3;   -webkit-box-orient: vertical; }  h2 {   color: white;   margin: 0;   font-family: 'Raleway';   font-size: 10vh; }  footer {   flex: 0 0;   min-height: 20vh;   display: flex;   align-items: center;   background: rgb(11,35,238);   background: linear-gradient(90deg, rgba(11,35,238,1) 0%, rgba(246,52,12,1) 100%);   padding: 2vh 3vw 2vh 3vw; }  footer img {   width: auto;   height: 100%;   border-radius: 50%;   margin-right: 3vw; }

Most of the sizes rely on vw and vh units to help anticipate the various contexts that the template might be rendered. We are going to follow Facebook’s recommndations, which are 1200×630. Twitter Cards, on the other hand, are sized differently. We could render images in a low resolution, like 600×315, but let’s go with 1200×630 so we we only need to work in pixels.

The viewer renders the Facebook preview at 1200×630 and scales it down to fit the screen. If the preview fulfills your expectations, so will the actual Open Graph images.

So far, the template matches our needs:

What about the image?

There is one little thing to add before we are done with the template. Some of our blog posts will have images, but not all of them. In situations where a post doesn’t have an image, it would be cool to use the image to fill the space on the left.

This is a new template parameter, so we need to update resoc-templates/default/resoc.manifest.json once again:

{   "partials": {     "content": "./content.html.mustache",     "styles": "./styles.css.mustache"   },   "parameters": [     {       "name": "title",       "type": "text",       "demoValue": "A picture is worth a thousand words"     },     {       "name": "sideImage",       "type": "imageUrl",       "demoValue": "https://resoc.io/assets/img/demo/photos/pexels-photo-371589.jpeg"     }   ] }

Let’s declare an additional div in resoc-templates/default/content.html.mustache:

<div class="wrapper">   <main>     {{#sideImage}}     <div class="sideImage"></div>     {{/sideImage}}     <h1>{{ title }}</h1>   </main>   <footer>     <img src="profil-pic.jpg" />     <h2>Philippe Bernard</h2>   </footer> </div>

The new {{#sideImage}} ... {{/sideImage}} syntax is a Mustache section. It’s only present when the sideImage parameter is defined.

We need a little extra CSS to handle the image. Notice that we’re able to use the Mustache syntax here to inset the background-image value for a specific post. Here’s how I approached it in the resoc-templates/default/styles.css.mustache file:

{{#sideImage}} .sideImage {   position: absolute;   width: 100%;   height: 100%;   background-image: url({{{ sideImage }}});   background-repeat: no-repeat;   background-size: auto 150vh;   background-position: -35vw 0vh;   -webkit-mask-image: linear-gradient(45deg, rgba(0,0,0,0.5), transparent 40%); } {{/sideImage}}

Our template looks great!

We commit our template:

git add resoc-templates git commit -m "Resoc image template"

Before we automate the social images, let’s generate one manually, just as a teaser. The viewer provides a command line to generate the corresponding image for our testing purposes:

Copy it, run it from a terminal and open output-image.jpg:

Social images automation

OK, so we created one image via the command line. What should we do now? Call it as many times as there are pages on our blog? This sounds like a boring task, and there is a deeper issue with this approach: time. Even if creating a single image took something like two seconds, we can multiply by the number of pages and we easily see the effort grow and grow.

The original Eleventy blog template is generated almost instantly, but we should wait about a minute for something as marginal as social images? This is not acceptable.

Instead of performing this task at build time, we are going to defer it, lazy style, with a Netlify Function and a Netlify on-demand builder. Actually, we aren’t actually dealing directly with a Netlify Function — an Eleventy plugin is going to handle this for us.

Let’s install that now. We can add the Resoc Social Image plugin for Eleventy, along with its companion Netlify plugin, with this command:

npm install --save-dev @resoc/eleventy-plugin-social-image @resoc/netlify-plugin-social-image

Why two plugins? The first one is dedicated to Eleventy, while the second one is framework-agnostic (for example, it can be used for Next.js).

Edit .eleventy.js at the root of the project so that we’re importing the plugin:

const pluginResoc = require("@resoc/eleventy-plugin-social-image");

Configure it near the top of .eleventy.js, right after the existing eleventyConfig.addPlugin:

eleventyConfig.addPlugin(pluginResoc, {   templatesDir: 'resoc-templates',   patchNetlifyToml: true });

templatesDir is where we stored our image template. patchNetlifyToml is asking the plugin to configure @resoc/netlify-plugin-social-image in netlify.toml for us.

We want all our pages to have automated social images. So, let’s open the master template, _includes/layouts/base.njk, and add this near the top of the file:

{% set socialImageUrl %} {%- resoc   template = "default",   slug = (title or metadata.title) | slug,   values = {     title: title or metadata.title,     sideImage: featuredImage   } -%} {% endset %}

This declares a new variable named socialImageUrl. The content of this variable is provided by the resoc short code, which takes three parameters:

  • The template is the sub directory of our template (it is in resoc-templates/default).
  • The slug is used to build the social image URL (e.g. /social-images/brand-new-post.jpg). We slug-ify the page title to provide a unique and sharable URL.
  • The values are the content, as defined in resoc-templates/default/resoc.manifest.json. title is obvious, because pages already have a title. sideImage is set to a meta named featuredImage, which we are going to define for illustrated pages.

Now we can open up _includes/layouts/base.njk, place our cursor in the <head>, add some new markup to populate all that stuff

<meta name="og:title" content="{{ title or metadata.title }}"/> <meta name="og:description" content="{{ description or metadata.description }}"/> <meta name="og:image" content="{{ socialImageUrl }}"/> <meta name="og:image:width" content="1200"/> <meta name="og:image:height" content="630"/>

The title and description markups are similar to the existing <title> and <meta name="description">. We’re using socialImageUrl as-is for the og:image meta. We also provide the social image dimensions to round things out.

Automated social images are ready!

Let’s deploy this

When we deploy the blog again, all pages will show the text-only version of our template. To see the full version , we assign an image to an existing page. that requires us to edit one of the posts — I created four posts and am editing the fourth one, posts/fourthpost.md — so there’s a featuredImage entry after the existing meta:

--- title: This is my fourth post. description: This is a post on My Blog about touchpoints and circling wagons. date: 2018-09-30 tags: second tag layout: layouts/post.njk featuredImage: https://resoc.io/assets/img/demo/photos/pexels-pixabay-459653.jpg ---

Using an external URL is enough here, but we normally drop images in an img directory with Eleventy and provide the base URL once and for all in _includes/layouts/base.njk.

Build the site again:

npm run build

When running git status, we might notice two modified files in addition to the ones we edited ourselves. In .gitignore, the plugin added resoc-image-data.json. This file stores our social image data used internally by the Netlify plugin, and netlify.toml now contains the Netlify plugin configuration.

Deploy time!

git commit -a -m "Automated social images" git push

Netlify is notified and deploys the site. Once the latest version is online, share the homepage somewhere (e.g. Slack it to yourself or use the Facebook debugger). Here’s how the social card looks for the homepage, which does not contain an image:

This is our text-only card.

And here’s how it looks for a post that does contain an image:

This card sports an image.



So far, automated social images have mostly been a matter of developers willing to explore and play around with lots of different ideas and approaches, some easy and some tough. We kept things relatively simple.

With a few lines of code, we were able to quickly setup automated social images on a blog based on Eleventy and hosted on Netlify. The part we spent the most time on was the image template, but that’s not a problem. With the viewer and Mustache already integrated, we focused on what we know, love, and value: web design.

Hopefully something like the Resoc image template dev kit and its related tools will help make the automated social images go from being a niche hobby into the mainstream.

The post Create Your Own Automated Social Images With Resoc appeared first on CSS-Tricks. You can support CSS-Tricks by being an MVP Supporter.


, , , ,

How to Create a Contact Form With Next.js and Netlify

We’re going to create a contact form with Next.js and Netlify that displays a confirmation screen and features enhanced spam detection.

Next.js is a powerful React framework for developing performant React applications that scale. By integrating a Next.js site with Netlify’s technology, we can quickly get a working contact form up and running without having to write any server-side code.

Not only is it a relatively fast process to set up forms to be processed by Netlify, but it’s also free to get started (with up to 100 free submissions/per site hosted on Netlify). Form submissions automatically go through Netlify’s built-in spam filter which uses Akismet and there are also options that can be configured to increase the level of spam detection.

Creating the contact form

Within the Next.js application we should create a ContactForm component to render the contact form inside of the contact page. If you’d like for this form to render at /contact, then the ContactForm component below with labels and input fields should be used within the pages/contact.js file.

const ContactForm = (   <form     name="contact-form"     method="POST"     action="contact/?success=true"   >     <label htmlFor="name">Name *</label>     <input       id="name"       name="name"       required       type="text"     />     <label htmlFor="company">Company *</label>     <input id="company" name="company" required type="text" />     <label htmlFor="email">E-mail Address *</label>     <input id="email" type="email" name="email" required />     <label htmlFor="message">Message *</label>     <textarea id="message" name="message" required></textarea>     <button type="submit">Submit</button>   </form> );

The above markup is required to render a form with a field for Name, Company, Email address and message with a submit button. When submitting the form, based on the value of the form’s action, it should redirect to contact/?success=true from /contact. Right now there is not yet a difference between the page’s appearance with and without the success query parameter, but we will update that later.

Our Contact.js file looks like this so far:

import React from "react"; const ContactPage = () => {  const ContactForm = (/* code in above code sample*/)    return (    <div>      <h1>Contact Us</h1>      {ContactForm}    </div>  ); };   export default ContactPage;

Now that we have the basic form set up, the real magic will happen after we add additional information for Netlify to auto-recognize the form during future site deployments. To accomplish this we should update the form to have the attribute data-netlify="true" and a hidden input field that contains the name of our contact form. In Netlify, once we navigate to our site in the dashboard and then click on the “forms” tab  we will be able to view our form responses based on the name that we’ve put in our hidden field. It’s important that if you have multiple forms within a site that they have unique names so that they are recorded properly in Netlify.

<form   method="POST"   name="contact-form"   action="contact/?success=true"   data-netlify="true" > <input type="hidden" name="form-name" value="contact-form" />

After successfully deploying the site to Netlify with the data-netlify attribute and the form-name field  then we can go to the deployed version of the site and fill out the form. Upon submitting the form and navigating to https://app.netlify.com/sites/site-name/forms (where site-name is the name of your site) then our most recent form submission should appear if we have successfully set up the form. 

Redirect to confirmation screen 

In order to improve the user experience, we should add some logic to redirect to a confirmation screen on form submission when the URL changes to /contact/?success=true. There is also the option to redirect to an entirely different page as the action when the form is submitted but using query params we can achieve something similar with the Next Router. We can accomplish this by creating a new variable to determine if the confirmation screen or the form should be visible based on the query parameter. The next/router which is imported with import { useRouter } from "next/router"; can be used to retrieve the current query params. 

const router = useRouter();   const confirmationScreenVisible = router.query?.success && router.query.success === "true";

In our case, the confirmation screen and form can never be visible at the same time; therefore, the following statement can be used to determine if the form is visible or not.

const formVisible = !confirmationScreenVisible; 

To give users the option to resubmit the form, we can add a button to the confirmation screen to reset the form by clearing the query params. Using router.replace (instead of router.push) not only updates the page but replaces the current page in the history to the version without query params. 

<button onClick={() => router.replace("/contact", undefined, { shallow: true })}> Submit Another Response </button>

We can then conditionally render the form based on whether or not the form is visible with:

{formVisible ? ContactForm : ConfirmationMessage}

Putting it all together, we can use the following code to conditionally render the form based on the query params (which are updated when the form is submitted):

import React, { useState } from "react"; import { useRouter } from "next/router";   const ContactPage = () => {  const [submitterName, setSubmitterName] = useState("");  const router = useRouter();  const confirmationScreenVisible =    router.query?.success && router.query.success === "true";  const formVisible = !confirmationScreenVisible;    const ConfirmationMessage = (    <React.Fragment>      <p>        Thank you for submitting this form. Someone should get back to you within 24-48 hours.      </p>        <button onClick={() => router.replace("/contact", undefined, { shallow: true })}> Submit Another Response </button>    </React.Fragment>  );    const ContactForm = (/* code in first code example */);    return (    <div>      <h1>Contact Us</h1> {formVisible ? ContactForm : ConfirmationMessage}    </div>  ); };   export default ContactPage;

Adding a hidden bot field

Now that the core functionality of our form is working, we can add additional spam detection to our form in addition to the base spam detection because Akismet is included with all Netlify Forms by default. We can enable this by adding data-netlify-honeypot="bot-field" to our form.

<form   className="container"   method="POST"   name="contact-form"   action="contact/?success=true"   data-netlify="true"   data-netlify-honeypot="bot-field" >

We also need to create a new hidden paragraph that contains a label named bot-field that contains the input. This field is “visible” to bots, but not humans. When this honeypot form field is filled, Netlify detects a bot and then the submission is flagged as spam.

<p hidden>   <label>     Don’t fill this out: <input name="bot-field" />   </label> </p>

Further customizations

  • We could explore another spam prevention option that Netlify supports by adding reCAPTCHA 2 to a Netlify form.
  • We could update the form to allow uploaded files with input <input type="file">.
  • We could set up notifications for form submissions. That happens over at https://app.netlify.com/sites/[your-site-name]/settings/forms where we can include a custom subject field (which can be hidden) for email notifications.

Full code

The code for the full site code is available over at GitHub.


The following code includes everything we covered as well as the logic for setting a custom subject line with what was submitted in the name field.

import React, { useState } from "react"; import { useRouter } from "next/router";   const ContactPage = () => {  const [submitterName, setSubmitterName] = useState("");  const router = useRouter();  const confirmationScreenVisible =    router.query?.success && router.query.success === "true";  const formVisible = !confirmationScreenVisible;    const ConfirmationMessage = (    <React.Fragment>      <p>        Thank you for submitting this form. Someone should get back to you        within 24-48 hours.      </p>        <button onClick={() => router.replace("/contact", undefined, { shallow: true })}> Submit Another Response </button>    </React.Fragment>  );    const ContactForm = (    <form      className="container"      method="POST"      name="contact-form"      action="contact/?success=true"      data-netlify="true"      data-netlify-honeypot="bot-field"    >      <input        type="hidden"        name="subject"        value={`You've got mail from $ {submitterName}`}      />      <input type="hidden" name="form-name" value="contact-form" />      <p hidden>        <label>          Don’t fill this out: <input name="bot-field" />        </label>      </p>        <label htmlFor="name">Name *</label>      <input        id="name"        name="name"        required        onChange={(e) => setSubmitterName(e.target.value)}        type="text"      />      <label htmlFor="company">Company *</label>      <input id="company" name="company" required type="text" />      <label htmlFor="email">E-mail Address *</label>      <input id="email" type="email" name="email" required />      <label htmlFor="message">Message *</label>      <textarea id="message" name="message" required/>      <button type="submit">Submit</button>    </form>  );    return (    <div>      <h1>Contact Us</h1> {formVisible ? ContactForm : ConfirmationMessage}    </div>  ); };   export default ContactPage;

The post How to Create a Contact Form With Next.js and Netlify appeared first on CSS-Tricks. You can support CSS-Tricks by being an MVP Supporter.


, , , ,

How to Create CSS Charts With Interesting Shapes, Glyphs and Emoji

Let’s forego the usual circles and bars we typically see used in charts for more eccentric shapes. With online presentations more and more common today, a quick way to spruce up your web slides and make them stand out is to give the charts a shapely makeover 🪄

I’ll show you how to create charts with interesting shapes using glyphs, CSS shapes, and emojis with minimal effort.

Let’s start with a simple example.

Using glyphs

<div id="chart">   <div id="chart-shape">⬠</div>   <div id="chart-value"></div>  </div>
#chart {   width: 300px;    height: 300px;   display: grid;   background: white; } #chart * {   height: inherit;   grid-area: 1 / 1; }

We first give the chart some dimensions and stack the two div inside it by assigning them to the same grid cell. They can be stacked up using any other way, too — with position property, for instance.

Look at the HTML above one more time. One of the divs has a pentagon symbol — the chart shape we want. I added that symbol using the “Emoji and Symbols” keyboard, though it can also be done with the HTML entity value for pentagon, &#x2B20;, inside the div.

The div with the symbol is then styled with CSS font properties as well as a desired chart color. It’s large enough and centered.

#chart-shape {   font: 300px/300px serif;   text-align: center;    color: limegreen; }

To the second div in the HTML contains a conic gradient background image. The percentage of the gradient represents the visual value of the chart. The same div also has mix-blend-mode: screen;.

#chart-value {   background: conic-gradient(transparent 75%, darkseagreen 75%);   mix-blend-mode: screen; }

The mix-blend-mode property blends colors inside an element with its backdrop. The screen blend mode value causes a lighter blend to come through. A lighter green shows through the portion where the darkseagreen colored part of the conic gradient overlaps with the limegreen colored pentagram, while the rest of the darskseagreen gradient disappears against the white backdrop of the chart.

An alternative to adding the chart shape in the HTML is to add it as another background layer in CSS and use background-blend-mode instead of mix-blend-mode. However, the code for a chart shape inside the CSS can be less legible for a quick glance. So it’s up to you to see where it’ll be easier for you to add the chart shape in: HTML or CSS. You’ve both options.

#chart {   width: 300px;    height: 300px;   background:   url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'><foreignObject width='300px' height='100%'><div xmlns='http://www.w3.org/1999/xhtml' style='font:300px/300px serif;color:limegreen;text-align: center;background:white'>⬠</div></foreignObject></svg>"),   conic-gradient(transparent 75%, darkseagreen 75%);   background-blend-mode: screen; }

The pentagon symbol is added as a background image in addition to the conic gradient. Then, finally, the property-value pair background-blend-mode: screen; kicks in and the result looks same as the previous demo.

The pentagon background image is created by embedding the HTML with the pentagon symbol () into an SVG that is embedded into a data URL.

<!-- Unwrapped SVG code from the Data URL --> <svg xmlns='http://www.w3.org/2000/svg'>   <foreignObject width='300px' height='100%'>     <div xmlns='http://www.w3.org/1999/xhtml'           style='           font:300px/300px serif;           color:limegreen;           text-align: center;           background:white;'>           ⬠     </div>   </foreignObject> </svg>

Which becomes this in CSS:

background: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'><foreignObject width='300px' height='100%'><div xmlns='http://www.w3.org/1999/xhtml' style='font:300px/300px serif;color:limegreen;text-align: center;background:white'>⬠</div></foreignObject></svg>");

Using CSS shapes

Next, let’s use CSS shapes in place of symbols. CSS shapes are primarily created with the use of border properties. We have a collection of CSS shapes in our archive for your reference.

Here’s a set of properties that can create a simple triangle shape in an element we’ll later add to the SVG, replacing the symbol:

border: 150px solid white;  border-bottom: 300px solid lime;  border-top: unset;

When combined with the conic gradient and the background blend, the result is:

<div id="chart"></div>
#chart {   width: 300px;   height: 300px;   background:   url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'><foreignObject width='300px' height='100%'><html xmlns='http://www.w3.org/1999/xhtml'><div style='border:150px solid white; border-bottom:300px solid lime; border-top:unset'></div><div style='border:150px solid transparent; border-bottom:300px solid white; border-top:unset; transform:scale(0.8) translateY(-360px);'></div></html></foreignObject></svg>"),   conic-gradient(transparent 75%, darkseagreen 75%);   background-blend-mode: screen; }

To restrict the design to the border, a smaller white triangle was added to the design.

<!-- Unwrapped SVG code from the Data URL --> <svg xmlns='http://www.w3.org/2000/svg'>   <foreignObject width='300px' height='100%'>    <html xmlns='http://www.w3.org/1999/xhtml'>     /* green triangle */     <div style='          border: 150px solid white;           border-bottom: 300px solid lime;           border-top: unset'></div>     /* smaller white triangle */     <div style='          border: 150px solid transparent;           border-bottom: 300px solid white;           border-top: unset;           transform: scale(0.8) translateY(-360px);'></div>    </html>   </foreignObject> </svg>

Which, again, becomes this in CSS:

background: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'><foreignObject width='300px' height='100%'><html xmlns='http://www.w3.org/1999/xhtml'><div style='border:150px solid white; border-bottom:300px solid lime; border-top:unset'></div><div style='border:150px solid transparent; border-bottom:300px solid white; border-top:unset; transform:scale(0.8) translateY(-360px);'></div></html></foreignObject></svg>");

Using emojis

Will emojis work with this approach to charts? You bet it will! 🥳

A block-colored emoji is fed into the SVG image the same way the HTML symbols are. The block color of the emoji is created by giving it a transparent color value, followed by adding a desired color as text-shadow. I covered this technique in another post.

<div id="chart"></div>
#chart {   width: 300px;    height: 300px;   background: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'><foreignObject width='300px' height='300px'><body style='margin:0;text-align:center;color:transparent;' xmlns='http://www.w3.org/1999/xhtml'><div style='text-shadow: 0 0 limegreen;font:200px/300px serif;background:white;'>🍏</div><div style='text-shadow:0 0 white;font:170px/300px serif;position:relative;top:-300px;'>🍏</div></body></foreignObject></svg>"),   conic-gradient(transparent 64%, darkseagreen 64%);   background-blend-mode: screen; }

Just as with the last demo, a smaller white apple shape is added at the center to create the border design.

<!-- Unwrapped SVG code from the Data URL --> <svg xmlns='http://www.w3.org/2000/svg'>   <foreignObject width='300px' height='300px'>     <body xmlns='http://www.w3.org/1999/xhtml' style='           margin: 0;           text-align: center;           color:transparent;'>        /* green apple shape */        <div style='             text-shadow: 0 0 limegreen;              font-size: 200px;              background: white;'>🍏</div>        /* smaller white apple shape */        <div style='             text-shadow:0 0 white;              font-size: 170px;              position: relative;              top: -300px;'>🍏</div>     </body>   </foreignObject> </svg>

I added the two divs inside the <body> element so the repeating style properties of the divs are declared only once in the body element. The divs will then automatically inherit those properties.

Chris had the idea to animate the conic gradient — its percent value to be specific — using the CSS @property (supported in Chrome at the time of writing this article), and it just has the most beautiful effect on the design. @property is a CSS at-rule that explicitly defines a custom CSS property. In supported browsers, when a custom property is defined using @property it can be animated.

@property --n {   syntax: '<percentage>';   inherits: true;   initial-value: 30%; } #chart {   width: 300px;    height: 300px;   --n: 30%;  /*declaration for browsers with no @property support */   background:      url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'><foreignObject width='300px' height='300px'><body style='margin:0;text-align:center;color:transparent;' xmlns='http://www.w3.org/1999/xhtml'><div style='text-shadow: 0 0 limegreen;font:200px/300px serif;background:white;'>🍏</div><div style='text-shadow:0 0 white;font:170px/300px serif;position:relative;top:-300px;'>🍏</div></body></foreignObject></svg>"),     conic-gradient(transparent var(--n), darkseagreen var(--n));   background-blend-mode: screen;   transition: --n 2s ease-in-out	 } #chart:hover { --n: 70%; }

The chart above will change its value on hover. In Chrome, the change will look animated.

And although it won’t be as smooth as CSS animation, you can also try animating the gradient using JavaScript. The following JavaScript will cause a somewhat similar animating effect as the above when the cursor moves over the chart.

const chart = document.querySelector('#chart') chart.onpointerover = ()=>{   var i = 30,       timer = setInterval(()=> {         if (i < 70)           chart.style.setProperty('--n', i++ + '%')         else clearInterval(timer)       }, 10) } chart.onpointerout = ()=>{   var i = 70,       timer = setInterval(()=> {         if (i >= 30)            chart.style.setProperty('--n', i-- + '%')         else clearInterval(timer)       }, 10) }

When trying your own designs, keep in mind how the different blend modes work. I used the screen blend mode in all my demos just to keep things simple. But with different blend modes, and different backdrop colors, you’ll get varying results. So, I recommend going deeper into blend modes if you haven’t already.

Also, if you want to exclude an element’s color from the final result, try isolation: isolate; on the element — the browser will ignore that backdrop color when applying the blend.

And even though there are all kinds of unusual and quirky shapes we can use in any wild colors we want, always be mindful of the legibility by making the chart value large and clear enough to read.

The post How to Create CSS Charts With Interesting Shapes, Glyphs and Emoji appeared first on CSS-Tricks.

You can support CSS-Tricks by being an MVP Supporter.


, , , , ,

Can We Create a “Resize Hack” With Container Queries?

If you follow new developments in CSS, you’ve likely heard of the impending arrival of container queries. We’re going to look at the basics here, but if you’d like another look, check out Una’s “Next Gen CSS: @container” article. After we have a poke at the basics ourselves, we’re going to build something super fun with them: a fresh take on the classic CSS meme featuring Peter Griffin fussing with window blinds. 😉

So, what is a container query? It’s… exactly that. Much like we have media queries for querying things such as the viewport size, a container query allows us to query the size of a container. Based on that, we can then apply different styles to the children of said container.

What does it look like? Well, the exact standards are being worked out. Currently, though, it’s something like this:

.container {   contain: layout size;   /* Or... */   contain: layout inline-size; }  @container (min-width: 768px) {   .child { background: hotpink; } }

The layout keyword turns on layout-containment for an element. inline-size allows users to be more specific about containment. This currently means we can only query the container’s width. With size, we are able to query the container’s height.

Again, we things could still change. At the time of writing, the only way to use container queries (without a polyfill) is behind a flag in Chrome Canary (chrome://flags). I would definitely recommend having a quick read through the drafts over on csswg.org.

The easiest way to start playing would be to whip up a couple quick demos that sport a resizable container element.

Try changing the contain values (in Chrome Canary) and see how the demos respond. These demo uses contain: layout size which doesn’t restrict the axis. When both the height and width of the containers meet certain thresholds, the shirt sizing adjusts in the first demo. The second demo shows how the axes can work individually instead, where the beard changes color, but only when adjusting the horizontal axis.

@container (min-width: 400px) and (min-height: 400px) {   .t-shirt__container {     --size: "L";     --scale: 2;   } }

That’s what you need to know to about container queries for now. It’s really just a few new lines of CSS.

The only thing is: most demos for container queries I’ve seen so far use a pretty standard “card” example to demonstrate the concept. Don’t get me wrong, because cards are a great use case for container queries. A card component is practically the poster child of container queries. Consider a generic card design and how it could get affected when used in different layouts. This is a common problem. Many of us have worked on projects where we wind up making various card variations, all catering to the different layouts that use them.

But cards don‘t inspire much to start playing with container queries. I want to see them pushed to greater limits to do interesting things. I‘ve played with them a little in that t-shirt sizing demo. And I was going to wait until there was better browser support until I started digging in further (I’m a Brave user currently). But then Bramus shared there was a container query polyfill!

And this got me thinking about ways to “hack” container queries.

⚠️ Spoiler alert: My hack didn’t work. It did momentarily, or at least I thought it did. But, this was actually a blessing because it prompted more conversation around container queries.

What was my idea? I wanted to create something sort of like the “Checkbox Hack” but for container queries.

<div class="container">   <div class="container__resizer"></div>   <div class="container__fixed-content"></div> </div>

The idea is that you could have a container with a resizable element inside it, and then another element that gets fixed positioning outside of the container. Resizing containers could trigger container queries and restyle the fixed elements.

.container {   contain: layout size; }  .container__resize {   resize: vertical;   overflow: hidden;   width: 200px;   min-height: 100px;   max-height: 500px; }  .container__fixed-content {   position: fixed;   left: 200%;   top: 0;   background: red; }  @container(min-height: 300px) {   .container__fixed-content {     background: blue;   } }

Try resizing the red box in this demo. It will change the color of the purple box.

Can we debunk a classic CSS meme with container queries?

Seeing this work excited me a bunch. Finally, an opportunity to create a version of the Peter Griffin CSS meme with CSS and debunk it!

You’ve probably seen the meme. It’s a knock on the Cascade and how difficult it is to manage it. I created the demo using cqfill@0.5.0… with my own little touches, of course. 😅

Moving the cord handle, resizes an element which in turn affects the container size. Different container breakpoints would update a CSS variable, --open, from 0 to 1, where 1 is equal to an “open” and 0 is equal to a “closed” state.

@container (min-height: 54px) {   .blinds__blinds {     --open: 0.1;   } } @media --css-container and (min-height: 54px) {   .blinds__blinds {     --open: 0.1;   } } @container (min-height: 58px) {   .blinds__blinds {     --open: 0.2;   } } @media --css-container and (min-height: 58px) {   .blinds__blinds {     --open: 0.2;   } } @container (min-height: 62px) {   .blinds__blinds {     --open: 0.3;   } } @media --css-container and (min-height: 62px) {   .blinds__blinds {     --open: 0.3;   } }

But…. as I mentioned, this hack isn’t possible.

What’s great here is that it prompted conversation around how container queries work. It also highlighted a bug with the container query polyfill which is now fixed. I would love to see this “hack” work though.

Miriam Suzanne has been creating some fantastic content around container queries. The capabilities have been changing a bunch. That’s the risk of living on the bleeding edge. One of her latest articles sums up the current status.

Although my original demo/hack didn’t work, we can still kinda use a “resize” hack to create those blinds. Again, we can query height if we use contain: layout size. Side note: it’s interesting how we’re currently unable to use contain to query a container’s height based on resizing its child elements.

Anyway. Consider this demo:

The arrow rotates as the container is resized. The trick here is to use a container query to update a scoped CSS custom property.

.container {   contain: layout size; }  .arrow {   transform: rotate(var(--rotate, 0deg)); }  @container(min-height: 200px) {   .arrow {     --rotate: 90deg;   } }

We‘ve kinda got a container query trick here then. The drawback with not being able to use the first hack concept is that we can’t go completely 3D. Overflow hidden will stop that. We also need the cord to go beneath the window which means the windowsill would get in the way.

But, we can almost get there.

This demo uses a preprocessor to generate the container query steps. At each step, a scoped custom property gets updated. This reveals Peter and opens the blinds.

The trick here is to scale up the container to make the resize handle bigger. Then I scale down the content to fit back where it’s meant to.

This fun demo “debunking the meme” isn’t 100% there yet, but, we’re getting closer. Container queries are an exciting prospect. And it’ll be interesting to see how they change as browser support evolves. It’ll also be exciting to see how people push the limits with them or use them in different ways.

Who know? The “Resize Hack” might fit in nicely alongside the infamous “Checkbox Hack” one day.

The post Can We Create a “Resize Hack” With Container Queries? appeared first on CSS-Tricks.

You can support CSS-Tricks by being an MVP Supporter.


, , , ,

How to Create Neon Text With CSS

Neon text can add a nice, futuristic touch to any website. I’ve always loved the magic of neon signs, and wanted to recreate them using CSS. I thought I’d share some tips on how to do it! In this article, we’re going to take a look at how to add glowing effects to text. We’ll also take a look at various ways to animate the neon signs, all using CSS and keyframes.

Here’s what we’ll be making:

Adding a glow effect to text

First, let’s make the text glow. This can be done in CSS with the text-shadow property. What’s neat about text-shadow is that we can apply multiple shadows on it just by comma-separating them:

.neonText {   color: #fff;   text-shadow:     0 0 7px #fff,     0 0 10px #fff,     0 0 21px #fff,     0 0 42px #0fa,     0 0 82px #0fa,     0 0 92px #0fa,     0 0 102px #0fa,     0 0 151px #0fa; }

text-shadow requires four values, the first two of which represent the horizontal and vertical position of the shadow, respectively. The third value represents the size of the blur radius while the last value represents the color of the shadow. To increase the size of the glow effect, we would increase the third value, which represents the blur radius. Or, expressed another way:

text-shadow: [x-offset] [y-offset] [blur-radius] [color];

Here’s what we get with that small bit of CSS:

The next thing you might be wondering is what’s up with all of those values? How did I get those and why are there so many? First, we added white glow effects to the outer edges of the text’s letters with a small blur radius.

.neonText {   color: #fff;   text-shadow:     /* White glow */     0 0 7px #fff,     0 0 10px #fff,     0 0 21px #fff, }

The last five values are wider text shadows of a larger blur radius that forms the green glow.

.neonText {   color: #fff;   text-shadow:     /* White glow */     0 0 7px #fff,     0 0 10px #fff,     0 0 21px #fff,     /* Green glow */     0 0 42px #0fa,     0 0 82px #0fa,     0 0 92px #0fa,     0 0 102px #0fa,     0 0 151px #0fa; }

It’d be great if we could accomplish this with fewer than five shadows, but we need all these shadows so that they can be stacked over one another to add more depth to the glow. If we had used a single text-shadow instead, the effect would not have the depth required to make it look realistic.

Go ahead and experiment with various hues and colors as well as blur radius sizes! There’s a huge variety of cool glow effects you can make, so try different variations — you can even mix and match colors where one color blends into another.

The “flickering” effect

One thing you might notice about neon signs is that some of them — particularly older ones — tend to flicker. The light kind of goes in and out. We can do the same sort of thing with CSS animations! Let’s reach for @keyframes to make an animation that flickers the light on and off in quick, seemingly random flashes.

@keyframes flicker {   0%, 18%, 22%, 25%, 53%, 57%, 100% {     text-shadow:       0 0 4px #fff,       0 0 11px #fff,       0 0 19px #fff,       0 0 40px #0fa,       0 0 80px #0fa,       0 0 90px #0fa,       0 0 100px #0fa,       0 0 150px #0fa;   }   20%, 24%, 55% {            text-shadow: none;   } }

That’s really it! We’ve taken the exact same text-shadow property and values we had before, wrapped them in a @keyframes animation called flicker, and chose points in the timeline to apply the shadows, as well as points that completely remove the shadows.

All that’s left is to call the animation where we want the light to flicker. In this particular case, let’s only add it to the <h1> element. Having one part of the entire sign flicker feels a little more realistic than if we applied the flicker to all of the text.

h1 {   animation: flicker 1.5s infinite alternate;      } 

Note that if we did want the entire sign to flicker, then we could technically remove the text-shadow values on the .neonText class, add the animation to it, and let the @keyframes apply the shadows instead.

It’s quite a cool effect, and adds more realism to our neon text! Of course, there are other effects you could try out too, which will also be explored further in this article. For example, how about more of a pulsating animation or a more subtle flicker?

Let’s explore those and other effects!

Pulsating glow

We just got a quick peek at this. It uses keyframes, just as the previous example does, where we specify the size of the blur radius at the start and end of the animation.

We want the size of the blur radius to be smallest at the end of the animation, so we simply decrease the blur radius values for each text-shadow value in the 0% keyframe. This way, the size of the blur gradually ebbs and flows, creating a pulsating effect.

@keyframes pulsate {   100% {     /* Larger blur radius */     text-shadow:       0 0 4px #fff,       0 0 11px #fff,       0 0 19px #fff,       0 0 40px #0fa,       0 0 80px #0fa,       0 0 90px #0fa,       0 0 100px #0fa,       0 0 150px #0fa;   }   0% {     /* Smaller blur radius */     text-shadow:       0 0 2px #fff,       0 0 4px #fff,       0 0 6px #fff,       0 0 10px #0fa,       0 0 45px #0fa,       0 0 55px #0fa,       0 0 70px #0fa,       0 0 80px #0fa;   } }

Once again, we add the animation to some element. We’ll go with <h1> again:

h1 {   animation: pulsate 2.5s infinite alternate;      }

Here it is with it all put together:

Subtle flicker

We can tone things down a bit and make the flickering action super subtle. All we need to do is slightly decrease the size of the blur radius in the 0% keyframe, just not to the extent as seen in the previous example.

@keyframes pulsate {   100% {     /* Larger blur radius */     text-shadow:       0 0 4px #fff,       0 0 11px #fff,       0 0 19px #fff,       0 0 40px #f09,       0 0 80px #f09,       0 0 90px #f09,       0 0 100px #f09,       0 0 150px #f09;   }  0% {     /* A slightly smaller blur radius */     text-shadow:       0 0 4px #fff,       0 0 10px #fff,       0 0 18px #fff,       0 0 38px #f09,       0 0 73px #f09,       0 0 80px #f09,       0 0 94px #f09,       0 0 140px #f09;   } }

Since the flickering is more subtle and the reduction of the blur radius is not as large, we should increase the number of times this animation occurs per second in order to emulate more frequent flickering. This can be done by decreasing the animation’s duration, say to a mere 0.11s:

h1 {   animation: pulsate 0.11s ease-in-out infinite alternate;     }

Using a background image

It would be really neat if our sign was hanging on a wall instead of empty space. Let’s grab a background image for that, maybe some sort of brick texture from Unsplash or something:

body {   background-image: url(wall.jpg); }

Adding a border

One last detail we can add is some sort of circular or rectangular border around the sign. It’s just a nice way to frame the text and make it look like, you know, an actual sign. By adding a shadow to the border, we can give it the same neon effect as the text!

Whatever element is the container for the text is what needs a border. Let’s say we’re only working with an <h1> element. That’s what gets the border. We call the border shorthand property to make a solid white border around the heading, plus a little padding to give the text some room to breathe:

h1 {   border: 0.2rem solid #fff;   padding: 0.4em; }

We can round the corners of the border a bit so things aren’t so sharp by applying a border-radius on the heading. You can use whatever value works best for you to get the exact roundness you want.

h1 {   border: 0.2rem solid #fff;   border-radius: 2rem;   padding: 0.4em; }

The last piece is the glow! Now, text-shadow won’t work for the border here but that’s okay because that’s what the box-shadow property is designed to do. The syntax is extremely similar, so we can even pull exactly what we have for text-shadow and tweak the values slightly:

h1 {   border: 0.2rem solid #fff;   border-radius: 2rem;   padding: 0.4em;   box-shadow: 0 0 .2rem #fff,               0 0 .2rem #fff,               0 0 2rem #bc13fe,               0 0 0.8rem #bc13fe,               0 0 2.8rem #bc13fe,               inset 0 0 1.3rem #bc13fe; }

Notice that inset keyword? That’s something text-shadow is unable to do but adding it to the border’s box-shadow allows us to get some of the glow on both sides of the border for some realistic depth.

What about accessibility?

If users have a preference for reduced motion, we’ll need to accommodate for this using the prefers-reduced-motion media query. This allows us to remove our animation effects in order to make our text more accessible to those with a preference for reduced motion.

For example, we could modify the flashing animation from the Pen above so that users who have prefers-reduced-motion enabled don’t see the animation. Recall that we applied the flashing effect to the <h1> element only, so we’ll switch off the animation for this element:

@media screen and (prefers-reduced-motion) {    h1 {     animation: none;   } }

It’s incredibly important to ensure that users’ preferences are catered for, and making use of this media query is a great way to make the effect more accessible for those with a preference for reduced motion.


Hopefully this has shown you how to create cool neon text for your next project! Make sure to experiment with various fonts, blur radius sizes and colors and don’t forget to try out different animations, too — there’s a world of possibilities out there. And add a comment if you’ve created a neat shadow effect you want to share. Thanks for reading!

The post How to Create Neon Text With CSS appeared first on CSS-Tricks.

You can support CSS-Tricks by being an MVP Supporter.


, ,

SVGator 3.0 Reshapes the Way You Create and Animate SVG With Extensive New Features

Building animations can get complicated, particularly compelling animations where you aren’t just rolling a ball across the screen, but building a scene where many things are moving and changing in concert. When compelling animation is the goal, a timeline GUI interface is a long-proven way to get there. That was true in the days of Flash (RIP), and still true today in every serious video editing tool.

But what do we have on the web for animating vector graphics? We have SVGator, an innovative and unique SVG animation creator that doesn’t require any coding skills! With the new SVGator 3.0 release it becomes a ground-breaking tool for building web animations in a visually intuitive way.

It’s worth just looking at the homepage to see all the amazing animations built using the tool itself.

A powerful tool right at your fingertips

I love a good browser-based design tool. All my projects in the cloud, waiting for me no matter where I roam. Here’s my SVGator project as I was working on a basic animation myself:

Users of any design tool should feel at home here. It comes with an intuitive interface that rises to the demands of a professional workflow with options that allow more control over your workspace. You will find a list of your elements and groups on the left side, along with the main editing tools right at the top. All the properties can be found on the right side and you can bring any elements to life by setting up keyframes on the timeline.

I’ll be honest: I’ve never really used SVGator before, I read zero documentation, and I was able to fiddle together these animated CSS-Tricks stars in just a few minutes:

See that animation above?

  • It was output as a single animation-stars.svg document I was able to upload to this blog post without any problem.
  • Uses only CSS inside the SVG for animation. There is a JavaScript-animation output option too for greater cross-browser compatibility and extra features (e.g. start animation only when it enters the view or start the animation on click!)
  • The final file is just 5 KB. Tiny!
  • The SVG, including the CSS animation bits, are pre-optimized and compressed.
  • And again, it only took me a couple minutes to figure out.

Imagine what you could do if you actually had design and animation talent.

Intuitive and useful export options

Optimized SVG output

SVGator is also an outstanding vector editor

With the 3.0 release, SVGator is no longer just an animation tool, but a full-featured SVG creator! This means that there’s no need for a third-party vector editing tool, you can start from scratch in SVGator or edit any pre-existing SVGs that you’ve ever created. A Pen tool with fast editing options, easily editable shapes, compound options, and lots of other amazing features will assist you in your work.

That’s particularly useful with morphing tweens, which SVGator totally supports!

Here’s my one word review:

Cool (animating in and out with scale and blur effects)

The all-new interface in SVGator has a sidebar of collapsible panels for controlling all aspects of the design. See here how easy this animation was to make by controlling a line of text, the transforms, and the filters, and then choosing keyframes for those things below.

Suitable plans for everyone

If you want to use SVGator as a vector editing tool, that’s absolutely free, which means that you can create and export an endless number of static vector graphics, regardless of your subscription plan In fact, even on the Free plan, you export three animations per month. It’s just when you want more advanced animation tooling or unlimited animated exports per month than that you have to upgrade to a paid plan.

The post SVGator 3.0 Reshapes the Way You Create and Animate SVG With Extensive New Features appeared first on CSS-Tricks.

You can support CSS-Tricks by being an MVP Supporter.


, , , , ,

How to Create Actions for Selected Text With the Selection API

Click, drag, release: you’ve just selected some text on a webpage — probably to copy and paste it somewhere or to share it. Wouldn’t it be cool if selecting that text revealed some options that make those tasks easier? That’s what a selection menu does.

You may already be familiar with selection menus if you’ve ever used an online editor. When you select text, options to format the selection might float above it. In fact, I’m writing this draft in an editor that does exactly this.

Let’s see how we can create a selection menu like this using JavaScript’s Selection API. The API gives us access to the space and content of the selected area on a webpage. This way we can place the selection menu exactly above the selected text and get access to the selected text itself.

Here’s an HTML snippet with some sample text:

<article>   <h1>Select over the text below</h1>    <p>Cascading Style Sheets (CSS) is a style sheet language used for describing the presentation of a document written in a markup language such as HTML. CSS is a cornerstone technology of the World Wide Web, alongside HTML and JavaScript. CSS is designed to enable the separation of presentation and content, including layout, colors, and fonts. This separation can improve content accessibility, provide more flexibility and control in the specification of presentation characteristics. </p> </article> <template><span id="control"></span></template>

There’s a <template> tag at the end there. The <span> inside it is our selection menu control. Anything inside a <template> tag is not rendered on the page until it’s later added to the page with JavaScript. We’ll add the selection menu control to the page when user selects text. And when the user selects that text, our selection menu will prompt the user to tweet it.

Here’s the CSS to style it:

#control {     background-image: url("data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width='40px' height='40px'><foreignObject width='40px' height='40px'><div xmlns='http://www.w3.org/1999/xhtml' style='width:40px;height:40px;line-height:40px;text-align:center;color:transparent;text-shadow: 0 0 yellow, 2px 4px black, -1px -1px black;font-size:35px;'>💬</div></foreignObject></svg>");   cursor: pointer;   position: absolute;   width: 40px;   height: 40px; } #control::before{   background-color: black;   color: white;   content: " tweet this! ";   display: block;   font-weight: bold;   margin-left: 37px;   margin-top: 6px;   padding: 2px;   width: max-content;   height: 20px; }

Check out this article to learn how I used an emoji (💬) for the background image.

So far, the sample text is ready, and the selection menu control has been styled. Let’s move on to the JavaScript. When a selection is made, we’ll get the size and position of the selected area on the page. We then use those measurements to assign the position of the selection menu control at the top-middle of the selected area.

var control = document.importNode(document.querySelector('template').content, true).childNodes[0]; document.querySelector('p').onpointerup = () => {   let selection = document.getSelection(), text = selection.toString();   if (text !== "") {     let rect = selection.getRangeAt(0).getBoundingClientRect();     control.style.top = `calc($ {rect.top}px - 48px)`;     control.style.left = `calc($ {rect.left}px + calc($ {rect.width}px / 2) - 40px)`;     control['text']= text;      document.body.appendChild(control);   } } 

In this code, we first get a copy of the selection menu control inside <template>, then assign it to the control variable.

Next, we write the handler function for the onpointerup event of the element carrying the sample text. Inside the function, we get the selection and the selected string using document.getSelection(). If the selected string is not empty, then we get the selected area’s size and position, via getBoundingClientRect(), and place it in the rect variable.

Using rect, we calculate and assign the top and left positions of the control. This way, the selection menu control is placed a little above the selected area and centered horizontally. We’re also assigning the selected string to a user-defined property of control. This will later be used to share the text.

And, finally, we add control to the webpage using appendChild(). At this point, if we select some of the sample text on the page, the selection menu control will appear on the screen.

Now we get to code what happens when the selection menu control is clicked. In other words, we’re going to make it so that the text is tweeted when the prompt is clicked.

control.addEventListener('pointerdown', oncontroldown, true);  function oncontroldown(event) {   window.open(`https://twitter.com/intent/tweet?text=$ {this.text}`)   this.remove();   document.getSelection().removeAllRanges();   event.stopPropagation(); }

When the control is clicked, a tab opens with Twitter’s “New Tweet” page, complete with the selected text ready to go.

After the tweet prompt, the selection menu control is no longer needed and is removed, along with any selection made on the page. The way that the pointerdown event cascades further down the DOM tree is also stopped at this point.

We also need an event handler for the onpointerdown event of the page:

document.onpointerdown = ()=> {       let control = document.querySelector('#control');   if (control !== null) {control.remove();document.getSelection().removeAllRanges();} }

Now the control and any selection made on the page are removed when clicking anywhere on the page but the selection menu control.


Here’s a more prettified version that Chris put together:

And here’s an example showing more than one control in the selection menu:

About that <template>

It’s not totally necessary that we use it. Instead, you can also try simply hiding and showing the control some other way, like the hidden HTML attribute or the CSS display. You can also build a selection menu control in the JavaScript itself. The coding choices will depend on how efficiently you execute them, and their fallbacks, if needed, as well as how they fit in with your application.

Some UI/UX advice

While this is a nice effect, there are a couple of things to consider when using it to ensure a good user experience. For example, avoid injecting your own text into the text selection — you know, like appending a link back to your site in the auto-generated tweet. It’s intrusive and annoying. If there’s any reason to do that, like adding the source citation, let the user see a preview of the final text before posting it. Otherwise, the user might be confused or surprised by the addition.

One more thing: It’s best if the menu control is out of the way. We don’t want it covering up too much of the surrounding content. That sort of thing adds up to CSS “data loss” and we want to avoid that.

Bottom line: Understand why your users need to select text on your website and add the controls in a way that gets out of the way of what they’re trying to do.

The post How to Create Actions for Selected Text With the Selection API appeared first on CSS-Tricks.

You can support CSS-Tricks by being an MVP Supporter.


, , , ,

Let’s Create an Image Pop-Out Effect With SVG Clip Path

Few weeks ago, I stumbled upon this cool pop-out effect by Mikael Ainalem. It showcases the clip-path: path() in CSS, which just got proper support in most modern browsers. I wanted to dig into it myself to get a better feel for how it works. But in the process, I found some issues with clip-path: path(); and wound up finding an alternative approach that I wanted to walk through with you in this article.

If you haven’t used clip-path or you are unfamiliar with it, it basically allows us to specify a display region for an element based on a clipping path and hide portions of the element that fall outside the clip path.

A rectangle with a pastel pattern, plus an unfilled star shape with a black border, equals a star shape with the pastel background pattern.
You can kind of think of it as though the star is a cookie cutter, the element is the cookie dough, and the result is a star-shaped cookie.

Possible values for clip-path include circle , ellipse and polygon which limit the use-case to just those specific shapes. This is where the new path value comes in — it allows us to use a more flexible SVG path to create various clipping paths that go beyond basic shapes.

Let’s take what we know about clip-path and start working on the hover effect. The basic idea of the is to make the foreground image of a person appear to pop-out from the colorful background and scale up in size when the element is hovered. An important detail is how the foreground image animation (scale up and move up) appears to be independent from the background image animation (scale up only).

This effect looks cool, but there are some issues with the path value. For starters, while we mentioned that support is generally good, it’s not great and hovers around 82% coverage at the time of writing. So, keep in mind that mobile support is currently limited to Chrome and Safari.

Besides support, the bigger and more bizarre issue with path is that it currently only works with pixel values, meaning that it is not responsive. For example, let’s say we zoom into the page. Right off the bat, the path shape starts to cut things off.

This severely limits the number of use cases for clip-path: path(), as it can only be used on fixed-sized elements. Responsive web design has been a widely-accepted standard for many years now, so it’s weird to see a new CSS property that doesn’t follow the principle and exclusively uses pixel units.

What we’re going to do is re-create this effect using standard, widely-supported CSS techniques so that it not only works, but is truly responsive as well.

The tricky part

We want anything that overflows the clip-path to be visible only on the top part of the image. We cannot use a standard CSS overflow property since it affects both the top and bottom.

Photo of a young woman against a pastel floral pattern cropped to the shape of a circle.
Using overflow-y: hidden, the bottom part looks good, but the image is cut-off at the top where the overflow should be visible.

So, what are our options besides overflow and clip-path? Well, let’s just use <clipPath> in the SVG itself. <clipPath> is an SVG property, which is different than the newly-released and non-responsive clip-path: path.

SVG <clipPath> element

SVG <clipPath> and <path> elements adapt to the coordinate system of the SVG element, so they are responsive out of the box. As the SVG element is being scaled, its coordinate system is also being scaled, and it maintains its proportions based on the various properties that cover a wide range of possible use cases. As an added benefit, using clip-path in CSS on SVG has 95% browser support, which is a 13% increase compared to clip-path: path.

Let’s start by setting up our SVG element. I’ve used Inkscape to create the basic SVG markup and clipping paths, just to make it easy for myself. Once I did that, I updated the markup by adding my own class attributes.

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -10 100 120" class="image">   <defs>     <clipPath id="maskImage" clipPathUnits="userSpaceOnUse">       <path d="..." />     </clipPath>     <clipPath id="maskBackground" clipPathUnits="userSpaceOnUse">       <path d="..." />     </clipPath>   </defs>   <g clip-path="url(#maskImage)" transform="translate(0 -7)">     <!-- Background image -->     <image clip-path="url(#maskBackground)" width="120" height="120" x="70" y="38" href="..." transform="translate(-90 -31)" />     <!-- Foreground image -->     <image width="120" height="144" x="-15" y="0" fill="none" class="image__foreground" href="..." />   </g> </svg>
A bright green circle with a bright red shape coming out from the top of it, as if another shape is behind the green circle.
SVG <clipPath> elements created in Inkscape. The green element represents a clipping path that will be applied to the background image. The red is a clipping path that will be applied to both the background and foreground image.

This markup can be easily reused for other background and foreground images. We just need to replace the URL in the href attribute inside image elements.

Now we can work on the hover animation in CSS. We can get by with transforms and transitions, making sure the foreground is nicely centered, then scaling and moving things when the hover takes place.

.image {   transform: scale(0.9, 0.9);   transition: transform 0.2s ease-in; }  .image__foreground {   transform-origin: 50% 50%;   transform: translateY(4px) scale(1, 1);   transition: transform 0.2s ease-in; }  .image:hover {   transform: scale(1, 1); }  .image:hover .image__foreground {   transform: translateY(-7px) scale(1.05, 1.05); }

Here is the result of the above HTML and CSS code. Try resizing the screen and changing the dimensions of the SVG element to see how the effect scales with the screen size.

This looks great! However, we’re not done. We still need to address some issues that we get now that we’ve changed the markup from an HTML image element to an SVG element.

SEO and accessibility

Inline SVG elements won’t get indexed by search crawlers. If the SVG elements are an important part of the content, your page SEO might take a hit because those images probably won’t get picked up.

We’ll need additional markup that uses a regular <img> element that’s hidden with CSS. Images declared this way are automatically picked up by crawlers and we can provide links to those images in an image sitemap to make sure that the crawlers manage to find them. We’re using loading="lazy" which allows the browser to decide if loading the image should be deferred.

We’ll wrap both elements in a <figure> element so that we markup reflects the relationship between those two images and groups them together:

<figure>   <!-- SVG element -->   <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -10 100 120" class="image">      <!-- ... -->   </svg>   <!-- Fallback image -->   <img src="..." alt="..." loading="lazy" class="fallback-image" /> </figure>

We also need to address some accessibility concerns for this effect. More specifically, we need to make improvements for users who prefer browsing the web without animations and users who browse the web using screen readers.

Making SVG elements accessible takes a lot of additional markup. Additionally, if we want to remove transitions, we would have to override quite a few CSS properties which can cause issues if our selector specificities aren’t consistent. Luckily, our newly added regular image has great accessibility features baked right in and can easily serve as a replacement for users who browse the web without animations.

<figure>   <!-- Animated SVG element -->   <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -10 100 120" class="image" aria-hidden="true">     <!-- ... -->   </svg>    <!-- Fallback SEO & a11y image -->   <img src="..." alt="..." loading="lazy" class="fallback-image" /> </figure>

We need to hide the SVG element from assistive devices, by adding aria-hidden="true", and we need to update our CSS to include the prefers-reduced-motion media query. We are inclusively hiding the fallback image for users without the reduced motion preference meanwhile keeping it available for assistive devices like screen readers.

@media (prefers-reduced-motion: no-preference) { .fallback-image {   clip: rect(0 0 0 0);    clip-path: inset(50%);   height: 1px;   overflow: hidden;   position: absolute;   white-space: nowrap;    width: 1px;   }  }  @media (prefers-reduced-motion) {   .image {     display: none;   } }

Here is the result after the improvements:

Please note that these improvements won’t change how the effect looks and behaves for users who don’t have the prefers-reduced-motion preference set or who aren’t using screen readers.

That’s a wrap

Developers were excited about path option for clip-path CSS attribute and new styling possibilities, but many were displeased to find out that these values only support pixel values. Not only does that mean the feature is not responsive, but it severely limits the number of use cases where we’d want to use it.

We converted an interesting image pop-out hover effect that uses clip-path: path into an SVG element that utilizes the responsiveness of the <clipPath> SVG element to achieve the same thing. But in doing so, we introduced some SEO and accessibility issues, that we managed to work around with a bit of extra markup and a fallback image.

Thank you for taking the time to read this article! Let me know if this approach gave you an idea on how to implement your own effects and if you have any suggestions on how to approach this effect in a different way.

The post Let’s Create an Image Pop-Out Effect With SVG Clip Path appeared first on CSS-Tricks.

You can support CSS-Tricks by being an MVP Supporter.


, , , , , ,