I’ve encountered two bugs in Chrome while testing the new CSS text-decoration-thickness and text-underline-offset properties, and I want to share them with you here in this article.
But first, let’s acknowledge one thing:
Default underlines are inconsistent
Let’s add a text link to a plain web page, set its font-family to Arial, and compare the underlines across browsers and operating systems.
From left to right: Chrome, Safari, and Firefox on macOS; Safari on iOS; Chrome, and Firefox on Windows; Chrome, and Firefox on Android.
As you can see, the default underline is inconsistent across browsers. Each browser chooses their own default thickness and vertical position (offset from the baseline) for the underline. This is in line with the CSS Text Decoration module, which specifies the following default behavior (auto value):
The user agent chooses an appropriate thickness for text decoration lines. […] The user agent chooses an appropriate offset for underlines.
Luckily, we can override the browsers’ defaults
There are two new, widely supported CSS properties that allow us to precisely define the thickness and offset for our underlines:
With these properties, we can create consistent underlines even across two very different browsers, such as the Gecko-based Firefox on Android and the WebKit-based Safari on macOS.
Top row: the browsers’ default underlines; bottom row: consistent underlines with CSS. (Demo)
Note: The text-decoration-thickness property also has a special from-font value that instructs browsers to use the font’s own preferred underline width, if available. I tested this value with a few different fonts, but the underlines were inconsistent.
OK, so let’s move on to the two Chrome bugs I noted earlier.
Chrome bug 1: Underlines are too thin on macOS
If you set the text-decoration-thickness property to a font-relative length value that computes to a non-integer pixel value, Chrome will “floor” that value instead of rounding it to the nearest integer. For example, if the declared thickness is 0.06em, and that computes to 1.92px, Chrome will paint a thickness of 1px instead of 2px. This issue is limited to macOS.
a {
font-size: 2em; /* computes to 32px */
text-decoration-thickness: 0.06em; /* computes to 1.92px */
}
In the following screenshot, notice how the text decoration lines are twice as thin in Chrome (third row) than in Safari and Firefox.
From top to bottom: Safari, Firefox, and Chrome on macOS. (Demo)
The text-underline-offset property allows us to precisely set the distance between the alphabetic baseline and the underline (the underline’s offset from the baseline). Unfortunately, this feature is currently not implemented correctly in Chrome and, as a result, the underline is positioned too low.
Note: As you might have noticed from the image above, Safari draws underlines on top of descenders instead of beneath them. This is a WebKit bug that was fixed very recently. The fix should ship in the next version of Safari.
Help prioritize the Chrome bugs
The two new CSS properties for styling underlines are a welcome addition to CSS. Hopefully, the two related Chrome bugs will be fixed sooner rather than later. If these CSS features are important to you, make your voice heard by starring the bugs in Chromium’s bug tracker.
Sign in with your Google account and click the star button on issues #1172623 and #1255280.
We use and love Jetpack around here. It’s a WordPress plugin that brings a whole suite of functionality to your site ranging from security to marketing with lots of ridiculously useful stuff in between! Here’s our favorite features around here.
Powerful Search
Jetpack’s Search feature gives your site an incredibly powerful search engine with the flip of a switch. You get a very fast, truly intelligent search for your entire site that is easily sortable and filterable with literally zero work on your part. You can’t rely on default WordPress search — this is a must-have. Bonus: it’s all handled offsite, so there is minimal burden on your server.
We sleep easy knowing CSS-Tricks is entirely backed up in real-time. Everything is backed up from the site’s content, comments, settings, theme files, media, even WordPress itself.
An activity log shows off everything that happens on the site, and I use that same log to restore the site to any point in time.
There are at least four things you have to do with images on websites to make sure you’re serving them in a performance responsible way: (1) use the responsive images syntax to serve an appropriately sized version, (2) optimize the image, (3) lazy load the image, and (4) serve the image from a CDN. Fortunately, WordPress itself helps with #1, which can be tricky. Jetpack helps with the others with the flip of a switch.
And don’t forget about video! VideoPress does even more for your hosted videos. No ads, beautiful feature-rich player, CDN-hosted optimized video, poster graphics for mobile, and you do absolutely nothing different with your workflow: just drag and drop videos into posts.
JetPack Markdown
Writing content in Markdown can be awful handy. Especially on a developer-focused site, it makes sense to offer it to users in the comment section.
With Jetpack Markdown, you also get a Markdown block to use in the block editor so you can write in chunks of Markdown wherever needed.
CSS-Tricks has thousands of pages of content! It’s a challenge for us to surface all the best stuff, particularly on a per-topic basis and without having to hand-pick everything. Showing related posts is tricky to pull off and we love that Jetpack does a great job with it, all without burdening our servers the way other related content solutions can.
We like to tell the world as best as we can when we publish new content. Rather than having to do that manually, we can share the post to Twitter and Facebook the second we hit that “Publish” button. You can always head back to older content and re-publish to social media as well.
This isn’t a complete list. The official features page will show you even more. Every site’s needs will be different. There are all sorts of security, design, and promotion features that might be your favorites. If you manage a lot of WordPress sites, as agencies often too, take note there is a new Licensing Portal to manage billing across multiple sites much more easily.
Fingerprinting is bad. It’s a term that refers to building up enough metadata about a user that you can essentially figure out who they are. JavaScript has access to all sorts of fingerprinting possibilities, which then combined with the IP address that the server has access to, means fingerprinting is all too common.
You don’t generally think of CSS as being a fingerprinting vector though, and thus “safe” in that way. But Oliver Brotchie has documented an idea that allows for some degree of fingerprinting with CSS alone.
Think of all the @media queries we have. We can test for pointer type with any-pointer. Imagine that for each value, we request a totally unique background-image from a server. If that image was requested, we know those @media queries were true. We can start to fingerprint with something like this:
Combine that with the fact that we can test for a dark mode preference with prefers-color-scheme, the fingerprint gets a bit clearer. In fact, it’s the current draft for CSS user prefer media queries that Oliver is most concerned about:
Not only will the upcoming draft make this method scalable, but it will also increase its precision. Currently, without alternative means, it is hard to conclusively link every request to a specific visitor as the only feasible way to determine their origin, is to group the requests by the IP address of the connection. However, with the new draft, by generating a randomised string and interpolating it into the URL tag for every visitor, we can accurately identify all requests from said visitor.
There are tons more. We can make media queries that are 1px apart and request a background image for each, perfectly guessing the visitor’s window size. There are probably a dozen or more exotic media queries that are rarely used, but are useful specifically to fingerprinting with CSS. Combine that with @supports queries for all sorts of things to essentially guess the exact browser. And combine that with the classic technique of testing for installation of specific local fonts, and you have a half-decent fingerprinting machine.
The generated CSS to do it is massive (here’s the Sass to generate it), but apparently it’s heavily reduced once we can use custom properties in URLs.
I’m not heavily worried about it, mostly because I don’t disable JavaScript and JavaScript is so much more widely capable of fingerprinting already. Plus, there are already other types of CSS security vulnerabilities, from reading visited links (which browsers have addressed), keylogging, and user generated inline styles, among others that folks have pointed out in another article on the topic.
But Oliver’s research on fingerprinting is really good and worthy of a look by everyone who knows more about web security than I do.
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.
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.
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.
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).
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.
chrome.i18n.getMessage('name');
That code returns our defined translation (which is Transcribers of Reddit). Placeholders can be done in a similar way.
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.
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 we dive into actual programming, we we need to create two pages:
An options page that contains user settings
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:
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>.
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:
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.
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:
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.
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:
The entries we want to load
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.
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:
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:
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.
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.
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:
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:
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.
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
Conclusion
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!
The year has come to a close and it’s time again for our end-of-year wrapup. The most important message is this: thank you. (thankyouthankyou)
Thanks for stopping by and reading this site. If you didn’t, I’d be out of a job around here, and I quite like this job so I owe it all to you. Like a family holiday card, allow me to share how the year went from our perspective, both with numbers and feelings, and then do a review of our goals.
Overall Traffic Analytics Numbers
The site saw 88m pageviews this year down 6% from the 93m last year. Traffic has yo-yo’d up and down a smidge like that a little over the last 4-5 years, but this 6% is a bit of an alarming drop that I don’t like to see. These numbers are from Google Analytics, and some of my own reasearch this year suggests perhaps 20-30% of visitors to this site actually block the run-of-the-mill client-side JavaScript-powered Google Analytics I use. So perhaps the real traffic is higher, but as the analytics implementation is exactly the same and I don’t see any reason blocking would have skyrocketed just this past year alone, the downward movement seems real.
A ~3% drop in organic search traffic was largely responsible for the dip. That’s big, as search is 74.6% of all traffic. This points to us just not hitting the mark well enough for what people are searching for. A nice 36% increase in direct traffic points to pretty decent brand awareness, but direct traffic is only 5% of overall traffic anyway so it doesn’t make much of a difference compared to search engine traffic. Referral traffic is down, social is up, but both are such small slices right now they just don’t move the needle.
You might think, well hey content ages out, search engine traffic to existing content will decline over time. That’s true, but we publish a ton of new content every year as well as maintain and improve existing content, hence the concern.
We invest well into 6-figures in new and updated content every year. So seeing a decline in traffic is disheartening.
But hey that’s the game sometimes. I suspect it’s heavy competition in the developer writing space, which is something we all benefit from as developers, so it ain’t all bad. We’ll live and learn and do our best to turn it around for the sake of the health of this site. I’ve already got (counts fingers and toes) a million ideas.
All that said, while I do think pageviews is an interesting and relevant metric to a site that uses advertising as a primary business model, there are many others. Unique Visitors are up year over year to 26.3m from 25.8m, suggesting more different people came to the site this year, which is great, they just didn’t bop around the site as much or come back quite as often. Pages per visit is very steady at 1.35 meaning for the most part people come, they read, they leave. No surprise there. It’s mostly that “come back” thing to work on.
The Biggest Leap in Mobile Traffic Yet
Pretty big jump in mobile usage this year!
2021: 20%
2020: 15%
2019: 15%
2018: 12%
A fifth of all traffic is pretty interesting. Before 2018, even though mobile traffic was surging then too, we were in the low single digits, which I always thought hey this is a reference site for coding and people code on desktop. But clearly, that’s changing and perhaps people are reading the site in a more news kinda way, which I like. For years I had goals of making this site both full of referential long-green content and a site you could subscribe to for news, like an industry rag. So far so good.
Content by the Numbers
You’d think if we missed the mark on new content this year, that perhaps some better year would beat articles-written-in-2021 in traffic, but that’s not the case. Articles written in 2021 drove the most traffic to the site in 2021 (13.5% of overall traffic). Here are the articles that were top-by-pageviews in 2021 that were written in 2021:
Those articles above range from 100k pageviews to 71k pageviews. What’s interesting is that if you group together all posts that got 40k or more pageviews, there are 44 of them, putting them at about 2.5-3m pageviews. That’s kinda cool I think — the “medium tail” of content is pretty thick around here. The flexbox guide page alone did 6.7m pageviews, so that’s still a beast, but it is bested by all content published in 2021 which clocks in at 11.8m. So investing in content works, it just needs to get tuned such that we aren’t dropping overall. Perhaps that means SEO tuning of both new content and old.
I like seeing the Almanac not only perform pretty well overall but have some individual pages be top-performers on their own.
Comments
We had about 4,320 legit comments on the site this year, almost exactly the number from last year. Weird!
That seems like a lot, especially as we approve… I’d say half?… of commenters that are left. There is a lot of junk posts (e.g. “good post!” kinda stuff, that we just don’t post as to not bother the author with useless email notifications of new comments, nor readers with useless content). We just delete those junk posts (as in, not approve them in the first place).
There is spam too of course. We crossed the 2m spam comments threshold, but through a combination of Akismet and Anti-Spam not too much spam sneaks through and is easily trashed before approval.
Mentally, I really rollercoaster on comments. Sometimes they are great and helpful. Sometimes they are full of rudeness, hate, and anger. Those need to be looked at and trashed, meaning comments represent an entry point into my brain for all that negativity. Part of me thinks we should just shut them off, and if people have something important to say, we can encourage them to use their own blog (it ain’t hard to spin one up!) to comment and we’ll link to it if it’s good.
But then I think of all the helpful comments and comments that help keep me up to date. Heck I just learned that Chrome is postponing all that removal of alert() stuff via a comment from Kyle, and I probably would have missed that otherwise. Plus the fact that there are
4,320 of them this year that pass muster feels like the scale is tipped toward keeping them.
Newsletter
We’re at about 91,000 newsletter subscribers as this year wraps, up from 81,000 last year. A respectable march forward and makes it likely we’ll hit that 100k milestone sometime in 2022.
Huge props to Robin for leading up the newsletter with wonderful writing. I think he really found a voice and stride on the newsletter this year.
We didn’t miss a single week. Part of what helps there is that they have sponsors so there is some clear obligation to get them out on time, but I think it’s more like we have a system and the system works.
I’d really like to juice up newsletter subscriptions moreso because I think it’s actually a darn nice weekly read than for any specific business reason.
The design evolved a bit this year, but nothing overly dramatic. Normally this time of year my fingers are itching for a new design, and believe me there are Figma drafts cooking, but I just haven’t had the time or inspiration for a true v19 just yet.
So no major changes to the tech behind the site, but plenty of minor ones. For instance:
The Yoast SEO plugin was giving me problems. It had super frequent updates, which I guess is good, but there was a high frequency of problems with the updates where either the core plugin or the pro plugin wouldn’t update correctly (up to causing such problems as literally taking down the site) and settings getting messed up during updates. For a while I just turned it off entirely. But then I started hearing good things about RankMath so I’m trying that, and so far so good. It’s got me kinda inspired to take content SEO more seriously. Yoast had some claws in the site in other ways, for example it provides a pretty nice Table of Contents block that I’m still searching for a solution for (maybe it’s coming to core?). It also had pretty nice breadcrumbs, and had to switch over to Breadcrumb NavXT.
Jetpack Boost is new to the site this year, and I’m impressed at how it handles critical CSS. Jetpack (full disclosure: a long time sponsor) is generally extremely helpful. I particularly like how the site search works, which is just out-of-the-box Jetpack Instant Search.
We also dialed in the eCommerce situation. The MVP Supporter membership unlocks additional content on the site, which I can now provide in eBook formats. So I’m really all set to produce more of that type of content.
Goal Review
🚫 Publish Three Guides. I thought this would be easy since last year our goal was 2 guides and we published 9! But this year we only managed one: A Complete Guide to Custom Properties. We did publish some other pretty big series like Tobias Günther’s 9-part Advanced Git series and four more entries in Jay Hoffman’s Web History series.
🚫Stay focused on how-to technical content around our strengths. Kind of a close call here. It’s not like we didn’t publish quite a bit of how-to technical content. But I’m going to say we failed because I don’t think we kept this in mind strongly enough throughout the year. We didn’t say “we’re good at this type of content so we’re going to lean into that specifically” like this goal suggested we should.
🚫Complete all missing Almanac entries. I hate marking this as failed, but I’m only doing that because of how it was worded with “all”. I think I had in mind that there was a really clear finite number of Alamanc articles to finish and we just had to do that. I think it’s a lot more wishy-washy than that, partially because of editorial choices (do you publish a unique entry for every single logical property or group them, for example).
But also, should we build an SVG-specific section? Should we have a new section for all the @at rules? It’s hard to say when the Almanac would be “complete”, so I’d just rather not. This page really needs a cleanup, but it’s got many ideas in there for more work that needs to be done/commissioned if anyone is so inclined.
We did do a pretty good job on publishing new entries though — more than any relatively recent year!
More SEO focus. I’ve almost shunned SEO in the past. Partially because the HTML best practices seem pretty easy and obvious, and my inbox is so full of total slimeball link builders I’d like to see do literally anything else with their time. Butttt. I’m just being ignorant about it. I think it will be fun, interesting, and likely useful to take a more considered look at SEO best practices for a content site like this and make a stab at improving it. The related goal being: Gain 10% in pageview traffic. We lost 6% this year, so I think 10% will get us back on track and moving upward. But it’s a big goal so I’m already nervous about it.
Another digital book. All the infrastructure is there for this and I’ve got ideas. I just need to write and put it in place.
More social media experimentation. That’s a loosey-goosey goal but whatever, we’ve got our work cut out for us in other ways. Like SEO, for a few years there I kinda shunned dedicated social media work for the CSS-Tricks brand. Mostly because when I look at the traffic numbers, so very little of it comes from social media, especially considering how much time we were spending on it in the past. We don’t really benefit much from brand social media, so why bother? Well, maybe I was thinking about it the wrong way. Maybe we can just not care what traffic it drives but care about the connection with readers directly there. If we’re more fun and interesting on social media, maybe we continue to build trust in what we’re doing here. Maybe it can help drive sales if we get that second goal done. Maybe its more directly monetizeable.
Thank You
Special thanks to Geoff! If you didn’t know, he’s our lead editor around here and keeping this entire site humming along nicely. You’ll work with Geoff if you do any guest writing here at all.