Archiwum kategorii: CSS

Alt Text: Not Always Needed

Post pobrano z: Alt Text: Not Always Needed

Alt text is one of those things in my muscle memory that pops up anytime I’m working with an image element. The attribute almost writes itself.

<img src="image.jpg" alt="">

Or if you use Emmet, that’s autocompleted for you. Don’t forget the alt text! Use it even if there’s no need for it, as an empty string is simply skipped by screen readers. That’s called “nulling” the alternative text and many screen readers simply announce the image file name. Just be sure it’s truly an empty string because even a space gets picked up by some assistive tech, which causes a screen reader to completely skip the image:

<!-- Not empty -->
<img src="image.jpg" alt=" ">

But wait… are there situations where an image doesn’t need alt text? I tend to agree with Eric that the vast majority of images are more than decorative and need to be described. Your images are probably not decorative and ought to be described with alt text.

Probably is doing a lot of lifting there because not all images are equal when it comes to content and context. Emma Cionca and Tanner Kohler have a fresh study on those situations where you probably don’t need alt. It’s a well-written and researched piece and I’m rounding up some nuggets from it.

What Users Need from Alt Text

It’s the same as what anyone else would need from an image: an easy path to accomplish basic tasks. A product image is a good example of that. Providing a visual smooths the path to purchasing because it’s context about what the item looks like and what to expect when you get it. Not providing an image almost adds friction to the experience if you have to stop and ask customer support basic questions about the size and color of that shirt you want.

So, yes. Describe that image in alt! But maybe “describe” isn’t the best wording because the article moves on to make the next point…

Quit Describing What Images Look Like

The article gets into a common trap that I’m all too guilty of, which is describing an image in a way that I find helpful. Or, as the article says, it’s a lot like I’m telling myself, “I’ll describe it in the alt text so screen-reader users can imagine what they aren’t seeing.”

That’s the wrong way of going about it. Getting back to the example of a product image, the article outlines how a screen reader might approach it:

For example, here’s how a screen-reader user might approach a product page:

  1. Jump between the page headers to get a sense of the page structure.
  2. Explore the details of a specific section with the heading label Product Description.
  3. Encounter an image and wonder “What information that I might have missed elsewhere does this image communicate about the product?

Interesting! Where I might encounter an image and evaluate it based on the text around it, a screen reader is already questioning what content has been missed around it. This passage is one I need to reflect on (emphasis mine):

Most of the time, screen-reader users don’t wonder what images look like. Instead, they want to know their purpose. (Exceptions to this rule might include websites presenting images, such as artwork, purely for visual enjoyment, or users who could previously see and have lost their sight.)

OK, so how in the heck do we know when an image needs describing? It feels so awkward making what’s ultimately a subjective decision. Even so, the article presents three questions to pose to ourselves to determine the best route.

  1. Is the image repetitive? Is the task-related information in the image also found elsewhere on the page?
  2. Is the image referential? Does the page copy directly reference the image?
  3. Is the image efficient? Could alt text help users more efficiently complete a task?

This is the meat of the article, so I’m gonna break those out.

Is the image repetitive?

Repetitive in the sense that the content around it is already doing a bang-up job painting a picture. If the image is already aptly “described” by content, then perhaps it’s possible to get away with nulling the alt attribute.

This is the figure the article uses to make the point (and, yes, I’m alt-ing it):

Showing a caption describing an image located directly above it.

The caption for this image describes exactly what the image communicates. Therefore, any alt text for the image will be redundant and a waste of time for screen-reader users. In this case, the actual alt text was the same as the caption. Coming across the same information twice in a row feels even more confusing and unnecessary.

The happy path:

<img src="image.jpg" alt="">

But check this out this image about informal/semi-formal table setting showing how it is not described by the text around it (and, no, I’m not alt-ing it):

If I was to describe this image, I might get carried away describing the diagram and all the points outlined in the legend. If I can read all of that, then a screen reader should, too, right? Not exactly. I really appreciate the slew of examples provided in the article. A sampling:

  1. Bread plate and butter knife, located in the top left corner. 
  2. Dessert fork, placed horizontally at the top center. 
  3. Dessert spoon, placed horizontally at the top center, below the dessert fork.

That’s way less verbose than I would have gone. Talking about how long (or short) alt ought to be is another topic altogether.

Is the image referential?

The second image I dropped in that last section is a good example of a referential image because I directly referenced it in the content preceding it. I nulled the alt attribute because of that. But what I messed up is not making the image recognizable to screen readers. If the alt attribute is null, then the screen reader skips it. But the screen reader should still know it’s there even if it’s aptly described.

The happy path:

<img src="image.jpg" alt="">

Remember that a screen reader may announce the image’s file name. So maybe use that as an opportunity to both call out the image and briefly describe it. Again, we want the screen reader to announce the image if we make mention of it in the content around it. Simply skipping it may cause more confusion than clarity.

Is the image efficient?

My mind always goes to performance when I see the word efficient pop up in reference to images. But in this context the article means whether or not the image can help visitors efficiently complete a task.

If the image helps complete a task, say purchasing a product, then yes, the image needs alt text. But if the content surrounding it already does the job then we can leave it null (alt="") or skip it (alt=" ") if there’s no mention of it.

Wrapping up

I put a little demo together with some testing results from a few different screen readers to see how all of that shakes out.

CodePen Embed Fallback

Alt Text: Not Always Needed originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

WordPress Multi-Multisite: A Case Study

Post pobrano z: WordPress Multi-Multisite: A Case Study

The mission: Provide a dashboard within the WordPress admin area for browsing Google Analytics data for all your blogs.

The catch? You’ve got about 900 live blogs, spread across about 25 WordPress multisite instances. Some instances have just one blog, others have as many as 250. In other words, what you need is to compress a data set that normally takes a very long time to compile into a single user-friendly screen.

The implementation details are entirely up to you, but the final result should look like this Figma comp:

Design courtesy of the incomparable Brian Biddle.

I want to walk you through my approach and some of the interesting challenges I faced coming up with it, as well as the occasional nitty-gritty detail in between. I’ll cover topics like the WordPress REST API, choosing between a JavaScript or PHP approach, rate/time limits in production web environments, security, custom database design — and even a touch of AI. But first, a little orientation.

Let’s define some terms

We’re about to cover a lot of ground, so it’s worth spending a couple of moments reviewing some key terms we’ll be using throughout this post.

What is WordPress multisite?

WordPress Multisite is a feature of WordPress core — no plugins required — whereby you can run multiple blogs (or websites, or stores, or what have you) from a single WordPress installation. All the blogs share the same WordPress core files, wp-content folder, and MySQL database. However, each blog gets its own folder within wp-content/uploads for its uploaded media, and its own set of database tables for its posts, categories, options, etc. Users can be members of some or all blogs within the multisite installation.

What is WordPress multi-multisite?

It’s just a nickname for managing multiple instances of WordPress multisite. It can get messy to have different customers share one multisite instance, so I prefer to break it up so that each customer has their own multisite, but they can have many blogs within their multisite.

So that’s different from a “Network of Networks”?

It’s apparently possible to run multiple instances of WordPress multisite against the same WordPress core installation. I’ve never looked into this, but I recall hearing about it over the years. I’ve heard the term “Network of Networks” and I like it, but that is not the scenario I’m covering in this article.

Why do you keep saying “blogs”? Do people still blog?

You betcha! And people read them, too. You’re reading one right now. Hence, the need for a robust analytics solution. But this article could just as easily be about any sort of WordPress site. I happen to be dealing with blogs, and the word “blog” is a concise way to express “a subsite within a WordPress multisite instance”.

One more thing: In this article, I’ll use the term dashboard site to refer to the site from which I observe the compiled analytics data. I’ll use the term client sites to refer to the 25 multisites I pull data from.

My implementation

My strategy was to write one WordPress plugin that is installed on all 25 client sites, as well as on the dashboard site. The plugin serves two purposes:

  • Expose data at API endpoints of the client sites
  • Scrape the data from the client sites from the dashboard site, cache it in the database, and display it in a dashboard.

The WordPress REST API is the Backbone

The WordPress REST API is my favorite part of WordPress. Out of the box, WordPress exposes default WordPress stuff like posts, authors, comments, media files, etc., via the WordPress REST API. You can see an example of this by navigating to /wp-json from any WordPress site, including CSS-Tricks. Here’s the REST API root for the WordPress Developer Resources site:

The root URL for the WordPress REST API exposes structured JSON data, such as this example from the WordPress Developer Resources website.

What’s so great about this? WordPress ships with everything developers need to extend the WordPress REST API and publish custom endpoints. Exposing data via an API endpoint is a fantastic way to share it with other websites that need to consume it, and that’s exactly what I did:

Open the code

<?php

[...]

function register(\WP_REST_Server $server) {
  $endpoints = $this->get();

  foreach ($endpoints as $endpoint_slug => $endpoint) {
    register_rest_route(
      $endpoint['namespace'],
      $endpoint['route'],
      $endpoint['args']
    );
  }
}

function get() {

  $version = 'v1';

  return array(
      
    'empty_db' => array(
      'namespace' => 'LXB_DBA/' . $version,
      'route'     => '/empty_db',
      'args'      => array(
        'methods' => array( 'DELETE' ),
        'callback' => array($this, 'empty_db_cb'),
        'permission_callback' => array( $this, 'is_admin' ),
      ),
    ),

    'get_blogs' => array(
      'namespace' => 'LXB_DBA/' . $version,
      'route'     => '/get_blogs',
      'args'      => array(
        'methods' => array('GET', 'OPTIONS'),
        'callback' => array($this, 'get_blogs_cb'),
        'permission_callback' => array($this, 'is_dba'),
      ),
    ),

    'insert_blogs' => array(
      'namespace' => 'LXB_DBA/' . $version,
      'route'     => '/insert_blogs',
      'args'      => array(
        'methods' => array( 'POST' ),
        'callback' => array($this, 'insert_blogs_cb'),
        'permission_callback' => array( $this, 'is_admin' ),
      ),
    ),

    'get_blogs_from_db' => array(
      'namespace' => 'LXB_DBA/' . $version,
      'route'     => '/get_blogs_from_db',
      'args'      => array(
        'methods' => array( 'GET' ),
        'callback' => array($this, 'get_blogs_from_db_cb'),
        'permission_callback' => array($this, 'is_admin'),
      ),
    ),  

    'get_blog_details' => array(
      'namespace' => 'LXB_DBA/' . $version,
      'route'     => '/get_blog_details',
      'args'      => array(
        'methods' => array( 'GET' ),
        'callback' => array($this, 'get_blog_details_cb'),
        'permission_callback' => array($this, 'is_dba'),
      ),
    ),   

    'update_blogs' => array(
      'namespace' => 'LXB_DBA/' . $version,
      'route'     => '/update_blogs',
      'args'      => array(
        'methods' => array( 'PATCH' ),
        'callback' => array($this, 'update_blogs_cb'),
        'permission_callback' => array($this, 'is_admin'),
      ),
    ),     

  );
}

We don’t need to get into every endpoint’s details, but I want to highlight one thing. First, I provided a function that returns all my endpoints in an array. Next, I wrote a function to loop through the array and register each array member as a WordPress REST API endpoint. Rather than doing both steps in one function, this decoupling allows me to easily retrieve the array of endpoints in other parts of my plugin to do other interesting things with them, such as exposing them to JavaScript. More on that shortly.

Once registered, the custom API endpoints are observable in an ordinary web browser like in the example above, or via purpose-built tools for API work, such as Postman:

JSON output.

PHP vs. JavaScript

I tend to prefer writing applications in PHP whenever possible, as opposed to JavaScript, and executing logic on the server, as nature intended, rather than in the browser. So, what would that look like on this project?

  • On the dashboard site, upon some event, such as the user clicking a “refresh data” button or perhaps a cron job, the server would make an HTTP request to each of the 25 multisite installs.
  • Each multisite install would query all of its blogs and consolidate its analytics data into one response per multisite.

Unfortunately, this strategy falls apart for a couple of reasons:

  • PHP operates synchronously, meaning you wait for one line of code to execute before moving to the next. This means that we’d be waiting for all 25 multisites to respond in series. That’s sub-optimal.
  • My production environment has a max execution limit of 60 seconds, and some of my multisites contain hundreds of blogs. Querying their analytics data takes a second or two per blog.

Damn. I had no choice but to swallow hard and commit to writing the application logic in JavaScript. Not my favorite, but an eerily elegant solution for this case:

  • Due to the asynchronous nature of JavaScript, it pings all 25 Multisites at once.
  • The endpoint on each Multisite returns a list of all the blogs on that Multisite.
  • The JavaScript compiles that list of blogs and (sort of) pings all 900 at once.
  • All 900 blogs take about one-to-two seconds to respond concurrently.

Holy cow, it just went from this:

( 1 second per Multisite * 25 installs ) + ( 1 second per blog * 900 blogs ) = roughly 925 seconds to scrape all the data.

To this:

1 second for all the Multisites at once + 1 second for all 900 blogs at once = roughly 2 seconds to scrape all the data.

That is, in theory. In practice, two factors enforce a delay:

  1. Browsers have a limit as to how many concurrent HTTP requests they will allow, both per domain and regardless of domain. I’m having trouble finding documentation on what those limits are. Based on observing the network panel in Chrome while working on this, I’d say it’s about 50-100.
  2. Web hosts have a limit on how many requests they can handle within a given period, both per IP address and overall. I was frequently getting a “429; Too Many Requests” response from my production environment, so I introduced a delay of 150 milliseconds between requests. They still operate concurrently, it’s just that they’re forced to wait 150ms per blog. Maybe “stagger” is a better word than “wait” in this context:
Open the code
async function getBlogsDetails(blogs) {
  let promises = [];

  // Iterate and set timeouts to stagger requests by 100ms each
  blogs.forEach((blog, index) => {
    if (typeof blog.url === 'undefined') {
      return;
    }

    let id = blog.id;
    const url = blog.url + '/' + blogDetailsEnpointPath + '?uncache=' + getRandomInt();

    // Create a promise that resolves after 150ms delay per blog index
    const delayedPromise = new Promise(resolve => {
      setTimeout(async () => {
        try {
          const blogResult = await fetchBlogDetails(url, id);
                
          if( typeof blogResult.urls == 'undefined' ) {
            console.error( url, id, blogResult );

          } else if( ! blogResult.urls ) {
            console.error( blogResult );
                
                
          } else if( blogResult.urls.length == 0 ) {
            console.error( blogResult );
                
          } else {
            console.log( blogResult );
          }
                
          resolve(blogResult);
        } catch (error) {
          console.error(`Error fetching details for blog ID ${id}:`, error);
          resolve(null); // Resolve with null to handle errors gracefully
      }
    }, index * 150); // Offset each request by 100ms
  });

  promises.push(delayedPromise);
});

  // Wait for all requests to complete
  const blogsResults = await Promise.all(promises);

  // Filter out any null results in case of caught errors
  return blogsResults.filter(result => result !== null);
}

With these limitations factored in, I found that it takes about 170 seconds to scrape all 900 blogs. This is acceptable because I cache the results, meaning the user only has to wait once at the start of each work session.

The result of all this madness — this incredible barrage of Ajax calls, is just plain fun to watch:

PHP and JavaScript: Connecting the dots

I registered my endpoints in PHP and called them in JavaScript. Merging these two worlds is often an annoying and bug-prone part of any project. To make it as easy as possible, I use wp_localize_script():

<?php

[...]

class Enqueue {

  function __construct() {
    add_action( 'admin_enqueue_scripts', array( $this, 'lexblog_network_analytics_script' ), 10 );
    add_action( 'admin_enqueue_scripts', array( $this, 'lexblog_network_analytics_localize' ), 11 );
  }

  function lexblog_network_analytics_script() {
    wp_register_script( 'lexblog_network_analytics_script', LXB_DBA_URL . '/js/lexblog_network_analytics.js', array( 'jquery', 'jquery-ui-autocomplete' ), false, false );
  }

  function lexblog_network_analytics_localize() {
    $a = new LexblogNetworkAnalytics;
    $data = $a -> get_localization_data();
    $slug = $a -> get_slug();

    wp_localize_script( 'lexblog_network_analytics_script', $slug, $data );

  }

  // etc.              
}

In that script, I’m telling WordPress two things:

  1. Load my JavaScript file.
  2. When you do, take my endpoint URLs, bundle them up as JSON, and inject them into the HTML document as a global variable for my JavaScript to read. This is leveraging the point I noted earlier where I took care to provide a convenient function for defining the endpoint URLs, which other functions can then invoke without fear of causing any side effects.

Here’s how that ended up looking:

The JSON and its associated JavaScript file, where I pass information from PHP to JavaScript using wp_localize_script().

Auth: Fort Knox or Sandbox?

We need to talk about authentication. To what degree do these endpoints need to be protected by server-side logic? Although exposing analytics data is not nearly as sensitive as, say, user passwords, I’d prefer to keep things reasonably locked up. Also, since some of these endpoints perform a lot of database queries and Google Analytics API calls, it’d be weird to sit here and be vulnerable to weirdos who might want to overload my database or Google Analytics rate limits.

That’s why I registered an application password on each of the 25 client sites. Using an app password in php is quite simple. You can authenticate the HTTP requests just like any basic authentication scheme.

I’m using JavaScript, so I had to localize them first, as described in the previous section. With that in place, I was able to append these credentials when making an Ajax call:

async function fetchBlogsOfInstall(url, id) {
  let install = lexblog_network_analytics.installs[id];
  let pw = install.pw;
  let user = install.user;

  // Create a Basic Auth token
  let token = btoa(`${user}:${pw}`);
  let auth = {
      'Authorization': `Basic ${token}`
  };

  try {
    let data = await $.ajax({
        url: url,
        method: 'GET',
        dataType: 'json',
        headers: auth
    });

    return data;

  } catch (error) {
    console.error('Request failed:', error);
    return [];
  }
}

That file uses this cool function called btoa() for turning the raw username and password combo into basic authentication.

The part where we say, “Oh Right, CORS.”

Whenever I have a project where Ajax calls are flying around all over the place, working reasonably well in my local environment, I always have a brief moment of panic when I try it on a real website, only to get errors like this:

CORS console error.

Oh. Right. CORS. Most reasonably secure websites do not allow other websites to make arbitrary Ajax requests. In this project, I absolutely do need the Dashboard Site to make many Ajax calls to the 25 client sites, so I have to tell the client sites to allow CORS:

<?php

  // ...

  function __construct() {
  add_action( 'rest_api_init', array( $this, 'maybe_add_cors_headers' ), 10 );
}

function maybe_add_cors_headers() {   
  // Only allow CORS for the endpoints that pertain to this plugin.
  if( $this->is_dba() ) {
      add_filter( 'rest_pre_serve_request', array( $this, 'send_cors_headers' ), 10, 2 );
  }
}

function is_dba() {
  $url = $this->get_current_url();
  $ep_urls = $this->get_endpoint_urls();
  $out = in_array( $url, $ep_urls );
          
  return $out;
          
}

function send_cors_headers( $served, $result ) {

          // Only allow CORS from the dashboard site.
  $dashboard_site_url = $this->get_dashboard_site_url();

  header( "Access-Control-Allow-Origin: $dashboard_site_url" );
  header( 'Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept, Authorization' );
  header( 'Access-Control-Allow-Methods: GET, OPTIONS' );
  return $served;
  
}

  [...]

}

You’ll note that I’m following the principle of least privilege by taking steps to only allow CORS where it’s necessary.

Auth, Part 2: I’ve been known to auth myself

I authenticated an Ajax call from the dashboard site to the client sites. I registered some logic on all the client sites to allow the request to pass CORS. But then, back on the dashboard site, I had to get that response from the browser to the server.

The answer, again, was to make an Ajax call to the WordPress REST API endpoint for storing the data. But since this was an actual database write, not merely a read, it was more important than ever to authenticate. I did this by requiring that the current user be logged into WordPress and possess sufficient privileges. But how would the browser know about this?

In PHP, when registering our endpoints, we provide a permissions callback to make sure the current user is an admin:

<?php

// ...

function get() {
  $version = 'v1';
  return array(

    'update_blogs' => array(
      'namespace' => 'LXB_DBA/' . $version,
      'route'     => '/update_blogs',
      'args'      => array(
        'methods' => array( 'PATCH' ),
        'callback' => array( $this, 'update_blogs_cb' ),
        'permission_callback' => array( $this, 'is_admin' ),
        ),
      ),
      // ...
    );           
  }
                      
function is_admin() {
    $out = current_user_can( 'update_core' );
    return $out;
}

JavaScript can use this — it’s able to identify the current user — because, once again, that data is localized. The current user is represented by their nonce:

async function insertBlog( data ) {
    
  let url = lexblog_network_analytics.endpoint_urls.insert_blog;

  try {
    await $.ajax({
      url: url,
      method: 'POST',
      dataType: 'json',
      data: data,
      headers: {
        'X-WP-Nonce': getNonce()
      }
    });
  } catch (error) {
    console.error('Failed to store blogs:', error);
  }
}

function getNonce() {
  if( typeof wpApiSettings.nonce == 'undefined' ) { return false; }
  return wpApiSettings.nonce;
}

The wpApiSettings.nonce global variable is automatically present in all WordPress admin screens. I didn’t have to localize that. WordPress core did it for me.

Cache is King

Compressing the Google Analytics data from 900 domains into a three-minute loading .gif is decent, but it would be totally unacceptable to have to wait for that long multiple times per work session. Therefore I cache the results of all 25 client sites in the database of the dashboard site.

I’ve written before about using the WordPress Transients API for caching data, and I could have used it on this project. However, something about the tremendous volume of data and the complexity implied within the Figma design made me consider a different approach. I like the saying, “The wider the base, the higher the peak,” and it applies here. Given that the user needs to query and sort the data by date, author, and metadata, I think stashing everything into a single database cell — which is what a transient is — would feel a little claustrophobic. Instead, I dialed up E.F. Codd and used a relational database model via custom tables:

In the Dashboard Site, I created seven custom database tables, including one relational table, to cache the data from the 25 client sites, as shown in the image.

It’s been years since I’ve paged through Larry Ullman’s career-defining (as in, my career) books on database design, but I came into this project with a general idea of what a good architecture would look like. As for the specific details — things like column types — I foresaw a lot of Stack Overflow time in my future. Fortunately, LLMs love MySQL and I was able to scaffold out my requirements using DocBlocks and let Sam Altman fill in the blanks:

Open the code
<?php 

/**
* Provides the SQL code for creating the Blogs table.  It has columns for:
* - ID: The ID for the blog.  This should just autoincrement and is the primary key.
* - name: The name of the blog.  Required.
* - slug: A machine-friendly version of the blog name.  Required.
* - url:  The url of the blog.  Required.
* - mapped_domain: The vanity domain name of the blog.  Optional.
* - install: The name of the Multisite install where this blog was scraped from.  Required.
* - registered:  The date on which this blog began publishing posts.  Optional.
* - firm_id:  The ID of the firm that publishes this blog.  This will be used as a foreign key to relate to the Firms table.  Optional.
* - practice_area_id:  The ID of the firm that publishes this blog.  This will be used as a foreign key to relate to the PracticeAreas table.  Optional.
* - amlaw:  Either a 0 or a 1, to indicate if the blog comes from an AmLaw firm.  Required.
* - subscriber_count:  The number of email subscribers for this blog.  Optional.
* - day_view_count:  The number of views for this blog today.  Optional.
* - week_view_count:  The number of views for this blog this week.  Optional.
* - month_view_count:  The number of views for this blog this month.  Optional.
* - year_view_count:  The number of views for this blog this year.  Optional.
* 
* @return string The SQL for generating the blogs table.
*/
function get_blogs_table_sql() {
  $slug = 'blogs';
  $out = "CREATE TABLE {$this->get_prefix()}_$slug (
      id BIGINT NOT NULL AUTO_INCREMENT,
      slug VARCHAR(255) NOT NULL,
      name VARCHAR(255) NOT NULL,
      url VARCHAR(255) NOT NULL UNIQUE, /* adding unique constraint */
      mapped_domain VARCHAR(255) UNIQUE,
      install VARCHAR(255) NOT NULL,
      registered DATE DEFAULT NULL,
      firm_id BIGINT,
      practice_area_id BIGINT,
      amlaw TINYINT NOT NULL,
      subscriber_count BIGINT,
      day_view_count BIGINT,
      week_view_count BIGINT,
      month_view_count BIGINT,
      year_view_count BIGINT,
      PRIMARY KEY (id),
      FOREIGN KEY (firm_id) REFERENCES {$this->get_prefix()}_firms(id),
      FOREIGN KEY (practice_area_id) REFERENCES {$this->get_prefix()}_practice_areas(id)
  ) DEFAULT CHARSET=utf8mb4;";
  return $out;
}

In that file, I quickly wrote a DocBlock for each function, and let the OpenAI playground spit out the SQL. I tested the result and suggested some rigorous type-checking for values that should always be formatted as numbers or dates, but that was the only adjustment I had to make. I think that’s the correct use of AI at this moment: You come in with a strong idea of what the result should be, AI fills in the details, and you debate with it until the details reflect what you mostly already knew.

How it’s going

I’ve implemented most of the user stories now. Certainly enough to release an MVP and begin gathering whatever insights this data might have for us:

Screenshot of the final dashboard which looks similar to the Figma mockups from earlier.
It’s working!

One interesting data point thus far: Although all the blogs are on the topic of legal matters (they are lawyer blogs, after all), blogs that cover topics with a more general appeal seem to drive more traffic. Blogs about the law as it pertains to food, cruise ships, germs, and cannabis, for example. Furthermore, the largest law firms on our network don’t seem to have much of a foothold there. Smaller firms are doing a better job of connecting with a wider audience. I’m positive that other insights will emerge as we work more deeply with this.

Regrets? I’ve had a few.

This project probably would have been a nice opportunity to apply a modern JavaScript framework, or just no framework at all. I like React and I can imagine how cool it would be to have this application be driven by the various changes in state rather than… drumrolla couple thousand lines of jQuery!

I like jQuery’s ajax() method, and I like the jQueryUI autocomplete component. Also, there’s less of a performance concern here than on a public-facing front-end. Since this screen is in the WordPress admin area, I’m not concerned about Google admonishing me for using an extra library. And I’m just faster with jQuery. Use whatever you want.

I also think it would be interesting to put AWS to work here and see what could be done through Lambda functions. Maybe I could get Lambda to make all 25 plus 900 requests concurrently with no worries about browser limitations. Heck, maybe I could get it to cycle through IP addresses and sidestep the 429 rate limit as well.

And what about cron? Cron could do a lot of work for us here. It could compile the data on each of the 25 client sites ahead of time, meaning that the initial three-minute refresh time goes away. Writing an application in cron, initially, I think is fine. Coming back six months later to debug something is another matter. Not my favorite. I might revisit this later on, but for now, the cron-free implementation meets the MVP goal.

I have not provided a line-by-line tutorial here, or even a working repo for you to download, and that level of detail was never my intention. I wanted to share high-level strategy decisions that might be of interest to fellow Multi-Multisite people. Have you faced a similar challenge? I’d love to hear about it in the comments!


WordPress Multi-Multisite: A Case Study originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

WordPress Multi-Multisite: A Case Study

Post pobrano z: WordPress Multi-Multisite: A Case Study

The mission: Provide a dashboard within the WordPress admin area for browsing Google Analytics data for all your blogs.

The catch? You’ve got about 900 live blogs, spread across about 25 WordPress multisite instances. Some instances have just one blog, others have as many as 250. In other words, what you need is to compress a data set that normally takes a very long time to compile into a single user-friendly screen.

The implementation details are entirely up to you, but the final result should look like this Figma comp:

Design courtesy of the incomparable Brian Biddle.

I want to walk you through my approach and some of the interesting challenges I faced coming up with it, as well as the occasional nitty-gritty detail in between. I’ll cover topics like the WordPress REST API, choosing between a JavaScript or PHP approach, rate/time limits in production web environments, security, custom database design — and even a touch of AI. But first, a little orientation.

Let’s define some terms

We’re about to cover a lot of ground, so it’s worth spending a couple of moments reviewing some key terms we’ll be using throughout this post.

What is WordPress multisite?

WordPress Multisite is a feature of WordPress core — no plugins required — whereby you can run multiple blogs (or websites, or stores, or what have you) from a single WordPress installation. All the blogs share the same WordPress core files, wp-content folder, and MySQL database. However, each blog gets its own folder within wp-content/uploads for its uploaded media, and its own set of database tables for its posts, categories, options, etc. Users can be members of some or all blogs within the multisite installation.

What is WordPress multi-multisite?

It’s just a nickname for managing multiple instances of WordPress multisite. It can get messy to have different customers share one multisite instance, so I prefer to break it up so that each customer has their own multisite, but they can have many blogs within their multisite.

So that’s different from a “Network of Networks”?

It’s apparently possible to run multiple instances of WordPress multisite against the same WordPress core installation. I’ve never looked into this, but I recall hearing about it over the years. I’ve heard the term “Network of Networks” and I like it, but that is not the scenario I’m covering in this article.

Why do you keep saying “blogs”? Do people still blog?

You betcha! And people read them, too. You’re reading one right now. Hence, the need for a robust analytics solution. But this article could just as easily be about any sort of WordPress site. I happen to be dealing with blogs, and the word “blog” is a concise way to express “a subsite within a WordPress multisite instance”.

One more thing: In this article, I’ll use the term dashboard site to refer to the site from which I observe the compiled analytics data. I’ll use the term client sites to refer to the 25 multisites I pull data from.

My implementation

My strategy was to write one WordPress plugin that is installed on all 25 client sites, as well as on the dashboard site. The plugin serves two purposes:

  • Expose data at API endpoints of the client sites
  • Scrape the data from the client sites from the dashboard site, cache it in the database, and display it in a dashboard.

The WordPress REST API is the Backbone

The WordPress REST API is my favorite part of WordPress. Out of the box, WordPress exposes default WordPress stuff like posts, authors, comments, media files, etc., via the WordPress REST API. You can see an example of this by navigating to /wp-json from any WordPress site, including CSS-Tricks. Here’s the REST API root for the WordPress Developer Resources site:

The root URL for the WordPress REST API exposes structured JSON data, such as this example from the WordPress Developer Resources website.

What’s so great about this? WordPress ships with everything developers need to extend the WordPress REST API and publish custom endpoints. Exposing data via an API endpoint is a fantastic way to share it with other websites that need to consume it, and that’s exactly what I did:

Open the code

<?php

[...]

function register(\WP_REST_Server $server) {
  $endpoints = $this->get();

  foreach ($endpoints as $endpoint_slug => $endpoint) {
    register_rest_route(
      $endpoint['namespace'],
      $endpoint['route'],
      $endpoint['args']
    );
  }
}

function get() {

  $version = 'v1';

  return array(
      
    'empty_db' => array(
      'namespace' => 'LXB_DBA/' . $version,
      'route'     => '/empty_db',
      'args'      => array(
        'methods' => array( 'DELETE' ),
        'callback' => array($this, 'empty_db_cb'),
        'permission_callback' => array( $this, 'is_admin' ),
      ),
    ),

    'get_blogs' => array(
      'namespace' => 'LXB_DBA/' . $version,
      'route'     => '/get_blogs',
      'args'      => array(
        'methods' => array('GET', 'OPTIONS'),
        'callback' => array($this, 'get_blogs_cb'),
        'permission_callback' => array($this, 'is_dba'),
      ),
    ),

    'insert_blogs' => array(
      'namespace' => 'LXB_DBA/' . $version,
      'route'     => '/insert_blogs',
      'args'      => array(
        'methods' => array( 'POST' ),
        'callback' => array($this, 'insert_blogs_cb'),
        'permission_callback' => array( $this, 'is_admin' ),
      ),
    ),

    'get_blogs_from_db' => array(
      'namespace' => 'LXB_DBA/' . $version,
      'route'     => '/get_blogs_from_db',
      'args'      => array(
        'methods' => array( 'GET' ),
        'callback' => array($this, 'get_blogs_from_db_cb'),
        'permission_callback' => array($this, 'is_admin'),
      ),
    ),  

    'get_blog_details' => array(
      'namespace' => 'LXB_DBA/' . $version,
      'route'     => '/get_blog_details',
      'args'      => array(
        'methods' => array( 'GET' ),
        'callback' => array($this, 'get_blog_details_cb'),
        'permission_callback' => array($this, 'is_dba'),
      ),
    ),   

    'update_blogs' => array(
      'namespace' => 'LXB_DBA/' . $version,
      'route'     => '/update_blogs',
      'args'      => array(
        'methods' => array( 'PATCH' ),
        'callback' => array($this, 'update_blogs_cb'),
        'permission_callback' => array($this, 'is_admin'),
      ),
    ),     

  );
}

We don’t need to get into every endpoint’s details, but I want to highlight one thing. First, I provided a function that returns all my endpoints in an array. Next, I wrote a function to loop through the array and register each array member as a WordPress REST API endpoint. Rather than doing both steps in one function, this decoupling allows me to easily retrieve the array of endpoints in other parts of my plugin to do other interesting things with them, such as exposing them to JavaScript. More on that shortly.

Once registered, the custom API endpoints are observable in an ordinary web browser like in the example above, or via purpose-built tools for API work, such as Postman:

JSON output.

PHP vs. JavaScript

I tend to prefer writing applications in PHP whenever possible, as opposed to JavaScript, and executing logic on the server, as nature intended, rather than in the browser. So, what would that look like on this project?

  • On the dashboard site, upon some event, such as the user clicking a “refresh data” button or perhaps a cron job, the server would make an HTTP request to each of the 25 multisite installs.
  • Each multisite install would query all of its blogs and consolidate its analytics data into one response per multisite.

Unfortunately, this strategy falls apart for a couple of reasons:

  • PHP operates synchronously, meaning you wait for one line of code to execute before moving to the next. This means that we’d be waiting for all 25 multisites to respond in series. That’s sub-optimal.
  • My production environment has a max execution limit of 60 seconds, and some of my multisites contain hundreds of blogs. Querying their analytics data takes a second or two per blog.

Damn. I had no choice but to swallow hard and commit to writing the application logic in JavaScript. Not my favorite, but an eerily elegant solution for this case:

  • Due to the asynchronous nature of JavaScript, it pings all 25 Multisites at once.
  • The endpoint on each Multisite returns a list of all the blogs on that Multisite.
  • The JavaScript compiles that list of blogs and (sort of) pings all 900 at once.
  • All 900 blogs take about one-to-two seconds to respond concurrently.

Holy cow, it just went from this:

( 1 second per Multisite * 25 installs ) + ( 1 second per blog * 900 blogs ) = roughly 925 seconds to scrape all the data.

To this:

1 second for all the Multisites at once + 1 second for all 900 blogs at once = roughly 2 seconds to scrape all the data.

That is, in theory. In practice, two factors enforce a delay:

  1. Browsers have a limit as to how many concurrent HTTP requests they will allow, both per domain and regardless of domain. I’m having trouble finding documentation on what those limits are. Based on observing the network panel in Chrome while working on this, I’d say it’s about 50-100.
  2. Web hosts have a limit on how many requests they can handle within a given period, both per IP address and overall. I was frequently getting a “429; Too Many Requests” response from my production environment, so I introduced a delay of 150 milliseconds between requests. They still operate concurrently, it’s just that they’re forced to wait 150ms per blog. Maybe “stagger” is a better word than “wait” in this context:
Open the code
async function getBlogsDetails(blogs) {
  let promises = [];

  // Iterate and set timeouts to stagger requests by 100ms each
  blogs.forEach((blog, index) => {
    if (typeof blog.url === 'undefined') {
      return;
    }

    let id = blog.id;
    const url = blog.url + '/' + blogDetailsEnpointPath + '?uncache=' + getRandomInt();

    // Create a promise that resolves after 150ms delay per blog index
    const delayedPromise = new Promise(resolve => {
      setTimeout(async () => {
        try {
          const blogResult = await fetchBlogDetails(url, id);
                
          if( typeof blogResult.urls == 'undefined' ) {
            console.error( url, id, blogResult );

          } else if( ! blogResult.urls ) {
            console.error( blogResult );
                
                
          } else if( blogResult.urls.length == 0 ) {
            console.error( blogResult );
                
          } else {
            console.log( blogResult );
          }
                
          resolve(blogResult);
        } catch (error) {
          console.error(`Error fetching details for blog ID ${id}:`, error);
          resolve(null); // Resolve with null to handle errors gracefully
      }
    }, index * 150); // Offset each request by 100ms
  });

  promises.push(delayedPromise);
});

  // Wait for all requests to complete
  const blogsResults = await Promise.all(promises);

  // Filter out any null results in case of caught errors
  return blogsResults.filter(result => result !== null);
}

With these limitations factored in, I found that it takes about 170 seconds to scrape all 900 blogs. This is acceptable because I cache the results, meaning the user only has to wait once at the start of each work session.

The result of all this madness — this incredible barrage of Ajax calls, is just plain fun to watch:

PHP and JavaScript: Connecting the dots

I registered my endpoints in PHP and called them in JavaScript. Merging these two worlds is often an annoying and bug-prone part of any project. To make it as easy as possible, I use wp_localize_script():

<?php

[...]

class Enqueue {

  function __construct() {
    add_action( 'admin_enqueue_scripts', array( $this, 'lexblog_network_analytics_script' ), 10 );
    add_action( 'admin_enqueue_scripts', array( $this, 'lexblog_network_analytics_localize' ), 11 );
  }

  function lexblog_network_analytics_script() {
    wp_register_script( 'lexblog_network_analytics_script', LXB_DBA_URL . '/js/lexblog_network_analytics.js', array( 'jquery', 'jquery-ui-autocomplete' ), false, false );
  }

  function lexblog_network_analytics_localize() {
    $a = new LexblogNetworkAnalytics;
    $data = $a -> get_localization_data();
    $slug = $a -> get_slug();

    wp_localize_script( 'lexblog_network_analytics_script', $slug, $data );

  }

  // etc.              
}

In that script, I’m telling WordPress two things:

  1. Load my JavaScript file.
  2. When you do, take my endpoint URLs, bundle them up as JSON, and inject them into the HTML document as a global variable for my JavaScript to read. This is leveraging the point I noted earlier where I took care to provide a convenient function for defining the endpoint URLs, which other functions can then invoke without fear of causing any side effects.

Here’s how that ended up looking:

The JSON and its associated JavaScript file, where I pass information from PHP to JavaScript using wp_localize_script().

Auth: Fort Knox or Sandbox?

We need to talk about authentication. To what degree do these endpoints need to be protected by server-side logic? Although exposing analytics data is not nearly as sensitive as, say, user passwords, I’d prefer to keep things reasonably locked up. Also, since some of these endpoints perform a lot of database queries and Google Analytics API calls, it’d be weird to sit here and be vulnerable to weirdos who might want to overload my database or Google Analytics rate limits.

That’s why I registered an application password on each of the 25 client sites. Using an app password in php is quite simple. You can authenticate the HTTP requests just like any basic authentication scheme.

I’m using JavaScript, so I had to localize them first, as described in the previous section. With that in place, I was able to append these credentials when making an Ajax call:

async function fetchBlogsOfInstall(url, id) {
  let install = lexblog_network_analytics.installs[id];
  let pw = install.pw;
  let user = install.user;

  // Create a Basic Auth token
  let token = btoa(`${user}:${pw}`);
  let auth = {
      'Authorization': `Basic ${token}`
  };

  try {
    let data = await $.ajax({
        url: url,
        method: 'GET',
        dataType: 'json',
        headers: auth
    });

    return data;

  } catch (error) {
    console.error('Request failed:', error);
    return [];
  }
}

That file uses this cool function called btoa() for turning the raw username and password combo into basic authentication.

The part where we say, “Oh Right, CORS.”

Whenever I have a project where Ajax calls are flying around all over the place, working reasonably well in my local environment, I always have a brief moment of panic when I try it on a real website, only to get errors like this:

CORS console error.

Oh. Right. CORS. Most reasonably secure websites do not allow other websites to make arbitrary Ajax requests. In this project, I absolutely do need the Dashboard Site to make many Ajax calls to the 25 client sites, so I have to tell the client sites to allow CORS:

<?php

  // ...

  function __construct() {
  add_action( 'rest_api_init', array( $this, 'maybe_add_cors_headers' ), 10 );
}

function maybe_add_cors_headers() {   
  // Only allow CORS for the endpoints that pertain to this plugin.
  if( $this->is_dba() ) {
      add_filter( 'rest_pre_serve_request', array( $this, 'send_cors_headers' ), 10, 2 );
  }
}

function is_dba() {
  $url = $this->get_current_url();
  $ep_urls = $this->get_endpoint_urls();
  $out = in_array( $url, $ep_urls );
          
  return $out;
          
}

function send_cors_headers( $served, $result ) {

          // Only allow CORS from the dashboard site.
  $dashboard_site_url = $this->get_dashboard_site_url();

  header( "Access-Control-Allow-Origin: $dashboard_site_url" );
  header( 'Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept, Authorization' );
  header( 'Access-Control-Allow-Methods: GET, OPTIONS' );
  return $served;
  
}

  [...]

}

You’ll note that I’m following the principle of least privilege by taking steps to only allow CORS where it’s necessary.

Auth, Part 2: I’ve been known to auth myself

I authenticated an Ajax call from the dashboard site to the client sites. I registered some logic on all the client sites to allow the request to pass CORS. But then, back on the dashboard site, I had to get that response from the browser to the server.

The answer, again, was to make an Ajax call to the WordPress REST API endpoint for storing the data. But since this was an actual database write, not merely a read, it was more important than ever to authenticate. I did this by requiring that the current user be logged into WordPress and possess sufficient privileges. But how would the browser know about this?

In PHP, when registering our endpoints, we provide a permissions callback to make sure the current user is an admin:

<?php

// ...

function get() {
  $version = 'v1';
  return array(

    'update_blogs' => array(
      'namespace' => 'LXB_DBA/' . $version,
      'route'     => '/update_blogs',
      'args'      => array(
        'methods' => array( 'PATCH' ),
        'callback' => array( $this, 'update_blogs_cb' ),
        'permission_callback' => array( $this, 'is_admin' ),
        ),
      ),
      // ...
    );           
  }
                      
function is_admin() {
    $out = current_user_can( 'update_core' );
    return $out;
}

JavaScript can use this — it’s able to identify the current user — because, once again, that data is localized. The current user is represented by their nonce:

async function insertBlog( data ) {
    
  let url = lexblog_network_analytics.endpoint_urls.insert_blog;

  try {
    await $.ajax({
      url: url,
      method: 'POST',
      dataType: 'json',
      data: data,
      headers: {
        'X-WP-Nonce': getNonce()
      }
    });
  } catch (error) {
    console.error('Failed to store blogs:', error);
  }
}

function getNonce() {
  if( typeof wpApiSettings.nonce == 'undefined' ) { return false; }
  return wpApiSettings.nonce;
}

The wpApiSettings.nonce global variable is automatically present in all WordPress admin screens. I didn’t have to localize that. WordPress core did it for me.

Cache is King

Compressing the Google Analytics data from 900 domains into a three-minute loading .gif is decent, but it would be totally unacceptable to have to wait for that long multiple times per work session. Therefore I cache the results of all 25 client sites in the database of the dashboard site.

I’ve written before about using the WordPress Transients API for caching data, and I could have used it on this project. However, something about the tremendous volume of data and the complexity implied within the Figma design made me consider a different approach. I like the saying, “The wider the base, the higher the peak,” and it applies here. Given that the user needs to query and sort the data by date, author, and metadata, I think stashing everything into a single database cell — which is what a transient is — would feel a little claustrophobic. Instead, I dialed up E.F. Codd and used a relational database model via custom tables:

In the Dashboard Site, I created seven custom database tables, including one relational table, to cache the data from the 25 client sites, as shown in the image.

It’s been years since I’ve paged through Larry Ullman’s career-defining (as in, my career) books on database design, but I came into this project with a general idea of what a good architecture would look like. As for the specific details — things like column types — I foresaw a lot of Stack Overflow time in my future. Fortunately, LLMs love MySQL and I was able to scaffold out my requirements using DocBlocks and let Sam Altman fill in the blanks:

Open the code
<?php 

/**
* Provides the SQL code for creating the Blogs table.  It has columns for:
* - ID: The ID for the blog.  This should just autoincrement and is the primary key.
* - name: The name of the blog.  Required.
* - slug: A machine-friendly version of the blog name.  Required.
* - url:  The url of the blog.  Required.
* - mapped_domain: The vanity domain name of the blog.  Optional.
* - install: The name of the Multisite install where this blog was scraped from.  Required.
* - registered:  The date on which this blog began publishing posts.  Optional.
* - firm_id:  The ID of the firm that publishes this blog.  This will be used as a foreign key to relate to the Firms table.  Optional.
* - practice_area_id:  The ID of the firm that publishes this blog.  This will be used as a foreign key to relate to the PracticeAreas table.  Optional.
* - amlaw:  Either a 0 or a 1, to indicate if the blog comes from an AmLaw firm.  Required.
* - subscriber_count:  The number of email subscribers for this blog.  Optional.
* - day_view_count:  The number of views for this blog today.  Optional.
* - week_view_count:  The number of views for this blog this week.  Optional.
* - month_view_count:  The number of views for this blog this month.  Optional.
* - year_view_count:  The number of views for this blog this year.  Optional.
* 
* @return string The SQL for generating the blogs table.
*/
function get_blogs_table_sql() {
  $slug = 'blogs';
  $out = "CREATE TABLE {$this->get_prefix()}_$slug (
      id BIGINT NOT NULL AUTO_INCREMENT,
      slug VARCHAR(255) NOT NULL,
      name VARCHAR(255) NOT NULL,
      url VARCHAR(255) NOT NULL UNIQUE, /* adding unique constraint */
      mapped_domain VARCHAR(255) UNIQUE,
      install VARCHAR(255) NOT NULL,
      registered DATE DEFAULT NULL,
      firm_id BIGINT,
      practice_area_id BIGINT,
      amlaw TINYINT NOT NULL,
      subscriber_count BIGINT,
      day_view_count BIGINT,
      week_view_count BIGINT,
      month_view_count BIGINT,
      year_view_count BIGINT,
      PRIMARY KEY (id),
      FOREIGN KEY (firm_id) REFERENCES {$this->get_prefix()}_firms(id),
      FOREIGN KEY (practice_area_id) REFERENCES {$this->get_prefix()}_practice_areas(id)
  ) DEFAULT CHARSET=utf8mb4;";
  return $out;
}

In that file, I quickly wrote a DocBlock for each function, and let the OpenAI playground spit out the SQL. I tested the result and suggested some rigorous type-checking for values that should always be formatted as numbers or dates, but that was the only adjustment I had to make. I think that’s the correct use of AI at this moment: You come in with a strong idea of what the result should be, AI fills in the details, and you debate with it until the details reflect what you mostly already knew.

How it’s going

I’ve implemented most of the user stories now. Certainly enough to release an MVP and begin gathering whatever insights this data might have for us:

Screenshot of the final dashboard which looks similar to the Figma mockups from earlier.
It’s working!

One interesting data point thus far: Although all the blogs are on the topic of legal matters (they are lawyer blogs, after all), blogs that cover topics with a more general appeal seem to drive more traffic. Blogs about the law as it pertains to food, cruise ships, germs, and cannabis, for example. Furthermore, the largest law firms on our network don’t seem to have much of a foothold there. Smaller firms are doing a better job of connecting with a wider audience. I’m positive that other insights will emerge as we work more deeply with this.

Regrets? I’ve had a few.

This project probably would have been a nice opportunity to apply a modern JavaScript framework, or just no framework at all. I like React and I can imagine how cool it would be to have this application be driven by the various changes in state rather than… drumrolla couple thousand lines of jQuery!

I like jQuery’s ajax() method, and I like the jQueryUI autocomplete component. Also, there’s less of a performance concern here than on a public-facing front-end. Since this screen is in the WordPress admin area, I’m not concerned about Google admonishing me for using an extra library. And I’m just faster with jQuery. Use whatever you want.

I also think it would be interesting to put AWS to work here and see what could be done through Lambda functions. Maybe I could get Lambda to make all 25 plus 900 requests concurrently with no worries about browser limitations. Heck, maybe I could get it to cycle through IP addresses and sidestep the 429 rate limit as well.

And what about cron? Cron could do a lot of work for us here. It could compile the data on each of the 25 client sites ahead of time, meaning that the initial three-minute refresh time goes away. Writing an application in cron, initially, I think is fine. Coming back six months later to debug something is another matter. Not my favorite. I might revisit this later on, but for now, the cron-free implementation meets the MVP goal.

I have not provided a line-by-line tutorial here, or even a working repo for you to download, and that level of detail was never my intention. I wanted to share high-level strategy decisions that might be of interest to fellow Multi-Multisite people. Have you faced a similar challenge? I’d love to hear about it in the comments!


WordPress Multi-Multisite: A Case Study originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Solved by CSS: Donuts Scopes

Post pobrano z: Solved by CSS: Donuts Scopes

Imagine you have a web component that can show lots of different content. It will likely have a slot somewhere where other components can be injected. The parent component also has its own styles unrelated to the styles of the content components it may hold.

This makes a challenging situation: how can we prevent the parent component styles from leaking inwards?

This isn’t a new problem — Nicole Sullivan described it way back in 2011! The main problem is writing CSS so that it doesn’t affect the content, and she accurately coined it as donut scoping.

“We need a way of saying, not only where scope starts, but where it ends. Thus, the scope donut”.

Diagram showing a rectangle colored salmon inside another rectangle colored dark red. The larger rectangle is the donut and the smaller rectangle is the hole.

Even if donut scoping is an ancient issue in web years, if you do a quick search on “CSS Donut Scope” in your search engine of choice, you may notice two things:

  1. Most of them talk about the still recent @scope at-rule.
  2. Almost every result is from 2021 onwards.

We get similar results even with a clever “CSS Donut Scope –@scope” query, and going year by year doesn’t seem to bring anything new to the donut scope table. It seems like donut scopes stayed at the back of our minds as just another headache of the ol’ CSS global scope until @scope.

And (spoiler!), while the @scope at-rule brings an easier path for donut scoping, I feel there must have been more attempted solutions over the years. We will venture through each of them, making a final stop at today’s solution, @scope. It’s a nice exercise in CSS history!

Take, for example, the following game screen. We have a .parent element with a tab set and a .content slot, in which an .inventory component is injected. If we change the .parent color, then so does the color inside .content.

CodePen Embed Fallback

How can we stop this from happening? I want to prevent the text inside of .content from inheriting the .parent‘s color.

Just ignore it!

The first solution is no solution at all! This may be the most-used approach since most developers can live their lives without the joys of donut scoping (crazy, right?). Let’s be more tangible here, it isn’t just blatantly ignoring it, but rather accepting CSS’s global scope and writing styles with that in mind. Back to our first example, we assume we can’t stop the parent’s styles from leaking inwards to the content component, so we write our parent’s styles with less specificity, so they can be overridden by the content styles.

body {
  color: blue;
}

.parent {
  color: orange; /* Initial background */
}

.content {
  color: blue; /* Overrides parent's background */
}
CodePen Embed Fallback

While this approach is sufficient for now, managing styles just by their specificity as a project grows larger becomes tedious, at best, and chaotic at worst. Components may behave differently depending on where they are slotted and changing our CSS or HTML can break other styles in unexpected ways.

Two CSS properties walk into a bar. A barstool in a completely different bar falls over.

Thomas Fuchs

You can see how in this small example we have to override the styles twice:

Dev Tools showing the body styles getting overridden twice

Shallow donuts scopes with :not()

Our goal then it’s to only scope the .parent, leaving out whatever may be inserted into the .content slot. So, not the .content but the rest of .parent… not the .content:not()! We can use the :not() selector to scope only the direct descendants of .parent that aren’t .content.

body {
  color: blue;
}

.parent > :not(.content) {
  color: orange;
}

This way the .content styles won’t be bothered by the styles defined in their .parent:

CodePen Embed Fallback

You can see an immense difference when we open the DevTools for each example:

Dev Tools Comparison between specificity overrides and donut scopes

As good as an improvement, the last example has a shallow reach. So, if there were another slot nested deeper in, we wouldn’t be able to reach it unless we know beforehand where it is going to be slotted.

CodePen Embed Fallback

This is because we are using the direct descendant selector (>), but I couldn’t find a way to make it work without it. Even using a combination of complex selectors inside :not() doesn’t seem to lead anywhere useful. For example, back in 2021, Dr. Lea Verou mentioned donut scoping with :not() using the following selector cocktail:

.container:not(.content *) {
  /* Donut Scoped styles (?) */
}

However, this snippet appears to match the .container/.parent class instead of its descendants, and it’s noted that it still would be shallow donut scoping:

TIL that all modern browsers now support complex selectors in :not()! 😍

Test: https://t.co/rHSJARDvSW

So you can do things like:
– .foo :not(.foo .foo *) to match things inside one .foo wrapper but not two
– .container :not(.content *) to get simple (shallow) “donut scope”

— Dr Lea Verou (@LeaVerou) January 28, 2021

Donut scoping with @scope

So our last step for donut scoping completion is being able to go beyond one DOM layer. Luckily, last year we were gifted the @scope at-rule (you can read more about it in its Almanac entry). In a nutshell, it lets us select a subtree in the DOM where our styles will be scoped, so no more global scope!

@scope (.parent) {
 /* Styles written here will only affect .parent */
}

What’s better, we can leave slots inside the subtree we selected (usually called the scope root). In this case, we would want to style the .parent element without scoping .content:

@scope (.parent) to (.content) {
  /* Styles written here will only affect .parent but skip .content*/
}

And what’s better, it detects every .content element inside .parent, no matter how nested it may be. So we don’t need to worry about where we are writing our slots. In the last example, we could instead write the following style to change the text color of the element in .parent without touching .content:

body {
  color: blue;
}

@scope (.parent) to (.content) {
  h2,
  p,
  span,
  a {
    color: orange;
  }
}

While it may seem inconvenient to list all the elements we are going to change, we can’t use something like the universal selector (*) since it would mess up the scoping of nested slots. In this example, it would leave the nested .content out of scope, but not its container. Since the color property inherits, the nested .content would change colors regardless!

And voilà! Both .content slots are inside our scoped donut holes:

CodePen Embed Fallback

Shallow scoping is still possible with this method, we would just have to rewrite our slot selector so that only direct .content descendants of .parent are left out of the scope. However, we have to use the :scope selector, which refers back to the scoping root, or .parent in this case:

@scope (.parent) to (:scope > .content) {
  * {
    color: orange;
  }
}

We can use the universal selector in this instance since it’s shallow scoping.

CodePen Embed Fallback

Conclusion

Donut scoping, a wannabe feature coined back in 2011 has finally been brought to life in the year 2024. It’s still baffling how it appeared to sit in the back of our minds until recently, as just another consequence of CSS Global Scope, while it had so many quirks by itself. It would be unfair, however, to say that it went under everyone’s radars since the CSSWG (the people behind writing the spec for new CSS features) clearly had the intention to address it when writing the spec for the @scope at-rule.

Whatever it may be, I am grateful we can have true donut scoping in our CSS. To some degree, we still have to wait for Firefox to support it. 😉

This browser support data is from Caniuse, which has more detail. A number indicates that browser supports the feature at that version and up.

Desktop

Chrome Firefox IE Edge Safari
118 No No 118 17.4

Mobile / Tablet

Android Chrome Android Firefox Android iOS Safari
131 No 131 17.4

Solved by CSS: Donuts Scopes originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Invoker Commands: Additional Ways to Work With Dialog, Popover… and More?

Post pobrano z: Invoker Commands: Additional Ways to Work With Dialog, Popover… and More?

The Popover API and <dialog> element are two of my favorite new platform features. In fact, I recently [wrote a detailed overview of their use cases] and the sorts of things you can do with them, even learning a few tricks in the process that I couldn’t find documented anywhere else.

I’ll admit that one thing that I really dislike about popovers and dialogs is that they could’ve easily been combined into a single API. They cover different use cases (notably, dialogs are typically modal) but are quite similar in practice, and yet their implementations are different.

Well, web browsers are now experimenting with two HTML attributes — technically, they’re called “invoker commands” — that are designed to invoke popovers, dialogs, and further down the line, all kinds of actions without writing JavaScript. Although, if you do reach for JavaScript, the new attributes — command and commandfor — come with some new events that we can listen for.

Invoker commands? I’m sure you have questions, so let’s dive in.

We’re in experimental territory

Before we get into the weeds, we’re dealing with experimental features. To use invoker commands today in November 2024 you’ll need Chrome Canary 134+ with the enable-experimental-web-platform-features flag set to Enabled, Firefox Nightly 135+ with the dom.element.invokers.enabled flag set to true, or Safari Technology Preview with the InvokerAttributesEnabled flag set to true.

I’m optimistic we’ll get baseline coverage for command and commandfor in due time considering how nicely they abstract the kind of work that currently takes a hefty amount of scripting.

Basic command and commandfor usage

First, you’ll need a <button> or a button-esque <input> along the lines of <input type="button"> or <input type="reset">. Next, tack on the command attribute. The command value should be the command name that you want the button to invoke (e.g., show-modal). After that, drop the commandfor attribute in there referencing the dialog or popover you’re targeting by its id.

<button command="show-modal" commandfor="dialogA">Show dialogA</button>

<dialog id="dialogA">...</dialog>

In this example, I have a <button> element with a command attribute set to show-modal and a commandfor attribute set to dialogA, which matches the id of a <dialog> element we’re targeting:

Let’s get into the possible values for these invoker commands and dissect what they’re doing.

Looking closer at the attribute values

CodePen Embed Fallback

The show-modal value is the command that I just showed you in that last example. Specifically, it’s the HTML-invoked equivalent of JavaScript’s showModal() method.

The main benefit is that show-modal enables us to, well… show a modal without reaching directly for JavaScript. Yes, this is almost identical to how HTML-invoked popovers already work with thepopovertarget and popovertargetaction attributes, so it’s cool that the “balance is being redressed” as the Open UI explainer describes it, even more so because you can use the command and commandfor invoker commands for popovers too.

There isn’t a show command to invoke show() for creating non-modal dialogs. I’ve mentioned before that non-modal dialogs are redundant now that we have the Popover API, especially since popovers have ::backdrops and other dialog-like features. My bold prediction is that non-modal dialogs will be quietly phased out over time.

The close command is the HTML-invoked equivalent of JavaScript’s close() method used for closing the dialog. You probably could have guessed that based on the name alone!

<dialog id="dialogA">
  <!-- Close #dialogA -->
  <button command="close" commandfor="dialogA">Close dialogA</button>
</dialog>

The show-popover, hide-popover, and toggle-popover values

<button command="show-popover" commandfor="id">

…invokes showPopover(), and is the same thing as:

<button popovertargetaction="show" popovertarget="id">

Similarly:

<button command="hide-popover" commandfor="id">

…invokes hidePopover(), and is the same thing as:

<button popovertargetaction="hide" popovertarget="id">

Finally:

<button command="toggle-popover" commandfor="id">

…invokes togglePopover(), and is the same thing as:

<button popovertargetaction="toggle" popovertarget="id">
<!--  or <button popovertarget="id">, since ‘toggle’ is the default action anyway. -->

I know all of this can be tough to organize in your mind’s eye, so perhaps a table will help tie things together:

command Invokes popovertargetaction equivalent
show-popover showPopover() show
hide-popover hidePopover() hide
toggle-popover togglePopover() toggle

So… yeah, popovers can already be invoked using HTML attributes, making command and commandfor not all that useful in this context. But like I said, invoker commands also come with some useful JavaScript stuff, so let’s dive into all of that.

Listening to commands with JavaScript

Invoker commands dispatch a command event to the target whenever their source button is clicked on, which we can listen for and work with in JavaScript. This isn’t required for a <dialog> element’s close event, or a popover attribute’s toggle or beforetoggle event, because we can already listen for those, right?

For example, the Dialog API doesn’t dispatch an event when a <dialog> is shown. So, let’s use invoker commands to listen for the command event instead, and then read event.command to take the appropriate action.

// Select all dialogs
const dialogs = document.querySelectorAll("dialog");

// Loop all dialogs
dialogs.forEach(dialog => {

  // Listen for close (as normal)
  dialog.addEventListener("close", () => {
    // Dialog was closed
  });

  // Listen for command
  dialog.addEventListener("command", event => {

    // If command is show-modal
    if (event.command == "show-modal") {
      // Dialog was shown (modally)
    }

    // Another way to listen for close
    else if (event.command == "close") {
      // Dialog was closed
    }

  });
});

So invoker commands give us additional ways to work with dialogs and popovers, and in some scenarios, they’ll be less verbose. In other scenarios though, they’ll be more verbose. Your approach should depend on what you need your dialogs and popovers to do.

For the sake of completeness, here’s an example for popovers, even though it’s largely the same:

// Select all popovers
const popovers = document.querySelectorAll("[popover]");

// Loop all popovers
popovers.forEach(popover => {

  // Listen for command
  popover.addEventListener("command", event => {

    // If command is show-popover
    if (event.command == "show-popover") {
      // Popover was shown
    }

    // If command is hide-popover
    else if (event.command == "hide-popover") {
      // Popover was hidden
    }

    // If command is toggle-popover
    else if (event.command == "toggle-popover") {
      // Popover was toggled
    }

  });
});

Being able to listen for show-popover and hide-popover is useful as we otherwise have to write a sort of “if opened, do this, else do that” logic from within a toggle or beforetoggle event listener or toggle-popover conditional. But <dialog> elements? Yeah, those benefit more from the command and commandfor attributes than they do from this command JavaScript event.

Another thing that’s available to us via JavaScript is event.source, which is the button that invokes the popover or <dialog>:

if (event.command == "toggle-popover") {
  // Toggle the invoker’s class
  event.source.classList.toggle("active");
}

You can also set the command and commandfor attributes using JavaScript:

const button = document.querySelector("button");
const dialog = document.querySelector("dialog");

button.command = "show-modal";
button.commandForElement = dialog; /* Not dialog.id */

…which is only slightly less verbose than:

button.command = "show-modal";
button.setAttribute("commandfor", dialog.id);

Creating custom commands

The command attribute also accepts custom commands prefixed with two dashes (--). I suppose this makes them like CSS custom properties but for JavaScript events and event handler HTML attributes. The latter observation is maybe a bit (or definitely a lot) controversial since using event handler HTML attributes is considered bad practice. But let’s take a look at that anyway, shall we?

Custom commands look like this:

<button command="--spin-me-a-bit" commandfor="record">Spin me a bit</button>
<button command="--spin-me-a-lot" commandfor="record">Spin me a lot</button>
<button command="--spin-me-right-round" commandfor="record">Spin me right round</button>
const record = document.querySelector("#record");

record.addEventListener("command", event => {
  if (event.command == "--spin-me-a-bit") {
    record.style.rotate = "90deg";
  } else if (event.command == "--spin-me-a-lot") {
    record.style.rotate = "180deg";
  } else if (event.command == "--spin-me-right-round") {
    record.style.rotate = "360deg";
  }
});

event.command must match the string with the dashed (--) prefix.

Are popover and <dialog> the only features that support invoker commands?

According to Open UI, invokers targeting additional elements such as <details> were deferred from the initial release. I think this is because HTML-invoked dialogs and an API that unifies dialogs and popovers is a must-have, whereas other commands (even custom commands) feel more like a nice-to-have deal.

However, based on experimentation (I couldn’t help myself!) web browsers have actually implemented additional invokers to varying degrees. For example, <details> commands work as expected whereas <select> commands match event.command (e.g., show-picker) but fail to actually invoke the method (showPicker()). I missed all of this at first because MDN only mentions dialog and popover.

Open UI also alludes to commands for <input type="file">, <input type="number">, <video>, <audio>, and fullscreen-related methods, but I don’t think that anything is certain at this point.

So, what would be the benefits of invoker commands?

Well, a whole lot less JavaScript for one, especially if more invoker commands are implemented over time. Additionally, we can listen for these commands almost as if they were JavaScript events. But if nothing else, invoker commands simply provide more ways to interact with APIs such as the Dialog and Popover APIs. In a nutshell, it seems like a lot of “dotting i’s” and “crossing-t’s” which is never a bad thing.


Invoker Commands: Additional Ways to Work With Dialog, Popover… and More? originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Complete CSS Course

Post pobrano z: Complete CSS Course

Do you subscribe to Piccalilli? You should. If you’re reading that name for the first time, that would be none other than Andy Bell running the ship and he’s reimagined the site from the ground-up after coming out of hibernation this year. You’re likely familiar with Andy’s great writing here on CSS-Tricks.

Andy is more than a great writer — he’s a teacher, too. And you’ll see that in spades next week when his brand-new course Complete CSS is released one week from today on November 26.

As someone who also runs a front-end course, I can tell you it takes a non-trivial amount of time and effort to put something like Complete CSS together. I’ve been able to sneak peek at the course and like love how it’s made for many CSS-Tricks readers — you know CSS and use it regularly but need to ratchet it up from good to great. If my course is for those just getting into CSS, Andy will graduate you from hobbyist to practitioner in Complete CSS. It’s the perfect next step for narrowing the ever-growing learning gaps in this industry.

Early bird price is £189 (~$240) which is a steep cut from the full £249 (~$325) price tag.


Complete CSS Course originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Anchoreum: A New Game for Learning Anchor Positioning

Post pobrano z: Anchoreum: A New Game for Learning Anchor Positioning

You’ve played Flexbox Froggy before, right? Or maybe Grid Garden? They’re both absolute musts for learning the basics of modern CSS layout using Flexbox and CSS Grid. I use both games in all of the classes I teach and I never get anything but high-fives from my students because they love them so much.

As widely known as those games are, you may be less familiar with the name of the developer who made them. That would be Thomas Park, and he has a couple of CSS-Tricks articles notched in his belt. He also has a horde of other games in his CodePip collection of free and premium games for learning front-end techniques.

Thomas wrote in to share his latest game with us: Anchoreum.

Showing level three of 40 in the Anchoreum game. Code is on the left and a live preview of the result is on the right,

I’ll bet the two nickels in my pocket that you know this game’s all about CSS Anchor Positioning. I love that Thomas has jumped on this so quickly because the feature is still fresh, and indeed is currently only supported in a couple of browsers at the moment.

This is the perfect time to learn about anchor positioning. It’s still relatively early days, but things are baked enough to be supported in Chrome and Edge so you can access the games. If you haven’t seen Juan’s big ol’ guide on anchor positioning, that’s another dandy way to get up to speed.

The objective is less on-the-nose than Flexbox Froggy and Grid Garden, which both lean heavily into positioning elements to complete game tasks. For example, Flexbox Froggy is about positioning frogs safely on lilypads. Grid Garden wants you to water specific garden areas to feed your carrots. Anchoreum? You’re in a museum and need to anchor labels to museum artifacts. I know, attaching target elements to the same anchor over and again could get boring. But thankfully the game goes beyond simple positioning by getting into multiple anchors, spanning, and position fallbacks.

Whatever the objective, the repetition is good for developing muscle memory and the overall outcome is still the same: learn CSS Anchor Positioning. I’m already planning how and where I’m going to use Anchoreum in my curriculum. It’s not often we get a fun interactive learning resource like this for such a new web feature and I think it’s worth jumping on it sooner rather than later.

Thomas prepped a video trailer for the game so I thought I’d drop that for reference.


Anchoreum: A New Game for Learning Anchor Positioning originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Fluid Everything Else

Post pobrano z: Fluid Everything Else

We all know how to do responsive design, right? We use media queries. Well no, we use container queries now, don’t we? Sometimes we get inventive with flexbox or autoflowing grids. If we’re feeling really adventurous we can reach for fluid typography.

I’m a bit uncomfortable that responsive design is often pushed into discreet chunks, like “layout A up to this size, then layout B until there’s enough space for layout C.” It’s OK, it works and fits into a workflow where screens are designed as static layouts in PhotoFigVa (caveat, I made that up). But the process feels like a compromise to me. I’ve long believed that responsive design should be almost invisible to the user. When they visit my site on a mobile device while waiting in line for K-Pop tickets, they shouldn’t notice that it’s different from just an hour ago, sitting at the huge curved gaming monitor they persuaded their boss they needed.

Consider this simple hero banner and its mobile equivalent. Sorry for the unsophisticated design. The image is AI generated, but It’s the only thing about this article that is.

Hero banner displaying a meerkat standing on its hind legs next to a white heading to the right. A mobile version is next to thjat with the heading above the meerkat.

The meerkat and the text are all positioned and sized differently. The traditional way to pull this off is to have two layouts, selected by a media, sorry, container query. There might be some flexibility in each layout, perhaps centering the content, and a little fluid typography on the font-size, but we’re going to choose a point at which we flip the layout in and out of the stacked version. As a result, there are likely to be widths near the breakpoint where the layout looks either a little empty or a little congested.

Is there another way?

It turns out there is. We can apply the concept of fluid typography to almost anything. This way we can have a layout that fluidly changes with the size of its parent container. Few users will ever see the transition, but they will all appreciate the results. Honestly, they will.

Let’s get this styled up

For the first step, let’s style the layouts individually, a little like we would when using width queries and a breakpoint. In fact, let’s use a container query and a breakpoint together so that we can easily see what properties need to change.

This is the markup for our hero, and it won’t change:

<div id="hero">
  <div class="details">
    <h1>LookOut</h1>
    <p>Eagle Defense System</p>
  </div>
</div>

This is the relevant CSS for the wide version:

#hero {
  container-type: inline-size;
  max-width: 1200px;
  min-width: 360px;

  .details {
    position: absolute;
    z-index: 2;

    top: 220px;
    left: 565px;

    h1 { font-size: 5rem; }

    p { font-size: 2.5rem; }
  }

  &::before {
    content: '';
    position: absolute;
    z-index: 1;

    top: 0; left: 0; right: 0; bottom: 0;

    background-image: url(../meerkat.jpg);
    background-origin: content-box;
    background-repeat: no-repeat;

    background-position-x: 0;
    background-position-y: 0;
    background-size: auto 589px;
  }
}

I’ve attached the background image to a ::before pseudo-element so I can use container queries on it (because containers cannot query themselves). We’ll keep this later on so that we can use inline container query (cqi) units. For now, here’s the container query that just shows the values we’re going to make fluid:

@container (max-width: 800px) {
  #hero {
    .details {
      top: 50px;
      left: 20px;

      h1 { font-size: 3.5rem; }

      p { font-size: 2rem; }
    }

    &::before {
      background-position-x: -310px;
      background-position-y: -25px;
      background-size: auto 710px;
    }
  }
}

You can see the code running in a live demo — it’s entirely static to show the limitations of a typical approach.

Let’s get fluid

Now we can take those start and end points for the size and position of both the text and background and make them fluid. The text size uses fluid typography in a way you are already familiar with. Here’s the result — I’ll explain the expressions once you’ve looked at the code.

First the changes to the position and size of the text:

/* Line changes
 * -12,27 +12,32
 */
  
.details {
  /* ... lines 14-16 unchanged */
  /* Evaluates to 50px for a 360px wide container, and 220px for 1200px */
  top: clamp(50px, 20.238cqi - 22.857px, 220px);

  /* Evaluates to 20px for a 360px wide container, and 565px for 1200px */
  left: clamp(20px, 64.881cqi - 213.571px, 565px);
  
  /* ... lines 20-25 unchanged */
  h1 {
    /* Evaluates to 3.5rem for a 360px wide container, and 5rem for 1200px */
    font-size: clamp(3.5rem, 2.857rem + 2.857cqi, 5rem);
    /* ... font-weight unchanged */

  }

  p {
    /* Evaluates to 2rem for a 360px wide container, and 2.5rem for 1200px */
    font-size: clamp(2rem, 1.786rem + 0.952cqi, 2.5rem);
  }
}

And here’s the background position and size for the meerkat image:

/* Line changes
 * -50,3 +55,8
 */

/* Evaluates to -310px for a 360px wide container, and 0px for 1200px */
background-position-x: clamp(-310px, 36.905cqi - 442.857px, 0px);
/* Evaluates to -25px for a 360px wide container, and 0px for 1200px */
background-position-y: clamp(-25px, 2.976cqi);
/* Evaluates to 710px for a 360px wide container, and 589px for 1200px */
background-size: auto clamp(589px, 761.857px - 14.405cqi, 710px);

Now we can drop the container query entirely.

Let’s explain those clamp() expressions. We’ll start with the expression for the top property.

/* Evaluates to 50px for a 360px wide container, and 220px for 1200px */
top: clamp(50px, 20.238cqi - 22.857px, 220px);

You’ll have noticed there’s a comment there. These expressions are a good example of how magic numbers are a bad thing. But we can’t avoid them here, as they are the result of solving some simultaneous equations — which CSS cannot do!

The upper and lower bounds passed to clamp() are clear enough, but the expression in the middle comes from these simultaneous equations:

f + 12v = 220
f + 3.6v = 50

…where f is the number of fixed-size length units (i.e., px) and v is the variable-sized unit (cqi). In the first equation, we are saying that we want the expression to evaluate to 220px when 1cqi is equal to 12px. In the second equation, we’re saying we want 50px when 1cqi is 3.6px, which solves to:

f = -22.857
v = 20.238

…and this tidies up to 20.238cqi – 22.857px in a calc()-friendly expression.

When the fixed unit is different, we must change the size of the variable units accordingly. So for the <h1> element’s font-size we have;

/* Evaluates to 2rem for a 360px wide container, and 2.5rem for 1200px */
font-size: clamp(2rem, 1.786rem + 0.952cqi, 2.5rem);

This is solving these equations because, at a container width of 1200px, 1cqi is the same as 0.75rem (my rems are relative to the default UA stylesheet, 16px), and at 360px wide, 1cqi is 0.225rem.

f + 0.75v = 2.5
f + 0.225v = 2

This is important to note: The equations are different depending on what unit you are targeting.

Honestly, this is boring math to do every time, so I made a calculator you can use. Not only does it solve the equations for you (to three decimal places to keep your CSS clean) it also provides that helpful comment to use alongside the expression so that you can see where they came from and avoid magic numbers. Feel free to use it. Yes, there are many similar calculators out there, but they concentrate on typography, and so (rightly) fixate on rem units. You could probably port the JavaScript if you’re using a CSS preprocessor.

The clamp() function isn’t strictly necessary at this point. In each case, the bounds of clamp() are set to the values of when the container is either 360px or 1200px wide. Since the container itself is constrained to those limits — by setting min-width and max-width values — the clamp() expression should never invoke either bound. However, I prefer to keep clamp() there in case we ever change our minds (which we are about to do) because implicit bounds like these are difficult to spot and maintain.

Avoiding injury

We could consider our work finished, but we aren’t. The layout still doesn’t quite work. The text passes right over the top of the meerkat’s head. While I have been assured this causes the meerkat no harm, I don’t like the look of it. So, let’s make some changes to make the text avoid hitting the meerkat.

The first is simple. We’ll move the meerkat to the left more quickly so that it gets out of the way. This is done most easily by changing the lower end of the interpolation to a wider container. We’ll set it so that the meerkat is fully left by 450px rather than down to 360px. There’s no reason the start and end points for all of our fluid expressions need to align with the same widths, so we can keep the other expressions fluid down to 360px.

Using my trusty calculator, all we need to do is change the clamp() expressions for the background-position properties:

/* Line changes
 * -55,5 +55,5
 */

/* Evaluates to -310px for a 450px wide container, and 0px for 1200px */
background-position-x: clamp(-310px, 41.333cqi - 496px, 0px);

/* Evaluates to -25px for a 450px wide container, and 0px for 1200px */
background-position-y: clamp(-25px, 3.333cqi - 40px, 0px);

This improves things, but not totally. I don’t want to move it any quicker, so next we’ll look at the path the text takes. At the moment it moves in a straight line, like this:

Showing the path the heading travels as the hero banner goes from a desktop size to a tablet size to a mobile size.

But can we bend it? Yes, we can.

A Bend in the path

One way we can do this is by defining two different interpolations for the top coordinate that places the line at different angles and then choosing the smallest one. This way, it allows the steeper line to “win” at larger container widths, and the shallower line becomes the value that wins when the container is narrower than about 780px. The result is a line with a bend that misses the meerkat.

All we’re changing is the top value, but we must calculate two intermediate values first:

/* Line changes
 * -18,2 +18,9 @@
 */

/* Evaluates to 220px for a 1200px wide container, and -50px for 360px */
--top-a: calc(32.143cqi - 165.714px);

/* Evaluates to 120px for a 1200px wide container, and 50px for 360px */
--top-b: calc(20px + 8.333cqi);

/* By taking the max, --topA is used at lower widths, with --topB taking over when wider.
We only need to apply clamp when the value is actually used */
top: clamp(50px, max(var(--top-a), var(--top-b)), 220px);

For these values, rather than calculating them formally using a carefully chosen midpoint, I experimented with the endpoints until I got the result I wanted. Experimentation is just as valid as calculation as a way of getting the result you need. In this case, I started with duplicates of the interpolation in custom variables. I could have split the path into explicit sections using a container query, but that doesn’t reduce the math overhead, and using the min() function is cleaner to my eye. Besides, this article isn’t strictly about container queries, is it?

Now the text moves along this path. Open up the live demo to see it in action.

Showing the path the heading travels as the hero banner goes from a desktop size to a tablet size to a mobile size. The path makes a sharp angle as it travels over the meerkat.

CSS can’t do everything

As a final note on the calculations, it’s worth pointing out that there are restrictions as far as what we can and can’t do. The first, which we have already mitigated a little, is that these interpolations are linear. This means that easing in or out, or other complex behavior, is not possible.

Another major restriction is that CSS can only generate length values this way, so there is no way in pure CSS to apply, for example, opacity or a rotation angle that is fluid based on the container or viewport size. Preprocessors can’t help us here either because the limitation is on the way calc() works in the browser.

Both of these restrictions can be lifted if you’re prepared to rely on a little JavaScript. A few lines to observe the width of the container and set a CSS custom property that is unitless is all that’s needed. I’m going to use that to make the text follow a quadratic Bezier curve, like this:

Showing the path the heading travels as the hero banner goes from a desktop size to a tablet size to a mobile size. The path makes a smooth curve as it travels over the meerkat.

There’s too much code to list here, and too much math to explain the Bezier curve, but go take a look at it in action in this live demo.

We wouldn’t even need JavaScript if expressions like calc(1vw / 1px) didn’t fail in CSS. There is no reason for them to fail since they represent a ratio between two lengths. Just as there are 2.54cm in 1in, there are 8px in 1vw when the viewport is 800px wide, so calc(1vw / 1px) should evaluate to a unitless 8 value.

They do fail though, so all we can do is state our case and move on.

Fluid everything doesn’t solve all layouts

There will always be some layouts that need size queries, of course; some designs will simply need to snap changes at fixed breakpoints. There is no reason to avoid that if it’s right. There is also no reason to avoid mixing the two, for example, by fluidly sizing and positioning the background while using a query to snap between grid definitions for the text placement. My meerkat example is deliberately contrived to be simple for the sake of demonstration.

One thing I’ll add is that I’m rather excited by the possibility of using the new Anchor Positioning API for fluid positioning. There’s the possibility of using anchor positioning to define how two elements might flow around the screen together, but that’s for another time.


Fluid Everything Else originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Web-Slinger.css: Like Wow.js But With CSS-y Scroll Animations

Post pobrano z: Web-Slinger.css: Like Wow.js But With CSS-y Scroll Animations

We had fun in my previous article exploring the goodness of scrolly animations supported in today’s versions of Chrome and Edge (and behind a feature flag in Firefox for now). Those are by and large referred to as “scroll-driven” animations. However, “scroll triggering” is something the Chrome team is still working on. It refers to the behavior you might have seen in the wild in which a point of no return activates a complete animation like a trap after our hapless scrolling user ventures past a certain point. You can see JavaScript examples of this on the Wow.js homepage which assembles itself in a sequence of animated entrances as you scroll down. There is no current official CSS solution for scroll-triggered animations — but Ryan Mulligan has shown how we can make it work by cleverly combining the animation-timeline property with custom properties and style queries.

That is a very cool way to combine new CSS features. But I am not done being overly demanding toward the awesome emergent animation timeline technology I didn’t know existed before I read up on it last month. I noticed scroll timelines and view timelines are geared toward animations that play backward when you scroll back up, unlike the Wow.js example where the dogs roll in and then stay. Bramus mentions the same point in his exploration of scroll-triggered animations. The animations run in reverse when scrolling back up. This is not always feasible. As a divorced Dad, I can attest that the Tinder UI is another example of a pattern in which scrolling and swiping can have irreversible consequences.

Scroll till the cows come home with Web-Slinger.css

Believe it or not, with a small amount of SCSS and no JavaScript, we can build a pure CSS replacement of the Wow.js library, which I hereby christen “Web-Slinger.css.” It feels good to use the scroll-driven optimized standards already supported by some major browsers to make a prototype library. Here’s the finished demo and then we will break down how it works. I have always enjoyed the deliberately lo-fi aesthetic of the original Wow.js page, so it’s nice to have an excuse to create a parody. Much profession, so impress.

CodePen Embed Fallback

Teach scrolling elements to roll over and stay

Web-Slinger.css introduces a set of class names in the format .scroll-trigger-n and .on-scroll-trigger-n. It also defines --scroll-trigger-n custom properties, which are inherited from the document root so we can access them from any CSS class. These conventions are more verbose than Wow.js but also more powerful. The two types of CSS classes decouple the triggers of our one-off animations from the elements they trigger, which means we can animate anything on the page based on the user reaching any scroll marker.

Here’s a basic example that triggers the Animate.css animation “flipInY” when the user has scrolled to the <div> marked as .scroll-trigger-8.

<div class="scroll-trigger-8"></div>
<img 
  class="on-scroll-trigger-8 animate__animated animate__flipInY" 
  src="https://i.imgur.com/wTWuv0U.jpeg"
>

A more advanced use is the sticky “Cownter” (trademark pending) at the top of the demo page, which takes advantage of the ability of one trigger to activate an arbitrary number of animations anywhere in the document. The Cownter increments as new cows appear then displays a reset button once we reach the final scroll trigger at the bottom of the page.

Here is the markup for the Cownter:

<div class="header">
  <h2 class="cownter"></h2>
  <div class="animate__animated  animate__backInDown on-scroll-trigger-12">
    <br>
    <a href="#" class="reset">🔁 Play again</a>
  </div>
</div>

…and the CSS:

.header {
  .cownter::after {
    --cownter: calc(var(--scroll-trigger-2) + var(--scroll-trigger-4) + var(--scroll-trigger-8) + var(--scroll-trigger-11));
    --pluralised-cow: 'cows';

    counter-set: cownter var(--cownter);
    content: "Have " counter(cownter) " " var(--pluralised-cow) ", man";
  }

  @container style(--scroll-trigger-2: 1) and style(--scroll-trigger-4: 0) {
    .cownter::after {
      --pluralised-cow: 'cow';
    }
  }
  
  a {
    text-decoration: none;
    color:blue;
  }
}

:root:has(.reset:active) * {
  animation-name: none;
}

The demo CodePen references Web-Slinger.css from a separate CodePen, which I reference in my final demo the same way I would an external resource.

Sidenote: If you have doubts about the utility of style queries, behold the age-old cow pluralization problem solved in pure CSS.

How does Web Slinger like to sling it?

The secret is based on an iconic thought experiment by the philosopher Friedrich Nietzsche who once asked: If the view() function lets you style an element once it comes into view, what if you take that opportunity to style it so it can never be scrolled out of view? Would that element not stare back into you for eternity?

.scroll-trigger {
  animation-timeline: view();
  animation-name: stick-to-the-top;
  animation-fill-mode: both;
  animation-duration: 1ms;
}

@keyframes stick-to-the-top {
  .1%, to {
    position: fixed;
    top: 0;
  }
}

This idea sounded too good to be true, reminiscent of the urge when you meet a genie to ask for unlimited wishes. But it works! The next puzzle piece is how to use this one-way animation technique to control something we’d want to display to the user. Divs that instantly stick to the ceiling as soon as they enter the viewport might have their place on a page discussing the movie Alien, but most of the time this type of animation won’t be something we want the user to see.

That’s where named view progress timelines come in. The empty scroll trigger element only has the job of sticking to the top of the viewport as soon as it enters. Next, we set the timeline-scope property of the <body> element so that it matches the sticky element’s view-timeline-name. Now we can apply Ryan’s toggle custom property and style query tricks to let each sticky element trigger arbitrary one-off animations anywhere on the page!

View CSS code
/** Each trigger element will cause a toggle named with 
  * the convention `--scroll-trigger-n` to be flipped 
  * from 0 to 1, which will unpause the animation on
  * any element with the class .on-scroll-trigger-n
 **/

:root {
  animation-name: run-scroll-trigger-1, run-scroll-trigger-2 /*etc*/;
  animation-duration: 1ms;
  animation-fill-mode: forwards;
  animation-timeline: --trigger-timeline-1, --trigger-timeline-2 /*etc*/;
  timeline-scope: --trigger-timeline-1, --trigger-timeline-2 /*etc*/;
}

@property --scroll-trigger-1 {
  syntax: "<integer>";
  initial-value: 0;
  inherits: true;
}
@keyframes run-scroll-trigger-1 {
  to {
    --scroll-trigger-1: 1;
  }
}

/** Add this class to arbitrary elements we want 
  * to only animate once `.scroll-trigger-1` has come 
  * into view, default them to paused state otherwise
 **/
.on-scroll-trigger-1 {
  animation-play-state: paused;
}

/** The style query hack will run the animations on
  * the element once the toggle is set to true
 **/
@container style(--scroll-trigger-1: 1) {
  .on-scroll-trigger-1 {
    animation-play-state: running;
  }
}

/** The trigger element which sticks to the top of 
  * the viewport and activates the one-way  animation 
  * that will unpause the animation on the 
  * corresponding element marked with `.on-scroll-trigger-n` 
  **/
.scroll-trigger-1 {
  view-timeline-name: --trigger-timeline-1;
} 

Trigger warning

We generate the genericized Web-Slinger.css in 95 lines of SCSS, which isn’t too bad. The drawback is that the more triggers we need, the larger the compiled CSS file. The numbered CSS classes also aren’t semantic, so it would be great to have native support for linking a scroll-triggered element to its trigger based on IDs, reminiscent of the popovertarget attribute for HTML buttons — except this hypothetical attribute would go on each target element and specify the ID of the trigger, which is the opposite of the way popovertarget works.

<!-- This is speculative — do not use -->
<scroll-trigger id="my-scroll-trigger"></scroll-trigger>
<div class="rollIn" scrolltrigger="my-scroll-trigger">Hello world</div>

Do androids dream of standardized scroll triggers?

As I mentioned at the start, Bramus has teased that scroll-triggered animations are something we’d like to ship in a future version of Chrome, but it still needs a bit of work before we can do that. I’m looking forward to standardized scroll-triggered animations built into the browser. We could do worse than a convention resembling Web-Slinger.css for declaratively defining scroll-triggered animations, but I know I am not objective about Web Slinger as its creator. It’s become a bit of a sacred cow for me so I shall stop milking the topic — for now.

Feel free to reference the prototype Web-Slinger.css library in your experimental CodePens, or fork the library itself if you have better ideas about how scroll-triggered animations could be standardized.


Web-Slinger.css: Like Wow.js But With CSS-y Scroll Animations originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

State of CSS 2024 Results

Post pobrano z: State of CSS 2024 Results

They’re out! Like many of you, I look forward to these coming out each year. I don’t put much stock in surveys but they can be insightful and give a snapshot of the CSS zeitgeist. There are a few little nuggets in this year’s results that I find interesting. But before I get there, you’ll want to also check out what others have already written about it.

Oh, I guess that’s it — at least it’s the most formal write-up I’ve seen. There’s a little summary by Ahmad Shadeed at the end of the survey that generally rounds things up. I’ll drop in more links as I find ’em.

In no particular order…

Demographics

Josh has way more poignant thoughts on this than I do. He rightfully calls out discrepancies in gender pay and regional pay, where men are way more compensated than women (a nonsensical and frustratingly never-ending trend) and the United States boasts more $100,000 salaries than anywhere else. The countries with the highest salaries were also the most represented in survey responses, so perhaps the results are no surprise. We’re essentially looking at a snapshot of what it’s like to be a rich, white male developer in the West.

Besides pay, my eye caught the Age Group demographics. As an aging front-ender, I often wonder what we all do when we finally get to retirement age. I officially dropped from the most represented age group (30-39, 42%) a few years ago into the third most represented tier (40-49, 21%). Long gone are my days being with the cool kids (20-29, 27%).

Bar chart with age results from the state of CSS 2024 survey.

And if the distribution is true to life, I’m riding fast into my sunset years and will be only slightly more represented than those getting into the profession. I don’t know if anyone else feels similarly anxious about aging in this industry — but if you’re one of the 484 folks who identify with the 50+ age group, I’d love to talk with you.

Before we plow ahead, I think it’s worth calling out how relatively “new” most people are to front-end development.

Bar chart with years of experience from the state of CSS 2024 survey.

Wow! Forty-freaking-four percent of respondents have less than 10 years of experience. Yes, 10 years is a high threshold, but we’re still talking about a profession that popped up in recent memory.

For perspective, someone developing for 10 years came to the field around 2014. That’s just when we were getting Flexbox, and several years after the big bang of CSS 3 and HTML 5. That’s just under half of developers who never had to deal with the headaches of table layouts, clearfix hacks, image sprites, spacer images, and rasterized rounded corners. Ethan Marcotte’s seminal article on “Responsive Web Design” predates these folks by a whopping four years!

That’s just wild. And exciting. I’m a firm believer in the next generation of front-enders but always hope that they learn from our past mistakes and become masters at the basics.

Features

I’m not entirely sure what to make of this section. When there are so many CSS features, how do you determine which are most widely used? How do you pare it down to just 50 features? Like, are filter effects really the most widely used CSS feature? So many questions, but the results are always interesting nonetheless.

What I find most interesting are the underused features. For example, hanging-punctuation comes in dead last in usage (1.57%) but is the feature that most developers (52%) have on their reading list. (If you need some reading material on it, Chris initially published the Almanac entry for hanging-punctuation back in 2013.)

I also see Anchor Positioning at the end of the long tail with reported usage at 4.8%. That’ll go up for sure now that we have at least one supporting browser engine (Chromium) but also given all of the tutorials that have sprung up in the past few months. Yes, we’ve contributed to that noise… but it’s good noise! I think Juan published what might be the most thorough and thoughtful guide on the topic yet.

I’m excited to see Cascade Layers falling smack dab in the middle of the pack at a fairly robust 18.7%. Cascade Layers are super approachable and elegantly designed that I have trouble believing anybody these days when they say that the CSS Cascade is difficult to manage. And even though @scope is currently low on the list (4.8%, same as Anchor Positioning), I’d bet the crumpled gum wrapper in my pocket that the overall sentiment of working with the Cascade will improve dramatically. We’ll still see “CSS is Awesome” memes galore, but they’ll be more like old familiar dad jokes in good time.

(Aside: Did you see the proposed designs for a new CSS logo? You can vote on them as of yesterday, but earlier versions played off the “CSS is Awesome” mean quite beautifully.)

Interestingly enough, viewport units come in at Number 11 with 44.2% usage… which lands them at Number 2 for most experience that developers have with CSS layout. Does that suggest that layout features are less widely used than CSS filters? Again, so many questions.

Frameworks

How many of you were surprised that Tailwind blew past Bootstrap as Top Dog framework in CSS Land? Nobody, right?

More interesting to me is that “No CSS framework” clocks in at Number 13 out of 21 list frameworks. Sure, its 46 votes are dwarfed by the 138 for Material UI at Number 10… but the fact that we’re seeing “no framework” as a ranking option at all would have been unimaginable just three years ago.

The same goes for CSS pre/post-processing. Sass (67%) and PostCSS (38%) are the power players, but “None” comes in third at 19%, ahead of Less, Stylus, and Lightning CSS.

It’s a real testament to the great work the CSSWG is doing to make CSS better every day. We don’t thank the CSSWG enough — thank you, team! Y’all are heroes around these parts.

CSS Usage

Josh already has a good take on the fact that only 67% of folks say they test their work on mobile phones. It should be at least tied with the 99% who test on desktops, right? Right?! Who knows, maybe some responses consider things like “Responsive Design Mode” desktop features to be the equivalent of testing on real mobile devices. I find it hard to believe that only 67% of us test mobile.

Oh, and The Great Divide is still alive and well if the results are true and 53% write more JavsScript than CSS in their day-to-day.

Missing CSS Features

This is always a fun topic to ponder. Some of the most-wanted CSS features have been lurking around 10+ years. But let’s look at the top three form this year’s survey:

  • Mixins
  • Conditional Logic
  • Masonry

We’re in luck team! There’s movement on all three of those fronts:

Resources

This is where I get to toot our own horn a bit because CSS-Tricks continues to place first among y’all when it comes to the blogs you follow for CSS happenings.

I’m also stoked to see Smashing Magazine right there as well. It was fifth in 2023 and I’d like to think that rise is due to me joining the team last year. Correlation implies causation, amirite?

But look at Kevin Powell and Josh in the Top 10. That’s just awesome. It speaks volumes about their teaching talents and the hard work they put into “helping people fall in love with CSS” as Kevin might say it. I was able to help Kevin with a couple of his videos last year (here’s one) and can tell you the guy cares a heckuva lot about making CSS approachable and fun.

Honestly, the rankings are not what we live for. Now that I’ve been given a second wind to work on CSS-Tricks, all I want is to publish things that are valuable to your everyday work as front-enders. That’s traditionally happened as a stream of daily articles but is shifting to more tutorials and resources, whether it’s guides (we’ve published four new ones this year), taking notes on interesting developments, spotlighting good work with links, or expanding the ol’ Almanac to account for things like functions, at-rules, and pseudos (we have lots of work to do).

My 2024 Pick

No one asked my opinion but I’ll say it anyway: Personal blogging. I’m seeing more of us in the front-end community getting back behind the keyboards of their personal websites and I’ve never been subscribed to more RSS feeds than I am today. Some started blogging as a “worry stone” during the 2020 lockdown. Some abandoned socials when Twitter X imploded. Some got way into the IndieWeb. Webrings and guestbooks are even gaining new life. Sure, it can be tough keeping up, but what a good problem to have! Let’s make RSS king once and for all.

That’s a wrap!

Seriously, a huge thanks to Sacha Greif and the entire Devographics team for the commitment to putting this survey together every year. It’s always fun. And the visualizations are always to die for.


State of CSS 2024 Results originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.